Compare commits

..

4 Commits

Author SHA1 Message Date
Scott McKendry db911a41c3 refactor(db): cleanup sqlc-wrapper gen 2026-05-10 07:59:32 +12:00
Scott McKendry ef8bbd8c9f feat(db): add memory storage driver
removes the sqlite dependency for tests, also brings back the option for
users to run zero persistence instances of tinyauth.

adds new mapErr fn for sqlc wrapper gen to prevent sql errors from
leaking out of the store implementation.
2026-05-10 07:59:32 +12:00
Scott McKendry 9566105245 feat(db): add code gen to build sqlc-compatible wrappers 2026-05-10 07:50:33 +12:00
Scott McKendry 7e9d21462d refactor(db): use new store interface 2026-05-10 07:49:54 +12:00
123 changed files with 3702 additions and 12061 deletions
+38
View File
@@ -0,0 +1,38 @@
---
name: Bug report
about: Create a report to help improve Tinyauth
title: "[BUG]"
labels: bug
assignees:
- steveiliop56
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Logs**
Please include the Tinyauth logs below, make sure to not include sensitive info.
**Device (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Tinyauth [e.g. v2.1.1]
- Docker [e.g. 27.3.1]
**
**Additional context**
Add any other context about the problem here.
-89
View File
@@ -1,89 +0,0 @@
name: Bug Report
description: Create a report to help us improve this project
title: "[BUG]"
labels: bug
assignees:
- steveiliop56
body:
- type: markdown
attributes:
value: |
Thanks for reporting a bug! Please provide detailed information below.
- type: textarea
id: description
attributes:
label: Describe the Bug
description: "A clear and concise description of what the bug is."
validations:
required: true
- type: textarea
id: reproduce
attributes:
label: How to Reproduce
description: Steps to reproduce the behavior.
value: |
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
validations:
required: false
- type: textarea
id: expected
attributes:
label: Expected Behavior
description: "A clear and concise description of what you expected to happen."
validations:
required: true
- type: textarea
id: context
attributes:
label: "Additional Context"
description: "If applicable add screenshots to help explain your problem."
validations:
required: false
- type: textarea
id: logs
attributes:
label: "Logs"
description: "Please include the Tinyauth logs, make sure to not include sensitive info."
validations:
required: false
- type: input
id: os
attributes:
label: Operating System
placeholder: "e.g. iOS, Android, Windows, Linux, etc"
- type: input
id: browser
attributes:
label: Browser
placeholder: "e.g. Chrome, Firefox, Safari, Edge, etc"
- type: input
id: tinyauth
attributes:
label: Tinyauth Version
placeholder: "e.g. v5.0.0"
- type: input
id: docker
attributes:
label: Docker Version (if applicable)
placeholder: "e.g. 27.3.1"
- type: checkboxes
id: not-llm
attributes:
label: Human Written Confirmation
options:
- label: I confirm this issue was written by me and not generated by an LLM or AI assistant.
required: true
-8
View File
@@ -1,8 +0,0 @@
blank_issues_enabled: true
contact_links:
- name: Tinyauth Community Support on Discord
url: https://discord.gg/eHzVaCzRRd
about: Please ask and answer questions here.
- name: Tinyauth Documentation
url: https://tinyauth.app/docs/getting-started/
about: Please check the documentation here.
+21
View File
@@ -0,0 +1,21 @@
---
name: Feature request
about: Suggest an idea for this project
title: "[FEATURE]"
labels: enhancement
assignees:
- steveiliop56
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
@@ -1,52 +0,0 @@
name: Feature request
description: Suggest an idea for this project
title: "[FEATURE]"
labels: enhancement
assignees:
- steveiliop56
body:
- type: markdown
attributes:
value: |
Thanks for suggesting a feature! Please provide detailed information below.
- type: textarea
id: problem
attributes:
label: Is your feature request related to a problem? Please describe.
description: "A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]"
validations:
required: false
- type: textarea
id: solution
attributes:
label: Describe the solution you'd like.
description: "A clear and concise description of what you want to happen."
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Describe alternatives you've considered.
description: "A clear and concise description of any alternative solutions or features you've considered."
validations:
required: false
- type: textarea
id: context
attributes:
label: Additional context
description: "Add any other context or screenshots about the feature request here."
validations:
required: false
- type: checkboxes
id: not-llm
attributes:
label: Human Written Confirmation
options:
- label: I confirm this request was written by me and not generated by an LLM or AI assistant.
required: true
+1 -1
View File
@@ -1,6 +1,6 @@
version: 2 version: 2
updates: updates:
- package-ecosystem: "npm" - package-ecosystem: "bun"
directory: "/frontend" directory: "/frontend"
groups: groups:
minor-patch: minor-patch:
+15 -18
View File
@@ -15,10 +15,8 @@ jobs:
- name: Checkout code - name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup pnpm - name: Setup bun
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
package_json_file: ./frontend/package.json
- name: Setup go - name: Setup go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
@@ -28,35 +26,34 @@ jobs:
- name: Go dependencies - name: Go dependencies
run: go mod download run: go mod download
- name: Setup sqlc
uses: sqlc-dev/setup-sqlc@v4
with:
sqlc-version: "1.31.1"
- name: Check codegen is up to date - name: Check codegen is up to date
run: | run: |
sqlc generate
go generate ./internal/repository/... go generate ./internal/repository/...
git diff --exit-code -- internal/repository/ git diff --exit-code -- internal/repository/
git status --porcelain -- internal/repository/ | grep -q . && echo "untracked files in internal/repository/" && exit 1 || true git status --porcelain -- internal/repository/ | grep -q . && echo "untracked files in internal/repository/" && exit 1 || true
- name: Install frontend dependencies - name: Install frontend dependencies
working-directory: ./frontend run: |
run: pnpm ci cd frontend
bun install --frozen-lockfile
- name: Set version - name: Set version
run: echo testing > internal/assets/version run: |
echo testing > internal/assets/version
- name: Lint frontend - name: Lint frontend
working-directory: ./frontend run: |
run: pnpm run lint cd frontend
bun run lint
- name: Build frontend - name: Build frontend
working-directory: ./frontend run: |
run: pnpm run build cd frontend
bun run build
- name: Copy frontend - name: Copy frontend
run: cp -r frontend/dist internal/assets/dist run: |
cp -r frontend/dist internal/assets/dist
- name: Run tests - name: Run tests
run: go test -coverprofile=coverage.txt -v ./... run: go test -coverprofile=coverage.txt -v ./...
+20 -18
View File
@@ -59,10 +59,8 @@ jobs:
with: with:
ref: nightly ref: nightly
- name: Setup pnpm - name: Install bun
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
package_json_file: ./frontend/package.json
- name: Install go - name: Install go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
@@ -70,15 +68,18 @@ jobs:
go-version: "^1.26.0" go-version: "^1.26.0"
- name: Install frontend dependencies - name: Install frontend dependencies
working-directory: ./frontend run: |
run: pnpm ci cd frontend
bun install --frozen-lockfile
- name: Install backend dependencies - name: Install backend dependencies
run: go mod download run: |
go mod download
- name: Build frontend - name: Build frontend
working-directory: ./frontend run: |
run: pnpm run build cd frontend
bun run build
- name: Build - name: Build
run: | run: |
@@ -104,10 +105,8 @@ jobs:
with: with:
ref: nightly ref: nightly
- name: Setup pnpm - name: Install bun
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
package_json_file: ./frontend/package.json
- name: Install go - name: Install go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
@@ -115,15 +114,18 @@ jobs:
go-version: "^1.26.0" go-version: "^1.26.0"
- name: Install frontend dependencies - name: Install frontend dependencies
working-directory: ./frontend run: |
run: pnpm ci cd frontend
bun install --frozen-lockfile
- name: Install backend dependencies - name: Install backend dependencies
run: go mod download run: |
go mod download
- name: Build frontend - name: Build frontend
working-directory: ./frontend run: |
run: pnpm run build cd frontend
bun run build
- name: Build - name: Build
run: | run: |
+20 -18
View File
@@ -35,10 +35,8 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup pnpm - name: Install bun
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
package_json_file: ./frontend/package.json
- name: Install go - name: Install go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
@@ -46,15 +44,18 @@ jobs:
go-version: "^1.26.0" go-version: "^1.26.0"
- name: Install frontend dependencies - name: Install frontend dependencies
working-directory: ./frontend run: |
run: pnpm ci cd frontend
bun install --frozen-lockfile
- name: Install backend dependencies - name: Install backend dependencies
run: go mod download run: |
go mod download
- name: Build frontend - name: Build frontend
working-directory: ./frontend run: |
run: pnpm run build cd frontend
bun run build
- name: Build - name: Build
run: | run: |
@@ -77,10 +78,8 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup pnpm - name: Install bun
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
package_json_file: ./frontend/package.json
- name: Install go - name: Install go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
@@ -88,15 +87,18 @@ jobs:
go-version: "^1.26.0" go-version: "^1.26.0"
- name: Install frontend dependencies - name: Install frontend dependencies
working-directory: ./frontend run: |
run: pnpm ci cd frontend
bun install --frozen-lockfile
- name: Install backend dependencies - name: Install backend dependencies
run: go mod download run: |
go mod download
- name: Build frontend - name: Build frontend
working-directory: ./frontend run: |
run: pnpm run build cd frontend
bun run build
- name: Build - name: Build
run: | run: |
+1 -1
View File
@@ -38,6 +38,6 @@ jobs:
retention-days: 5 retention-days: 5
- name: Upload to code-scanning - name: Upload to code-scanning
uses: github/codeql-action/upload-sarif@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4 uses: github/codeql-action/upload-sarif@e46ed2cbd01164d986452f91f178727624ae40d7 # v4
with: with:
sarif_file: results.sarif sarif_file: results.sarif
+24
View File
@@ -0,0 +1,24 @@
name: Close stale issues and PRs
on:
schedule:
- cron: 0 10 * * *
permissions:
issues: write
pull-requests: write
jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10
with:
days-before-stale: 30
stale-pr-message: This PR has been inactive for 30 days and will be marked as stale.
stale-issue-message: This issue has been inactive for 30 days and will be marked as stale.
close-issue-message: Closed for inactivity.
close-pr-message: Closed for inactivity.
stale-issue-label: stale
stale-pr-label: stale
exempt-issue-labels: pinned
exempt-pr-labels: pinned
-3
View File
@@ -48,6 +48,3 @@ __debug_*
# testing config # testing config
config.certify.yml config.certify.yml
# deepsec
/.deepsec
+2 -2
View File
@@ -7,7 +7,7 @@ Contributing to Tinyauth is straightforward. Follow the steps below to set up a
## Requirements ## Requirements
- pnpm - Bun
- Golang v1.24.0 or later - Golang v1.24.0 or later
- Git - Git
- Docker - Docker
@@ -34,7 +34,7 @@ Frontend dependencies can be installed as follows:
```sh ```sh
cd frontend/ cd frontend/
pnpm ci bun install
``` ```
## Create the `.env` file ## Create the `.env` file
+4 -6
View File
@@ -1,14 +1,12 @@
# Site builder # Site builder
FROM node:26.1-alpine3.23 AS frontend-builder FROM oven/bun:1.3.13-alpine AS frontend-builder
WORKDIR /frontend WORKDIR /frontend
RUN npm install -g pnpm@11.1.2
COPY ./frontend/package.json ./ COPY ./frontend/package.json ./
COPY ./frontend/pnpm-lock.yaml ./ COPY ./frontend/bun.lock ./
RUN pnpm ci RUN bun install --frozen-lockfile
COPY ./frontend/public ./public COPY ./frontend/public ./public
COPY ./frontend/src ./src COPY ./frontend/src ./src
@@ -19,7 +17,7 @@ COPY ./frontend/tsconfig.app.json ./
COPY ./frontend/tsconfig.node.json ./ COPY ./frontend/tsconfig.node.json ./
COPY ./frontend/vite.config.ts ./ COPY ./frontend/vite.config.ts ./
RUN pnpm run build RUN bun run build
# Builder # Builder
FROM golang:1.26-alpine3.23 AS builder FROM golang:1.26-alpine3.23 AS builder
+1 -1
View File
@@ -8,7 +8,7 @@ COPY go.sum ./
RUN go mod download RUN go mod download
RUN go install github.com/air-verse/air@v1.61.7 RUN go install github.com/air-verse/air@v1.61.7
RUN go install github.com/go-delve/delve/cmd/dlv@v1.26.3 RUN go install github.com/go-delve/delve/cmd/dlv@latest
COPY ./cmd ./cmd COPY ./cmd ./cmd
COPY ./internal ./internal COPY ./internal ./internal
+4 -6
View File
@@ -1,14 +1,12 @@
# Site builder # Site builder
FROM node:26.1-alpine3.23 AS frontend-builder FROM oven/bun:1.3.13-alpine AS frontend-builder
WORKDIR /frontend WORKDIR /frontend
RUN npm install -g pnpm@11.1.2
COPY ./frontend/package.json ./ COPY ./frontend/package.json ./
COPY ./frontend/pnpm-lock.yaml ./ COPY ./frontend/bun.lock ./
RUN pnpm ci RUN bun install --frozen-lockfile
COPY ./frontend/public ./public COPY ./frontend/public ./public
COPY ./frontend/src ./src COPY ./frontend/src ./src
@@ -19,7 +17,7 @@ COPY ./frontend/tsconfig.app.json ./
COPY ./frontend/tsconfig.node.json ./ COPY ./frontend/tsconfig.node.json ./
COPY ./frontend/vite.config.ts ./ COPY ./frontend/vite.config.ts ./
RUN pnpm run build RUN bun run build
# Builder # Builder
FROM golang:1.26-alpine3.23 AS builder FROM golang:1.26-alpine3.23 AS builder
+77 -64
View File
@@ -1,5 +1,5 @@
GNU AFFERO GENERAL PUBLIC LICENSE GNU GENERAL PUBLIC LICENSE
Version 3, 19 November 2007 Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/> Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies Everyone is permitted to copy and distribute verbatim copies
@@ -7,15 +7,17 @@
Preamble Preamble
The GNU Affero General Public License is a free, copyleft license for The GNU General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure software and other kinds of works.
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast, to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free share and change all versions of a program--to make sure it remains free
software for all its users. software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you price. Our General Public Licenses are designed to make sure that you
@@ -24,34 +26,44 @@ them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things. free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights To protect your rights, we need to prevent others from denying you
with two steps: (1) assert copyright on the software, and (2) offer these rights or asking you to surrender the rights. Therefore, you have
you this License which gives you legal permission to copy, distribute certain responsibilities if you distribute copies of the software, or if
and/or modify the software. you modify it: responsibilities to respect the freedom of others.
A secondary benefit of defending all users' freedom is that For example, if you distribute copies of such a program, whether
improvements made in alternate versions of the program, if they gratis or for a fee, you must pass on to the recipients the same
receive widespread use, become available for other developers to freedoms that you received. You must make sure that they, too, receive
incorporate. Many developers of free software are heartened and or can get the source code. And you must show them these terms so they
encouraged by the resulting cooperation. However, in the case of know their rights.
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to Developers that use the GNU GPL protect your rights with two steps:
ensure that, in such cases, the modified source code becomes available (1) assert copyright on the software, and (2) offer you this License
to the community. It requires the operator of a network server to giving you legal permission to copy, distribute and/or modify it.
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and For the developers' and authors' protection, the GPL clearly explains
published by Affero, was designed to accomplish similar goals. This is that there is no warranty for this free software. For both users' and
a different license, not a version of the Affero GPL, but Affero has authors' sake, the GPL requires that modified versions be marked as
released a new version of the Affero GPL which permits relicensing under changed, so that their problems will not be attributed erroneously to
this license. authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and The precise terms and conditions for copying, distribution and
modification follow. modification follow.
@@ -60,7 +72,7 @@ modification follow.
0. Definitions. 0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License. "This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of "Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks. works, such as semiconductor masks.
@@ -537,45 +549,35 @@ to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program. License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License. 13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work, License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version but the special requirements of the GNU Affero General Public License,
3 of the GNU General Public License. section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License. 14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions the GNU General Public License from time to time. Such new versions will
will be similar in spirit to the present version, but may differ in detail to be similar in spirit to the present version, but may differ in detail to
address new problems or concerns. address new problems or concerns.
Each version is given a distinguishing version number. If the Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published GNU General Public License, you may choose any version ever published
by the Free Software Foundation. by the Free Software Foundation.
If the Program specifies that a proxy can decide which future If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you public statement of acceptance of a version permanently authorizes you
to choose that version for the Program. to choose that version for the Program.
@@ -633,29 +635,40 @@ the "copyright" line and a pointer to where the full notice is found.
Copyright (C) <year> <name of author> Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or the Free Software Foundation, either version 3 of the License, or
(at your option) any later version. (at your option) any later version.
This program is distributed in the hope that it will be useful, This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details. GNU General Public License for more details.
You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail. Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer If the program does terminal interaction, make it output a short
network, you should also make sure that it provides a way for users to notice like this when it starts in an interactive mode:
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive <program> Copyright (C) <year> <name of author>
of the code. There are many ways you could offer source, and different This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
solutions will be better for different programs; see section 13 for the This is free software, and you are welcome to redistribute it
specific requirements. under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school, You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary. if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>. <https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
+2 -3
View File
@@ -17,7 +17,7 @@ PROD_COMPOSE := $(shell test -f "docker-compose.test.prod.yml" && echo "docker-c
# Deps # Deps
deps: deps:
cd frontend && pnpm ci bun install --frozen-lockfile --cwd frontend
go mod download go mod download
# Clean data # Clean data
@@ -31,7 +31,7 @@ clean-webui:
# Build the web UI # Build the web UI
webui: clean-webui webui: clean-webui
cd frontend && pnpm run build bun run --cwd frontend build
cp -r frontend/dist internal/assets cp -r frontend/dist internal/assets
# Build the binary # Build the binary
@@ -84,5 +84,4 @@ sql:
# Go gen # Go gen
generate: generate:
go run ./gen
go generate ./internal/repository/... go generate ./internal/repository/...
+4 -1
View File
@@ -28,6 +28,9 @@ Tinyauth is the simplest and tiniest authentication and authorization server you
> [!NOTE] > [!NOTE]
> This is the main development branch. For the latest stable release, see the [documentation](https://tinyauth.app) or the latest stable tag. > This is the main development branch. For the latest stable release, see the [documentation](https://tinyauth.app) or the latest stable tag.
> [!NOTE]
> Tinyauth is in the process of migrating to the new [tinyauthapp](https://github.com/tinyauthapp) organization. The organization **is official** and it will host all of the Tinyauth related repositories in the future.
## Getting Started ## Getting Started
You can get started with Tinyauth by following the guide in the [documentation](https://tinyauth.app/docs/getting-started). There is also an available [docker-compose](./docker-compose.example.yml) file that has Traefik, Whoami and Tinyauth to demonstrate its capabilities (keep in mind that this file lives in the development branch so it may have updates that are not yet released). You can get started with Tinyauth by following the guide in the [documentation](https://tinyauth.app/docs/getting-started). There is also an available [docker-compose](./docker-compose.example.yml) file that has Traefik, Whoami and Tinyauth to demonstrate its capabilities (keep in mind that this file lives in the development branch so it may have updates that are not yet released).
@@ -56,7 +59,7 @@ If you like, you can help translate Tinyauth into more languages by visiting the
## License ## License
Tinyauth is licensed under the GNU Affero General Public License v3.0. TL;DR — You may copy, distribute and modify the software as long as you track changes/dates in source files. Any modifications to or software including (via compiler) AGPL-licensed code must also be made available under the AGPL along with build & install instructions. If you run a modified version over a network, you must also make the source available to the users of that service. For more information about the license check the [license](LICENSE) file. Tinyauth is licensed under the GNU General Public License v3.0. TL;DR — You may copy, distribute and modify the software as long as you track changes/dates in source files. Any modifications to or software including (via compiler) GPL-licensed code must also be made available under the GPL along with build & install instructions. For more information about the license check the [license](./LICENSE) file.
## Sponsors ## Sponsors
+2 -50
View File
@@ -2,56 +2,8 @@
## Supported Versions ## Supported Versions
It is recommended to use the [latest](https://github.com/tinyauthapp/tinyauth/releases/latest) available version of Tinyauth. This is because it includes security fixes, new features and dependency updates. Older versions, especially major ones, are not supported and won't receive security or patch updates. It is recommended to use the [latest](https://github.com/tinyauthapp/tinyauth/releases/latest) available version of tinyauth. This is because it includes security fixes, new features and dependency updates. Older versions, especially major ones, are not supported and won't receive security or patch updates.
## Reporting a Vulnerability ## Reporting a Vulnerability
Please **do not** report security vulnerabilities through public GitHub issues, discussions, or pull requests as I won't be able to patch them in time and they may get exploited by malicious actors. Due to the nature of this app, it needs to be secure. If you discover any security issues or vulnerabilities in the app please contact me as soon as possible at <security@tinyauth.app>. Please do not use the issues section to report security issues as I won't be able to patch them in time and they may get exploited by malicious actors.
Instead, report them privately using [GitHub's Private Vulnerability Reporting](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability) via the **Security** tab of this repository.
Or send us an email at <security@tinyauth.app>.
### A note on AI-assisted reports
If AI tooling (LLMs, automated scanners, agentic assistants, etc.) helped you discover, analyse, or write up this issue, please say so in your report. This isn't a judgement - AI-assisted findings are welcome - but disclosing it up front helps maintainers calibrate how much additional verification a report needs, and tends to make the report itself clearer.
When submitting a report, please use the structure below so it can be triaged quickly.
---
### 1. Summary
A short, one-paragraph description of the vulnerability and its impact (e.g. what an attacker can achieve, who is affected, and under what conditions).
### 2. Steps to Reproduce / Proof of Concept
Provide a minimal, reliable reproduction:
1. Step one
2. Step two
3. Step three
Include any required input, payloads, configuration, or code snippets. Attach a PoC script or screenshots where helpful.
### 3. Expected vs. Actual Behaviour
- **Expected:** what *should* happen
- **Actual:** what *does* happen, and why it's a security issue
### 4. Suggested Fix or Mitigation *(optional)*
If you have an idea for how to address the issue, describe it here. A private gist link is welcome but not required.
- **Have you tested this fix?** Yes / No
- **If yes,** briefly describe how it was tested and what was verified.
---
## What to Expect
- **Acknowledgement** within a reasonable timeframe after receiving your report
- **Updates** as the issue is investigated and addressed
- **Public credit** in the resulting advisory, along with any **CVE assigned**, unless you'd prefer to stay anonymous
We follow a **90-day coordinated disclosure** window: please allow up to 90 days from the date of your report for the issue to be investigated and patched before publicly disclosing it. The publication date - whether earlier if a fix lands sooner, or later if more time is genuinely needed - will be agreed with you in advance.
@@ -32,7 +32,6 @@ import (
var storeSrc string var storeSrc string
func main() { func main() {
fmt.Println("sqlc-wrapper: generating store.go files for sqlc driver packages...")
if err := run(); err != nil { if err := run(); err != nil {
log.Fatal(err) log.Fatal(err)
} }
@@ -89,11 +88,13 @@ func run() error {
if err != nil { if err != nil {
return err return err
} }
models, _ := collectTypes(driverTypePkg)
src, err := render(tmplData{ src, err := render(tmplData{
PkgName: driverTypePkg.Name(), PkgName: driverTypePkg.Name(),
RepoPkg: repoPkgPath, RepoPkg: repoPkgPath,
Methods: renderMethods(methods), ModelTypes: models,
Methods: renderMethods(methods),
}) })
if err != nil { if err != nil {
return fmt.Errorf("render: %w", err) return fmt.Errorf("render: %w", err)
@@ -259,6 +260,19 @@ func compareStructs(name string, driver, repo *types.Struct) error {
return nil return nil
} }
// collectTypes returns model and params struct names from the driver package.
func collectTypes(pkg *types.Package) (models []string, params []string) {
names, _ := scopeStructs(pkg)
for _, name := range names {
if strings.HasSuffix(name, "Params") {
params = append(params, name)
} else {
models = append(models, name)
}
}
return
}
type methodInfo struct { type methodInfo struct {
Name string Name string
Params []paramInfo Params []paramInfo
@@ -355,6 +369,14 @@ func repoName(t types.Type, driverPath string) string {
return "" return ""
} }
// converterFn maps a type name to its converter function name: "Session" → "sessionToRepo".
func converterFn(s string) string {
if s == "" {
return ""
}
return strings.ToLower(s[:1]) + s[1:] + "ToRepo"
}
// renderedMethod holds pre-built signature and body strings passed to the template. // renderedMethod holds pre-built signature and body strings passed to the template.
type renderedMethod struct { type renderedMethod struct {
Signature string Signature string
@@ -419,11 +441,35 @@ func callArgs(m methodInfo) string {
return "ctx, " + strings.Join(args, ", ") return "ctx, " + strings.Join(args, ", ")
} }
var bodyTmpl = template.Must(template.New("store").Parse(storeSrc)) // bodyTemplates holds the per-shape method body templates, parsed once at init.
var bodyTemplates = template.Must(
template.New("bodies").Parse(`
{{define "void"}} return mapErr({{.Call}})
{{end}}
{{define "scalar"}} r, err := {{.Call}}
if err != nil {
return {{.RepoType}}{}, mapErr(err)
}
return {{.Converter}}(r), nil
{{end}}
{{define "slice"}} rows, err := {{.Call}}
if err != nil {
return nil, mapErr(err)
}
out := make([]{{.RepoType}}, len(rows))
for i, row := range rows {
out[i] = {{.Converter}}(row)
}
return out, nil
{{end}}`),
)
type bodyData struct { type bodyData struct {
Call string Call string
RepoType string RepoType string
Converter string
} }
func buildBody(m methodInfo) string { func buildBody(m methodInfo) string {
@@ -440,28 +486,36 @@ func buildBody(m methodInfo) string {
data = bodyData{Call: call} data = bodyData{Call: call}
case m.Results[0].IsSlice: case m.Results[0].IsSlice:
name = "slice" name = "slice"
data = bodyData{Call: call, RepoType: m.Results[0].RepoType} data = bodyData{Call: call, RepoType: m.Results[0].RepoType, Converter: converterFn(m.Results[0].TypeStr)}
default: default:
name = "scalar" name = "scalar"
data = bodyData{Call: call, RepoType: m.Results[0].RepoType} data = bodyData{Call: call, RepoType: m.Results[0].RepoType, Converter: converterFn(m.Results[0].TypeStr)}
} }
var buf bytes.Buffer var buf bytes.Buffer
if err := bodyTmpl.ExecuteTemplate(&buf, name, data); err != nil { if err := bodyTemplates.ExecuteTemplate(&buf, name, data); err != nil {
panic(fmt.Sprintf("buildBody %s: %v", name, err)) panic(fmt.Sprintf("buildBody %s: %v", name, err))
} }
return buf.String() return buf.String()
} }
type tmplData struct { type tmplData struct {
PkgName string PkgName string
RepoPkg string RepoPkg string
Methods []renderedMethod ModelTypes []string
Methods []renderedMethod
} }
func render(data tmplData) ([]byte, error) { func render(data tmplData) ([]byte, error) {
t, err := template.New("store").Funcs(template.FuncMap{
"converterFn": converterFn,
}).Parse(storeSrc)
if err != nil {
return nil, fmt.Errorf("parse template: %w", err)
}
var buf bytes.Buffer var buf bytes.Buffer
if err := bodyTmpl.Execute(&buf, data); err != nil { if err := t.Execute(&buf, data); err != nil {
return nil, fmt.Errorf("execute template: %w", err) return nil, fmt.Errorf("execute template: %w", err)
} }
+46
View File
@@ -0,0 +1,46 @@
// Code generated by cmd/gen/sqlc-wrapper. DO NOT EDIT.
package {{.PkgName}}
import (
"context"
"database/sql"
"errors"
"{{.RepoPkg}}"
)
// Store wraps *Queries and implements repository.Store.
type Store struct {
q *Queries
}
// NewStore wraps a *Queries to satisfy repository.Store.
func NewStore(q *Queries) repository.Store {
return &Store{q: q}
}
var errMap = []struct {
from error
to error
}{
{sql.ErrNoRows, repository.ErrNotFound},
}
func mapErr(err error) error {
for _, e := range errMap {
if errors.Is(err, e.from) {
return e.to
}
}
return err
}
{{range .ModelTypes -}}
func {{converterFn .}}(v {{.}}) repository.{{.}} {
return repository.{{.}}(v)
}
{{end -}}
{{range .Methods}}{{.Signature}} {
{{.Body}}}
{{end}}
+4 -5
View File
@@ -6,8 +6,8 @@ import (
"strings" "strings"
"charm.land/huh/v2" "charm.land/huh/v2"
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
"github.com/tinyauthapp/paerser/cli" "github.com/tinyauthapp/paerser/cli"
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
@@ -40,8 +40,7 @@ func createUserCmd() *cli.Command {
Configuration: tCfg, Configuration: tCfg,
Resources: loaders, Resources: loaders,
Run: func(_ []string) error { Run: func(_ []string) error {
log := logger.NewLogger().WithSimpleConfig() tlog.NewSimpleLogger().Init()
log.Init()
if tCfg.Interactive { if tCfg.Interactive {
form := huh.NewForm( form := huh.NewForm(
@@ -74,7 +73,7 @@ func createUserCmd() *cli.Command {
return errors.New("username and password cannot be empty") 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) passwd, err := bcrypt.GenerateFromPassword([]byte(tCfg.Password), bcrypt.DefaultCost)
if err != nil { if err != nil {
@@ -87,7 +86,7 @@ func createUserCmd() *cli.Command {
passwdStr = strings.ReplaceAll(passwdStr, "$", "$$") 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 return nil
}, },
+5 -6
View File
@@ -7,7 +7,7 @@ import (
"strings" "strings"
"github.com/tinyauthapp/tinyauth/internal/utils" "github.com/tinyauthapp/tinyauth/internal/utils"
"github.com/tinyauthapp/tinyauth/internal/utils/logger" "github.com/tinyauthapp/tinyauth/internal/utils/tlog"
"charm.land/huh/v2" "charm.land/huh/v2"
"github.com/mdp/qrterminal/v3" "github.com/mdp/qrterminal/v3"
@@ -40,8 +40,7 @@ func generateTotpCmd() *cli.Command {
Configuration: tCfg, Configuration: tCfg,
Resources: loaders, Resources: loaders,
Run: func(_ []string) error { Run: func(_ []string) error {
log := logger.NewLogger().WithSimpleConfig() tlog.NewSimpleLogger().Init()
log.Init()
if tCfg.Interactive { if tCfg.Interactive {
form := huh.NewForm( form := huh.NewForm(
@@ -89,9 +88,9 @@ func generateTotpCmd() *cli.Command {
secret := key.Secret() 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{ config := qrterminal.Config{
Level: qrterminal.L, Level: qrterminal.L,
@@ -110,7 +109,7 @@ func generateTotpCmd() *cli.Command {
user.Password = strings.ReplaceAll(user.Password, "$", "$$") 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 return nil
}, },
+4 -5
View File
@@ -9,8 +9,8 @@ import (
"os" "os"
"time" "time"
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
"github.com/tinyauthapp/paerser/cli" "github.com/tinyauthapp/paerser/cli"
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
) )
type healthzResponse struct { type healthzResponse struct {
@@ -26,8 +26,7 @@ func healthcheckCmd() *cli.Command {
Resources: nil, Resources: nil,
AllowArg: true, AllowArg: true,
Run: func(args []string) error { Run: func(args []string) error {
log := logger.NewLogger().WithSimpleConfig() tlog.NewSimpleLogger().Init()
log.Init()
srvAddr := os.Getenv("TINYAUTH_SERVER_ADDRESS") srvAddr := os.Getenv("TINYAUTH_SERVER_ADDRESS")
if srvAddr == "" { if srvAddr == "" {
@@ -49,7 +48,7 @@ func healthcheckCmd() *cli.Command {
return errors.New("Could not determine app URL") 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{ client := http.Client{
Timeout: 30 * time.Second, Timeout: 30 * time.Second,
@@ -87,7 +86,7 @@ func healthcheckCmd() *cli.Command {
return fmt.Errorf("failed to decode response: %w", err) 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 return nil
}, },
+6
View File
@@ -7,6 +7,7 @@ import (
"github.com/tinyauthapp/tinyauth/internal/bootstrap" "github.com/tinyauthapp/tinyauth/internal/bootstrap"
"github.com/tinyauthapp/tinyauth/internal/model" "github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/utils/loaders" "github.com/tinyauthapp/tinyauth/internal/utils/loaders"
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
"github.com/tinyauthapp/paerser/cli" "github.com/tinyauthapp/paerser/cli"
@@ -108,6 +109,11 @@ func main() {
} }
func runCmd(cfg model.Config) error { func runCmd(cfg model.Config) error {
logger := tlog.NewLogger(cfg.Log)
logger.Init()
tlog.App.Info().Str("version", model.Version).Msg("Starting tinyauth")
app := bootstrap.NewBootstrapApp(cfg) app := bootstrap.NewBootstrapApp(cfg)
err := app.Setup() err := app.Setup()
+5 -6
View File
@@ -5,7 +5,7 @@ import (
"fmt" "fmt"
"github.com/tinyauthapp/tinyauth/internal/utils" "github.com/tinyauthapp/tinyauth/internal/utils"
"github.com/tinyauthapp/tinyauth/internal/utils/logger" "github.com/tinyauthapp/tinyauth/internal/utils/tlog"
"charm.land/huh/v2" "charm.land/huh/v2"
"github.com/pquerna/otp/totp" "github.com/pquerna/otp/totp"
@@ -44,8 +44,7 @@ func verifyUserCmd() *cli.Command {
Configuration: tCfg, Configuration: tCfg,
Resources: loaders, Resources: loaders,
Run: func(_ []string) error { Run: func(_ []string) error {
log := logger.NewLogger().WithSimpleConfig() tlog.NewSimpleLogger().Init()
log.Init()
if tCfg.Interactive { if tCfg.Interactive {
form := huh.NewForm( form := huh.NewForm(
@@ -98,9 +97,9 @@ func verifyUserCmd() *cli.Command {
if user.TOTPSecret == "" { if user.TOTPSecret == "" {
if tCfg.Totp != "" { 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 return nil
} }
@@ -110,7 +109,7 @@ func verifyUserCmd() *cli.Command {
return fmt.Errorf("TOTP code incorrect") return fmt.Errorf("TOTP code incorrect")
} }
log.App.Info().Msg("User verified") tlog.App.Info().Msg("User verified")
return nil return nil
}, },
+1 -4
View File
@@ -1,10 +1,9 @@
services: services:
traefik: traefik:
image: traefik:v3.6 image: traefik:v3.6
command: --api.insecure=true --providers.docker --entrypoints.web.address=:80 --entrypoints.websecure.address=:443 command: --api.insecure=true --providers.docker
ports: ports:
- 80:80 - 80:80
- 443:443
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
@@ -26,8 +25,6 @@ services:
labels: labels:
traefik.enable: true traefik.enable: true
traefik.http.routers.tinyauth.rule: Host(`tinyauth.127.0.0.1.sslip.io`) traefik.http.routers.tinyauth.rule: Host(`tinyauth.127.0.0.1.sslip.io`)
traefik.http.routers.tinyauth.entrypoints: websecure
traefik.http.routers.tinyauth.tls: true
tinyauth-backend: tinyauth-backend:
build: build:
+6
View File
@@ -0,0 +1,6 @@
# Ignore artifacts:
dist
node_modules
bun.lock
package.json
src/lib/i18n/locales
+1
View File
@@ -0,0 +1 @@
{}
+4 -6
View File
@@ -1,13 +1,11 @@
FROM node:26.1-alpine3.23 FROM oven/bun:1.2.16-alpine
RUN npm install -g pnpm@11.1.2
WORKDIR /frontend WORKDIR /frontend
COPY ./frontend/package.json ./ COPY ./frontend/package.json ./
COPY ./frontend/pnpm-lock.yaml ./ COPY ./frontend/bun.lock ./
RUN pnpm ci RUN bun install --frozen-lockfile
COPY ./frontend/public ./public COPY ./frontend/public ./public
COPY ./frontend/src ./src COPY ./frontend/src ./src
@@ -21,4 +19,4 @@ COPY ./frontend/vite.config.ts ./
EXPOSE 5173 EXPOSE 5173
ENTRYPOINT ["pnpm", "run", "dev"] ENTRYPOINT ["bun", "run", "dev"]
+1107
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -10,7 +10,6 @@
"preview": "vite preview", "preview": "vite preview",
"tsc": "tsc -b" "tsc": "tsc -b"
}, },
"packageManager": "pnpm@11.1.2",
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
-5072
View File
File diff suppressed because it is too large Load Diff
-4
View File
@@ -1,4 +0,0 @@
dangerouslyAllowAllBuilds: false
blockExoticSubdeps: true
minimumReleaseAge: 1440 # 1 day
trustPolicy: no-downgrade
+2 -2
View File
@@ -2,9 +2,9 @@ import { Navigate } from "react-router";
import { useUserContext } from "./context/user-context"; import { useUserContext } from "./context/user-context";
export const App = () => { export const App = () => {
const { auth } = useUserContext(); const { isLoggedIn } = useUserContext();
if (auth.authenticated) { if (isLoggedIn) {
return <Navigate to="/logout" replace />; return <Navigate to="/logout" replace />;
} }
+7 -11
View File
@@ -6,17 +6,17 @@ import { DomainWarning } from "../domain-warning/domain-warning";
import { ThemeToggle } from "../theme-toggle/theme-toggle"; import { ThemeToggle } from "../theme-toggle/theme-toggle";
const BaseLayout = ({ children }: { children: React.ReactNode }) => { const BaseLayout = ({ children }: { children: React.ReactNode }) => {
const { ui } = useAppContext(); const { backgroundImage, title } = useAppContext();
useEffect(() => { useEffect(() => {
document.title = ui.title; document.title = title;
}, [ui.title]); }, [title]);
return ( return (
<div <div
className="flex flex-col justify-center items-center min-h-svh px-4" className="flex flex-col justify-center items-center min-h-svh px-4"
style={{ style={{
backgroundImage: `url(${ui.backgroundImage})`, backgroundImage: `url(${backgroundImage})`,
backgroundSize: "cover", backgroundSize: "cover",
backgroundPosition: "center", backgroundPosition: "center",
}} }}
@@ -31,7 +31,7 @@ const BaseLayout = ({ children }: { children: React.ReactNode }) => {
}; };
export const Layout = () => { export const Layout = () => {
const { app, ui } = useAppContext(); const { appUrl, warningsEnabled } = useAppContext();
const [ignoreDomainWarning, setIgnoreDomainWarning] = useState(() => { const [ignoreDomainWarning, setIgnoreDomainWarning] = useState(() => {
return window.sessionStorage.getItem("ignoreDomainWarning") === "true"; return window.sessionStorage.getItem("ignoreDomainWarning") === "true";
}); });
@@ -42,15 +42,11 @@ export const Layout = () => {
setIgnoreDomainWarning(true); setIgnoreDomainWarning(true);
}, [setIgnoreDomainWarning]); }, [setIgnoreDomainWarning]);
if ( if (!ignoreDomainWarning && warningsEnabled && appUrl !== currentUrl) {
!ignoreDomainWarning &&
ui.warningsEnabled &&
!app.trustedDomains.includes(currentUrl)
) {
return ( return (
<BaseLayout> <BaseLayout>
<DomainWarning <DomainWarning
appUrl={app.appUrl} appUrl={appUrl}
currentUrl={currentUrl} currentUrl={currentUrl}
onClick={() => handleIgnore()} onClick={() => handleIgnore()}
/> />
+1 -13
View File
@@ -80,17 +80,5 @@
"profileScopeDescription": "Allows the app to access your profile information.", "profileScopeDescription": "Allows the app to access your profile information.",
"groupsScopeName": "Groups", "groupsScopeName": "Groups",
"groupsScopeDescription": "Allows the app to access your group information.", "groupsScopeDescription": "Allows the app to access your group information.",
"backToLoginButton": "Back to login", "backToLoginButton": "Back to login"
"phoneScopeName": "Phone",
"phoneScopeDescription": "Allows the app to access your phone number.",
"addressScopeName": "Address",
"addressScopeDescription": "Allows the app to access your address.",
"loginTailscaleTitle": "Continue with Tailscale",
"loginTailscaleDescription": "You appear to be accessing Tinyauth from an authorized Tailscale device. Would you like to continue with your Tailscale connection?",
"loginTailscaleDeviceName": "Device name:",
"loginTailscaleSubmit": "Continue with Tailscale",
"loginTailscaleOtherMethod": "Login with another method",
"loginTailscaleSuccess": "Successfully authenticated with Tailscale.",
"loginTailscaleFail": "Failed to authenticate with Tailscale. Please try again or use another login method.",
"logoutTailscaleSubtitle": "You are currently logged in with Tailscale on your device <code>{{deviceName}}</code>. Click the button below to logout."
} }
+1 -9
View File
@@ -84,13 +84,5 @@
"phoneScopeName": "Phone", "phoneScopeName": "Phone",
"phoneScopeDescription": "Allows the app to access your phone number.", "phoneScopeDescription": "Allows the app to access your phone number.",
"addressScopeName": "Address", "addressScopeName": "Address",
"addressScopeDescription": "Allows the app to access your address.", "addressScopeDescription": "Allows the app to access your address."
"loginTailscaleTitle": "Continue with Tailscale",
"loginTailscaleDescription": "You appear to be accessing Tinyauth from an authorized Tailscale device. Would you like to continue with your Tailscale connection?",
"loginTailscaleDeviceName": "Device name:",
"loginTailscaleSubmit": "Continue with Tailscale",
"loginTailscaleOtherMethod": "Login with another method",
"loginTailscaleSuccess": "Successfully authenticated with Tailscale.",
"loginTailscaleFail": "Failed to authenticate with Tailscale. Please try again or use another login method.",
"logoutTailscaleSubtitle": "You are currently logged in with Tailscale on your device <code>{{deviceName}}</code>. Click the button below to logout."
} }
+2 -2
View File
@@ -77,7 +77,7 @@ const createScopeMap = (t: TFunction<"translation", undefined>): Scope[] => {
}; };
export const AuthorizePage = () => { export const AuthorizePage = () => {
const { auth } = useUserContext(); const { isLoggedIn } = useUserContext();
const { search } = useLocation(); const { search } = useLocation();
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -127,7 +127,7 @@ export const AuthorizePage = () => {
); );
} }
if (!auth.authenticated) { if (!isLoggedIn) {
return <Navigate to={`/login?${oidcParams.compiled}`} replace />; return <Navigate to={`/login?${oidcParams.compiled}`} replace />;
} }
+8 -9
View File
@@ -14,8 +14,8 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { useRedirectUri } from "@/lib/hooks/redirect-uri"; import { useRedirectUri } from "@/lib/hooks/redirect-uri";
export const ContinuePage = () => { export const ContinuePage = () => {
const { app, ui } = useAppContext(); const { cookieDomain, warningsEnabled } = useAppContext();
const { auth } = useUserContext(); const { isLoggedIn } = useUserContext();
const { search } = useLocation(); const { search } = useLocation();
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -29,18 +29,17 @@ export const ContinuePage = () => {
const { url, valid, trusted, allowedProto, httpsDowngrade } = useRedirectUri( const { url, valid, trusted, allowedProto, httpsDowngrade } = useRedirectUri(
redirectUri, redirectUri,
app.cookieDomain, cookieDomain,
); );
const urlHref = url?.href; const urlHref = url?.href;
const hasValidRedirect = valid && allowedProto; const hasValidRedirect = valid && allowedProto;
const showUntrustedWarning = const showUntrustedWarning = hasValidRedirect && !trusted && warningsEnabled;
hasValidRedirect && !trusted && ui.warningsEnabled;
const showInsecureWarning = const showInsecureWarning =
hasValidRedirect && httpsDowngrade && ui.warningsEnabled; hasValidRedirect && httpsDowngrade && warningsEnabled;
const shouldAutoRedirect = const shouldAutoRedirect =
auth.authenticated && isLoggedIn &&
hasValidRedirect && hasValidRedirect &&
!showUntrustedWarning && !showUntrustedWarning &&
!showInsecureWarning; !showInsecureWarning;
@@ -78,7 +77,7 @@ export const ContinuePage = () => {
}; };
}, [shouldAutoRedirect, redirectToTarget]); }, [shouldAutoRedirect, redirectToTarget]);
if (!auth.authenticated) { if (!isLoggedIn) {
return ( return (
<Navigate <Navigate
to={`/login${redirectUri ? `?redirect_uri=${encodeURIComponent(redirectUri)}` : ""}`} to={`/login${redirectUri ? `?redirect_uri=${encodeURIComponent(redirectUri)}` : ""}`}
@@ -105,7 +104,7 @@ export const ContinuePage = () => {
components={{ components={{
code: <code />, code: <code />,
}} }}
values={{ cookieDomain: app.cookieDomain }} values={{ cookieDomain }}
shouldUnescape={true} shouldUnescape={true}
/> />
</CardDescription> </CardDescription>
+3 -3
View File
@@ -13,7 +13,7 @@ import Markdown from "react-markdown";
import { useLocation } from "react-router"; import { useLocation } from "react-router";
export const ForgotPasswordPage = () => { export const ForgotPasswordPage = () => {
const { ui } = useAppContext(); const { forgotPasswordMessage } = useAppContext();
const { t } = useTranslation(); const { t } = useTranslation();
const { search } = useLocation(); const { search } = useLocation();
const searchParams = new URLSearchParams(search); const searchParams = new URLSearchParams(search);
@@ -26,8 +26,8 @@ export const ForgotPasswordPage = () => {
<CardContent> <CardContent>
<CardDescription> <CardDescription>
<Markdown> <Markdown>
{ui.forgotPasswordMessage !== "" {forgotPasswordMessage !== ""
? ui.forgotPasswordMessage ? forgotPasswordMessage
: t("forgotPasswordMessage")} : t("forgotPasswordMessage")}
</Markdown> </Markdown>
</CardDescription> </CardDescription>
+11 -81
View File
@@ -36,17 +36,12 @@ const iconMap: Record<string, React.ReactNode> = {
}; };
export const LoginPage = () => { export const LoginPage = () => {
const { auth, tailscale } = useUserContext(); const { isLoggedIn } = useUserContext();
const { const { providers, title, oauthAutoRedirect } = useAppContext();
ui,
oauth,
auth: { providers },
} = useAppContext();
const { search } = useLocation(); const { search } = useLocation();
const { t } = useTranslation(); const { t } = useTranslation();
const [showRedirectButton, setShowRedirectButton] = useState(false); const [showRedirectButton, setShowRedirectButton] = useState(false);
const [useTailscale, setUseTailscale] = useState(tailscale.nodeName !== undefined);
const hasAutoRedirectedRef = useRef(false); const hasAutoRedirectedRef = useRef(false);
@@ -60,7 +55,7 @@ export const LoginPage = () => {
const oidcParams = useOIDCParams(searchParams); const oidcParams = useOIDCParams(searchParams);
const [isOauthAutoRedirect, setIsOauthAutoRedirect] = useState( const [isOauthAutoRedirect, setIsOauthAutoRedirect] = useState(
providers.find((provider) => provider.id === oauth.autoRedirect) !== providers.find((provider) => provider.id === oauthAutoRedirect) !==
undefined && redirectUri !== undefined, undefined && redirectUri !== undefined,
); );
@@ -153,47 +148,21 @@ export const LoginPage = () => {
}, },
}); });
const { mutate: tailscaleMutate, isPending: tailscaleIsPending } =
useMutation({
mutationFn: () => axios.post("/api/user/tailscale"),
mutationKey: ["tailscale"],
onSuccess: () => {
toast.success(t("loginSuccessTitle"), {
description: t("loginTailscaleSuccess"),
});
redirectTimer.current = window.setTimeout(() => {
if (oidcParams.isOidc) {
window.location.replace(`/authorize?${oidcParams.compiled}`);
return;
}
window.location.replace(
`/continue${redirectUri ? `?redirect_uri=${encodeURIComponent(redirectUri)}` : ""}`,
);
}, 500);
},
onError: () => {
toast.error(t("loginFailTitle"), {
description: t("loginTailscaleFail"),
});
},
});
useEffect(() => { useEffect(() => {
if ( if (
!auth.authenticated && !isLoggedIn &&
isOauthAutoRedirect && isOauthAutoRedirect &&
!hasAutoRedirectedRef.current && !hasAutoRedirectedRef.current &&
redirectUri !== undefined redirectUri !== undefined
) { ) {
hasAutoRedirectedRef.current = true; hasAutoRedirectedRef.current = true;
oauthMutate(oauth.autoRedirect); oauthMutate(oauthAutoRedirect);
} }
}, [ }, [
auth.authenticated, isLoggedIn,
oauthMutate, oauthMutate,
hasAutoRedirectedRef, hasAutoRedirectedRef,
oauth.autoRedirect, oauthAutoRedirect,
isOauthAutoRedirect, isOauthAutoRedirect,
redirectUri, redirectUri,
]); ]);
@@ -210,11 +179,11 @@ export const LoginPage = () => {
}; };
}, [redirectTimer, redirectButtonTimer]); }, [redirectTimer, redirectButtonTimer]);
if (auth.authenticated && oidcParams.isOidc) { if (isLoggedIn && oidcParams.isOidc) {
return <Navigate to={`/authorize?${oidcParams.compiled}`} replace />; return <Navigate to={`/authorize?${oidcParams.compiled}`} replace />;
} }
if (auth.authenticated && redirectUri !== undefined) { if (isLoggedIn && redirectUri !== undefined) {
return ( return (
<Navigate <Navigate
to={`/continue${redirectUri ? `?redirect_uri=${encodeURIComponent(redirectUri)}` : ""}`} to={`/continue${redirectUri ? `?redirect_uri=${encodeURIComponent(redirectUri)}` : ""}`}
@@ -223,7 +192,7 @@ export const LoginPage = () => {
); );
} }
if (auth.authenticated) { if (isLoggedIn) {
return <Navigate to="/logout" replace />; return <Navigate to="/logout" replace />;
} }
@@ -259,49 +228,10 @@ export const LoginPage = () => {
</Card> </Card>
); );
} }
if (useTailscale) {
return (
<Card>
<CardHeader className="gap-3">
<TailscaleIcon className="mx-auto h-8 w-8" />
<CardTitle className="text-center text-xl">
{t("loginTailscaleTitle")}
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="text-muted-foreground text-sm">
{t("loginTailscaleDescription")}
</div>
<div className="text-muted-foreground text-sm">
{t("loginTailscaleDeviceName")} <code>{tailscale.nodeName}</code>
</div>
</CardContent>
<CardFooter className="flex flex-col items-stretch gap-3">
<Button
className="w-full"
onClick={() => tailscaleMutate()}
loading={tailscaleIsPending}
>
{t("loginTailscaleSubmit")}
</Button>
<Button
className="w-full"
variant="outline"
onClick={() => setUseTailscale(false)}
disabled={tailscaleIsPending}
>
{t("loginTailscaleOtherMethod")}
</Button>
</CardFooter>
</Card>
);
}
return ( return (
<Card> <Card>
<CardHeader className="gap-1.5"> <CardHeader className="gap-1.5">
<CardTitle className="text-center text-xl">{ui.title}</CardTitle> <CardTitle className="text-center text-xl">{title}</CardTitle>
{providers.length > 0 && ( {providers.length > 0 && (
<CardDescription className="text-center"> <CardDescription className="text-center">
{oauthProviders.length !== 0 {oauthProviders.length !== 0
+31 -73
View File
@@ -13,11 +13,9 @@ import { useEffect, useRef } from "react";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { Navigate } from "react-router"; import { Navigate } from "react-router";
import { toast } from "sonner"; import { toast } from "sonner";
import { type UseMutationResult } from "@tanstack/react-query";
import { type AxiosResponse } from "axios";
export const LogoutPage = () => { export const LogoutPage = () => {
const { auth, oauth, tailscale } = useUserContext(); const { provider, username, isLoggedIn, email, oauthName } = useUserContext();
const { t } = useTranslation(); const { t } = useTranslation();
const redirectTimer = useRef<number | null>(null); const redirectTimer = useRef<number | null>(null);
@@ -49,82 +47,42 @@ export const LogoutPage = () => {
}; };
}, [redirectTimer]); }, [redirectTimer]);
if (!auth.authenticated) { if (!isLoggedIn) {
return <Navigate to="/login" replace />; return <Navigate to="/login" replace />;
} }
if (oauth.active) {
return (
<LogoutLayout logoutMutation={logoutMutation}>
<Trans
i18nKey="logoutOauthSubtitle"
t={t}
components={{
code: <code />,
}}
values={{
username: auth.email,
provider: oauth.displayName,
}}
shouldUnescape={true}
/>
</LogoutLayout>
);
}
if (auth.providerId === "tailscale") {
return (
<LogoutLayout logoutMutation={logoutMutation}>
<Trans
i18nKey="logoutTailscaleSubtitle"
t={t}
components={{
code: <code />,
}}
values={{
deviceName: tailscale.nodeName,
}}
shouldUnescape={true}
/>
</LogoutLayout>
);
}
return (
<LogoutLayout logoutMutation={logoutMutation}>
<Trans
i18nKey="logoutUsernameSubtitle"
t={t}
components={{
code: <code />,
}}
values={{
username: auth.username,
}}
shouldUnescape={true}
/>
</LogoutLayout>
);
};
interface LogoutLayoutProps {
children: React.ReactNode;
logoutMutation: UseMutationResult<
//eslint-disable-next-line @typescript-eslint/no-explicit-any,@typescript-eslint/no-empty-object-type
AxiosResponse<any, any, {}>,
Error,
void,
unknown
>;
}
function LogoutLayout({ children, logoutMutation }: LogoutLayoutProps) {
const { t } = useTranslation();
return ( return (
<Card> <Card>
<CardHeader className="gap-1.5"> <CardHeader className="gap-1.5">
<CardTitle className="text-xl">{t("logoutTitle")}</CardTitle> <CardTitle className="text-xl">{t("logoutTitle")}</CardTitle>
<CardDescription>{children}</CardDescription> <CardDescription>
{provider !== "local" && provider !== "ldap" ? (
<Trans
i18nKey="logoutOauthSubtitle"
t={t}
components={{
code: <code />,
}}
values={{
username: email,
provider: oauthName,
}}
shouldUnescape={true}
/>
) : (
<Trans
i18nKey="logoutUsernameSubtitle"
t={t}
components={{
code: <code />,
}}
values={{
username,
}}
shouldUnescape={true}
/>
)}
</CardDescription>
</CardHeader> </CardHeader>
<CardFooter> <CardFooter>
<Button <Button
@@ -138,4 +96,4 @@ function LogoutLayout({ children, logoutMutation }: LogoutLayoutProps) {
</CardFooter> </CardFooter>
</Card> </Card>
); );
} };
+2 -2
View File
@@ -19,7 +19,7 @@ import { toast } from "sonner";
import { useOIDCParams } from "@/lib/hooks/oidc"; import { useOIDCParams } from "@/lib/hooks/oidc";
export const TotpPage = () => { export const TotpPage = () => {
const { totp } = useUserContext(); const { totpPending } = useUserContext();
const { t } = useTranslation(); const { t } = useTranslation();
const { search } = useLocation(); const { search } = useLocation();
const formId = useId(); const formId = useId();
@@ -64,7 +64,7 @@ export const TotpPage = () => {
}; };
}, [redirectTimer]); }, [redirectTimer]);
if (!totp.pending) { if (!totpPending) {
return <Navigate to="/" replace />; return <Navigate to="/" replace />;
} }
+4 -21
View File
@@ -6,32 +6,15 @@ export const providerSchema = z.object({
oauth: z.boolean(), oauth: z.boolean(),
}); });
const authSchema = z.object({ export const appContextSchema = z.object({
providers: z.array(providerSchema), providers: z.array(providerSchema),
});
const oauthSchema = z.object({
autoRedirect: z.string(),
});
const uiSchema = z.object({
title: z.string(), title: z.string(),
appUrl: z.string(),
cookieDomain: z.string(),
forgotPasswordMessage: z.string(), forgotPasswordMessage: z.string(),
backgroundImage: z.string(), backgroundImage: z.string(),
oauthAutoRedirect: z.string(),
warningsEnabled: z.boolean(), warningsEnabled: z.boolean(),
}); });
const appSchema = z.object({
appUrl: z.string(),
cookieDomain: z.string(),
trustedDomains: z.array(z.string()),
});
export const appContextSchema = z.object({
auth: authSchema,
oauth: oauthSchema,
ui: uiSchema,
app: appSchema,
});
export type AppContextSchema = z.infer<typeof appContextSchema>; export type AppContextSchema = z.infer<typeof appContextSchema>;
+6 -23
View File
@@ -1,31 +1,14 @@
import { z } from "zod"; import { z } from "zod";
const authSchema = z.object({ export const userContextSchema = z.object({
authenticated: z.boolean(), isLoggedIn: z.boolean(),
username: z.string(), username: z.string(),
name: z.string(), name: z.string(),
email: z.string(), email: z.string(),
providerId: z.string(), provider: z.string(),
}); oauth: z.boolean(),
totpPending: z.boolean(),
const oauthSchema = z.object({ oauthName: z.string(),
active: z.boolean(),
displayName: z.string(),
});
const totpSchema = z.object({
pending: z.boolean(),
});
const tailscaleSchema = z.object({
nodeName: z.string().optional(),
});
export const userContextSchema = z.object({
auth: authSchema,
oauth: oauthSchema,
totp: totpSchema,
tailscale: tailscaleSchema,
}); });
export type UserContextSchema = z.infer<typeof userContextSchema>; export type UserContextSchema = z.infer<typeof userContextSchema>;
-59
View File
@@ -1,59 +0,0 @@
// Code generated by cmd/gen/sqlc-wrapper. DO NOT EDIT.
package {{.PkgName}}
import (
"context"
"database/sql"
"errors"
"{{.RepoPkg}}"
)
// Store wraps *Queries and implements repository.Store.
type Store struct {
q *Queries
}
// NewStore wraps a *Queries to satisfy repository.Store.
func NewStore(q *Queries) repository.Store {
return &Store{q: q}
}
var errorMap = map[error]error{
sql.ErrNoRows: repository.ErrNotFound,
}
func mapErr(err error) error {
for from, to := range errorMap {
if errors.Is(err, from) {
return to
}
}
return err
}
{{range .Methods}}{{.Signature}} {
{{.Body}}}
{{end}}
{{- define "void"}} return mapErr({{.Call}})
{{end}}
{{- define "scalar"}} r, err := {{.Call}}
if err != nil {
return {{.RepoType}}{}, mapErr(err)
}
return {{.RepoType}}(r), nil
{{end}}
{{- define "slice"}} rows, err := {{.Call}}
if err != nil {
return nil, mapErr(err)
}
out := make([]{{.RepoType}}, len(rows))
for i, row := range rows {
out[i] = {{.RepoType}}(row)
}
return out, nil
{{end}}
+6 -56
View File
@@ -1,6 +1,6 @@
module github.com/tinyauthapp/tinyauth module github.com/tinyauthapp/tinyauth
go 1.26.1 go 1.26.0
require ( require (
charm.land/huh/v2 v2.0.3 charm.land/huh/v2 v2.0.3
@@ -24,7 +24,6 @@ require (
k8s.io/apimachinery v0.36.0 k8s.io/apimachinery v0.36.0
k8s.io/client-go v0.36.0 k8s.io/client-go v0.36.0
modernc.org/sqlite v1.50.0 modernc.org/sqlite v1.50.0
tailscale.com v1.96.5
) )
require ( require (
@@ -32,29 +31,13 @@ require (
charm.land/bubbletea/v2 v2.0.2 // indirect charm.land/bubbletea/v2 v2.0.2 // indirect
charm.land/lipgloss/v2 v2.0.1 // indirect charm.land/lipgloss/v2 v2.0.1 // indirect
dario.cat/mergo v1.0.1 // indirect dario.cat/mergo v1.0.1 // indirect
filippo.io/edwards25519 v1.2.0 // indirect
github.com/Azure/go-ntlmssp v0.1.1 // indirect github.com/Azure/go-ntlmssp v0.1.1 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect github.com/BurntSushi/toml v1.6.0 // indirect
github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/goutils v1.1.1 // indirect
github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/Masterminds/semver/v3 v3.3.0 // indirect
github.com/Masterminds/sprig/v3 v3.3.0 // indirect github.com/Masterminds/sprig/v3 v3.3.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/akutz/memconn v0.1.0 // indirect
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e // indirect
github.com/atotto/clipboard v0.1.4 // indirect github.com/atotto/clipboard v0.1.4 // indirect
github.com/aws/aws-sdk-go-v2 v1.41.0 // indirect
github.com/aws/aws-sdk-go-v2/config v1.29.5 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.17.58 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.27 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.24.14 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.13 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect
github.com/aws/smithy-go v1.24.0 // indirect
github.com/boombuler/barcode v1.0.2 // indirect github.com/boombuler/barcode v1.0.2 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic v1.15.0 // indirect
@@ -72,12 +55,10 @@ require (
github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect github.com/cloudwego/base64x v0.1.6 // indirect
github.com/coder/websocket v1.8.12 // indirect
github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/creachadair/msync v0.7.1 // indirect github.com/containerd/log v0.1.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect
github.com/distribution/reference v0.6.0 // indirect github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.5.0 // indirect github.com/docker/go-connections v0.5.0 // indirect
github.com/docker/go-units v0.5.0 // indirect github.com/docker/go-units v0.5.0 // indirect
@@ -85,10 +66,8 @@ require (
github.com/felixge/httpsnoop v1.0.4 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gaissmai/bart v0.26.1 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced // indirect
github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
@@ -96,21 +75,8 @@ require (
github.com/go-playground/validator/v10 v10.30.1 // indirect github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect github.com/goccy/go-yaml v1.19.2 // indirect
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/btree v1.1.3 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/hdevalence/ed25519consensus v0.2.0 // indirect
github.com/huandu/xstrings v1.5.0 // indirect github.com/huandu/xstrings v1.5.0 // indirect
github.com/huin/goupnp v1.3.0 // indirect
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.9.2 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jsimonetti/rtnetlink v1.4.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.18.2 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
@@ -118,46 +84,35 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.32 // indirect github.com/mattn/go-sqlite3 v1.14.32 // indirect
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect
github.com/mdlayher/socket v0.5.0 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/go-ps v1.0.0 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/sys/atomicwriter v0.1.0 // indirect 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/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.3-0.20250322232337-35a7c28c31ee // indirect
github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect github.com/opencontainers/image-spec v1.1.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pires/go-proxyproto v0.8.1 // indirect
github.com/pkg/errors v0.9.1 // indirect github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus-community/pro-bing v0.4.0 // indirect
github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect github.com/quic-go/quic-go v0.59.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/safchain/ethtool v0.3.0 // indirect
github.com/shopspring/decimal v1.4.0 // indirect github.com/shopspring/decimal v1.4.0 // indirect
github.com/spf13/cast v1.10.0 // indirect github.com/spf13/cast v1.10.0 // indirect
github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e // indirect
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect
github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a // indirect
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc // indirect
github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 // indirect
github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect
github.com/x448/float16 v0.8.4 // indirect github.com/x448/float16 v0.8.4 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect go.opentelemetry.io/otel/metric v1.43.0 // indirect
@@ -165,8 +120,6 @@ require (
go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
golang.org/x/arch v0.22.0 // indirect golang.org/x/arch v0.22.0 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/mod v0.34.0 // indirect golang.org/x/mod v0.34.0 // indirect
@@ -176,13 +129,10 @@ require (
golang.org/x/term v0.42.0 // indirect golang.org/x/term v0.42.0 // indirect
golang.org/x/text v0.36.0 // indirect golang.org/x/text v0.36.0 // indirect
golang.org/x/time v0.14.0 // indirect golang.org/x/time v0.14.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
golang.zx2c4.com/wireguard/windows v0.5.3 // indirect
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect
gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
gotest.tools/v3 v3.5.2 // indirect gotest.tools/v3 v3.5.2 // indirect
gvisor.dev/gvisor v0.0.0-20260224225140-573d5e7127a8 // indirect
k8s.io/klog/v2 v2.140.0 // indirect k8s.io/klog/v2 v2.140.0 // indirect
k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect
+8 -200
View File
@@ -1,5 +1,3 @@
9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f h1:1C7nZuxUMNz7eiQALRfiqNOm04+m3edWlRff/BYHf0Q=
9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f/go.mod h1:hHyrZRryGqVdqrknjq5OWDLGCTJ2NeEvtrpR96mjraM=
charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s= charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s=
charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI= charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI=
charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0= charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0=
@@ -10,10 +8,6 @@ charm.land/lipgloss/v2 v2.0.1 h1:6Xzrn49+Py1Um5q/wZG1gWgER2+7dUyZ9XMEufqPSys=
charm.land/lipgloss/v2 v2.0.1/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM= charm.land/lipgloss/v2 v2.0.1/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM=
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
filippo.io/mkcert v1.4.4 h1:8eVbbwfVlaqUM7OwuftKc2nuYOoTDQWqsoXmzoXZdbc=
filippo.io/mkcert v1.4.4/go.mod h1:VyvOchVuAye3BoUsPUOOofKygVwLV2KQMVFJNRq+1dA=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw= github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw=
@@ -24,50 +18,16 @@ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+hmvYS0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs=
github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A=
github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI= github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4=
github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
github.com/aws/aws-sdk-go-v2/config v1.29.5 h1:4lS2IB+wwkj5J43Tq/AwvnscBerBJtQQ6YS7puzCI1k=
github.com/aws/aws-sdk-go-v2/config v1.29.5/go.mod h1:SNzldMlDVbN6nWxM7XsUiNXPSa1LWlqiXtvh/1PrJGg=
github.com/aws/aws-sdk-go-v2/credentials v1.17.58 h1:/d7FUpAPU8Lf2KUdjniQvfNdlMID0Sd9pS23FJ3SS9Y=
github.com/aws/aws-sdk-go-v2/credentials v1.17.58/go.mod h1:aVYW33Ow10CyMQGFgC0ptMRIqJWvJ4nxZb0sUiuQT/A=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.27 h1:7lOW8NUwE9UZekS1DYoiPdVAqZ6A+LheHWb+mHbNOq8=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.27/go.mod h1:w1BASFIPOPUae7AgaH4SbjNbfdkxuggLyGfNFTn8ITY=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16/go.mod h1:L/UxsGeKpGoIj6DxfhOWHWQ/kGKcd4I1VncE4++IyKA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 h1:1jtGzuV7c82xnqOVfx2F0xmJcOw5374L7N6juGW6x6U=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16/go.mod h1:M2E5OQf+XLe+SZGmmpaI2yy+J326aFf6/+54PoxSANc=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2 h1:Pg9URiobXy85kgFev3og2CuOZ8JZUBENF+dcgWBaYNk=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy0ImIV0bsrX0X91GkV5nJAyv1l1CC9lnO0TI=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM=
github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7 h1:a8HvP/+ew3tKwSXqL3BCSjiuicr+XTU2eFYeogV9GJE=
github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7/go.mod h1:Q7XIWsMo0JcMpI/6TGD6XXcXcV1DbTj6e9BKNntIMIM=
github.com/aws/aws-sdk-go-v2/service/sso v1.24.14 h1:c5WJ3iHz7rLIgArznb3JCSQT3uUMiz9DLZhIX+1G8ok=
github.com/aws/aws-sdk-go-v2/service/sso v1.24.14/go.mod h1:+JJQTxB6N4niArC14YNtxcQtwEqzS3o9Z32n7q33Rfs=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.13 h1:f1L/JtUkVODD+k1+IiSJUUv8A++2qVr+Xvb3xWXETMU=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.13/go.mod h1:tvqlFoja8/s0o+UruA1Nrezo/df0PzdunMDDurUfg6U=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX1s+lFTg4+4DOy70=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk=
github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
github.com/axiomhq/hyperloglog v0.0.0-20240319100328-84253e514e02 h1:bXAPYSbdYbS5VTy92NIUbeDI1qyggi+JYh5op9IFlcQ=
github.com/axiomhq/hyperloglog v0.0.0-20240319100328-84253e514e02/go.mod h1:k08r+Yj1PRAmuayFiRK6MYuR5Ve4IuZtTfxErMIh0+c=
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
@@ -109,46 +69,26 @@ github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
github.com/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcOdFSsw= github.com/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcOdFSsw=
github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y= github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y=
github.com/cilium/ebpf v0.16.0 h1:+BiEnHL6Z7lXnlGUsXQPPAE7+kenAd4ES8MQ5min0Ok=
github.com/cilium/ebpf v0.16.0/go.mod h1:L7u2Blt2jMM/vLAVgjxluxtBKlz3/GWjB0dMOEngfwE=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 h1:8h5+bWd7R6AYUslN6c6iuZWTKsKxUFDlpnmilO6R2n0=
github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q=
github.com/creachadair/mds v0.25.9 h1:080Hr8laN2h+l3NeVCGMBpXtIPnl9mz8e4HLraGPqtA=
github.com/creachadair/mds v0.25.9/go.mod h1:4hatI3hRM+qhzuAmqPRFvaBM8mONkS7nsLxkcuTYUIs=
github.com/creachadair/msync v0.7.1 h1:SeZmuEBXQPe5GqV/C94ER7QIZPwtvFbeQiykzt/7uho=
github.com/creachadair/msync v0.7.1/go.mod h1:8CcFlLsSujfHE5wWm19uUBLHIPDAUr6LXDwneVMO008=
github.com/creachadair/taskgroup v0.13.2 h1:3KyqakBuFsm3KkXi/9XIb0QcA8tEzLHLgaoidf0MdVc=
github.com/creachadair/taskgroup v0.13.2/go.mod h1:i3V1Zx7H8RjwljUEeUWYT30Lmb9poewSb2XI1yTwD0g=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa h1:h8TfIT1xc8FWbwwpmHn1J5i43Y0uZP97GqasGCzSRJk=
github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa/go.mod h1:Nx87SkVqTKd8UtT+xu7sM/l+LgXs6c0aHrlKusR+2EQ=
github.com/dgryski/go-metro v0.0.0-20180109044635-280f6062b5bc h1:8WFBn63wegobsYAX0YjD+8suexZDga5CctH4CCTx2+8=
github.com/dgryski/go-metro v0.0.0-20180109044635-280f6062b5bc/go.mod h1:c9O8+fpSOX1DM8cPNSkX/qsBWdkD4yd2dpciOWQjpBw=
github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e h1:vUmf0yezR0y7jJ5pceLHthLaYf4bA5T14B6q39S4q2Q=
github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e/go.mod h1:YTIHhz/QFSYnu/EhlF2SpU2Uk+32abacUYA5ZPljz1A=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=
github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
@@ -167,20 +107,14 @@ github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sa
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= 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 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gaissmai/bart v0.26.1 h1:+w4rnLGNlA2GDVn382Tfe3jOsK5vOr5n4KmigJ9lbTo=
github.com/gaissmai/bart v0.26.1/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I=
github.com/github/fakeca v0.1.0/go.mod h1:+bormgoGMMuamOscx7N91aOuUST7wdaJ2rNjeohylyo=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo= github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced h1:Q311OHjMh/u5E2TITc++WlTP5We0xNseRMkHDyvhW7I=
github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
github.com/go-ldap/ldap/v3 v3.4.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ= github.com/go-ldap/ldap/v3 v3.4.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ=
github.com/go-ldap/ldap/v3 v3.4.13/go.mod h1:LxsGZV6vbaK0sIvYfsv47rfh4ca0JXokCoKjZxsszv0= github.com/go-ldap/ldap/v3 v3.4.13/go.mod h1:LxsGZV6vbaK0sIvYfsv47rfh4ca0JXokCoKjZxsszv0=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
@@ -188,12 +122,10 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
github.com/go-openapi/jsonreference v0.20.4 h1:bKlDxQxQJgwpUSgOENiMPzCTBVuc7vTdXSSgNeAhojU= github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=
github.com/go-openapi/jsonreference v0.20.4/go.mod h1:5pZJyJP2MnYCpoeoMAql78cCHauHj0V9Lhc506VOpw4= github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
@@ -204,20 +136,12 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737 h1:cf60tHxREO3g1nroKr2osU3JWZsJzkfi7rEg+oAB0Lo=
github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737/go.mod h1:MIS0jDzbU/vuM9MC4YnBITCv+RYuTRq8dJzmCrFsK9g=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 h1:sQspH8M4niEijh3PFscJRLDnkL547IeP7kpPe3uUhEg=
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466/go.mod h1:ZiQxhyQ+bbbfxUKVvjfO498oPYvtYhZzycal3G/NHmU=
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= 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/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.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
@@ -225,11 +149,7 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=
github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU=
github.com/google/go-tpm v0.9.4 h1:awZRf9FwOeTunQmHoDYSHJps3ie6f1UlhS1fOdPEt1I=
github.com/google/go-tpm v0.9.4/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 h1:wG8RYIyctLhdFk6Vl1yPGtSRtwGpVkWyZww1OCil2MI=
github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806/go.mod h1:Beg6V6zZ3oEn0JuiUQ4wqwuyqqzasOltcoXPtgLbFp4=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -238,29 +158,10 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/golang-lru v0.6.0 h1:uL2shRDx7RTrOrTCUZEGP/wJUFiUI8QT6E7z5o8jga4=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU=
github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo=
github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=
github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc=
github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8=
github.com/illarion/gonotify/v3 v3.0.2 h1:O7S6vcopHexutmpObkeWsnzMJt/r1hONIEogeVNmJMk=
github.com/illarion/gonotify/v3 v3.0.2/go.mod h1:HWGPdPe817GfvY3w7cx6zkbzNZfi3QjcBm/wgVvEL1U=
github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2 h1:9K06NfxkBh25x56yVhWWlKFE8YpicaSfHwoV8SFbueA=
github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2/go.mod h1:3A9PQ1cunSDF/1rbTq99Ts4pVnycWg+vlPkfeD2NLFI=
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa h1:s+4MhCQ6YrzisK6hFJUX53drDT4UsSW3DEhKn0ifuHw=
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo= github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
@@ -273,24 +174,12 @@ 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/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 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/jellydator/ttlcache/v3 v3.1.0 h1:0gPFG0IHHP6xyUyXq+JaD8fwkDCqgqwohXNJBcYE71g=
github.com/jellydator/ttlcache/v3 v3.1.0/go.mod h1:hi7MGFdMAwZna5n2tuvh63DvFLzVKySzCVW6+0gA2n4=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= 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/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/jsimonetti/rtnetlink v1.4.0 h1:Z1BF0fRgcETPEa0Kt0MRk3yV5+kF1FWTni6KUFKrq2I=
github.com/jsimonetti/rtnetlink v1.4.0/go.mod h1:5W1jDvWdnthFJ7fxYX1GMK07BUpI4oskfOqvPteYS6E=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 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/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a h1:+RR6SqnTkDLWyICxS1xpjCi/3dhyV+TgZwA6Ww3KncQ=
github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a/go.mod h1:YTtCCM3ryyfiu4F7t8HQ1mxvp1UBdWM2r6Xa+nGWvDk=
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@@ -311,22 +200,10 @@ github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjc
github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw=
github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o=
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0tpKMbdCXCIfBclan4oHk1Jb+Hrejirg=
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42/go.mod h1:BB4YCPDOzfy7FniQ/lxuYQ3dgmM2cZumHbK8RpTjN2o=
github.com/mdlayher/sdnotify v1.0.0 h1:Ma9XeLVN/l0qpyx1tNeMSeTjCPH6NtuD6/N9XdTlQ3c=
github.com/mdlayher/sdnotify v1.0.0/go.mod h1:HQUmpM4XgYkhDLtd+Uad8ZFK1T9D5+pNxnXQjCeJlGE=
github.com/mdlayher/socket v0.5.0 h1:ilICZmJcQz70vrWVes1MFera4jGiWNocSkykwwoy3XI=
github.com/mdlayher/socket v0.5.0/go.mod h1:WkcBFfvyG8QENs5+hfQPl1X6Jpd2yeLIYgrGFmJiJxI=
github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4= github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4=
github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU= github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU=
github.com/miekg/dns v1.1.58 h1:ca2Hdkz+cDg/7eNF6V56jjzuZ4aCAE+DbVkILdQWG/4=
github.com/miekg/dns v1.1.58/go.mod h1:Ypv+3b/KadlvW9vJfXOTf300O4UqaHFzFCuHz+rPkBY=
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc=
github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg=
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
@@ -353,33 +230,19 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 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 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0=
github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pires/go-proxyproto v0.8.1 h1:9KEixbdJfhrbtjpz/ZwCdWDD2Xem0NZ38qMYaASJgp0=
github.com/pires/go-proxyproto v0.8.1/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/sftp v1.13.6 h1:JFZT4XbOU7l77xGSpOdW+pwIMqP044IyjXX6FGyEKFo=
github.com/pkg/sftp v1.13.6/go.mod h1:tz1ryNURKu77RL+GuCzmoJYxQczL3wLNNpPWagdg4Qk=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs= github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/prometheus-community/pro-bing v0.4.0 h1:YMbv+i08gQz97OZZBwLyvmmQEEzyfyrrjEaAchdy3R4=
github.com/prometheus-community/pro-bing v0.4.0/go.mod h1:b7wRYZtCcPmt4Sz319BykUU241rWLe1VFXyiyWK/dH4=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
@@ -392,8 +255,6 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
github.com/safchain/ethtool v0.3.0 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP0=
github.com/safchain/ethtool v0.3.0/go.mod h1:SA9BwrgyAqNo7M+uaL6IYbxpm5wk3L7Mm6ocLW+CJUs=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
@@ -408,47 +269,18 @@ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpE
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e h1:PtWT87weP5LWHEY//SWsYkSO3RWRZo4OSWagh3YD2vQ=
github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e/go.mod h1:XrBNfAFN+pwoWuksbFS9Ccxnopa15zJGgXRFN90l3K4=
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4=
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg=
github.com/tailscale/golang-x-crypto v0.0.0-20250404221719-a5573b049869 h1:SRL6irQkKGQKKLzvQP/ke/2ZuB7Py5+XuqtOgSj+iMM=
github.com/tailscale/golang-x-crypto v0.0.0-20250404221719-a5573b049869/go.mod h1:ikbF+YT089eInTp9f2vmvy4+ZVnW5hzX1q2WknxSprQ=
github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a h1:SJy1Pu0eH1C29XwJucQo73FrleVK6t4kYz4NVhp34Yw=
github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a/go.mod h1:DFSS3NAGHthKo1gTlmEcSBiZrRJXi28rLNd/1udP1c8=
github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 h1:uFsXVBE9Qr4ZoF094vE6iYTLDl0qCiKzYXlL6UeWObU=
github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0=
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc h1:24heQPtnFR+yfntqhI3oAu9i27nEojcQ4NuBQOo5ZFA=
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc/go.mod h1:f93CXfllFsO9ZQVq+Zocb1Gp4G5Fz0b0rXHLOzt/Djc=
github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 h1:UBPHPtv8+nEAy2PD8RyAhOYvau1ek0HDJqLS/Pysi14=
github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ=
github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6 h1:l10Gi6w9jxvinoiq15g8OToDdASBni4CyJOdHY1Hr8M=
github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6/go.mod h1:ZXRML051h7o4OcI0d3AaILDIad/Xw0IkXaHM17dic1Y=
github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da h1:jVRUZPRs9sqyKlYHHzHjAqKN+6e/Vog6NpHYeNPJqOw=
github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4=
github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e h1:zOGKqN5D5hHhiYUp091JqK7DPCqSARyUfduhGUY8Bek=
github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg=
github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA=
github.com/tc-hib/winres v0.2.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk=
github.com/tinyauthapp/paerser v0.0.0-20260410140347-85c3740d6298 h1:EYSb5jv8ZL/0/NVFZtY7Ejplk0QG5+3lrdL3mSrjFZQ= github.com/tinyauthapp/paerser v0.0.0-20260410140347-85c3740d6298 h1:EYSb5jv8ZL/0/NVFZtY7Ejplk0QG5+3lrdL3mSrjFZQ=
github.com/tinyauthapp/paerser v0.0.0-20260410140347-85c3740d6298/go.mod h1:TlUDoCF66hMqFZqoBym9bUdJ0bKAWYMir6hLJeYN5z0= github.com/tinyauthapp/paerser v0.0.0-20260410140347-85c3740d6298/go.mod h1:TlUDoCF66hMqFZqoBym9bUdJ0bKAWYMir6hLJeYN5z0=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/u-root/u-root v0.14.0 h1:Ka4T10EEML7dQ5XDvO9c3MBN8z4nuSnGjcd1jmU2ivg=
github.com/u-root/u-root v0.14.0/go.mod h1:hAyZorapJe4qzbLWlAkmSVCJGbfoU9Pu4jpJ1WMluqE=
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM=
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= 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/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
github.com/weppos/publicsuffix-go v0.50.3 h1:eT5dcjHQcVDNc0igpFEsGHKIip30feuB2zuuI9eJxiE= 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/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 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
@@ -459,8 +291,8 @@ go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
@@ -483,30 +315,20 @@ 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/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 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek=
go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g=
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f h1:phY1HzDcf18Aq9A8KkmRtY9WvOFIxN8wgfvy6Zm1DV8=
golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w=
golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20220817070843-5a390386f1f2/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
@@ -518,10 +340,6 @@ golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= 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= golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE=
golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI=
google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4= google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M=
@@ -543,12 +361,6 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
gvisor.dev/gvisor v0.0.0-20260224225140-573d5e7127a8 h1:Zy8IV/+FMLxy6j6p87vk/vQGKcdnbprwjTxc8UiUtsA=
gvisor.dev/gvisor v0.0.0-20260224225140-573d5e7127a8/go.mod h1:QkHjoMIBaYtpVufgwv3keYAbln78mBoCuShZrPrer1Q=
honnef.co/go/tools v0.7.0-0.dev.0.20251022135355-8273271481d0 h1:5SXjd4ET5dYijLaf0O3aOenC0Z4ZafIWSpjUzsQaNho=
honnef.co/go/tools v0.7.0-0.dev.0.20251022135355-8273271481d0/go.mod h1:EPDDhEZqVHhWuPI5zPAsjU0U7v9xNIWjoOVyZ5ZcniQ=
howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM=
howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
k8s.io/api v0.36.0 h1:SgqDhZzHdOtMk40xVSvCXkP9ME0H05hPM3p9AB1kL80= k8s.io/api v0.36.0 h1:SgqDhZzHdOtMk40xVSvCXkP9ME0H05hPM3p9AB1kL80=
k8s.io/api v0.36.0/go.mod h1:m1LVrGPNYax5NBHdO+QuAedXyuzTt4RryI/qnmNvs34= 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 h1:jZyPzhd5Z+3h9vJLt0z9XdzW9VzNzWAUw+P1xZ9PXtQ=
@@ -599,7 +411,3 @@ sigs.k8s.io/structured-merge-diff/v6 v6.3.2 h1:kwVWMx5yS1CrnFWA/2QHyRVJ8jM6dBA80
sigs.k8s.io/structured-merge-diff/v6 v6.3.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/structured-merge-diff/v6 v6.3.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k=
software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
tailscale.com v1.96.5 h1:gNkfA/KSZAl6jCH9cj8urq00HRWItDDTtGsyATI89jA=
tailscale.com v1.96.5/go.mod h1:/3lnZBYb2UEwnN0MNu2SDXUtT06AGd5k0s+OWx3WmcY=
+1 -1
View File
@@ -11,5 +11,5 @@ var FrontendAssets embed.FS
// Migrations // Migrations
// //
//go:embed migrations/sqlite/*.sql migrations/postgres/*.sql //go:embed migrations/sqlite/*.sql
var Migrations embed.FS var Migrations embed.FS
@@ -1,4 +0,0 @@
DROP TABLE IF EXISTS "oidc_tokens";
DROP TABLE IF EXISTS "oidc_userinfo";
DROP TABLE IF EXISTS "oidc_codes";
DROP TABLE IF EXISTS "sessions";
@@ -1,60 +0,0 @@
CREATE TABLE "sessions" (
"uuid" TEXT NOT NULL PRIMARY KEY,
"username" TEXT NOT NULL,
"email" TEXT NOT NULL,
"name" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"totp_pending" BOOLEAN NOT NULL,
"oauth_groups" TEXT NOT NULL DEFAULT '',
"expiry" BIGINT NOT NULL,
"created_at" BIGINT NOT NULL,
"oauth_name" TEXT NOT NULL DEFAULT '',
"oauth_sub" TEXT NOT NULL DEFAULT ''
);
CREATE TABLE "oidc_codes" (
"sub" TEXT NOT NULL UNIQUE,
"code_hash" TEXT NOT NULL PRIMARY KEY,
"scope" TEXT NOT NULL,
"redirect_uri" TEXT NOT NULL,
"client_id" TEXT NOT NULL,
"expires_at" BIGINT NOT NULL,
"nonce" TEXT NOT NULL DEFAULT '',
"code_challenge" TEXT NOT NULL DEFAULT ''
);
CREATE TABLE "oidc_tokens" (
"sub" TEXT NOT NULL UNIQUE,
"access_token_hash" TEXT NOT NULL PRIMARY KEY,
"refresh_token_hash" TEXT NOT NULL,
"code_hash" TEXT NOT NULL,
"scope" TEXT NOT NULL,
"client_id" TEXT NOT NULL,
"token_expires_at" BIGINT NOT NULL,
"refresh_token_expires_at" BIGINT NOT NULL,
"nonce" TEXT NOT NULL DEFAULT ''
);
CREATE TABLE "oidc_userinfo" (
"sub" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"preferred_username" TEXT NOT NULL,
"email" TEXT NOT NULL,
"groups" TEXT NOT NULL,
"updated_at" BIGINT NOT NULL,
"given_name" TEXT NOT NULL,
"family_name" TEXT NOT NULL,
"middle_name" TEXT NOT NULL,
"nickname" TEXT NOT NULL,
"profile" TEXT NOT NULL,
"picture" TEXT NOT NULL,
"website" TEXT NOT NULL,
"gender" TEXT NOT NULL,
"birthdate" TEXT NOT NULL,
"zoneinfo" TEXT NOT NULL,
"locale" TEXT NOT NULL,
"phone_number" TEXT NOT NULL,
"address" TEXT NOT NULL
);
CREATE INDEX idx_sessions_expiry ON "sessions" ("expiry");
+130 -189
View File
@@ -3,53 +3,39 @@ package bootstrap
import ( import (
"bytes" "bytes"
"context" "context"
"database/sql"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
"os/signal"
"sort" "sort"
"strings" "strings"
"sync"
"syscall"
"time" "time"
"github.com/gin-gonic/gin" "github.com/tinyauthapp/tinyauth/internal/controller"
"github.com/tinyauthapp/tinyauth/internal/model" "github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/repository" "github.com/tinyauthapp/tinyauth/internal/repository"
"github.com/tinyauthapp/tinyauth/internal/service"
"github.com/tinyauthapp/tinyauth/internal/utils" "github.com/tinyauthapp/tinyauth/internal/utils"
"github.com/tinyauthapp/tinyauth/internal/utils/logger" "github.com/tinyauthapp/tinyauth/internal/utils/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
tailscaleService *service.TailscaleService
policyEngine *service.PolicyEngine
}
type BootstrapApp struct { type BootstrapApp struct {
config model.Config config model.Config
runtime model.RuntimeConfig context struct {
services Services appUrl string
log *logger.Logger uuid string
ctx context.Context cookieDomain string
cancel context.CancelFunc sessionCookieName string
queries repository.Store csrfCookieName string
router *gin.Engine redirectCookieName string
db *sql.DB oauthSessionCookieName string
wg sync.WaitGroup localUsers *[]model.LocalUser
listeners []Listener oauthProviders map[string]model.OAuthServiceConfig
oauthWhitelist []string
configuredProviders []controller.Provider
oidcClients []model.OIDCClientConfig
}
services Services
} }
func NewBootstrapApp(config model.Config) *BootstrapApp { func NewBootstrapApp(config model.Config) *BootstrapApp {
@@ -59,72 +45,56 @@ func NewBootstrapApp(config model.Config) *BootstrapApp {
} }
func (app *BootstrapApp) Setup() error { 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
app.log.App.Info().Msgf("Starting Tinyauth version: %s", model.Version)
// get app url // get app url
if app.config.AppURL == "" { if app.config.AppURL == "" {
return errors.New("app url cannot be empty, perhaps config loading failed") return fmt.Errorf("app URL cannot be empty, perhaps config loading failed")
} }
appUrl, err := url.Parse(app.config.AppURL) appUrl, err := url.Parse(app.config.AppURL)
if err != nil { 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
app.runtime.TrustedDomains = append(app.runtime.TrustedDomains, app.runtime.AppURL)
// validate session config // validate session config
if app.config.Auth.SessionMaxLifetime != 0 && app.config.Auth.SessionMaxLifetime < app.config.Auth.SessionExpiry { if app.config.Auth.SessionMaxLifetime != 0 && app.config.Auth.SessionMaxLifetime < app.config.Auth.SessionExpiry {
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 // Parse users
users, err := utils.GetUsers(app.config.Auth.Users, app.config.Auth.UsersFile, app.config.Auth.UserAttributes) users, err := utils.GetUsers(app.config.Auth.Users, app.config.Auth.UsersFile, app.config.Auth.UserAttributes)
if err != nil { if err != nil {
return fmt.Errorf("failed to load users: %w", err) return err
} }
app.runtime.LocalUsers = *users app.context.localUsers = users
// load oauth whitelist
oauthWhitelist, err := utils.GetStringList(app.config.OAuth.Whitelist, app.config.OAuth.WhitelistFile) oauthWhitelist, err := utils.GetStringList(app.config.OAuth.Whitelist, app.config.OAuth.WhitelistFile)
if err != nil { if err != nil {
return fmt.Errorf("failed to load oauth whitelist: %w", err) return err
} }
app.runtime.OAuthWhitelist = oauthWhitelist app.context.oauthWhitelist = oauthWhitelist
// setup oauth providers // Setup OAuth providers
app.runtime.OAuthProviders = app.config.OAuth.Providers app.context.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) secret := utils.GetSecret(provider.ClientSecret, provider.ClientSecretFile)
provider.ClientSecret = secret provider.ClientSecret = secret
provider.ClientSecretFile = "" provider.ClientSecretFile = ""
if provider.RedirectURL == "" { 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.context.oauthProviders {
for id, provider := range app.runtime.OAuthProviders {
if provider.Name == "" { if provider.Name == "" {
if name, ok := model.OverrideProviders[id]; ok { if name, ok := model.OverrideProviders[id]; ok {
provider.Name = name provider.Name = name
@@ -132,73 +102,68 @@ func (app *BootstrapApp) Setup() error {
provider.Name = utils.Capitalize(id) 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 { for id, client := range app.config.OIDC.Clients {
client.ID = id client.ID = id
app.runtime.OIDCClients = append(app.runtime.OIDCClients, client) app.context.oidcClients = append(app.context.oidcClients, client)
} }
// cookie domain // Get cookie domain
cookieDomainResolver := utils.GetCookieDomain cookieDomainResolver := utils.GetCookieDomain
if !app.config.Auth.SubdomainsEnabled { if !app.config.Auth.SubdomainsEnabled {
app.log.App.Warn().Msg("Subdomains are disabled, using standalone cookie domain resolver which will not work with subdomains") tlog.App.Info().Msg("Subdomains disabled, automatic authentication for proxied apps will not work")
cookieDomainResolver = utils.GetStandaloneCookieDomain cookieDomainResolver = utils.GetStandaloneCookieDomain
} }
cookieDomain, err := cookieDomainResolver(app.runtime.AppURL) cookieDomain, err := cookieDomainResolver(app.context.appUrl)
if err != nil { 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 // Cookie names
app.runtime.UUID = utils.GenerateUUID(appUrl.Hostname()) app.context.uuid = utils.GenerateUUID(appUrl.Hostname())
cookieId := strings.Split(app.context.uuid, "-")[0]
app.context.sessionCookieName = fmt.Sprintf("%s-%s", model.SessionCookieName, cookieId)
app.context.csrfCookieName = fmt.Sprintf("%s-%s", model.CSRFCookieName, cookieId)
app.context.redirectCookieName = fmt.Sprintf("%s-%s", model.RedirectCookieName, cookieId)
app.context.oauthSessionCookieName = fmt.Sprintf("%s-%s", model.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.localUsers).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) // Database
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
store, err := app.SetupStore() store, err := app.SetupStore()
if err != nil { if err != nil {
return fmt.Errorf("failed to setup database: %w", err) 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 // Services
// to ensure that resources are cleaned up properly in case of an error during initialization services, err := app.initServices(store)
defer func() {
app.cancel()
app.wg.Wait()
if app.db != nil {
app.db.Close()
}
}()
// store
app.queries = store
// services
err = app.setupServices()
if err != nil { if err != nil {
return fmt.Errorf("failed to initialize services: %w", err) return fmt.Errorf("failed to initialize services: %w", err)
} }
// configured providers app.services = services
configuredProviders := make([]model.Provider, 0)
for id, provider := range app.runtime.OAuthProviders { // Configured providers
configuredProviders = append(configuredProviders, model.Provider{ configuredProviders := make([]controller.Provider, 0)
for id, provider := range app.context.oauthProviders {
configuredProviders = append(configuredProviders, controller.Provider{
Name: provider.Name, Name: provider.Name,
ID: id, ID: id,
OAuth: true, OAuth: true,
@@ -209,100 +174,93 @@ func (app *BootstrapApp) Setup() error {
return configuredProviders[i].Name < configuredProviders[j].Name return configuredProviders[i].Name < configuredProviders[j].Name
}) })
if app.services.authService.LocalAuthConfigured() { if services.authService.LocalAuthConfigured() {
configuredProviders = append(configuredProviders, model.Provider{ configuredProviders = append(configuredProviders, controller.Provider{
Name: "Local", Name: "Local",
ID: "local", ID: "local",
OAuth: false, OAuth: false,
}) })
} }
if app.services.authService.LDAPAuthConfigured() { if services.authService.LDAPAuthConfigured() {
configuredProviders = append(configuredProviders, model.Provider{ configuredProviders = append(configuredProviders, controller.Provider{
Name: "LDAP", Name: "LDAP",
ID: "ldap", ID: "ldap",
OAuth: false, OAuth: false,
}) })
} }
tlog.App.Debug().Interface("providers", configuredProviders).Msg("Authentication providers")
if len(configuredProviders) == 0 { if len(configuredProviders) == 0 {
return errors.New("no authentication providers configured") return fmt.Errorf("no authentication providers configured")
} }
for _, provider := range configuredProviders { app.context.configuredProviders = configuredProviders
app.log.App.Debug().Str("provider", provider.Name).Msg("Configured authentication provider")
}
app.runtime.ConfiguredProviders = configuredProviders // Setup router
router, err := app.setupRouter()
// throw in tailscale if it's configured just before setting up the controllers
if app.services.tailscaleService != nil {
app.runtime.TrustedDomains = append(app.runtime.TrustedDomains, "https://"+app.services.tailscaleService.GetHostname())
}
// setup router
err = app.setupRouter()
if err != nil { if err != nil {
return fmt.Errorf("failed to setup routes: %w", err) return fmt.Errorf("failed to setup routes: %w", err)
} }
// start db cleanup routine // Start db cleanup routine
app.log.App.Debug().Msg("Starting database cleanup routine") tlog.App.Debug().Msg("Starting database cleanup routine")
app.wg.Go(app.dbCleanupRoutine) go app.dbCleanupRoutine(store)
// if analytics are not disabled, start heartbeat // If analytics are not disabled, start heartbeat
if app.config.Analytics.Enabled { if app.config.Analytics.Enabled {
app.log.App.Debug().Msg("Starting heartbeat routine") tlog.App.Debug().Msg("Starting heartbeat routine")
app.wg.Go(app.heartbeatRoutine) go app.heartbeatRoutine()
} }
// setup listeners // If we have an socket path, bind to it
app.listeners = app.calculateListenerPolicy() if app.config.Server.SocketPath != "" {
if _, err := os.Stat(app.config.Server.SocketPath); err == nil {
if app.config.Server.ConcurrentListenersEnabled { tlog.App.Info().Msgf("Removing existing socket file %s", app.config.Server.SocketPath)
app.log.App.Info().Msg("Concurrent listeners enabled, will run on all available listeners") err := os.Remove(app.config.Server.SocketPath)
}
// run listeners
lec, err := app.runListeners()
if err != nil {
return fmt.Errorf("failed to run listeners: %w", 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 := <-lec:
if err != nil { if err != nil {
return fmt.Errorf("listener error: %w", err) return fmt.Errorf("failed to remove existing socket file: %w", err)
} }
} }
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")
}
return nil
} }
// 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
} }
func (app *BootstrapApp) heartbeatRoutine() { func (app *BootstrapApp) heartbeatRoutine() {
ticker := time.NewTicker(time.Duration(12) * time.Hour) ticker := time.NewTicker(time.Duration(12) * time.Hour)
defer ticker.Stop() defer ticker.Stop()
type Heartbeat struct { type heartbeat struct {
UUID string `json:"uuid"` UUID string `json:"uuid"`
Version string `json:"version"` Version string `json:"version"`
} }
var body Heartbeat var body heartbeat
body.UUID = app.runtime.UUID body.UUID = app.context.uuid
body.Version = model.Version body.Version = model.Version
bodyJson, err := json.Marshal(body) bodyJson, err := json.Marshal(body)
if err != nil { 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 return
} }
@@ -312,60 +270,43 @@ func (app *BootstrapApp) heartbeatRoutine() {
heartbeatURL := model.APIServer + "/v1/instances/heartbeat" heartbeatURL := model.APIServer + "/v1/instances/heartbeat"
for { for range ticker.C {
select { tlog.App.Debug().Msg("Sending heartbeat")
case <-ticker.C:
app.log.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 { if err != nil {
app.log.App.Error().Err(err).Msg("Failed to create heartbeat request") tlog.App.Error().Err(err).Msg("Failed to create heartbeat request")
continue 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 { if err != nil {
app.log.App.Error().Err(err).Msg("Failed to send heartbeat") tlog.App.Error().Err(err).Msg("Failed to send heartbeat")
continue continue
} }
res.Body.Close() res.Body.Close()
if res.StatusCode != 200 && res.StatusCode != 201 { if res.StatusCode != 200 && res.StatusCode != 201 {
app.log.App.Debug().Str("status", res.Status).Msg("Heartbeat returned non-200/201 status") tlog.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
} }
} }
} }
func (app *BootstrapApp) dbCleanupRoutine() { func (app *BootstrapApp) dbCleanupRoutine(queries repository.Store) {
ticker := time.NewTicker(time.Duration(30) * time.Minute) ticker := time.NewTicker(time.Duration(30) * time.Minute)
defer ticker.Stop() defer ticker.Stop()
ctx := context.Background()
for { for range ticker.C {
select { tlog.App.Debug().Msg("Cleaning up old database sessions")
case <-ticker.C: err := queries.DeleteExpiredSessions(ctx, time.Now().Unix())
app.log.App.Debug().Msg("Running database cleanup") if err != nil {
tlog.App.Error().Err(err).Msg("Failed to clean up old database sessions")
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
} }
} }
} }
+7 -64
View File
@@ -6,18 +6,15 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"github.com/golang-migrate/migrate/v4"
pgxmigrate "github.com/golang-migrate/migrate/v4/database/pgx/v5"
"github.com/golang-migrate/migrate/v4/database/sqlite3"
"github.com/golang-migrate/migrate/v4/source/iofs"
_ "github.com/jackc/pgx/v5/stdlib"
_ "modernc.org/sqlite"
"github.com/tinyauthapp/tinyauth/internal/assets" "github.com/tinyauthapp/tinyauth/internal/assets"
"github.com/tinyauthapp/tinyauth/internal/repository" "github.com/tinyauthapp/tinyauth/internal/repository"
"github.com/tinyauthapp/tinyauth/internal/repository/memory" "github.com/tinyauthapp/tinyauth/internal/repository/memory"
"github.com/tinyauthapp/tinyauth/internal/repository/postgres"
"github.com/tinyauthapp/tinyauth/internal/repository/sqlite" "github.com/tinyauthapp/tinyauth/internal/repository/sqlite"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/sqlite3"
"github.com/golang-migrate/migrate/v4/source/iofs"
_ "modernc.org/sqlite"
) )
func (app *BootstrapApp) SetupStore() (repository.Store, error) { func (app *BootstrapApp) SetupStore() (repository.Store, error) {
@@ -26,10 +23,8 @@ func (app *BootstrapApp) SetupStore() (repository.Store, error) {
return memory.New(), nil return memory.New(), nil
case "sqlite", "": case "sqlite", "":
return app.setupSQLite(app.config.Database.Path) return app.setupSQLite(app.config.Database.Path)
case "postgres":
return app.setupPostgres(app.config.Database.Path)
default: default:
return nil, fmt.Errorf("unknown database driver %q: valid values are sqlite, postgres, memory", app.config.Database.Driver) return nil, fmt.Errorf("unknown database driver %q: valid values are sqlite, memory", app.config.Database.Driver)
} }
} }
@@ -46,13 +41,6 @@ func (app *BootstrapApp) setupSQLite(databasePath string) (repository.Store, err
return nil, fmt.Errorf("failed to open database: %w", err) return nil, fmt.Errorf("failed to open database: %w", err)
} }
cleanup := true
defer func() {
if cleanup {
db.Close()
}
}()
// Limit to 1 connection to sequence writes, this may need to be revisited in the future // Limit to 1 connection to sequence writes, this may need to be revisited in the future
// if the sqlite connection starts being a bottleneck // if the sqlite connection starts being a bottleneck
db.SetMaxOpenConns(1) db.SetMaxOpenConns(1)
@@ -75,54 +63,9 @@ func (app *BootstrapApp) setupSQLite(databasePath string) (repository.Store, err
return nil, fmt.Errorf("failed to create migrator: %w", err) return nil, fmt.Errorf("failed to create migrator: %w", err)
} }
if err = migrator.Up(); err != nil && err != migrate.ErrNoChange { if err := migrator.Up(); err != nil && err != migrate.ErrNoChange {
return nil, fmt.Errorf("failed to migrate database: %w", err) return nil, fmt.Errorf("failed to migrate database: %w", err)
} }
cleanup = false
app.db = db
return sqlite.NewStore(sqlite.New(db)), nil return sqlite.NewStore(sqlite.New(db)), nil
} }
func (app *BootstrapApp) setupPostgres(databaseURL string) (repository.Store, error) {
db, err := sql.Open("pgx", databaseURL)
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
cleanup := true
defer func() {
if cleanup {
db.Close()
}
}()
migrations, err := iofs.New(assets.Migrations, "migrations/postgres")
if err != nil {
return nil, fmt.Errorf("failed to create migrations: %w", err)
}
target, err := pgxmigrate.WithInstance(db, &pgxmigrate.Config{})
if err != nil {
return nil, fmt.Errorf("failed to create postgres instance: %w", err)
}
migrator, err := migrate.NewWithInstance("iofs", migrations, "pgx", target)
if err != nil {
return nil, fmt.Errorf("failed to create migrator: %w", err)
}
if err = migrator.Up(); err != nil && err != migrate.ErrNoChange {
return nil, fmt.Errorf("failed to migrate database: %w", err)
}
cleanup = false
app.db = db
return postgres.NewStore(postgres.New(db)), nil
}
+87 -195
View File
@@ -1,13 +1,8 @@
package bootstrap package bootstrap
import ( import (
"context"
"errors"
"fmt" "fmt"
"net" "slices"
"net/http"
"os"
"time"
"github.com/tinyauthapp/tinyauth/internal/controller" "github.com/tinyauthapp/tinyauth/internal/controller"
"github.com/tinyauthapp/tinyauth/internal/middleware" "github.com/tinyauthapp/tinyauth/internal/middleware"
@@ -16,17 +11,12 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
type Listener int var DEV_MODES = []string{"main", "test", "development"}
const ( func (app *BootstrapApp) setupRouter() (*gin.Engine, error) {
ListenerHTTP Listener = iota if !slices.Contains(DEV_MODES, model.Version) {
ListenerUnix gin.SetMode(gin.ReleaseMode)
ListenerTailscale }
)
func (app *BootstrapApp) setupRouter() error {
// we don't want gin debug mode
gin.SetMode(gin.ReleaseMode)
engine := gin.New() engine := gin.New()
engine.Use(gin.Recovery()) engine.Use(gin.Recovery())
@@ -35,199 +25,101 @@ func (app *BootstrapApp) setupRouter() error {
err := engine.SetTrustedProxies(app.config.Auth.TrustedProxies) err := engine.SetTrustedProxies(app.config.Auth.TrustedProxies)
if err != nil { 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, app.services.tailscaleService) contextMiddleware := middleware.NewContextMiddleware(middleware.ContextMiddlewareConfig{
engine.Use(contextMiddleware.Middleware()) CookieDomain: app.context.cookieDomain,
SessionCookieName: app.context.sessionCookieName,
}, app.services.authService, app.services.oauthBrokerService)
uiMiddleware, err := middleware.NewUIMiddleware() err := contextMiddleware.Init()
if err != nil { 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()) 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()) engine.Use(zerologMiddleware.Middleware())
apiRouter := engine.Group("/api") apiRouter := engine.Group("/api")
controller.NewContextController(app.log, app.config, app.runtime, apiRouter) contextController := controller.NewContextController(controller.ContextControllerConfig{
controller.NewOAuthController(app.log, app.config, app.runtime, apiRouter, app.services.authService) Providers: app.context.configuredProviders,
controller.NewOIDCController(app.log, app.services.oidcService, app.runtime, apiRouter) Title: app.config.UI.Title,
controller.NewProxyController(app.log, app.runtime, apiRouter, app.services.accessControlService, app.services.authService, app.services.policyEngine) AppURL: app.config.AppURL,
controller.NewUserController(app.log, app.runtime, apiRouter, app.services.authService) CookieDomain: app.context.cookieDomain,
controller.NewResourcesController(app.config, &engine.RouterGroup) ForgotPasswordMessage: app.config.UI.ForgotPasswordMessage,
controller.NewHealthController(apiRouter) BackgroundImage: app.config.UI.BackgroundImage,
controller.NewWellKnownController(app.services.oidcService, &engine.RouterGroup) OAuthAutoRedirect: app.config.OAuth.AutoRedirect,
WarningsEnabled: app.config.UI.WarningsEnabled,
}, apiRouter)
app.router = engine contextController.SetupRoutes()
return nil
} oauthController := controller.NewOAuthController(controller.OAuthControllerConfig{
AppURL: app.config.AppURL,
func (app *BootstrapApp) runListeners() (chan error, error) { SecureCookie: app.config.Auth.SecureCookie,
// lec -> listener error channel CSRFCookieName: app.context.csrfCookieName,
lec := make(chan error, len(app.listeners)) RedirectCookieName: app.context.redirectCookieName,
CookieDomain: app.context.cookieDomain,
for _, listenerType := range app.listeners { OAuthSessionCookieName: app.context.oauthSessionCookieName,
listenerFunc, err := app.listenerFromType(listenerType) SubdomainsEnabled: app.config.Auth.SubdomainsEnabled,
}, apiRouter, app.services.authService)
if err != nil {
return nil, fmt.Errorf("failed to get listener function: %w", err) oauthController.SetupRoutes()
}
oidcController := controller.NewOIDCController(controller.OIDCControllerConfig{}, app.services.oidcService, apiRouter)
app.wg.Go(func() {
lec <- listenerFunc() oidcController.SetupRoutes()
})
} proxyController := controller.NewProxyController(controller.ProxyControllerConfig{
AppURL: app.config.AppURL,
return lec, nil }, apiRouter, app.services.accessControlService, app.services.authService)
}
proxyController.SetupRoutes()
// The way we calculate listeners is as follows:
// If concurrent listeners are disabled, we pick the first available listener, so: userController := controller.NewUserController(controller.UserControllerConfig{
// 1. If tailscale is enabled, we use tailscale CookieDomain: app.context.cookieDomain,
// 2. If socket path is configured, we use unix socket SessionCookieName: app.context.sessionCookieName,
// 3. Finally if none is configured we use http }, apiRouter, app.services.authService)
// If concurrent listeners are enabled, we add all available listeners in the following order
func (app *BootstrapApp) calculateListenerPolicy() []Listener { userController.SetupRoutes()
l := []Listener{}
resourcesController := controller.NewResourcesController(controller.ResourcesControllerConfig{
if !app.config.Server.ConcurrentListenersEnabled { Path: app.config.Resources.Path,
if app.services.tailscaleService != nil { Enabled: app.config.Resources.Enabled,
l = append(l, ListenerTailscale) }, &engine.RouterGroup)
return l
} resourcesController.SetupRoutes()
if app.config.Server.SocketPath != "" { healthController := controller.NewHealthController(apiRouter)
l = append(l, ListenerUnix)
return l healthController.SetupRoutes()
}
wellknownController := controller.NewWellKnownController(controller.WellKnownControllerConfig{}, app.services.oidcService, engine)
l = append(l, ListenerHTTP)
return l wellknownController.SetupRoutes()
}
return engine, nil
if app.config.Server.SocketPath != "" {
l = append(l, ListenerUnix)
}
if app.services.tailscaleService != nil {
l = append(l, ListenerTailscale)
}
l = append(l, ListenerHTTP)
return l
}
func (app *BootstrapApp) listenerFromType(listenerType Listener) (func() error, error) {
switch listenerType {
case ListenerHTTP:
return app.serveHTTP, nil
case ListenerUnix:
return app.serveUnix, nil
case ListenerTailscale:
return app.serveTailscale, nil
default:
return nil, fmt.Errorf("invalid listener type: %d", listenerType)
}
}
func (app *BootstrapApp) serveHTTP() error {
address := fmt.Sprintf("%s:%d", app.config.Server.Address, app.config.Server.Port)
app.log.App.Info().Msgf("Starting server on %s", address)
listener, err := net.Listen("tcp", address)
if err != nil {
return fmt.Errorf("failed to create tcp listener: %w", err)
}
server := &http.Server{
Addr: address,
Handler: app.router.Handler(),
}
return app.serve(listener, server, "http")
}
func (app *BootstrapApp) serveUnix() error {
_, err := os.Stat(app.config.Server.SocketPath)
if err == nil {
app.log.App.Info().Msgf("Removing existing socket file %s", app.config.Server.SocketPath)
err := os.Remove(app.config.Server.SocketPath)
if err != nil {
return fmt.Errorf("failed to remove existing socket file: %w", err)
}
}
app.log.App.Info().Msgf("Starting server on unix socket %s", app.config.Server.SocketPath)
listener, err := net.Listen("unix", app.config.Server.SocketPath)
if err != nil {
return fmt.Errorf("failed to create unix socket listener: %w", err)
}
server := &http.Server{
Handler: app.router.Handler(),
}
return app.serve(listener, server, "unix socket")
}
func (app *BootstrapApp) serveTailscale() error {
app.log.App.Info().Msgf("Starting Tailscale server on %s", fmt.Sprintf("https://%s", app.services.tailscaleService.GetHostname()))
listener, err := app.services.tailscaleService.CreateListener()
if err != nil {
return fmt.Errorf("failed to create tailscale listener: %w", err)
}
server := &http.Server{
Handler: app.router.Handler(),
}
return app.serve(listener, server, "tailscale")
}
func (app *BootstrapApp) serve(listener net.Listener, server *http.Server, name string) error {
shutdown := func() {
ctx, cancel := context.WithTimeout(context.Background(), model.GracefulShutdownTimeout*time.Second)
defer cancel()
err := server.Shutdown(ctx)
if err != nil &&
// With tailscale, the goroutine for shutting down the tailscale connection
// runs first and causes the connection the tailscale listener is running on to close
// first so, the shutdown fails
// TODO: add priority to the goroutine shutdowns
!errors.Is(err, net.ErrClosed) {
app.log.App.Error().Err(err).Msgf("Failed to shutdown %s listener gracefully", name)
}
listener.Close()
}
go func() {
<-app.ctx.Done()
app.log.App.Debug().Msgf("Shutting down %s listener", name)
shutdown()
}()
err := server.Serve(listener)
if err != nil && !errors.Is(err, http.ErrServerClosed) {
shutdown()
return fmt.Errorf("failed to start %s listener: %w", name, err)
}
return nil
} }
+103 -106
View File
@@ -1,134 +1,131 @@
package bootstrap package bootstrap
import ( import (
"fmt"
"os" "os"
"github.com/tinyauthapp/tinyauth/internal/repository"
"github.com/tinyauthapp/tinyauth/internal/service" "github.com/tinyauthapp/tinyauth/internal/service"
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
) )
func (app *BootstrapApp) setupServices() error { type Services struct {
ldapService, err := service.NewLdapService(app.log, app.config, app.ctx, &app.wg) accessControlService *service.AccessControlsService
authService *service.AuthService
if err != nil { dockerService *service.DockerService
app.log.App.Warn().Err(err).Msg("Failed to initialize LDAP connection, will continue without it") kubernetesService *service.KubernetesService
} ldapService *service.LdapService
oauthBrokerService *service.OAuthBrokerService
app.services.ldapService = ldapService oidcService *service.OIDCService
labelProvider, err := app.getLabelProvider()
if err != nil {
return fmt.Errorf("failed to initialize label provider: %w", err)
}
tailscaleService, err := service.NewTailscaleService(app.log, app.config, app.ctx, &app.wg)
if err != nil {
app.log.App.Warn().Err(err).Msg("Failed to initialize Tailscale connection, will continue without it")
}
app.services.tailscaleService = tailscaleService
accessControlsService := service.NewAccessControlsService(app.log, app.config, &labelProvider)
app.services.accessControlService = accessControlsService
err = app.setupPolicyEngine()
if err != nil {
return fmt.Errorf("failed to initialize policy engine: %w", err)
}
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.tailscaleService)
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
} }
func (app *BootstrapApp) getLabelProvider() (service.LabelProvider, error) { func (app *BootstrapApp) initServices(queries repository.Store) (Services, error) {
switch app.config.LabelProvider { services := Services{}
case "none", "docker", "kubernetes", "auto":
if app.config.LabelProvider == "none" {
return nil, nil
}
useKubernetes := app.config.LabelProvider == "kubernetes" || ldapService := service.NewLdapService(service.LdapServiceConfig{
(app.config.LabelProvider == "auto" && os.Getenv("KUBERNETES_SERVICE_HOST") != "") 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,
})
if useKubernetes { err := ldapService.Init()
app.log.App.Debug().Msg("Using Kubernetes label provider")
kubernetesService, err := service.NewKubernetesService(app.log, app.ctx, &app.wg) if err != nil {
tlog.App.Warn().Err(err).Msg("Failed to setup LDAP service, starting without it")
ldapService.Unconfigure()
}
if err != nil { services.ldapService = ldapService
return nil, fmt.Errorf("failed to initialize kubernetes service: %w", err)
}
app.services.kubernetesService = kubernetesService var labelProvider service.LabelProvider
return kubernetesService, nil var dockerService *service.DockerService
} var kubernetesService *service.KubernetesService
app.log.App.Debug().Msg("Using Docker label provider") useKubernetes := app.config.LabelProvider == "kubernetes" ||
(app.config.LabelProvider == "auto" && os.Getenv("KUBERNETES_SERVICE_HOST") != "")
dockerService, err := service.NewDockerService(app.log, app.ctx, &app.wg)
if useKubernetes {
tlog.App.Debug().Msg("Using Kubernetes label provider")
kubernetesService = service.NewKubernetesService()
err = kubernetesService.Init()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to initialize docker service: %w", err) return Services{}, err
} }
services.kubernetesService = kubernetesService
if dockerService == nil { labelProvider = kubernetesService
if app.config.LabelProvider == "docker" { } else {
app.log.App.Warn().Msg("Docker label provider selected but Docker is not available, will continue without it") tlog.App.Debug().Msg("Using Docker label provider")
} dockerService = service.NewDockerService()
return nil, nil err = dockerService.Init()
if err != nil {
return Services{}, err
} }
services.dockerService = dockerService
app.services.dockerService = dockerService labelProvider = dockerService
return dockerService, nil
default:
return nil, fmt.Errorf("invalid label provider: %s", app.config.LabelProvider)
} }
}
func (app *BootstrapApp) setupPolicyEngine() error { accessControlsService := service.NewAccessControlsService(labelProvider, app.config.Apps)
policyEngine, err := service.NewPolicyEngine(app.config, app.log)
err = accessControlsService.Init()
if err != nil { if err != nil {
return fmt.Errorf("failed to initialize policy engine: %w", err) return Services{}, err
} }
policyEngine.RegisterRule(service.RuleUserAllowed, &service.UserAllowedRule{ services.accessControlService = accessControlsService
Log: app.log,
})
policyEngine.RegisterRule(service.RuleOAuthGroup, &service.OAuthGroupRule{
Log: app.log,
})
policyEngine.RegisterRule(service.RuleLDAPGroup, &service.LDAPGroupRule{
Log: app.log,
})
policyEngine.RegisterRule(service.RuleAuthEnabled, &service.AuthEnabledRule{
Log: app.log,
})
policyEngine.RegisterRule(service.RuleIPAllowed, &service.IPAllowedRule{
Log: app.log,
Config: app.config,
})
policyEngine.RegisterRule(service.RuleIPBypassed, &service.IPBypassedRule{
Log: app.log,
})
app.services.policyEngine = policyEngine oauthBrokerService := service.NewOAuthBrokerService(app.context.oauthProviders)
return nil
err = oauthBrokerService.Init()
if err != nil {
return Services{}, err
}
services.oauthBrokerService = oauthBrokerService
authService := service.NewAuthService(service.AuthServiceConfig{
LocalUsers: app.context.localUsers,
OauthWhitelist: app.context.oauthWhitelist,
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,
SubdomainsEnabled: app.config.Auth.SubdomainsEnabled,
}, 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
} }
+86 -119
View File
@@ -1,163 +1,130 @@
package controller package controller
import ( import (
"fmt"
"net/url"
"github.com/tinyauthapp/tinyauth/internal/model" "github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/utils/logger" "github.com/tinyauthapp/tinyauth/internal/utils/tlog"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
// UCR -> User Context Response
type UCRAuth struct {
Authenticated bool `json:"authenticated"`
Username string `json:"username"`
Name string `json:"name"`
Email string `json:"email"`
ProviderID string `json:"providerId"`
}
type UCROAuth struct {
Active bool `json:"active"`
DisplayName string `json:"displayName"`
}
type UCRTOTP struct {
Pending bool `json:"pending"`
}
type UCRTailscale struct {
NodeName string `json:"nodeName,omitempty"`
}
type UserContextResponse struct { type UserContextResponse struct {
Status int `json:"status"` Status int `json:"status"`
Message string `json:"message"` Message string `json:"message"`
Auth UCRAuth `json:"auth"` IsLoggedIn bool `json:"isLoggedIn"`
OAuth UCROAuth `json:"oauth"` Username string `json:"username"`
TOTP UCRTOTP `json:"totp"` Name string `json:"name"`
Tailscale UCRTailscale `json:"tailscale"` Email string `json:"email"`
} Provider string `json:"provider"`
OAuth bool `json:"oauth"`
// ACR -> App Context Response TOTPPending bool `json:"totpPending"`
OAuthName string `json:"oauthName"`
type ACRAuth struct {
Providers []model.Provider `json:"providers"`
}
type ACROAuth struct {
AutoRedirect string `json:"autoRedirect"`
}
type ACRUI struct {
Title string `json:"title"`
ForgotPasswordMessage string `json:"forgotPasswordMessage"`
BackgroundImage string `json:"backgroundImage"`
WarningsEnabled bool `json:"warningsEnabled"`
}
type ACRApp struct {
AppURL string `json:"appUrl"`
CookieDomain string `json:"cookieDomain"`
TrustedDomains []string `json:"trustedDomains"`
} }
type AppContextResponse struct { type AppContextResponse struct {
Status int `json:"status"` Status int `json:"status"`
Message string `json:"message"` Message string `json:"message"`
Auth ACRAuth `json:"auth"` Providers []Provider `json:"providers"`
OAuth ACROAuth `json:"oauth"` Title string `json:"title"`
UI ACRUI `json:"ui"` AppURL string `json:"appUrl"`
App ACRApp `json:"app"` CookieDomain string `json:"cookieDomain"`
ForgotPasswordMessage string `json:"forgotPasswordMessage"`
BackgroundImage string `json:"backgroundImage"`
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 { type ContextController struct {
log *logger.Logger config ContextControllerConfig
config model.Config router *gin.RouterGroup
runtime model.RuntimeConfig
} }
func NewContextController( func NewContextController(config ContextControllerConfig, router *gin.RouterGroup) *ContextController {
log *logger.Logger, if !config.WarningsEnabled {
config model.Config, tlog.App.Warn().Msg("UI warnings are disabled. This may expose users to security risks. Proceed with caution.")
runtimeConfig model.RuntimeConfig,
router *gin.RouterGroup,
) *ContextController {
controller := &ContextController{
log: log,
config: config,
runtime: runtimeConfig,
} }
if !config.UI.WarningsEnabled { return &ContextController{
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.") config: config,
router: router,
} }
}
contextGroup := router.Group("/context") func (controller *ContextController) SetupRoutes() {
contextGroup := controller.router.Group("/context")
contextGroup.GET("/user", controller.userContextHandler) contextGroup.GET("/user", controller.userContextHandler)
contextGroup.GET("/app", controller.appContextHandler) contextGroup.GET("/app", controller.appContextHandler)
return controller
} }
func (controller *ContextController) userContextHandler(c *gin.Context) { func (controller *ContextController) userContextHandler(c *gin.Context) {
context, err := new(model.UserContext).NewFromGin(c) context, err := new(model.UserContext).NewFromGin(c)
if err != nil { if err != nil {
controller.log.App.Error().Err(err).Msg("Failed to create user context from request") tlog.App.Debug().Err(err).Msg("No user context found in request")
c.JSON(200, UserContextResponse{ c.JSON(200, UserContextResponse{
Status: 401, Status: 401,
Message: "Unauthorized", Message: "Unauthorized",
Auth: UCRAuth{Authenticated: false}, IsLoggedIn: false,
}) })
return return
} }
userContext := UserContextResponse{ userContext := UserContextResponse{
Status: 200, Status: 200,
Message: "Success", Message: "Success",
Auth: UCRAuth{ IsLoggedIn: context.Authenticated,
Authenticated: context.Authenticated, Username: context.GetUsername(),
Username: context.GetUsername(), Name: context.GetName(),
Name: context.GetName(), Email: context.GetEmail(),
Email: context.GetEmail(), Provider: context.GetProviderID(),
ProviderID: context.GetProviderID(), OAuth: context.IsOAuth(),
}, TOTPPending: context.TOTPPending(),
OAuth: UCROAuth{ OAuthName: context.OAuthName(),
Active: context.IsOAuth(),
DisplayName: context.OAuthName(),
},
TOTP: UCRTOTP{
Pending: context.TOTPPending(),
},
Tailscale: UCRTailscale{
NodeName: context.TailscaleNodeName(),
},
} }
c.JSON(200, userContext) c.JSON(200, userContext)
} }
func (controller *ContextController) appContextHandler(c *gin.Context) { func (controller *ContextController) appContextHandler(c *gin.Context) {
appUrl, err := url.Parse(controller.config.AppURL)
if err != nil {
tlog.App.Error().Err(err).Msg("Failed to parse app URL")
c.JSON(500, gin.H{
"status": 500,
"message": "Internal Server Error",
})
return
}
c.JSON(200, AppContextResponse{ c.JSON(200, AppContextResponse{
Status: 200, Status: 200,
Message: "Success", Message: "Success",
Auth: ACRAuth{ Providers: controller.config.Providers,
Providers: controller.runtime.ConfiguredProviders, Title: controller.config.Title,
}, AppURL: fmt.Sprintf("%s://%s", appUrl.Scheme, appUrl.Host),
OAuth: ACROAuth{ CookieDomain: controller.config.CookieDomain,
AutoRedirect: controller.config.OAuth.AutoRedirect, ForgotPasswordMessage: controller.config.ForgotPasswordMessage,
}, BackgroundImage: controller.config.BackgroundImage,
UI: ACRUI{ OAuthAutoRedirect: controller.config.OAuthAutoRedirect,
Title: controller.config.UI.Title, WarningsEnabled: controller.config.WarningsEnabled,
ForgotPasswordMessage: controller.config.UI.ForgotPasswordMessage,
BackgroundImage: controller.config.UI.BackgroundImage,
WarningsEnabled: controller.config.UI.WarningsEnabled,
},
App: ACRApp{
AppURL: controller.runtime.AppURL,
CookieDomain: controller.runtime.CookieDomain,
TrustedDomains: controller.runtime.TrustedDomains,
},
}) })
} }
+42 -41
View File
@@ -8,19 +8,30 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tinyauthapp/tinyauth/internal/controller" "github.com/tinyauthapp/tinyauth/internal/controller"
"github.com/tinyauthapp/tinyauth/internal/model" "github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/test"
"github.com/tinyauthapp/tinyauth/internal/utils" "github.com/tinyauthapp/tinyauth/internal/utils"
"github.com/tinyauthapp/tinyauth/internal/utils/logger" "github.com/tinyauthapp/tinyauth/internal/utils/tlog"
) )
func TestContextController(t *testing.T) { func TestContextController(t *testing.T) {
log := logger.NewLogger().WithTestConfig() tlog.NewTestLogger().Init()
log.Init() controllerConfig := controller.ContextControllerConfig{
Providers: []controller.Provider{
cfg, runtime := test.CreateTestConfigs(t) {
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 { tests := []struct {
description string description string
@@ -34,28 +45,19 @@ func TestContextController(t *testing.T) {
path: "/api/context/app", path: "/api/context/app",
expected: func() string { expected: func() string {
expectedAppContextResponse := controller.AppContextResponse{ expectedAppContextResponse := controller.AppContextResponse{
Status: 200, Status: 200,
Message: "Success", Message: "Success",
Auth: controller.ACRAuth{ Providers: controllerConfig.Providers,
Providers: runtime.ConfiguredProviders, Title: controllerConfig.Title,
}, AppURL: controllerConfig.AppURL,
OAuth: controller.ACROAuth{ CookieDomain: controllerConfig.CookieDomain,
AutoRedirect: cfg.OAuth.AutoRedirect, ForgotPasswordMessage: controllerConfig.ForgotPasswordMessage,
}, BackgroundImage: controllerConfig.BackgroundImage,
UI: controller.ACRUI{ OAuthAutoRedirect: controllerConfig.OAuthAutoRedirect,
Title: cfg.UI.Title, WarningsEnabled: controllerConfig.WarningsEnabled,
ForgotPasswordMessage: cfg.UI.ForgotPasswordMessage,
BackgroundImage: cfg.UI.BackgroundImage,
WarningsEnabled: cfg.UI.WarningsEnabled,
},
App: controller.ACRApp{
AppURL: runtime.AppURL,
CookieDomain: runtime.CookieDomain,
TrustedDomains: runtime.TrustedDomains,
},
} }
bytes, err := json.Marshal(expectedAppContextResponse) bytes, err := json.Marshal(expectedAppContextResponse)
require.NoError(t, err) assert.NoError(t, err)
return string(bytes) return string(bytes)
}(), }(),
}, },
@@ -69,7 +71,7 @@ func TestContextController(t *testing.T) {
Message: "Unauthorized", Message: "Unauthorized",
} }
bytes, err := json.Marshal(expectedUserContextResponse) bytes, err := json.Marshal(expectedUserContextResponse)
require.NoError(t, err) assert.NoError(t, err)
return string(bytes) return string(bytes)
}(), }(),
}, },
@@ -84,7 +86,7 @@ func TestContextController(t *testing.T) {
BaseContext: model.BaseContext{ BaseContext: model.BaseContext{
Username: "johndoe", Username: "johndoe",
Name: "John Doe", Name: "John Doe",
Email: utils.CompileUserEmail("johndoe", runtime.CookieDomain), Email: utils.CompileUserEmail("johndoe", controllerConfig.CookieDomain),
}, },
}, },
}) })
@@ -93,18 +95,16 @@ func TestContextController(t *testing.T) {
path: "/api/context/user", path: "/api/context/user",
expected: func() string { expected: func() string {
expectedUserContextResponse := controller.UserContextResponse{ expectedUserContextResponse := controller.UserContextResponse{
Status: 200, Status: 200,
Message: "Success", Message: "Success",
Auth: controller.UCRAuth{ IsLoggedIn: true,
Authenticated: true, Username: "johndoe",
Username: "johndoe", Name: "John Doe",
Name: "John Doe", Email: utils.CompileUserEmail("johndoe", controllerConfig.CookieDomain),
Email: utils.CompileUserEmail("johndoe", runtime.CookieDomain), Provider: "local",
ProviderID: "local",
},
} }
bytes, err := json.Marshal(expectedUserContextResponse) bytes, err := json.Marshal(expectedUserContextResponse)
require.NoError(t, err) assert.NoError(t, err)
return string(bytes) return string(bytes)
}(), }(),
}, },
@@ -121,12 +121,13 @@ func TestContextController(t *testing.T) {
group := router.Group("/api") group := router.Group("/api")
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
controller.NewContextController(log, cfg, runtime, group) contextController := controller.NewContextController(controllerConfig, group)
contextController.SetupRoutes()
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
request, err := http.NewRequest("GET", test.path, nil) request, err := http.NewRequest("GET", test.path, nil)
require.NoError(t, err) assert.NoError(t, err)
router.ServeHTTP(recorder, request) router.ServeHTTP(recorder, request)
+8 -5
View File
@@ -3,15 +3,18 @@ package controller
import "github.com/gin-gonic/gin" import "github.com/gin-gonic/gin"
type HealthController struct { type HealthController struct {
router *gin.RouterGroup
} }
func NewHealthController(router *gin.RouterGroup) *HealthController { func NewHealthController(router *gin.RouterGroup) *HealthController {
controller := &HealthController{} return &HealthController{
router: router,
}
}
router.GET("/healthz", controller.healthHandler) func (controller *HealthController) SetupRoutes() {
router.HEAD("/healthz", controller.healthHandler) controller.router.GET("/healthz", controller.healthHandler)
controller.router.HEAD("/healthz", controller.healthHandler)
return controller
} }
func (controller *HealthController) healthHandler(c *gin.Context) { func (controller *HealthController) healthHandler(c *gin.Context) {
@@ -7,12 +7,13 @@ import (
"testing" "testing"
"github.com/gin-gonic/gin" "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/controller"
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
"github.com/stretchr/testify/assert"
) )
func TestHealthController(t *testing.T) { func TestHealthController(t *testing.T) {
tlog.NewTestLogger().Init()
tests := []struct { tests := []struct {
description string description string
path string path string
@@ -29,7 +30,7 @@ func TestHealthController(t *testing.T) {
"message": "Healthy", "message": "Healthy",
} }
bytes, err := json.Marshal(expectedHealthResponse) bytes, err := json.Marshal(expectedHealthResponse)
require.NoError(t, err) assert.NoError(t, err)
return string(bytes) return string(bytes)
}(), }(),
}, },
@@ -43,7 +44,7 @@ func TestHealthController(t *testing.T) {
"message": "Healthy", "message": "Healthy",
} }
bytes, err := json.Marshal(expectedHealthResponse) bytes, err := json.Marshal(expectedHealthResponse)
require.NoError(t, err) assert.NoError(t, err)
return string(bytes) return string(bytes)
}(), }(),
}, },
@@ -55,12 +56,13 @@ func TestHealthController(t *testing.T) {
group := router.Group("/api") group := router.Group("/api")
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
controller.NewHealthController(group) healthController := controller.NewHealthController(group)
healthController.SetupRoutes()
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
request, err := http.NewRequest(test.method, test.path, nil) request, err := http.NewRequest(test.method, test.path, nil)
require.NoError(t, err) assert.NoError(t, err)
router.ServeHTTP(recorder, request) router.ServeHTTP(recorder, request)
+73 -89
View File
@@ -6,11 +6,10 @@ import (
"strings" "strings"
"time" "time"
"github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/repository" "github.com/tinyauthapp/tinyauth/internal/repository"
"github.com/tinyauthapp/tinyauth/internal/service" "github.com/tinyauthapp/tinyauth/internal/service"
"github.com/tinyauthapp/tinyauth/internal/utils" "github.com/tinyauthapp/tinyauth/internal/utils"
"github.com/tinyauthapp/tinyauth/internal/utils/logger" "github.com/tinyauthapp/tinyauth/internal/utils/tlog"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/go-querystring/query" "github.com/google/go-querystring/query"
@@ -20,32 +19,34 @@ type OAuthRequest struct {
Provider string `uri:"provider" binding:"required"` Provider string `uri:"provider" binding:"required"`
} }
type OAuthController struct { type OAuthControllerConfig struct {
log *logger.Logger CSRFCookieName string
config model.Config OAuthSessionCookieName string
runtime model.RuntimeConfig RedirectCookieName string
auth *service.AuthService SecureCookie bool
AppURL string
CookieDomain string
SubdomainsEnabled bool
} }
func NewOAuthController( type OAuthController struct {
log *logger.Logger, config OAuthControllerConfig
config model.Config, router *gin.RouterGroup
runtimeConfig model.RuntimeConfig, auth *service.AuthService
router *gin.RouterGroup, }
auth *service.AuthService,
) *OAuthController {
controller := &OAuthController{
log: log,
config: config,
runtime: runtimeConfig,
auth: auth,
}
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("/url/:provider", controller.oauthURLHandler)
oauthGroup.GET("/callback/:provider", controller.oauthCallbackHandler) oauthGroup.GET("/callback/:provider", controller.oauthCallbackHandler)
return controller
} }
func (controller *OAuthController) oauthURLHandler(c *gin.Context) { func (controller *OAuthController) oauthURLHandler(c *gin.Context) {
@@ -53,7 +54,7 @@ func (controller *OAuthController) oauthURLHandler(c *gin.Context) {
err := c.BindUri(&req) err := c.BindUri(&req)
if err != nil { 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{ c.JSON(400, gin.H{
"status": 400, "status": 400,
"message": "Bad Request", "message": "Bad Request",
@@ -66,7 +67,7 @@ func (controller *OAuthController) oauthURLHandler(c *gin.Context) {
err = c.BindQuery(&reqParams) err = c.BindQuery(&reqParams)
if err != nil { 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{ c.JSON(400, gin.H{
"status": 400, "status": 400,
"message": "Bad Request", "message": "Bad Request",
@@ -75,10 +76,10 @@ func (controller *OAuthController) oauthURLHandler(c *gin.Context) {
} }
if !controller.isOidcRequest(reqParams) { if !controller.isOidcRequest(reqParams) {
isRedirectSafe := utils.IsRedirectSafe(reqParams.RedirectURI, controller.runtime.CookieDomain) isRedirectSafe := utils.IsRedirectSafe(reqParams.RedirectURI, controller.config.CookieDomain)
if !isRedirectSafe { 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 = "" reqParams.RedirectURI = ""
} }
} }
@@ -86,7 +87,7 @@ func (controller *OAuthController) oauthURLHandler(c *gin.Context) {
sessionId, _, err := controller.auth.NewOAuthSession(req.Provider, reqParams) sessionId, _, err := controller.auth.NewOAuthSession(req.Provider, reqParams)
if err != nil { if err != nil {
controller.log.App.Error().Err(err).Msg("Failed to create new OAuth session") tlog.App.Error().Err(err).Msg("Failed to create OAuth session")
c.JSON(500, gin.H{ c.JSON(500, gin.H{
"status": 500, "status": 500,
"message": "Internal Server Error", "message": "Internal Server Error",
@@ -97,7 +98,7 @@ func (controller *OAuthController) oauthURLHandler(c *gin.Context) {
authUrl, err := controller.auth.GetOAuthURL(sessionId) authUrl, err := controller.auth.GetOAuthURL(sessionId)
if err != nil { 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{ c.JSON(500, gin.H{
"status": 500, "status": 500,
"message": "Internal Server Error", "message": "Internal Server Error",
@@ -105,7 +106,7 @@ func (controller *OAuthController) oauthURLHandler(c *gin.Context) {
return 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()), "/", controller.getCookieDomain(), controller.config.SecureCookie, true)
c.JSON(200, gin.H{ c.JSON(200, gin.H{
"status": 200, "status": 200,
@@ -119,7 +120,7 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
err := c.BindUri(&req) err := c.BindUri(&req)
if err != nil { 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{ c.JSON(400, gin.H{
"status": 400, "status": 400,
"message": "Bad Request", "message": "Bad Request",
@@ -127,21 +128,21 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
return return
} }
sessionIdCookie, err := c.Cookie(controller.runtime.OAuthSessionCookieName) sessionIdCookie, err := c.Cookie(controller.config.OAuthSessionCookieName)
if err != nil { if err != nil {
controller.log.App.Error().Err(err).Msg("Failed to get OAuth session cookie") tlog.App.Warn().Err(err).Msg("OAuth session cookie missing")
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.runtime.AppURL)) c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
return return
} }
c.SetCookie(controller.runtime.OAuthSessionCookieName, "", -1, "/", controller.getCookieDomain(), controller.config.Auth.SecureCookie, true) c.SetCookie(controller.config.OAuthSessionCookieName, "", -1, "/", controller.getCookieDomain(), controller.config.SecureCookie, true)
oauthPendingSession, err := controller.auth.GetOAuthPendingSession(sessionIdCookie) oauthPendingSession, err := controller.auth.GetOAuthPendingSession(sessionIdCookie)
if err != nil { if err != nil {
controller.log.App.Error().Err(err).Msg("Failed to get pending OAuth session") tlog.App.Error().Err(err).Msg("Failed to get OAuth pending session")
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.runtime.AppURL)) c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
return return
} }
@@ -149,8 +150,8 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
state := c.Query("state") state := c.Query("state")
if state != oauthPendingSession.State { if state != oauthPendingSession.State {
controller.log.App.Warn().Msg("OAuth state mismatch") tlog.App.Warn().Err(err).Msg("CSRF token mismatch")
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.runtime.AppURL)) c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
return return
} }
@@ -158,85 +159,68 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
_, err = controller.auth.GetOAuthToken(sessionIdCookie, code) _, err = controller.auth.GetOAuthToken(sessionIdCookie, code)
if err != nil { if err != nil {
controller.log.App.Error().Err(err).Msg("Failed to exchange code for token") tlog.App.Error().Err(err).Msg("Failed to exchange code for token")
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.runtime.AppURL)) c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
return return
} }
user, err := controller.auth.GetOAuthUserinfo(sessionIdCookie) 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 == "" { if user.Email == "" {
controller.log.App.Warn().Msg("OAuth provider did not return an email") tlog.App.Error().Msg("OAuth provider did not return an email")
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.runtime.AppURL)) c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
return return
} }
if !controller.auth.IsEmailWhitelisted(user.Email) { if !controller.auth.IsEmailWhitelisted(user.Email) {
controller.log.App.Warn().Str("email", user.Email).Msg("Email not whitelisted, denying access") tlog.App.Warn().Str("email", user.Email).Msg("Email not whitelisted")
controller.log.AuditLoginFailure(user.Email, req.Provider, c.ClientIP(), "email not whitelisted") tlog.AuditLoginFailure(c, user.Email, req.Provider, "email not whitelisted")
queries, err := query.Values(UnauthorizedQuery{ queries, err := query.Values(UnauthorizedQuery{
Username: user.Email, Username: user.Email,
}) })
if err != nil { 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")
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.runtime.AppURL)) c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
return 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 return
} }
var name string var name string
if strings.TrimSpace(user.Name) != "" { 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 name = user.Name
} else { } 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")
parts := strings.SplitN(user.Email, "@", 2) name = fmt.Sprintf("%s (%s)", utils.Capitalize(strings.Split(user.Email, "@")[0]), strings.Split(user.Email, "@")[1])
if len(parts) == 2 {
name = fmt.Sprintf("%s (%s)", utils.Capitalize(parts[0]), parts[1])
} else {
name = utils.Capitalize(user.Email)
}
} }
var username string var username string
if strings.TrimSpace(user.PreferredUsername) != "" { 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 username = user.PreferredUsername
} else { } 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) username = strings.Replace(user.Email, "@", "_", 1)
} }
svc, err := controller.auth.GetOAuthService(sessionIdCookie) svc, err := controller.auth.GetOAuthService(sessionIdCookie)
if err != nil { if err != nil {
controller.log.App.Error().Err(err).Msg("Failed to get OAuth service for session") tlog.App.Error().Err(err).Msg("Failed to get OAuth service for session")
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.runtime.AppURL)) c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
return return
} }
if svc.ID() != req.Provider { if svc.ID() != req.Provider {
controller.log.App.Warn().Msgf("OAuth provider mismatch: expected %s, got %s", req.Provider, svc.ID()) 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.runtime.AppURL)) c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
return return
} }
@@ -250,29 +234,29 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
OAuthSub: user.Sub, 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) cookie, err := controller.auth.CreateSession(c, sessionCookie)
if err != nil { if err != nil {
controller.log.App.Error().Err(err).Msg("Failed to create session cookie") tlog.App.Error().Err(err).Msg("Failed to create session cookie")
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.runtime.AppURL)) c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
return return
} }
http.SetCookie(c.Writer, cookie) 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) { 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) queries, err := query.Values(oauthPendingSession.CallbackParams)
if err != nil { if err != nil {
controller.log.App.Error().Err(err).Msg("Failed to encode OIDC callback query") tlog.App.Error().Err(err).Msg("Failed to encode OIDC callback query")
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.runtime.AppURL)) c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
return 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 return
} }
@@ -282,16 +266,16 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
}) })
if err != nil { 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")
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.runtime.AppURL)) c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL))
return 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 return
} }
c.Redirect(http.StatusTemporaryRedirect, controller.runtime.AppURL) c.Redirect(http.StatusTemporaryRedirect, controller.config.AppURL)
} }
func (controller *OAuthController) isOidcRequest(params service.OAuthURLParams) bool { func (controller *OAuthController) isOidcRequest(params service.OAuthURLParams) bool {
@@ -302,8 +286,8 @@ func (controller *OAuthController) isOidcRequest(params service.OAuthURLParams)
} }
func (controller *OAuthController) getCookieDomain() string { func (controller *OAuthController) getCookieDomain() string {
if controller.config.Auth.SubdomainsEnabled { if controller.config.SubdomainsEnabled {
return "." + controller.runtime.CookieDomain return "." + controller.config.CookieDomain
} }
return controller.runtime.CookieDomain return controller.config.CookieDomain
} }
+56 -75
View File
@@ -13,13 +13,15 @@ import (
"github.com/tinyauthapp/tinyauth/internal/model" "github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/service" "github.com/tinyauthapp/tinyauth/internal/service"
"github.com/tinyauthapp/tinyauth/internal/utils" "github.com/tinyauthapp/tinyauth/internal/utils"
"github.com/tinyauthapp/tinyauth/internal/utils/logger" "github.com/tinyauthapp/tinyauth/internal/utils/tlog"
) )
type OIDCControllerConfig struct{}
type OIDCController struct { type OIDCController struct {
log *logger.Logger config OIDCControllerConfig
oidc *service.OIDCService router *gin.RouterGroup
runtime model.RuntimeConfig oidc *service.OIDCService
} }
type AuthorizeCallback struct { type AuthorizeCallback struct {
@@ -56,42 +58,29 @@ type ClientCredentials struct {
ClientSecret string ClientSecret string
} }
func NewOIDCController( func NewOIDCController(config OIDCControllerConfig, oidcService *service.OIDCService, router *gin.RouterGroup) *OIDCController {
log *logger.Logger, return &OIDCController{
oidcService *service.OIDCService, config: config,
runtimeConfig model.RuntimeConfig, oidc: oidcService,
router *gin.RouterGroup) *OIDCController { router: router,
controller := &OIDCController{
log: log,
oidc: oidcService,
runtime: runtimeConfig,
} }
}
oidcGroup := router.Group("/oidc") func (controller *OIDCController) SetupRoutes() {
oidcGroup := controller.router.Group("/oidc")
oidcGroup.GET("/clients/:id", controller.GetClientInfo) oidcGroup.GET("/clients/:id", controller.GetClientInfo)
oidcGroup.POST("/authorize", controller.Authorize) oidcGroup.POST("/authorize", controller.Authorize)
oidcGroup.POST("/token", controller.Token) oidcGroup.POST("/token", controller.Token)
oidcGroup.GET("/userinfo", controller.Userinfo) oidcGroup.GET("/userinfo", controller.Userinfo)
oidcGroup.POST("/userinfo", controller.Userinfo) oidcGroup.POST("/userinfo", controller.Userinfo)
return controller
} }
func (controller *OIDCController) GetClientInfo(c *gin.Context) { 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 var req ClientRequest
err := c.BindUri(&req) err := c.BindUri(&req)
if err != nil { 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{ c.JSON(400, gin.H{
"status": 400, "status": 400,
"message": "Bad Request", "message": "Bad Request",
@@ -102,7 +91,7 @@ func (controller *OIDCController) GetClientInfo(c *gin.Context) {
client, ok := controller.oidc.GetClient(req.ClientID) client, ok := controller.oidc.GetClient(req.ClientID)
if !ok { 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{ c.JSON(404, gin.H{
"status": 404, "status": 404,
"message": "Client not found", "message": "Client not found",
@@ -118,7 +107,7 @@ func (controller *OIDCController) GetClientInfo(c *gin.Context) {
} }
func (controller *OIDCController) Authorize(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", "", "", "") controller.authorizeError(c, errors.New("err_oidc_not_configured"), "OIDC not configured", "This instance is not configured for OIDC", "", "", "")
return return
} }
@@ -146,14 +135,14 @@ func (controller *OIDCController) Authorize(c *gin.Context) {
client, ok := controller.oidc.GetClient(req.ClientID) client, ok := controller.oidc.GetClient(req.ClientID)
if !ok { if !ok {
controller.authorizeError(c, fmt.Errorf("client not found: %s", req.ClientID), "Client not found", "The client ID is invalid", "", "", "") controller.authorizeError(c, err, "Client not found", "The client ID is invalid", "", "", "")
return return
} }
err = controller.oidc.ValidateAuthorizeParams(req) err = controller.oidc.ValidateAuthorizeParams(req)
if err != nil { 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" { if err.Error() != "invalid_request_uri" {
controller.authorizeError(c, err, "Failed validate authorize params", "Invalid request parameters", req.RedirectURI, err.Error(), req.State) controller.authorizeError(c, err, "Failed validate authorize params", "Invalid request parameters", req.RedirectURI, err.Error(), req.State)
return return
@@ -185,7 +174,7 @@ func (controller *OIDCController) Authorize(c *gin.Context) {
err = controller.oidc.StoreUserinfo(c, sub, *userContext, req) err = controller.oidc.StoreUserinfo(c, sub, *userContext, req)
if err != nil { 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) controller.authorizeError(c, err, "Failed to store user info", "Failed to store user info", req.RedirectURI, "server_error", req.State)
return return
} }
@@ -208,10 +197,10 @@ func (controller *OIDCController) Authorize(c *gin.Context) {
} }
func (controller *OIDCController) Token(c *gin.Context) { func (controller *OIDCController) Token(c *gin.Context) {
if controller.oidc == nil { if !controller.oidc.IsConfigured() {
controller.log.App.Warn().Msg("Received OIDC request but OIDC server is not configured") tlog.App.Warn().Msg("OIDC not configured")
c.JSON(500, gin.H{ c.JSON(404, gin.H{
"error": "server_error", "error": "not_found",
}) })
return return
} }
@@ -220,7 +209,7 @@ func (controller *OIDCController) Token(c *gin.Context) {
err := c.Bind(&req) err := c.Bind(&req)
if err != nil { 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{ c.JSON(400, gin.H{
"error": "invalid_request", "error": "invalid_request",
}) })
@@ -229,7 +218,7 @@ func (controller *OIDCController) Token(c *gin.Context) {
err = controller.oidc.ValidateGrantType(req.GrantType) err = controller.oidc.ValidateGrantType(req.GrantType)
if err != nil { 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{ c.JSON(400, gin.H{
"error": err.Error(), "error": err.Error(),
}) })
@@ -244,12 +233,12 @@ func (controller *OIDCController) Token(c *gin.Context) {
// If it fails, we try basic auth // If it fails, we try basic auth
if creds.ClientID == "" || creds.ClientSecret == "" { 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() clientId, clientSecret, ok := c.Request.BasicAuth()
if !ok { 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.Header("www-authenticate", `Basic realm="Tinyauth OIDC Token Endpoint"`)
c.JSON(400, gin.H{ c.JSON(400, gin.H{
"error": "invalid_client", "error": "invalid_client",
@@ -266,7 +255,7 @@ func (controller *OIDCController) Token(c *gin.Context) {
client, ok := controller.oidc.GetClient(creds.ClientID) client, ok := controller.oidc.GetClient(creds.ClientID)
if !ok { 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{ c.JSON(400, gin.H{
"error": "invalid_client", "error": "invalid_client",
}) })
@@ -274,7 +263,7 @@ func (controller *OIDCController) Token(c *gin.Context) {
} }
if client.ClientSecret != creds.ClientSecret { 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{ c.JSON(400, gin.H{
"error": "invalid_client", "error": "invalid_client",
}) })
@@ -288,30 +277,30 @@ func (controller *OIDCController) Token(c *gin.Context) {
entry, err := controller.oidc.GetCodeEntry(c, controller.oidc.Hash(req.Code), client.ClientID) entry, err := controller.oidc.GetCodeEntry(c, controller.oidc.Hash(req.Code), client.ClientID)
if err != nil { if err != nil {
if err := controller.oidc.DeleteTokenByCodeHash(c, controller.oidc.Hash(req.Code)); err != nil { if err := controller.oidc.DeleteTokenByCodeHash(c, controller.oidc.Hash(req.Code)); err != nil {
controller.log.App.Error().Err(err).Msg("Failed to revoke tokens for replayed code") tlog.App.Error().Err(err).Msg("Failed to delete access token by code hash")
} }
if errors.Is(err, service.ErrCodeNotFound) { 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{ c.JSON(400, gin.H{
"error": "invalid_grant", "error": "invalid_grant",
}) })
return return
} }
if errors.Is(err, service.ErrCodeExpired) { if errors.Is(err, service.ErrCodeExpired) {
controller.log.App.Warn().Msg("Code expired") tlog.App.Warn().Msg("Code expired")
c.JSON(400, gin.H{ c.JSON(400, gin.H{
"error": "invalid_grant", "error": "invalid_grant",
}) })
return return
} }
if errors.Is(err, service.ErrInvalidClient) { 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{ c.JSON(400, gin.H{
"error": "invalid_client", "error": "invalid_client",
}) })
return 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{ c.JSON(400, gin.H{
"error": "server_error", "error": "server_error",
}) })
@@ -319,7 +308,7 @@ func (controller *OIDCController) Token(c *gin.Context) {
} }
if entry.RedirectURI != req.RedirectURI { 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{ c.JSON(400, gin.H{
"error": "invalid_grant", "error": "invalid_grant",
}) })
@@ -329,7 +318,7 @@ func (controller *OIDCController) Token(c *gin.Context) {
ok := controller.oidc.ValidatePKCE(entry.CodeChallenge, req.CodeVerifier) ok := controller.oidc.ValidatePKCE(entry.CodeChallenge, req.CodeVerifier)
if !ok { if !ok {
controller.log.App.Warn().Msg("PKCE validation failed") tlog.App.Warn().Msg("PKCE validation failed")
c.JSON(400, gin.H{ c.JSON(400, gin.H{
"error": "invalid_grant", "error": "invalid_grant",
}) })
@@ -339,7 +328,7 @@ func (controller *OIDCController) Token(c *gin.Context) {
tokenRes, err := controller.oidc.GenerateAccessToken(c, client, entry) tokenRes, err := controller.oidc.GenerateAccessToken(c, client, entry)
if err != nil { if err != nil {
controller.log.App.Error().Err(err).Msg("Failed to generate access token") tlog.App.Error().Err(err).Msg("Failed to generate access token")
c.JSON(400, gin.H{ c.JSON(400, gin.H{
"error": "server_error", "error": "server_error",
}) })
@@ -352,7 +341,7 @@ func (controller *OIDCController) Token(c *gin.Context) {
if err != nil { if err != nil {
if errors.Is(err, service.ErrTokenExpired) { 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{ c.JSON(400, gin.H{
"error": "invalid_grant", "error": "invalid_grant",
}) })
@@ -360,14 +349,14 @@ func (controller *OIDCController) Token(c *gin.Context) {
} }
if errors.Is(err, service.ErrInvalidClient) { 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{ c.JSON(400, gin.H{
"error": "invalid_grant", "error": "invalid_grant",
}) })
return 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{ c.JSON(400, gin.H{
"error": "server_error", "error": "server_error",
}) })
@@ -384,10 +373,10 @@ func (controller *OIDCController) Token(c *gin.Context) {
} }
func (controller *OIDCController) Userinfo(c *gin.Context) { func (controller *OIDCController) Userinfo(c *gin.Context) {
if controller.oidc == nil { if !controller.oidc.IsConfigured() {
controller.log.App.Warn().Msg("Received OIDC userinfo request but OIDC server is not configured") tlog.App.Warn().Msg("OIDC not configured")
c.JSON(500, gin.H{ c.JSON(404, gin.H{
"error": "server_error", "error": "not_found",
}) })
return return
} }
@@ -398,7 +387,7 @@ func (controller *OIDCController) Userinfo(c *gin.Context) {
if authorization != "" { if authorization != "" {
tokenType, bearerToken, ok := strings.Cut(authorization, " ") tokenType, bearerToken, ok := strings.Cut(authorization, " ")
if !ok { 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{ c.JSON(401, gin.H{
"error": "invalid_request", "error": "invalid_request",
}) })
@@ -406,7 +395,7 @@ func (controller *OIDCController) Userinfo(c *gin.Context) {
} }
if strings.ToLower(tokenType) != "bearer" { 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{ c.JSON(401, gin.H{
"error": "invalid_request", "error": "invalid_request",
}) })
@@ -416,7 +405,7 @@ func (controller *OIDCController) Userinfo(c *gin.Context) {
token = bearerToken token = bearerToken
} else if c.Request.Method == http.MethodPost { } else if c.Request.Method == http.MethodPost {
if c.ContentType() != "application/x-www-form-urlencoded" { 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{ c.JSON(400, gin.H{
"error": "invalid_request", "error": "invalid_request",
}) })
@@ -424,14 +413,14 @@ func (controller *OIDCController) Userinfo(c *gin.Context) {
} }
token = c.PostForm("access_token") token = c.PostForm("access_token")
if 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{ c.JSON(401, gin.H{
"error": "invalid_request", "error": "invalid_request",
}) })
return return
} }
} else { } 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{ c.JSON(401, gin.H{
"error": "invalid_request", "error": "invalid_request",
}) })
@@ -442,14 +431,14 @@ func (controller *OIDCController) Userinfo(c *gin.Context) {
if err != nil { if err != nil {
if errors.Is(err, service.ErrTokenNotFound) { if errors.Is(err, service.ErrTokenNotFound) {
controller.log.App.Warn().Msg("OIDC userinfo accessed with invalid token") tlog.App.Warn().Msg("OIDC userinfo accessed with invalid token")
c.JSON(401, gin.H{ c.JSON(401, gin.H{
"error": "invalid_grant", "error": "invalid_grant",
}) })
return 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{ c.JSON(401, gin.H{
"error": "server_error", "error": "server_error",
}) })
@@ -458,7 +447,7 @@ func (controller *OIDCController) Userinfo(c *gin.Context) {
// If we don't have the openid scope, return an error // If we don't have the openid scope, return an error
if !slices.Contains(strings.Split(entry.Scope, ","), "openid") { if !slices.Contains(strings.Split(entry.Scope, ","), "openid") {
controller.log.App.Warn().Msg("OIDC userinfo accessed with token missing openid scope") tlog.App.Warn().Msg("OIDC userinfo accessed without openid scope")
c.JSON(401, gin.H{ c.JSON(401, gin.H{
"error": "invalid_scope", "error": "invalid_scope",
}) })
@@ -468,7 +457,7 @@ func (controller *OIDCController) Userinfo(c *gin.Context) {
user, err := controller.oidc.GetUserinfo(c, entry.Sub) user, err := controller.oidc.GetUserinfo(c, entry.Sub)
if err != nil { 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{ c.JSON(401, gin.H{
"error": "server_error", "error": "server_error",
}) })
@@ -479,7 +468,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) { 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 != "" { if callback != "" {
errorQueries := CallbackError{ errorQueries := CallbackError{
@@ -519,16 +508,8 @@ func (controller *OIDCController) authorizeError(c *gin.Context, err error, reas
return 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{ c.JSON(200, gin.H{
"status": 200, "status": 200,
"redirect_uri": redirectUrl, "redirect_uri": fmt.Sprintf("%s/error?%s", controller.oidc.GetIssuer(), queries.Encode()),
}) })
} }
+74 -61
View File
@@ -1,14 +1,13 @@
package controller_test package controller_test
import ( import (
"context"
"crypto/sha256" "crypto/sha256"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
"path"
"strings" "strings"
"sync"
"testing" "testing"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -19,15 +18,29 @@ import (
"github.com/tinyauthapp/tinyauth/internal/model" "github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/repository/memory" "github.com/tinyauthapp/tinyauth/internal/repository/memory"
"github.com/tinyauthapp/tinyauth/internal/service" "github.com/tinyauthapp/tinyauth/internal/service"
"github.com/tinyauthapp/tinyauth/internal/test" "github.com/tinyauthapp/tinyauth/internal/utils/tlog"
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
) )
func TestOIDCController(t *testing.T) { func TestOIDCController(t *testing.T) {
log := logger.NewLogger().WithTestConfig() tlog.NewTestLogger().Init()
log.Init() tempDir := t.TempDir()
cfg, runtime := test.CreateTestConfigs(t) oidcServiceCfg := service.OIDCServiceConfig{
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: 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) { simpleCtx := func(c *gin.Context) {
c.Set("context", &model.UserContext{ c.Set("context", &model.UserContext{
@@ -89,7 +102,7 @@ func TestOIDCController(t *testing.T) {
var res map[string]any var res map[string]any
err := json.Unmarshal(recorder.Body.Bytes(), &res) 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") assert.Equal(t, res["redirect_uri"], "https://tinyauth.example.com/error?error=User+is+not+logged+in+or+the+session+is+invalid")
}, },
@@ -109,7 +122,7 @@ func TestOIDCController(t *testing.T) {
Nonce: "some-nonce", Nonce: "some-nonce",
} }
reqBodyBytes, err := json.Marshal(reqBody) 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 := httptest.NewRequest("POST", "/api/oidc/authorize", strings.NewReader(string(reqBodyBytes)))
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
@@ -117,7 +130,7 @@ func TestOIDCController(t *testing.T) {
var res map[string]any var res map[string]any
err = json.Unmarshal(recorder.Body.Bytes(), &res) 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") assert.Equal(t, res["redirect_uri"], "https://test.example.com/callback?error=unsupported_response_type&error_description=Invalid+request+parameters&state=some-state")
}, },
@@ -137,7 +150,7 @@ func TestOIDCController(t *testing.T) {
Nonce: "some-nonce", Nonce: "some-nonce",
} }
reqBodyBytes, err := json.Marshal(reqBody) 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 := httptest.NewRequest("POST", "/api/oidc/authorize", strings.NewReader(string(reqBodyBytes)))
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
@@ -146,11 +159,11 @@ func TestOIDCController(t *testing.T) {
var res map[string]any var res map[string]any
err = json.Unmarshal(recorder.Body.Bytes(), &res) err = json.Unmarshal(recorder.Body.Bytes(), &res)
require.NoError(t, err) assert.NoError(t, err)
redirectURI := res["redirect_uri"].(string) redirectURI := res["redirect_uri"].(string)
url, err := url.Parse(redirectURI) url, err := url.Parse(redirectURI)
require.NoError(t, err) assert.NoError(t, err)
queryParams := url.Query() queryParams := url.Query()
assert.Equal(t, queryParams.Get("state"), "some-state") assert.Equal(t, queryParams.Get("state"), "some-state")
@@ -169,7 +182,7 @@ func TestOIDCController(t *testing.T) {
RedirectURI: "https://test.example.com/callback", RedirectURI: "https://test.example.com/callback",
} }
reqBodyEncoded, err := query.Values(reqBody) 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 := httptest.NewRequest("POST", "/api/oidc/token", strings.NewReader(reqBodyEncoded.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
@@ -177,7 +190,7 @@ func TestOIDCController(t *testing.T) {
var res map[string]any var res map[string]any
err = json.Unmarshal(recorder.Body.Bytes(), &res) err = json.Unmarshal(recorder.Body.Bytes(), &res)
require.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, res["error"], "unsupported_grant_type") assert.Equal(t, res["error"], "unsupported_grant_type")
}, },
@@ -192,7 +205,7 @@ func TestOIDCController(t *testing.T) {
RedirectURI: "https://test.example.com/callback", RedirectURI: "https://test.example.com/callback",
} }
reqBodyEncoded, err := query.Values(reqBody) 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 := httptest.NewRequest("POST", "/api/oidc/token", strings.NewReader(reqBodyEncoded.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
@@ -230,7 +243,7 @@ func TestOIDCController(t *testing.T) {
RedirectURI: "https://test.example.com/callback", RedirectURI: "https://test.example.com/callback",
} }
reqBodyEncoded, err := query.Values(reqBody) 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 := httptest.NewRequest("POST", "/api/oidc/token", strings.NewReader(reqBodyEncoded.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
@@ -253,11 +266,11 @@ func TestOIDCController(t *testing.T) {
var authorizeRes map[string]any var authorizeRes map[string]any
err := json.Unmarshal(authorizeTestRecorder.Body.Bytes(), &authorizeRes) err := json.Unmarshal(authorizeTestRecorder.Body.Bytes(), &authorizeRes)
require.NoError(t, err) assert.NoError(t, err)
redirectURI := authorizeRes["redirect_uri"].(string) redirectURI := authorizeRes["redirect_uri"].(string)
url, err := url.Parse(redirectURI) url, err := url.Parse(redirectURI)
require.NoError(t, err) assert.NoError(t, err)
queryParams := url.Query() queryParams := url.Query()
code := queryParams.Get("code") code := queryParams.Get("code")
@@ -269,7 +282,7 @@ func TestOIDCController(t *testing.T) {
RedirectURI: "https://test.example.com/callback", RedirectURI: "https://test.example.com/callback",
} }
reqBodyEncoded, err := query.Values(reqBody) 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 := httptest.NewRequest("POST", "/api/oidc/token", strings.NewReader(reqBodyEncoded.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
@@ -292,7 +305,7 @@ func TestOIDCController(t *testing.T) {
var tokenRes map[string]any var tokenRes map[string]any
err := json.Unmarshal(tokenRecorder.Body.Bytes(), &tokenRes) err := json.Unmarshal(tokenRecorder.Body.Bytes(), &tokenRes)
require.NoError(t, err) assert.NoError(t, err)
_, ok := tokenRes["refresh_token"] _, ok := tokenRes["refresh_token"]
assert.True(t, ok, "Expected refresh token in response") assert.True(t, ok, "Expected refresh token in response")
@@ -306,7 +319,7 @@ func TestOIDCController(t *testing.T) {
ClientSecret: "some-client-secret", ClientSecret: "some-client-secret",
} }
reqBodyEncoded, err := query.Values(reqBody) 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 := httptest.NewRequest("POST", "/api/oidc/token", strings.NewReader(reqBodyEncoded.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
@@ -318,7 +331,7 @@ func TestOIDCController(t *testing.T) {
assert.Equal(t, 200, recorder.Code) assert.Equal(t, 200, recorder.Code)
var refreshRes map[string]any var refreshRes map[string]any
err = json.Unmarshal(recorder.Body.Bytes(), &refreshRes) err = json.Unmarshal(recorder.Body.Bytes(), &refreshRes)
require.NoError(t, err) assert.NoError(t, err)
_, ok = refreshRes["access_token"] _, ok = refreshRes["access_token"]
assert.True(t, ok, "Expected access token in refresh response") assert.True(t, ok, "Expected access token in refresh response")
@@ -339,11 +352,11 @@ func TestOIDCController(t *testing.T) {
var authorizeRes map[string]any var authorizeRes map[string]any
err := json.Unmarshal(authorizeTestRecorder.Body.Bytes(), &authorizeRes) err := json.Unmarshal(authorizeTestRecorder.Body.Bytes(), &authorizeRes)
require.NoError(t, err) assert.NoError(t, err)
redirectURI := authorizeRes["redirect_uri"].(string) redirectURI := authorizeRes["redirect_uri"].(string)
url, err := url.Parse(redirectURI) url, err := url.Parse(redirectURI)
require.NoError(t, err) assert.NoError(t, err)
queryParams := url.Query() queryParams := url.Query()
code := queryParams.Get("code") code := queryParams.Get("code")
@@ -355,7 +368,7 @@ func TestOIDCController(t *testing.T) {
RedirectURI: "https://test.example.com/callback", RedirectURI: "https://test.example.com/callback",
} }
reqBodyEncoded, err := query.Values(reqBody) 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 := httptest.NewRequest("POST", "/api/oidc/token", strings.NewReader(reqBodyEncoded.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
@@ -375,7 +388,7 @@ func TestOIDCController(t *testing.T) {
var secondRes map[string]any var secondRes map[string]any
err = json.Unmarshal(secondRecorder.Body.Bytes(), &secondRes) err = json.Unmarshal(secondRecorder.Body.Bytes(), &secondRes)
require.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, "invalid_grant", secondRes["error"]) assert.Equal(t, "invalid_grant", secondRes["error"])
}, },
@@ -403,7 +416,7 @@ func TestOIDCController(t *testing.T) {
var tokenRes map[string]any var tokenRes map[string]any
err := json.Unmarshal(tokenRecorder.Body.Bytes(), &tokenRes) err := json.Unmarshal(tokenRecorder.Body.Bytes(), &tokenRes)
require.NoError(t, err) assert.NoError(t, err)
accessToken := tokenRes["access_token"].(string) accessToken := tokenRes["access_token"].(string)
assert.NotEmpty(t, accessToken) assert.NotEmpty(t, accessToken)
@@ -415,7 +428,7 @@ func TestOIDCController(t *testing.T) {
var userInfoRes map[string]any var userInfoRes map[string]any
err = json.Unmarshal(recorder.Body.Bytes(), &userInfoRes) err = json.Unmarshal(recorder.Body.Bytes(), &userInfoRes)
require.NoError(t, err) assert.NoError(t, err)
_, ok := userInfoRes["sub"] _, ok := userInfoRes["sub"]
assert.True(t, ok, "Expected sub claim in userinfo response") assert.True(t, ok, "Expected sub claim in userinfo response")
@@ -435,7 +448,7 @@ func TestOIDCController(t *testing.T) {
var res map[string]any var res map[string]any
err := json.Unmarshal(recorder.Body.Bytes(), &res) err := json.Unmarshal(recorder.Body.Bytes(), &res)
require.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, "invalid_request", res["error"]) assert.Equal(t, "invalid_request", res["error"])
}, },
}, },
@@ -450,7 +463,7 @@ func TestOIDCController(t *testing.T) {
var res map[string]any var res map[string]any
err := json.Unmarshal(recorder.Body.Bytes(), &res) err := json.Unmarshal(recorder.Body.Bytes(), &res)
require.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, "invalid_request", res["error"]) assert.Equal(t, "invalid_request", res["error"])
}, },
}, },
@@ -465,7 +478,7 @@ func TestOIDCController(t *testing.T) {
var res map[string]any var res map[string]any
err := json.Unmarshal(recorder.Body.Bytes(), &res) err := json.Unmarshal(recorder.Body.Bytes(), &res)
require.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, "invalid_request", res["error"]) assert.Equal(t, "invalid_request", res["error"])
}, },
}, },
@@ -480,7 +493,7 @@ func TestOIDCController(t *testing.T) {
var res map[string]any var res map[string]any
err := json.Unmarshal(recorder.Body.Bytes(), &res) err := json.Unmarshal(recorder.Body.Bytes(), &res)
require.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, "invalid_grant", res["error"]) assert.Equal(t, "invalid_grant", res["error"])
}, },
}, },
@@ -495,7 +508,7 @@ func TestOIDCController(t *testing.T) {
var res map[string]any var res map[string]any
err := json.Unmarshal(recorder.Body.Bytes(), &res) err := json.Unmarshal(recorder.Body.Bytes(), &res)
require.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, "invalid_request", res["error"]) assert.Equal(t, "invalid_request", res["error"])
}, },
}, },
@@ -510,7 +523,7 @@ func TestOIDCController(t *testing.T) {
var res map[string]any var res map[string]any
err := json.Unmarshal(recorder.Body.Bytes(), &res) err := json.Unmarshal(recorder.Body.Bytes(), &res)
require.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, "invalid_request", res["error"]) assert.Equal(t, "invalid_request", res["error"])
}, },
}, },
@@ -527,7 +540,7 @@ func TestOIDCController(t *testing.T) {
var tokenRes map[string]any var tokenRes map[string]any
err := json.Unmarshal(tokenRecorder.Body.Bytes(), &tokenRes) err := json.Unmarshal(tokenRecorder.Body.Bytes(), &tokenRes)
require.NoError(t, err) assert.NoError(t, err)
accessToken := tokenRes["access_token"].(string) accessToken := tokenRes["access_token"].(string)
assert.NotEmpty(t, accessToken) assert.NotEmpty(t, accessToken)
@@ -541,7 +554,7 @@ func TestOIDCController(t *testing.T) {
var userInfoRes map[string]any var userInfoRes map[string]any
err = json.Unmarshal(recorder.Body.Bytes(), &userInfoRes) err = json.Unmarshal(recorder.Body.Bytes(), &userInfoRes)
require.NoError(t, err) assert.NoError(t, err)
_, ok := userInfoRes["sub"] _, ok := userInfoRes["sub"]
assert.True(t, ok, "Expected sub claim in userinfo response") assert.True(t, ok, "Expected sub claim in userinfo response")
@@ -565,7 +578,7 @@ func TestOIDCController(t *testing.T) {
CodeChallengeMethod: "", CodeChallengeMethod: "",
} }
reqBodyBytes, err := json.Marshal(reqBody) 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 := httptest.NewRequest("POST", "/api/oidc/authorize", strings.NewReader(string(reqBodyBytes)))
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
@@ -574,11 +587,11 @@ func TestOIDCController(t *testing.T) {
var res map[string]any var res map[string]any
err = json.Unmarshal(recorder.Body.Bytes(), &res) err = json.Unmarshal(recorder.Body.Bytes(), &res)
require.NoError(t, err) assert.NoError(t, err)
redirectURI := res["redirect_uri"].(string) redirectURI := res["redirect_uri"].(string)
url, err := url.Parse(redirectURI) url, err := url.Parse(redirectURI)
require.NoError(t, err) assert.NoError(t, err)
queryParams := url.Query() queryParams := url.Query()
assert.Equal(t, queryParams.Get("state"), "some-state") assert.Equal(t, queryParams.Get("state"), "some-state")
@@ -595,7 +608,7 @@ func TestOIDCController(t *testing.T) {
CodeVerifier: "some-challenge", CodeVerifier: "some-challenge",
} }
reqBodyEncoded, err := query.Values(tokenReqBody) 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 = httptest.NewRequest("POST", "/api/oidc/token", strings.NewReader(reqBodyEncoded.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
@@ -626,7 +639,7 @@ func TestOIDCController(t *testing.T) {
CodeChallengeMethod: "S256", CodeChallengeMethod: "S256",
} }
reqBodyBytes, err := json.Marshal(reqBody) 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 := httptest.NewRequest("POST", "/api/oidc/authorize", strings.NewReader(string(reqBodyBytes)))
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
@@ -635,11 +648,11 @@ func TestOIDCController(t *testing.T) {
var res map[string]any var res map[string]any
err = json.Unmarshal(recorder.Body.Bytes(), &res) err = json.Unmarshal(recorder.Body.Bytes(), &res)
require.NoError(t, err) assert.NoError(t, err)
redirectURI := res["redirect_uri"].(string) redirectURI := res["redirect_uri"].(string)
url, err := url.Parse(redirectURI) url, err := url.Parse(redirectURI)
require.NoError(t, err) assert.NoError(t, err)
queryParams := url.Query() queryParams := url.Query()
assert.Equal(t, queryParams.Get("state"), "some-state") assert.Equal(t, queryParams.Get("state"), "some-state")
@@ -656,7 +669,7 @@ func TestOIDCController(t *testing.T) {
CodeVerifier: "some-challenge", CodeVerifier: "some-challenge",
} }
reqBodyEncoded, err := query.Values(tokenReqBody) 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 = httptest.NewRequest("POST", "/api/oidc/token", strings.NewReader(reqBodyEncoded.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
@@ -687,7 +700,7 @@ func TestOIDCController(t *testing.T) {
CodeChallengeMethod: "S256", CodeChallengeMethod: "S256",
} }
reqBodyBytes, err := json.Marshal(reqBody) 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 := httptest.NewRequest("POST", "/api/oidc/authorize", strings.NewReader(string(reqBodyBytes)))
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
@@ -696,11 +709,11 @@ func TestOIDCController(t *testing.T) {
var res map[string]any var res map[string]any
err = json.Unmarshal(recorder.Body.Bytes(), &res) err = json.Unmarshal(recorder.Body.Bytes(), &res)
require.NoError(t, err) assert.NoError(t, err)
redirectURI := res["redirect_uri"].(string) redirectURI := res["redirect_uri"].(string)
url, err := url.Parse(redirectURI) url, err := url.Parse(redirectURI)
require.NoError(t, err) assert.NoError(t, err)
queryParams := url.Query() queryParams := url.Query()
assert.Equal(t, queryParams.Get("state"), "some-state") assert.Equal(t, queryParams.Get("state"), "some-state")
@@ -717,7 +730,7 @@ func TestOIDCController(t *testing.T) {
CodeVerifier: "some-challenge-1", CodeVerifier: "some-challenge-1",
} }
reqBodyEncoded, err := query.Values(tokenReqBody) 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 = httptest.NewRequest("POST", "/api/oidc/token", strings.NewReader(reqBodyEncoded.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
@@ -748,7 +761,7 @@ func TestOIDCController(t *testing.T) {
CodeChallengeMethod: "foo", CodeChallengeMethod: "foo",
} }
reqBodyBytes, err := json.Marshal(reqBody) 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 := httptest.NewRequest("POST", "/api/oidc/authorize", strings.NewReader(string(reqBodyBytes)))
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
@@ -757,11 +770,11 @@ func TestOIDCController(t *testing.T) {
var res map[string]any var res map[string]any
err = json.Unmarshal(recorder.Body.Bytes(), &res) err = json.Unmarshal(recorder.Body.Bytes(), &res)
require.NoError(t, err) assert.NoError(t, err)
redirectURI := res["redirect_uri"].(string) redirectURI := res["redirect_uri"].(string)
url, err := url.Parse(redirectURI) url, err := url.Parse(redirectURI)
require.NoError(t, err) assert.NoError(t, err)
queryParams := url.Query() queryParams := url.Query()
error := queryParams.Get("error") error := queryParams.Get("error")
@@ -780,11 +793,11 @@ func TestOIDCController(t *testing.T) {
var res map[string]any var res map[string]any
err := json.Unmarshal(recorder.Body.Bytes(), &res) err := json.Unmarshal(recorder.Body.Bytes(), &res)
require.NoError(t, err) assert.NoError(t, err)
redirectURI := res["redirect_uri"].(string) redirectURI := res["redirect_uri"].(string)
url, err := url.Parse(redirectURI) url, err := url.Parse(redirectURI)
require.NoError(t, err) assert.NoError(t, err)
queryParams := url.Query() queryParams := url.Query()
code := queryParams.Get("code") code := queryParams.Get("code")
@@ -796,7 +809,7 @@ func TestOIDCController(t *testing.T) {
RedirectURI: "https://test.example.com/callback", RedirectURI: "https://test.example.com/callback",
} }
reqBodyEncoded, err := query.Values(reqBody) 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 := httptest.NewRequest("POST", "/api/oidc/token", strings.NewReader(reqBodyEncoded.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
@@ -807,7 +820,7 @@ func TestOIDCController(t *testing.T) {
assert.Equal(t, 200, recorder.Code) assert.Equal(t, 200, recorder.Code)
err = json.Unmarshal(recorder.Body.Bytes(), &res) err = json.Unmarshal(recorder.Body.Bytes(), &res)
require.NoError(t, err) assert.NoError(t, err)
accessToken := res["access_token"].(string) accessToken := res["access_token"].(string)
assert.NotEmpty(t, accessToken) assert.NotEmpty(t, accessToken)
@@ -832,7 +845,7 @@ func TestOIDCController(t *testing.T) {
assert.Equal(t, 401, recorder.Code) assert.Equal(t, 401, recorder.Code)
err = json.Unmarshal(recorder.Body.Bytes(), &res) err = json.Unmarshal(recorder.Body.Bytes(), &res)
require.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, "invalid_grant", res["error"]) assert.Equal(t, "invalid_grant", res["error"])
}, },
}, },
@@ -840,9 +853,8 @@ func TestOIDCController(t *testing.T) {
store := memory.New() store := memory.New()
wg := &sync.WaitGroup{} oidcService := service.NewOIDCService(oidcServiceCfg, store)
err := oidcService.Init()
oidcService, err := service.NewOIDCService(log, cfg, runtime, store, context.TODO(), wg)
require.NoError(t, err) require.NoError(t, err)
for _, test := range tests { for _, test := range tests {
@@ -856,7 +868,8 @@ func TestOIDCController(t *testing.T) {
group := router.Group("/api") group := router.Group("/api")
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
controller.NewOIDCController(log, oidcService, runtime, group) oidcController := controller.NewOIDCController(controllerCfg, oidcService, group)
oidcController.SetupRoutes()
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
+66 -63
View File
@@ -3,7 +3,6 @@ package controller
import ( import (
"errors" "errors"
"fmt" "fmt"
"net"
"net/http" "net/http"
"net/url" "net/url"
"regexp" "regexp"
@@ -12,7 +11,7 @@ import (
"github.com/tinyauthapp/tinyauth/internal/model" "github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/service" "github.com/tinyauthapp/tinyauth/internal/service"
"github.com/tinyauthapp/tinyauth/internal/utils" "github.com/tinyauthapp/tinyauth/internal/utils"
"github.com/tinyauthapp/tinyauth/internal/utils/logger" "github.com/tinyauthapp/tinyauth/internal/utils/tlog"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/go-querystring/query" "github.com/google/go-querystring/query"
@@ -51,34 +50,29 @@ type ProxyContext struct {
ProxyType ProxyType ProxyType ProxyType
} }
type ProxyController struct { type ProxyControllerConfig struct {
log *logger.Logger AppURL string
runtime model.RuntimeConfig
acls *service.AccessControlsService
auth *service.AuthService
policyEngine *service.PolicyEngine
} }
func NewProxyController( type ProxyController struct {
log *logger.Logger, config ProxyControllerConfig
runtime model.RuntimeConfig, router *gin.RouterGroup
router *gin.RouterGroup, acls *service.AccessControlsService
acls *service.AccessControlsService, auth *service.AuthService
auth *service.AuthService, }
policyEngine *service.PolicyEngine,
) *ProxyController { func NewProxyController(config ProxyControllerConfig, router *gin.RouterGroup, acls *service.AccessControlsService, auth *service.AuthService) *ProxyController {
controller := &ProxyController{ return &ProxyController{
log: log, config: config,
runtime: runtime, router: router,
acls: acls, acls: acls,
auth: auth, auth: auth,
policyEngine: policyEngine,
} }
}
proxyGroup := router.Group("/auth") func (controller *ProxyController) SetupRoutes() {
proxyGroup := controller.router.Group("/auth")
proxyGroup.Any("/:proxy", controller.proxyHandler) proxyGroup.Any("/:proxy", controller.proxyHandler)
return controller
} }
func (controller *ProxyController) proxyHandler(c *gin.Context) { func (controller *ProxyController) proxyHandler(c *gin.Context) {
@@ -86,7 +80,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
proxyCtx, err := controller.getProxyContext(c) proxyCtx, err := controller.getProxyContext(c)
if err != nil { 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{ c.JSON(400, gin.H{
"status": 400, "status": 400,
"message": "Bad request", "message": "Bad request",
@@ -94,24 +88,22 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
return return
} }
tlog.App.Trace().Interface("ctx", proxyCtx).Msg("Got proxy context")
// Get acls // Get acls
acls, err := controller.acls.GetAccessControls(proxyCtx.Host) acls, err := controller.acls.GetAccessControls(proxyCtx.Host)
if err != nil { 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) controller.handleError(c, proxyCtx)
return return
} }
tlog.App.Trace().Interface("acls", acls).Msg("ACLs for resource")
clientIP := c.ClientIP() clientIP := c.ClientIP()
aclsCtx := &service.ACLContext{ if controller.auth.IsBypassedIP(clientIP, acls) {
ACLs: acls,
IP: net.ParseIP(clientIP),
Path: proxyCtx.Path,
}
if controller.policyEngine.Evaluate(service.RuleIPBypassed, aclsCtx) {
controller.setHeaders(c, acls) controller.setHeaders(c, acls)
c.JSON(200, gin.H{ c.JSON(200, gin.H{
"status": 200, "status": 200,
@@ -120,8 +112,16 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
return return
} }
if controller.policyEngine.Evaluate(service.RuleAuthEnabled, aclsCtx) { authEnabled, err := controller.auth.IsAuthEnabled(proxyCtx.Path, acls)
controller.log.App.Debug().Msg("Authentication is disabled for this resource, allowing access without authentication")
if err != nil {
tlog.App.Error().Err(err).Msg("Failed to check if auth is enabled for resource")
controller.handleError(c, proxyCtx)
return
}
if !authEnabled {
tlog.App.Debug().Msg("Authentication disabled for resource, allowing access")
controller.setHeaders(c, acls) controller.setHeaders(c, acls)
c.JSON(200, gin.H{ c.JSON(200, gin.H{
"status": 200, "status": 200,
@@ -130,25 +130,25 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
return return
} }
if !controller.policyEngine.Evaluate(service.RuleIPAllowed, aclsCtx) { if !controller.auth.CheckIP(clientIP, acls) {
queries, err := query.Values(UnauthorizedQuery{ queries, err := query.Values(UnauthorizedQuery{
Resource: strings.Split(proxyCtx.Host, ".")[0], Resource: strings.Split(proxyCtx.Host, ".")[0],
IP: clientIP, IP: clientIP,
}) })
if err != nil { 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) controller.handleError(c, proxyCtx)
return 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) { if !controller.useBrowserResponse(proxyCtx) {
c.Header("x-tinyauth-location", redirectURL) c.Header("x-tinyauth-location", redirectURL)
c.JSON(403, gin.H{ c.JSON(401, gin.H{
"status": 403, "status": 401,
"message": "Forbidden", "message": "Unauthorized",
}) })
return return
} }
@@ -160,24 +160,26 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
userContext, err := new(model.UserContext).NewFromGin(c) userContext, err := new(model.UserContext).NewFromGin(c)
if err != nil { if err != nil {
controller.log.App.Debug().Err(err).Msg("Failed to create user context from request, treating as unauthenticated") tlog.App.Debug().Err(err).Msg("No user context found in request, treating as unauthenticated")
userContext = &model.UserContext{ userContext = &model.UserContext{
Authenticated: false, Authenticated: false,
} }
} }
aclsCtx.UserContext = userContext tlog.App.Trace().Interface("context", userContext).Msg("User context from request")
if userContext.Authenticated { if userContext.Authenticated {
if !controller.policyEngine.Evaluate(service.RuleUserAllowed, aclsCtx) { userAllowed := controller.auth.IsUserAllowed(c, *userContext, acls)
controller.log.App.Warn().Str("user", userContext.GetUsername()).Str("resource", strings.Split(proxyCtx.Host, ".")[0]).Msg("User is not allowed to access resource")
if !userAllowed {
tlog.App.Warn().Str("user", userContext.GetUsername()).Str("resource", strings.Split(proxyCtx.Host, ".")[0]).Msg("User not allowed to access resource")
queries, err := query.Values(UnauthorizedQuery{ queries, err := query.Values(UnauthorizedQuery{
Resource: strings.Split(proxyCtx.Host, ".")[0], Resource: strings.Split(proxyCtx.Host, ".")[0],
}) })
if err != nil { 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) controller.handleError(c, proxyCtx)
return return
} }
@@ -188,7 +190,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
queries.Set("username", userContext.GetUsername()) queries.Set("username", userContext.GetUsername())
} }
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) { if !controller.useBrowserResponse(proxyCtx) {
c.Header("x-tinyauth-location", redirectURL) c.Header("x-tinyauth-location", redirectURL)
@@ -207,13 +209,13 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
var groupOK bool var groupOK bool
if userContext.IsOAuth() { if userContext.IsOAuth() {
groupOK = controller.policyEngine.Evaluate(service.RuleOAuthGroup, aclsCtx) groupOK = controller.auth.IsInOAuthGroup(c, *userContext, acls)
} else { } else {
groupOK = controller.policyEngine.Evaluate(service.RuleLDAPGroup, aclsCtx) groupOK = controller.auth.IsInLDAPGroup(c, *userContext, acls)
} }
if !groupOK { if !groupOK {
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.GetUsername()).Str("resource", strings.Split(proxyCtx.Host, ".")[0]).Msg("User groups do not match resource requirements")
queries, err := query.Values(UnauthorizedQuery{ queries, err := query.Values(UnauthorizedQuery{
Resource: strings.Split(proxyCtx.Host, ".")[0], Resource: strings.Split(proxyCtx.Host, ".")[0],
@@ -221,7 +223,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
}) })
if err != nil { 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) controller.handleError(c, proxyCtx)
return return
} }
@@ -232,7 +234,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
queries.Set("username", userContext.GetUsername()) queries.Set("username", userContext.GetUsername())
} }
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) { if !controller.useBrowserResponse(proxyCtx) {
c.Header("x-tinyauth-location", redirectURL) c.Header("x-tinyauth-location", redirectURL)
@@ -275,12 +277,12 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
}) })
if err != nil { 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) controller.handleError(c, proxyCtx)
return 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) { if !controller.useBrowserResponse(proxyCtx) {
c.Header("x-tinyauth-location", redirectURL) c.Header("x-tinyauth-location", redirectURL)
@@ -304,19 +306,20 @@ func (controller *ProxyController) setHeaders(c *gin.Context, acls *model.App) {
headers := utils.ParseHeaders(acls.Response.Headers) headers := utils.ParseHeaders(acls.Response.Headers)
for key, value := range headers { for key, value := range headers {
tlog.App.Debug().Str("header", key).Msg("Setting header")
c.Header(key, value) c.Header(key, value)
} }
basicPassword := utils.GetSecret(acls.Response.BasicAuth.Password, acls.Response.BasicAuth.PasswordFile) basicPassword := utils.GetSecret(acls.Response.BasicAuth.Password, acls.Response.BasicAuth.PasswordFile)
if acls.Response.BasicAuth.Username != "" && basicPassword != "" { if acls.Response.BasicAuth.Username != "" && basicPassword != "" {
controller.log.App.Debug().Msg("Setting basic auth header for response") tlog.App.Debug().Str("username", acls.Response.BasicAuth.Username).Msg("Setting basic auth header")
c.Header("Authorization", fmt.Sprintf("Basic %s", utils.EncodeBasicAuth(acls.Response.BasicAuth.Username, basicPassword))) c.Header("Authorization", fmt.Sprintf("Basic %s", utils.EncodeBasicAuth(acls.Response.BasicAuth.Username, basicPassword)))
} }
} }
func (controller *ProxyController) handleError(c *gin.Context, proxyCtx ProxyContext) { 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) { if !controller.useBrowserResponse(proxyCtx) {
c.Header("x-tinyauth-location", redirectURL) c.Header("x-tinyauth-location", redirectURL)
@@ -517,7 +520,7 @@ func (controller *ProxyController) getProxyContext(c *gin.Context) (ProxyContext
return ProxyContext{}, err 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) authModules := controller.determineAuthModules(proxy)
@@ -528,13 +531,13 @@ func (controller *ProxyController) getProxyContext(c *gin.Context) (ProxyContext
var ctx ProxyContext var ctx ProxyContext
for _, module := range authModules { 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) ctx, err = controller.getContextFromAuthModule(c, module)
if err == nil { 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 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 { if err != nil {
@@ -546,9 +549,9 @@ func (controller *ProxyController) getProxyContext(c *gin.Context) (ProxyContext
isBrowser := BrowserUserAgentRegex.MatchString(userAgent) isBrowser := BrowserUserAgentRegex.MatchString(userAgent)
if isBrowser { 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 { } 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 ctx.IsBrowser = isBrowser
+70 -35
View File
@@ -1,9 +1,7 @@
package controller_test package controller_test
import ( import (
"context"
"net/http/httptest" "net/http/httptest"
"sync"
"testing" "testing"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -13,15 +11,61 @@ import (
"github.com/tinyauthapp/tinyauth/internal/model" "github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/repository/memory" "github.com/tinyauthapp/tinyauth/internal/repository/memory"
"github.com/tinyauthapp/tinyauth/internal/service" "github.com/tinyauthapp/tinyauth/internal/service"
"github.com/tinyauthapp/tinyauth/internal/test" "github.com/tinyauthapp/tinyauth/internal/utils/tlog"
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
) )
func TestProxyController(t *testing.T) { func TestProxyController(t *testing.T) {
log := logger.NewLogger().WithTestConfig() tlog.NewTestLogger().Init()
log.Init()
cfg, runtime := test.CreateTestConfigs(t) authServiceCfg := service.AuthServiceConfig{
LocalUsers: &[]model.LocalUser{
{
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",
}
controllerCfg := controller.ProxyControllerConfig{
AppURL: "https://tinyauth.example.com",
}
acls := map[string]model.App{
"app_path_allow": {
Config: model.AppConfig{
Domain: "path-allow.example.com",
},
Path: model.AppPath{
Allow: "/allowed",
},
},
"app_user_allow": {
Config: model.AppConfig{
Domain: "user-allow.example.com",
},
Users: model.AppUsers{
Allow: "testuser",
},
},
"ip_bypass": {
Config: model.AppConfig{
Domain: "ip-bypass.example.com",
},
IP: model.AppIP{
Bypass: []string{"10.10.10.10"},
},
},
}
const browserUserAgent = ` const browserUserAgent = `
Mozilla/5.0 (Linux; Android 8.0.0; SM-G955U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Mobile Safari/537.36` Mozilla/5.0 (Linux; Android 8.0.0; SM-G955U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Mobile Safari/537.36`
@@ -351,37 +395,27 @@ func TestProxyController(t *testing.T) {
}, },
} }
oauthBrokerCfgs := make(map[string]model.OAuthServiceConfig)
store := memory.New() store := memory.New()
wg := &sync.WaitGroup{} docker := service.NewDockerService()
ctx := context.TODO() err := docker.Init()
broker := service.NewOAuthBrokerService(log, map[string]model.OAuthServiceConfig{}, ctx)
authService := service.NewAuthService(log, cfg, runtime, ctx, wg, nil, store, broker, nil)
aclsService := service.NewAccessControlsService(log, cfg, nil)
policyEngine, err := service.NewPolicyEngine(cfg, log)
require.NoError(t, err) require.NoError(t, err)
policyEngine.RegisterRule(service.RuleUserAllowed, &service.UserAllowedRule{ ldap := service.NewLdapService(service.LdapServiceConfig{})
Log: log, err = ldap.Init()
}) require.NoError(t, err)
policyEngine.RegisterRule(service.RuleOAuthGroup, &service.OAuthGroupRule{
Log: log, broker := service.NewOAuthBrokerService(oauthBrokerCfgs)
}) err = broker.Init()
policyEngine.RegisterRule(service.RuleLDAPGroup, &service.LDAPGroupRule{ require.NoError(t, err)
Log: log,
}) authService := service.NewAuthService(authServiceCfg, ldap, store, broker)
policyEngine.RegisterRule(service.RuleAuthEnabled, &service.AuthEnabledRule{ err = authService.Init()
Log: log, require.NoError(t, err)
})
policyEngine.RegisterRule(service.RuleIPAllowed, &service.IPAllowedRule{ aclsService := service.NewAccessControlsService(docker, acls)
Log: log,
Config: cfg,
})
policyEngine.RegisterRule(service.RuleIPBypassed, &service.IPBypassedRule{
Log: log,
})
for _, test := range tests { for _, test := range tests {
t.Run(test.description, func(t *testing.T) { t.Run(test.description, func(t *testing.T) {
@@ -396,7 +430,8 @@ func TestProxyController(t *testing.T) {
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
controller.NewProxyController(log, runtime, group, aclsService, authService, policyEngine) proxyController := controller.NewProxyController(controllerCfg, group, aclsService, authService)
proxyController.SetupRoutes()
test.run(t, router, recorder) test.run(t, router, recorder)
}) })
+17 -14
View File
@@ -4,39 +4,42 @@ import (
"net/http" "net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/tinyauthapp/tinyauth/internal/model"
) )
type ResourcesControllerConfig struct {
Path string
Enabled bool
}
type ResourcesController struct { type ResourcesController struct {
config model.Config config ResourcesControllerConfig
router *gin.RouterGroup
fileServer http.Handler fileServer http.Handler
} }
func NewResourcesController( func NewResourcesController(config ResourcesControllerConfig, router *gin.RouterGroup) *ResourcesController {
config model.Config, fileServer := http.StripPrefix("/resources", http.FileServer(http.Dir(config.Path)))
router *gin.RouterGroup,
) *ResourcesController {
fileServer := http.StripPrefix("/resources", http.FileServer(http.Dir(config.Resources.Path)))
controller := &ResourcesController{ return &ResourcesController{
config: config, config: config,
router: router,
fileServer: fileServer, fileServer: fileServer,
} }
}
router.GET("/resources/*resource", controller.resourcesHandler) func (controller *ResourcesController) SetupRoutes() {
controller.router.GET("/resources/*resource", controller.resourcesHandler)
return controller
} }
func (controller *ResourcesController) resourcesHandler(c *gin.Context) { func (controller *ResourcesController) resourcesHandler(c *gin.Context) {
if controller.config.Resources.Path == "" { if controller.config.Path == "" {
c.JSON(404, gin.H{ c.JSON(404, gin.H{
"status": 404, "status": 404,
"message": "Resource not found", "message": "Resources not found",
}) })
return return
} }
if !controller.config.Resources.Enabled { if !controller.config.Enabled {
c.JSON(403, gin.H{ c.JSON(403, gin.H{
"status": 403, "status": 403,
"message": "Resources are disabled", "message": "Resources are disabled",
@@ -3,20 +3,26 @@ package controller_test
import ( import (
"net/http/httptest" "net/http/httptest"
"os" "os"
"path/filepath" "path"
"testing" "testing"
"github.com/gin-gonic/gin" "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/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/tinyauthapp/tinyauth/internal/controller"
"github.com/tinyauthapp/tinyauth/internal/test"
) )
func TestResourcesController(t *testing.T) { 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) require.NoError(t, err)
type testCase struct { 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) err = os.WriteFile(testFilePath, []byte("This is a test file."), 0777)
require.NoError(t, err) 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) err = os.WriteFile(testFilePathParent, []byte("This file should not be accessible."), 0777)
require.NoError(t, err) require.NoError(t, err)
@@ -69,7 +75,8 @@ func TestResourcesController(t *testing.T) {
group := router.Group("/") group := router.Group("/")
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
controller.NewResourcesController(cfg, group) resourcesController := controller.NewResourcesController(resourcesControllerCfg, group)
resourcesController.SetupRoutes()
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
test.run(t, router, recorder) test.run(t, router, recorder)
+63 -123
View File
@@ -10,7 +10,7 @@ import (
"github.com/tinyauthapp/tinyauth/internal/repository" "github.com/tinyauthapp/tinyauth/internal/repository"
"github.com/tinyauthapp/tinyauth/internal/service" "github.com/tinyauthapp/tinyauth/internal/service"
"github.com/tinyauthapp/tinyauth/internal/utils" "github.com/tinyauthapp/tinyauth/internal/utils"
"github.com/tinyauthapp/tinyauth/internal/utils/logger" "github.com/tinyauthapp/tinyauth/internal/utils/tlog"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/pquerna/otp/totp" "github.com/pquerna/otp/totp"
@@ -25,31 +25,30 @@ type TotpRequest struct {
Code string `json:"code"` Code string `json:"code"`
} }
type UserController struct { type UserControllerConfig struct {
log *logger.Logger CookieDomain string
runtime model.RuntimeConfig SessionCookieName string
auth *service.AuthService
} }
func NewUserController( type UserController struct {
log *logger.Logger, config UserControllerConfig
runtimeConfig model.RuntimeConfig, router *gin.RouterGroup
router *gin.RouterGroup, auth *service.AuthService
auth *service.AuthService, }
) *UserController {
controller := &UserController{
log: log,
runtime: runtimeConfig,
auth: auth,
}
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("/login", controller.loginHandler)
userGroup.POST("/logout", controller.logoutHandler) userGroup.POST("/logout", controller.logoutHandler)
userGroup.POST("/totp", controller.totpHandler) userGroup.POST("/totp", controller.totpHandler)
userGroup.POST("/tailscale", controller.tailscaleHandler)
return controller
} }
func (controller *UserController) loginHandler(c *gin.Context) { func (controller *UserController) loginHandler(c *gin.Context) {
@@ -57,7 +56,7 @@ func (controller *UserController) loginHandler(c *gin.Context) {
err := c.ShouldBindJSON(&req) err := c.ShouldBindJSON(&req)
if err != nil { 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{ c.JSON(400, gin.H{
"status": 400, "status": 400,
"message": "Bad Request", "message": "Bad Request",
@@ -65,13 +64,13 @@ func (controller *UserController) loginHandler(c *gin.Context) {
return 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) isLocked, remaining := controller.auth.IsAccountLocked(req.Username)
if isLocked { if isLocked {
controller.log.App.Warn().Str("username", req.Username).Msg("Account is locked due to too many failed login attempts") tlog.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.AuditLoginFailure(c, req.Username, "username", "account locked")
c.Writer.Header().Add("x-tinyauth-lock-locked", "true") c.Writer.Header().Add("x-tinyauth-lock-locked", "true")
c.Writer.Header().Add("x-tinyauth-lock-reset", time.Now().Add(time.Duration(remaining)*time.Second).Format(time.RFC3339)) c.Writer.Header().Add("x-tinyauth-lock-reset", time.Now().Add(time.Duration(remaining)*time.Second).Format(time.RFC3339))
c.JSON(429, gin.H{ c.JSON(429, gin.H{
@@ -85,16 +84,16 @@ func (controller *UserController) loginHandler(c *gin.Context) {
if err != nil { if err != nil {
if errors.Is(err, service.ErrUserNotFound) { if errors.Is(err, service.ErrUserNotFound) {
controller.log.App.Warn().Str("username", req.Username).Msg("User not found during login attempt") tlog.App.Warn().Str("username", req.Username).Msg("User not found")
controller.auth.RecordLoginAttempt(req.Username, false) controller.auth.RecordLoginAttempt(req.Username, false)
controller.log.AuditLoginFailure(req.Username, "unknown", c.ClientIP(), "user not found") tlog.AuditLoginFailure(c, req.Username, "username", "user not found")
c.JSON(401, gin.H{ c.JSON(401, gin.H{
"status": 401, "status": 401,
"message": "Unauthorized", "message": "Unauthorized",
}) })
return return
} }
controller.log.App.Error().Err(err).Str("username", req.Username).Msg("Error searching for user during login attempt") tlog.App.Error().Err(err).Str("username", req.Username).Msg("Error searching for user")
c.JSON(500, gin.H{ c.JSON(500, gin.H{
"status": 500, "status": 500,
"message": "Internal Server Error", "message": "Internal Server Error",
@@ -103,13 +102,9 @@ func (controller *UserController) loginHandler(c *gin.Context) {
} }
if err := controller.auth.CheckUserPassword(*search, req.Password); err != nil { if err := controller.auth.CheckUserPassword(*search, req.Password); err != nil {
controller.log.App.Warn().Str("username", req.Username).Msg("Invalid password during login attempt") tlog.App.Warn().Err(err).Str("username", req.Username).Msg("Failed to verify password")
controller.auth.RecordLoginAttempt(req.Username, false) controller.auth.RecordLoginAttempt(req.Username, false)
if search.Type == model.UserLocal { tlog.AuditLoginFailure(c, req.Username, "username", "invalid password")
controller.log.AuditLoginFailure(req.Username, "local", c.ClientIP(), "invalid password")
} else {
controller.log.AuditLoginFailure(req.Username, "ldap", c.ClientIP(), "invalid password")
}
c.JSON(401, gin.H{ c.JSON(401, gin.H{
"status": 401, "status": 401,
"message": "Unauthorized", "message": "Unauthorized",
@@ -123,7 +118,7 @@ func (controller *UserController) loginHandler(c *gin.Context) {
localUser = controller.auth.GetLocalUser(req.Username) localUser = controller.auth.GetLocalUser(req.Username)
if localUser == nil { if localUser == nil {
controller.log.App.Error().Str("username", req.Username).Msg("Local user not found after successful password verification") tlog.App.Warn().Str("username", req.Username).Msg("User disappeared during login")
c.JSON(401, gin.H{ c.JSON(401, gin.H{
"status": 401, "status": 401,
"message": "Unauthorized", "message": "Unauthorized",
@@ -132,7 +127,7 @@ func (controller *UserController) loginHandler(c *gin.Context) {
} }
if localUser.TOTPSecret != "" { if localUser.TOTPSecret != "" {
controller.log.App.Debug().Str("username", req.Username).Msg("TOTP required for user, creating pending TOTP session") tlog.App.Debug().Str("username", req.Username).Msg("User has TOTP enabled, requiring TOTP verification")
name := localUser.Attributes.Name name := localUser.Attributes.Name
if name == "" { if name == "" {
@@ -141,7 +136,7 @@ func (controller *UserController) loginHandler(c *gin.Context) {
email := localUser.Attributes.Email email := localUser.Attributes.Email
if email == "" { if email == "" {
email = utils.CompileUserEmail(localUser.Username, controller.runtime.CookieDomain) email = utils.CompileUserEmail(localUser.Username, controller.config.CookieDomain)
} }
cookie, err := controller.auth.CreateSession(c, repository.Session{ cookie, err := controller.auth.CreateSession(c, repository.Session{
@@ -153,7 +148,7 @@ func (controller *UserController) loginHandler(c *gin.Context) {
}) })
if err != nil { 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{ c.JSON(500, gin.H{
"status": 500, "status": 500,
"message": "Internal Server Error", "message": "Internal Server Error",
@@ -175,7 +170,7 @@ func (controller *UserController) loginHandler(c *gin.Context) {
sessionCookie := repository.Session{ sessionCookie := repository.Session{
Username: req.Username, Username: req.Username,
Name: utils.Capitalize(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", Provider: "local",
} }
@@ -190,15 +185,14 @@ func (controller *UserController) loginHandler(c *gin.Context) {
if search.Type == model.UserLDAP { if search.Type == model.UserLDAP {
sessionCookie.Provider = "ldap" sessionCookie.Provider = "ldap"
if search.Email != "" {
sessionCookie.Email = search.Email
}
} }
tlog.App.Trace().Interface("session_cookie", sessionCookie).Msg("Creating session cookie")
cookie, err := controller.auth.CreateSession(c, sessionCookie) cookie, err := controller.auth.CreateSession(c, sessionCookie)
if err != nil { 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{ c.JSON(500, gin.H{
"status": 500, "status": 500,
"message": "Internal Server Error", "message": "Internal Server Error",
@@ -208,13 +202,8 @@ func (controller *UserController) loginHandler(c *gin.Context) {
http.SetCookie(c.Writer, cookie) http.SetCookie(c.Writer, cookie)
controller.log.App.Info().Str("username", req.Username).Msg("Login successful") tlog.App.Info().Str("username", req.Username).Msg("Login successful")
tlog.AuditLoginSuccess(c, req.Username, "username")
if search.Type == model.UserLocal {
controller.log.AuditLoginSuccess(req.Username, "local", c.ClientIP())
} else {
controller.log.AuditLoginSuccess(req.Username, "ldap", c.ClientIP())
}
controller.auth.RecordLoginAttempt(req.Username, true) controller.auth.RecordLoginAttempt(req.Username, true)
@@ -225,20 +214,20 @@ func (controller *UserController) loginHandler(c *gin.Context) {
} }
func (controller *UserController) logoutHandler(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) uuid, err := c.Cookie(controller.config.SessionCookieName)
if err != nil { if err != nil {
if errors.Is(err, http.ErrNoCookie) { if errors.Is(err, http.ErrNoCookie) {
controller.log.App.Warn().Msg("Logout attempt without session cookie, treating as successful logout") tlog.App.Warn().Msg("No session cookie found on logout request")
c.JSON(200, gin.H{ c.JSON(200, gin.H{
"status": 200, "status": 200,
"message": "Logout successful", "message": "Logout successful",
}) })
return return
} }
controller.log.App.Error().Err(err).Msg("Error retrieving session cookie on logout") tlog.App.Error().Err(err).Msg("Error retrieving session cookie on logout")
c.JSON(500, gin.H{ c.JSON(500, gin.H{
"status": 500, "status": 500,
"message": "Internal Server Error", "message": "Internal Server Error",
@@ -249,7 +238,7 @@ func (controller *UserController) logoutHandler(c *gin.Context) {
cookie, err := controller.auth.DeleteSession(c, uuid) cookie, err := controller.auth.DeleteSession(c, uuid)
if err != nil { if err != nil {
controller.log.App.Error().Err(err).Msg("Error deleting session on logout") tlog.App.Error().Err(err).Msg("Error deleting session on logout")
c.JSON(500, gin.H{ c.JSON(500, gin.H{
"status": 500, "status": 500,
"message": "Internal Server Error", "message": "Internal Server Error",
@@ -260,10 +249,10 @@ func (controller *UserController) logoutHandler(c *gin.Context) {
context, err := new(model.UserContext).NewFromGin(c) context, err := new(model.UserContext).NewFromGin(c)
if err == nil { if err == nil {
controller.log.AuditLogout(context.GetUsername(), context.GetProviderID(), c.ClientIP()) tlog.AuditLogout(c, context.GetUsername(), context.GetProviderID())
} else { } else {
controller.log.App.Warn().Err(err).Msg("Failed to get user context during logout, logging audit with unknown user") tlog.App.Warn().Err(err).Msg("Failed to get user context for logout audit, proceeding without username")
controller.log.AuditLogout("unknown", "unknown", c.ClientIP()) tlog.AuditLogout(c, "unknown", "unknown")
} }
http.SetCookie(c.Writer, cookie) http.SetCookie(c.Writer, cookie)
@@ -279,7 +268,7 @@ func (controller *UserController) totpHandler(c *gin.Context) {
err := c.ShouldBindJSON(&req) err := c.ShouldBindJSON(&req)
if err != nil { 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{ c.JSON(400, gin.H{
"status": 400, "status": 400,
"message": "Bad Request", "message": "Bad Request",
@@ -290,7 +279,7 @@ func (controller *UserController) totpHandler(c *gin.Context) {
context, err := new(model.UserContext).NewFromGin(c) context, err := new(model.UserContext).NewFromGin(c)
if err != nil { if err != nil {
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{ c.JSON(500, gin.H{
"status": 500, "status": 500,
"message": "Internal Server Error", "message": "Internal Server Error",
@@ -299,7 +288,7 @@ func (controller *UserController) totpHandler(c *gin.Context) {
} }
if !context.TOTPPending() { if !context.TOTPPending() {
controller.log.App.Warn().Str("username", context.GetUsername()).Msg("TOTP verification attempt without pending TOTP session") tlog.App.Warn().Msg("TOTP attempt without a pending TOTP session")
c.JSON(401, gin.H{ c.JSON(401, gin.H{
"status": 401, "status": 401,
"message": "Unauthorized", "message": "Unauthorized",
@@ -307,13 +296,12 @@ func (controller *UserController) totpHandler(c *gin.Context) {
return return
} }
controller.log.App.Debug().Str("username", context.GetUsername()).Msg("TOTP verification attempt") tlog.App.Debug().Str("username", context.GetUsername()).Msg("TOTP verification attempt")
isLocked, remaining := controller.auth.IsAccountLocked(context.GetUsername()) isLocked, remaining := controller.auth.IsAccountLocked(context.GetUsername())
if isLocked { if isLocked {
controller.log.App.Warn().Str("username", context.GetUsername()).Msg("Account is locked due to too many failed TOTP attempts") tlog.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")
c.Writer.Header().Add("x-tinyauth-lock-locked", "true") c.Writer.Header().Add("x-tinyauth-lock-locked", "true")
c.Writer.Header().Add("x-tinyauth-lock-reset", time.Now().Add(time.Duration(remaining)*time.Second).Format(time.RFC3339)) c.Writer.Header().Add("x-tinyauth-lock-reset", time.Now().Add(time.Duration(remaining)*time.Second).Format(time.RFC3339))
c.JSON(429, gin.H{ c.JSON(429, gin.H{
@@ -326,7 +314,7 @@ func (controller *UserController) totpHandler(c *gin.Context) {
user := controller.auth.GetLocalUser(context.GetUsername()) user := controller.auth.GetLocalUser(context.GetUsername())
if user == nil { if user == nil {
controller.log.App.Error().Str("username", context.GetUsername()).Msg("Local user not found during TOTP verification") tlog.App.Error().Str("username", context.GetUsername()).Msg("User not found in TOTP handler")
c.JSON(401, gin.H{ c.JSON(401, gin.H{
"status": 401, "status": 401,
"message": "Unauthorized", "message": "Unauthorized",
@@ -337,9 +325,9 @@ func (controller *UserController) totpHandler(c *gin.Context) {
ok := totp.Validate(req.Code, user.TOTPSecret) ok := totp.Validate(req.Code, user.TOTPSecret)
if !ok { if !ok {
controller.log.App.Warn().Str("username", context.GetUsername()).Msg("Invalid TOTP code during verification attempt") tlog.App.Warn().Str("username", context.GetUsername()).Msg("Invalid TOTP code")
controller.auth.RecordLoginAttempt(context.GetUsername(), false) controller.auth.RecordLoginAttempt(context.GetUsername(), false)
controller.log.AuditLoginFailure(context.GetUsername(), "local", c.ClientIP(), "invalid TOTP code") tlog.AuditLoginFailure(c, context.GetUsername(), "totp", "invalid totp code")
c.JSON(401, gin.H{ c.JSON(401, gin.H{
"status": 401, "status": 401,
"message": "Unauthorized", "message": "Unauthorized",
@@ -347,15 +335,15 @@ func (controller *UserController) totpHandler(c *gin.Context) {
return return
} }
uuid, err := c.Cookie(controller.runtime.SessionCookieName) uuid, err := c.Cookie(controller.config.SessionCookieName)
if err == nil { if err == nil {
_, err = controller.auth.DeleteSession(c, uuid) _, err = controller.auth.DeleteSession(c, uuid)
if err != nil { if err != nil {
controller.log.App.Error().Err(err).Msg("Failed to delete pending TOTP session after successful verification") tlog.App.Warn().Err(err).Msg("Failed to delete pending TOTP session")
} }
} else { } else {
controller.log.App.Warn().Err(err).Msg("Failed to retrieve session cookie for pending TOTP session, cannot delete it") tlog.App.Warn().Err(err).Msg("Failed to retrieve session cookie for pending TOTP session, proceeding without deleting it")
} }
controller.auth.RecordLoginAttempt(context.GetUsername(), true) controller.auth.RecordLoginAttempt(context.GetUsername(), true)
@@ -363,7 +351,7 @@ func (controller *UserController) totpHandler(c *gin.Context) {
sessionCookie := repository.Session{ sessionCookie := repository.Session{
Username: user.Username, Username: user.Username,
Name: utils.Capitalize(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", Provider: "local",
} }
@@ -374,10 +362,12 @@ func (controller *UserController) totpHandler(c *gin.Context) {
sessionCookie.Email = 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) cookie, err := controller.auth.CreateSession(c, sessionCookie)
if err != nil { 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{ c.JSON(500, gin.H{
"status": 500, "status": 500,
"message": "Internal Server Error", "message": "Internal Server Error",
@@ -387,58 +377,8 @@ func (controller *UserController) totpHandler(c *gin.Context) {
http.SetCookie(c.Writer, cookie) http.SetCookie(c.Writer, cookie)
controller.log.App.Info().Str("username", context.GetUsername()).Msg("TOTP verification successful, login complete") tlog.App.Info().Str("username", context.GetUsername()).Msg("TOTP verification successful")
controller.log.AuditLoginSuccess(context.GetUsername(), "local", c.ClientIP()) tlog.AuditLoginSuccess(c, context.GetUsername(), "totp")
c.JSON(200, gin.H{
"status": 200,
"message": "Login successful",
})
}
func (controller *UserController) tailscaleHandler(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(401, gin.H{
"status": 401,
"message": "Unauthorized",
})
return
}
if context.Tailscale == nil {
controller.log.App.Warn().Msg("Tailscale login attempt without Tailscale context")
c.JSON(401, gin.H{
"status": 401,
"message": "Unauthorized",
})
return
}
sessionCookie := repository.Session{
Username: context.Tailscale.Username,
Name: context.Tailscale.Name,
Email: context.Tailscale.Email,
Provider: "tailscale",
}
cookie, err := controller.auth.CreateSession(c, sessionCookie)
if err != nil {
controller.log.App.Error().Err(err).Str("username", context.GetUsername()).Msg("Failed to create session cookie after successful Tailscale login")
c.JSON(500, gin.H{
"status": 500,
"message": "Internal Server Error",
})
return
}
http.SetCookie(c.Writer, cookie)
controller.log.App.Info().Str("username", context.GetUsername()).Msg("Tailscale login successful, login complete")
controller.log.AuditLoginSuccess(context.GetUsername(), "tailscale", c.ClientIP())
c.JSON(200, gin.H{ c.JSON(200, gin.H{
"status": 200, "status": 200,
+76 -27
View File
@@ -6,7 +6,6 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
"sync"
"testing" "testing"
"time" "time"
@@ -19,15 +18,52 @@ import (
"github.com/tinyauthapp/tinyauth/internal/repository" "github.com/tinyauthapp/tinyauth/internal/repository"
"github.com/tinyauthapp/tinyauth/internal/repository/memory" "github.com/tinyauthapp/tinyauth/internal/repository/memory"
"github.com/tinyauthapp/tinyauth/internal/service" "github.com/tinyauthapp/tinyauth/internal/service"
"github.com/tinyauthapp/tinyauth/internal/test" "github.com/tinyauthapp/tinyauth/internal/utils/tlog"
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
) )
func TestUserController(t *testing.T) { func TestUserController(t *testing.T) {
log := logger.NewLogger().WithTestConfig() tlog.NewTestLogger().Init()
log.Init()
cfg, runtime := test.CreateTestConfigs(t) authServiceCfg := service.AuthServiceConfig{
LocalUsers: &[]model.LocalUser{
{
Username: "testuser",
Password: "$2a$10$ZwVYQH07JX2zq7Fjkt3gU.BjwvvwPeli4OqOno04RQIv0P7usBrXa", // password
},
{
Username: "totpuser",
Password: "$2a$10$ZwVYQH07JX2zq7Fjkt3gU.BjwvvwPeli4OqOno04RQIv0P7usBrXa", // password
TOTPSecret: "JPIEBDKJH6UGWJMX66RR3S55UFP2SGKK",
},
{
Username: "attruser",
Password: "$2a$10$ZwVYQH07JX2zq7Fjkt3gU.BjwvvwPeli4OqOno04RQIv0P7usBrXa", // password
Attributes: model.UserAttributes{
Name: "Alice Smith",
Email: "alice@example.com",
},
},
{
Username: "attrtotpuser",
Password: "$2a$10$ZwVYQH07JX2zq7Fjkt3gU.BjwvvwPeli4OqOno04RQIv0P7usBrXa", // password
TOTPSecret: "JPIEBDKJH6UGWJMX66RR3S55UFP2SGKK",
Attributes: model.UserAttributes{
Name: "Bob Jones",
Email: "bob@example.com",
},
},
},
SessionExpiry: 10, // 10 seconds, useful for testing
CookieDomain: "example.com",
LoginTimeout: 10, // 10 seconds, useful for testing
LoginMaxRetries: 3,
SessionCookieName: "tinyauth-session",
}
userControllerCfg := controller.UserControllerConfig{
CookieDomain: "example.com",
SessionCookieName: "tinyauth-session",
}
totpCtx := func(c *gin.Context) { totpCtx := func(c *gin.Context) {
c.Set("context", &model.UserContext{ c.Set("context", &model.UserContext{
@@ -73,14 +109,14 @@ func TestUserController(t *testing.T) {
}) })
} }
store := memory.New()
type testCase struct { type testCase struct {
description string description string
middlewares []gin.HandlerFunc middlewares []gin.HandlerFunc
run func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) run func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder)
} }
store := memory.New()
tests := []testCase{ tests := []testCase{
{ {
description: "Should be able to login with valid credentials", description: "Should be able to login with valid credentials",
@@ -91,7 +127,7 @@ func TestUserController(t *testing.T) {
Password: "password", Password: "password",
} }
loginReqBody, err := json.Marshal(loginReq) 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 := httptest.NewRequest("POST", "/api/user/login", strings.NewReader(string(loginReqBody)))
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
@@ -99,7 +135,7 @@ func TestUserController(t *testing.T) {
router.ServeHTTP(recorder, req) router.ServeHTTP(recorder, req)
assert.Equal(t, 200, recorder.Code) 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] cookie := recorder.Result().Cookies()[0]
assert.Equal(t, "tinyauth-session", cookie.Name) assert.Equal(t, "tinyauth-session", cookie.Name)
@@ -119,7 +155,7 @@ func TestUserController(t *testing.T) {
Password: "wrongpassword", Password: "wrongpassword",
} }
loginReqBody, err := json.Marshal(loginReq) 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 := httptest.NewRequest("POST", "/api/user/login", strings.NewReader(string(loginReqBody)))
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
@@ -140,7 +176,7 @@ func TestUserController(t *testing.T) {
Password: "wrongpassword", Password: "wrongpassword",
} }
loginReqBody, err := json.Marshal(loginReq) loginReqBody, err := json.Marshal(loginReq)
require.NoError(t, err) assert.NoError(t, err)
for range 3 { for range 3 {
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
@@ -175,7 +211,7 @@ func TestUserController(t *testing.T) {
Password: "password", Password: "password",
} }
loginReqBody, err := json.Marshal(loginReq) 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 := httptest.NewRequest("POST", "/api/user/login", strings.NewReader(string(loginReqBody)))
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
@@ -186,12 +222,12 @@ func TestUserController(t *testing.T) {
decodedBody := make(map[string]any) decodedBody := make(map[string]any)
err = json.Unmarshal(recorder.Body.Bytes(), &decodedBody) err = json.Unmarshal(recorder.Body.Bytes(), &decodedBody)
require.NoError(t, err) assert.NoError(t, err)
assert.Equal(t, decodedBody["totpPending"], true) assert.Equal(t, decodedBody["totpPending"], true)
// should set the session cookie // should set the session cookie
require.Len(t, recorder.Result().Cookies(), 1) assert.Len(t, recorder.Result().Cookies(), 1)
cookie := recorder.Result().Cookies()[0] cookie := recorder.Result().Cookies()[0]
assert.Equal(t, "tinyauth-session", cookie.Name) assert.Equal(t, "tinyauth-session", cookie.Name)
assert.True(t, cookie.HttpOnly) assert.True(t, cookie.HttpOnly)
@@ -212,7 +248,7 @@ func TestUserController(t *testing.T) {
Password: "password", Password: "password",
} }
loginReqBody, err := json.Marshal(loginReq) 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 := httptest.NewRequest("POST", "/api/user/login", strings.NewReader(string(loginReqBody)))
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
@@ -221,7 +257,7 @@ func TestUserController(t *testing.T) {
assert.Equal(t, 200, recorder.Code) assert.Equal(t, 200, recorder.Code)
cookies := recorder.Result().Cookies() cookies := recorder.Result().Cookies()
require.Len(t, cookies, 1) assert.Len(t, cookies, 1)
cookie := cookies[0] cookie := cookies[0]
assert.Equal(t, "tinyauth-session", cookie.Name) assert.Equal(t, "tinyauth-session", cookie.Name)
@@ -235,7 +271,7 @@ func TestUserController(t *testing.T) {
assert.Equal(t, 200, recorder.Code) assert.Equal(t, 200, recorder.Code)
cookies = recorder.Result().Cookies() cookies = recorder.Result().Cookies()
require.Len(t, cookies, 1) assert.Len(t, cookies, 1)
cookie = cookies[0] cookie = cookies[0]
assert.Equal(t, "tinyauth-session", cookie.Name) assert.Equal(t, "tinyauth-session", cookie.Name)
@@ -262,14 +298,14 @@ func TestUserController(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
code, err := totp.GenerateCode("JPIEBDKJH6UGWJMX66RR3S55UFP2SGKK", time.Now()) code, err := totp.GenerateCode("JPIEBDKJH6UGWJMX66RR3S55UFP2SGKK", time.Now())
require.NoError(t, err) assert.NoError(t, err)
totpReq := controller.TotpRequest{ totpReq := controller.TotpRequest{
Code: code, Code: code,
} }
totpReqBody, err := json.Marshal(totpReq) totpReqBody, err := json.Marshal(totpReq)
require.NoError(t, err) assert.NoError(t, err)
recorder = httptest.NewRecorder() recorder = httptest.NewRecorder()
req := httptest.NewRequest("POST", "/api/user/totp", strings.NewReader(string(totpReqBody))) req := httptest.NewRequest("POST", "/api/user/totp", strings.NewReader(string(totpReqBody)))
@@ -284,7 +320,7 @@ func TestUserController(t *testing.T) {
router.ServeHTTP(recorder, req) router.ServeHTTP(recorder, req)
assert.Equal(t, 200, recorder.Code) 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 // should set a new session cookie with totp pending removed
totpCookie := recorder.Result().Cookies()[0] totpCookie := recorder.Result().Cookies()[0]
@@ -307,7 +343,7 @@ func TestUserController(t *testing.T) {
} }
totpReqBody, err := json.Marshal(totpReq) totpReqBody, err := json.Marshal(totpReq)
require.NoError(t, err) assert.NoError(t, err)
recorder = httptest.NewRecorder() recorder = httptest.NewRecorder()
req := httptest.NewRequest("POST", "/api/user/totp", strings.NewReader(string(totpReqBody))) req := httptest.NewRequest("POST", "/api/user/totp", strings.NewReader(string(totpReqBody)))
@@ -411,11 +447,23 @@ func TestUserController(t *testing.T) {
}, },
} }
ctx := context.TODO() oauthBrokerCfgs := make(map[string]model.OAuthServiceConfig)
wg := &sync.WaitGroup{}
broker := service.NewOAuthBrokerService(log, map[string]model.OAuthServiceConfig{}, ctx) docker := service.NewDockerService()
authService := service.NewAuthService(log, cfg, runtime, ctx, wg, nil, store, broker, nil) 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, ldap, store, broker)
err = authService.Init()
require.NoError(t, err)
beforeEach := func() { beforeEach := func() {
// Clear failed login attempts before each test // Clear failed login attempts before each test
@@ -434,7 +482,8 @@ func TestUserController(t *testing.T) {
group := router.Group("/api") group := router.Group("/api")
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
controller.NewUserController(log, runtime, group, authService) userController := controller.NewUserController(userControllerCfg, group, authService)
userController.SetupRoutes()
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
+15 -25
View File
@@ -26,30 +26,28 @@ type OpenIDConnectConfiguration struct {
RequestObjectSigningAlgValuesSupported []string `json:"request_object_signing_alg_values_supported"` RequestObjectSigningAlgValuesSupported []string `json:"request_object_signing_alg_values_supported"`
} }
type WellKnownControllerConfig struct{}
type WellKnownController struct { type WellKnownController struct {
oidc *service.OIDCService config WellKnownControllerConfig
engine *gin.Engine
oidc *service.OIDCService
} }
func NewWellKnownController(oidc *service.OIDCService, router *gin.RouterGroup) *WellKnownController { func NewWellKnownController(config WellKnownControllerConfig, oidc *service.OIDCService, engine *gin.Engine) *WellKnownController {
controller := &WellKnownController{ return &WellKnownController{
oidc: oidc, config: config,
oidc: oidc,
engine: engine,
} }
}
router.GET("/.well-known/openid-configuration", controller.OpenIDConnectConfiguration) func (controller *WellKnownController) SetupRoutes() {
router.GET("/.well-known/jwks.json", controller.JWKS) controller.engine.GET("/.well-known/openid-configuration", controller.OpenIDConnectConfiguration)
controller.engine.GET("/.well-known/jwks.json", controller.JWKS)
return controller
} }
func (controller *WellKnownController) OpenIDConnectConfiguration(c *gin.Context) { 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() issuer := controller.oidc.GetIssuer()
c.JSON(200, OpenIDConnectConfiguration{ c.JSON(200, OpenIDConnectConfiguration{
Issuer: issuer, Issuer: issuer,
@@ -71,19 +69,11 @@ func (controller *WellKnownController) OpenIDConnectConfiguration(c *gin.Context
} }
func (controller *WellKnownController) JWKS(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() jwks, err := controller.oidc.GetJWK()
if err != nil { if err != nil {
c.JSON(500, gin.H{ c.JSON(500, gin.H{
"status": 500, "status": "500",
"message": "failed to get JWK", "message": "failed to get JWK",
}) })
return return
@@ -1,28 +1,40 @@
package controller_test package controller_test
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http/httptest" "net/http/httptest"
"sync" "path"
"testing" "testing"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/tinyauthapp/tinyauth/internal/controller" "github.com/tinyauthapp/tinyauth/internal/controller"
"github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/repository/memory" "github.com/tinyauthapp/tinyauth/internal/repository/memory"
"github.com/tinyauthapp/tinyauth/internal/service" "github.com/tinyauthapp/tinyauth/internal/service"
"github.com/tinyauthapp/tinyauth/internal/test" "github.com/tinyauthapp/tinyauth/internal/utils/tlog"
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
) )
func TestWellKnownController(t *testing.T) { func TestWellKnownController(t *testing.T) {
log := logger.NewLogger().WithTestConfig() tlog.NewTestLogger().Init()
log.Init() tempDir := t.TempDir()
cfg, runtime := test.CreateTestConfigs(t) oidcServiceCfg := service.OIDCServiceConfig{
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: path.Join(tempDir, "key.pem"),
PublicKeyPath: path.Join(tempDir, "key.pub"),
Issuer: "https://tinyauth.example.com",
SessionExpiry: 500,
}
type testCase struct { type testCase struct {
description string description string
@@ -43,11 +55,11 @@ func TestWellKnownController(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
expected := controller.OpenIDConnectConfiguration{ expected := controller.OpenIDConnectConfiguration{
Issuer: runtime.AppURL, Issuer: oidcServiceCfg.Issuer,
AuthorizationEndpoint: fmt.Sprintf("%s/authorize", runtime.AppURL), AuthorizationEndpoint: fmt.Sprintf("%s/authorize", oidcServiceCfg.Issuer),
TokenEndpoint: fmt.Sprintf("%s/api/oidc/token", runtime.AppURL), TokenEndpoint: fmt.Sprintf("%s/api/oidc/token", oidcServiceCfg.Issuer),
UserinfoEndpoint: fmt.Sprintf("%s/api/oidc/userinfo", runtime.AppURL), UserinfoEndpoint: fmt.Sprintf("%s/api/oidc/userinfo", oidcServiceCfg.Issuer),
JwksUri: fmt.Sprintf("%s/.well-known/jwks.json", runtime.AppURL), JwksUri: fmt.Sprintf("%s/.well-known/jwks.json", oidcServiceCfg.Issuer),
ScopesSupported: service.SupportedScopes, ScopesSupported: service.SupportedScopes,
ResponseTypesSupported: service.SupportedResponseTypes, ResponseTypesSupported: service.SupportedResponseTypes,
GrantTypesSupported: service.SupportedGrantTypes, GrantTypesSupported: service.SupportedGrantTypes,
@@ -88,12 +100,10 @@ func TestWellKnownController(t *testing.T) {
}, },
} }
ctx := context.TODO()
wg := &sync.WaitGroup{}
store := memory.New() store := memory.New()
oidcService, err := service.NewOIDCService(log, cfg, runtime, store, ctx, wg) oidcService := service.NewOIDCService(oidcServiceCfg, store)
err := oidcService.Init()
require.NoError(t, err) require.NoError(t, err)
for _, test := range tests { for _, test := range tests {
@@ -103,7 +113,8 @@ func TestWellKnownController(t *testing.T) {
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
controller.NewWellKnownController(oidcService, &router.RouterGroup) wellKnownController := controller.NewWellKnownController(controller.WellKnownControllerConfig{}, oidcService, router)
wellKnownController.SetupRoutes()
test.run(t, router, recorder) test.run(t, router, recorder)
}) })
+29 -101
View File
@@ -10,7 +10,7 @@ import (
"github.com/tinyauthapp/tinyauth/internal/model" "github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/service" "github.com/tinyauthapp/tinyauth/internal/service"
"github.com/tinyauthapp/tinyauth/internal/utils" "github.com/tinyauthapp/tinyauth/internal/utils"
"github.com/tinyauthapp/tinyauth/internal/utils/logger" "github.com/tinyauthapp/tinyauth/internal/utils/tlog"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -35,30 +35,29 @@ var (
} }
) )
type ContextMiddleware struct { type ContextMiddlewareConfig struct {
log *logger.Logger CookieDomain string
runtime model.RuntimeConfig SessionCookieName string
auth *service.AuthService
broker *service.OAuthBrokerService
tailscale *service.TailscaleService
} }
func NewContextMiddleware( type ContextMiddleware struct {
log *logger.Logger, config ContextMiddlewareConfig
runtime model.RuntimeConfig, auth *service.AuthService
auth *service.AuthService, broker *service.OAuthBrokerService
broker *service.OAuthBrokerService, }
tailscale *service.TailscaleService,
) *ContextMiddleware { func NewContextMiddleware(config ContextMiddlewareConfig, auth *service.AuthService, broker *service.OAuthBrokerService) *ContextMiddleware {
return &ContextMiddleware{ return &ContextMiddleware{
log: log, config: config,
runtime: runtime, auth: auth,
auth: auth, broker: broker,
broker: broker,
tailscale: tailscale,
} }
} }
func (m *ContextMiddleware) Init() error {
return nil
}
func (m *ContextMiddleware) Middleware() gin.HandlerFunc { func (m *ContextMiddleware) Middleware() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
if m.isIgnorePath(c.Request.Method + " " + c.Request.URL.Path) { if m.isIgnorePath(c.Request.Method + " " + c.Request.URL.Path) {
@@ -66,22 +65,22 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc {
return return
} }
uuid, err := c.Cookie(m.runtime.SessionCookieName) uuid, err := c.Cookie(m.config.SessionCookieName)
if err == nil { if err == nil {
userContext, cookie, err := m.cookieAuth(c.Request.Context(), uuid, c.RemoteIP()) userContext, cookie, err := m.cookieAuth(c.Request.Context(), uuid)
if err == nil { if err == nil {
if cookie != nil { if cookie != nil {
http.SetCookie(c.Writer, cookie) http.SetCookie(c.Writer, cookie)
} }
m.log.App.Debug().Msgf("Authenticated user %s via session cookie", userContext.GetUsername()) tlog.App.Trace().Msgf("Authenticated user from session cookie: %s", userContext.GetUsername())
c.Set("context", userContext) c.Set("context", userContext)
c.Next() c.Next()
return return
} else { } else {
m.log.App.Debug().Msgf("Error authenticating session cookie: %v", err) tlog.App.Error().Msgf("Error authenticating session cookie: %v", err)
} }
} }
@@ -91,7 +90,7 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc {
userContext, headers, err := m.basicAuth(username, password) userContext, headers, err := m.basicAuth(username, password)
if err != nil { if err != nil {
m.log.App.Error().Msgf("Error authenticating basic auth: %v", err) tlog.App.Error().Msgf("Error authenticating basic auth: %v", err)
c.Next() c.Next()
return return
} }
@@ -105,28 +104,11 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc {
return return
} }
// Lastly check if we have a tailscale session to add
if m.tailscale != nil {
tailscaleContext, err := m.tailscaleWhois(c.Request.Context(), c.RemoteIP())
if err != nil {
m.log.App.Error().Err(err).Msgf("Error performing tailscale whois for IP %s: %v", c.RemoteIP(), err)
}
if tailscaleContext != nil {
c.Set("context", &model.UserContext{
Authenticated: false,
Provider: model.ProviderTailscale,
Tailscale: tailscaleContext,
})
}
}
c.Next() c.Next()
} }
} }
func (m *ContextMiddleware) cookieAuth(ctx context.Context, uuid string, ip string) (*model.UserContext, *http.Cookie, error) { func (m *ContextMiddleware) cookieAuth(ctx context.Context, uuid string) (*model.UserContext, *http.Cookie, error) {
session, err := m.auth.GetSession(ctx, uuid) session, err := m.auth.GetSession(ctx, uuid)
if err != nil { if err != nil {
@@ -159,20 +141,8 @@ func (m *ContextMiddleware) cookieAuth(ctx context.Context, uuid string, ip stri
} }
if userContext.Local.Attributes.Email == "" { if userContext.Local.Attributes.Email == "" {
userContext.Local.Attributes.Email = utils.CompileUserEmail(user.Username, m.runtime.CookieDomain) userContext.Local.Attributes.Email = utils.CompileUserEmail(user.Username, m.config.CookieDomain)
} }
case model.ProviderTailscale:
tailscaleContext, err := m.tailscaleWhois(ctx, ip)
if err != nil {
return nil, nil, fmt.Errorf("error performing tailscale whois: %w", err)
}
if tailscaleContext == nil {
return nil, nil, fmt.Errorf("tailscale whois returned no result for IP: %s", ip)
}
userContext.Tailscale = tailscaleContext
case model.ProviderLDAP: case model.ProviderLDAP:
search, err := m.auth.SearchUser(userContext.LDAP.Username) search, err := m.auth.SearchUser(userContext.LDAP.Username)
@@ -192,12 +162,7 @@ func (m *ContextMiddleware) cookieAuth(ctx context.Context, uuid string, ip stri
userContext.LDAP.Groups = user.Groups userContext.LDAP.Groups = user.Groups
userContext.LDAP.Name = utils.Capitalize(userContext.LDAP.Username) userContext.LDAP.Name = utils.Capitalize(userContext.LDAP.Username)
userContext.LDAP.Email = utils.CompileUserEmail(userContext.LDAP.Username, m.config.CookieDomain)
userContext.LDAP.Email = utils.CompileUserEmail(userContext.LDAP.Username, m.runtime.CookieDomain)
if search.Email != "" {
userContext.LDAP.Email = search.Email
}
case model.ProviderOAuth: case model.ProviderOAuth:
_, exists := m.broker.GetService(userContext.OAuth.ID) _, exists := m.broker.GetService(userContext.OAuth.ID)
@@ -226,7 +191,7 @@ func (m *ContextMiddleware) basicAuth(username string, password string) (*model.
locked, remaining := m.auth.IsAccountLocked(username) locked, remaining := m.auth.IsAccountLocked(username)
if locked { if locked {
m.log.App.Debug().Msgf("Account for user %s is locked for %d seconds, denying auth", username, remaining) tlog.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-locked"] = "true"
headers["x-tinyauth-lock-reset"] = time.Now().Add(time.Duration(remaining) * time.Second).Format(time.RFC3339) headers["x-tinyauth-lock-reset"] = time.Now().Add(time.Duration(remaining) * time.Second).Format(time.RFC3339)
return nil, headers, nil return nil, headers, nil
@@ -259,7 +224,7 @@ func (m *ContextMiddleware) basicAuth(username string, password string) (*model.
BaseContext: model.BaseContext{ BaseContext: model.BaseContext{
Username: user.Username, Username: user.Username,
Name: utils.Capitalize(user.Username), Name: utils.Capitalize(user.Username),
Email: utils.CompileUserEmail(user.Username, m.runtime.CookieDomain), Email: utils.CompileUserEmail(user.Username, m.config.CookieDomain),
}, },
Attributes: user.Attributes, Attributes: user.Attributes,
} }
@@ -275,15 +240,11 @@ func (m *ContextMiddleware) basicAuth(username string, password string) (*model.
BaseContext: model.BaseContext{ BaseContext: model.BaseContext{
Username: username, Username: username,
Name: utils.Capitalize(username), Name: utils.Capitalize(username),
Email: utils.CompileUserEmail(username, m.config.CookieDomain),
}, },
Groups: user.Groups, Groups: user.Groups,
} }
userContext.Provider = model.ProviderLDAP userContext.Provider = model.ProviderLDAP
userContext.LDAP.Email = utils.CompileUserEmail(username, m.runtime.CookieDomain)
if search.Email != "" {
userContext.LDAP.Email = search.Email
}
} }
userContext.Authenticated = true userContext.Authenticated = true
@@ -298,36 +259,3 @@ func (m *ContextMiddleware) isIgnorePath(path string) bool {
} }
return false return false
} }
func (m *ContextMiddleware) tailscaleWhois(ctx context.Context, ip string) (*model.TailscaleContext, error) {
if m.tailscale == nil {
return nil, nil
}
whois, err := m.tailscale.Whois(ctx, ip)
if err != nil {
m.log.App.Error().Err(err).Msgf("Error performing Tailscale whois for IP %s: %v", ip, err)
return nil, err
}
if whois == nil {
return nil, nil
}
uctx := model.TailscaleContext{
BaseContext: model.BaseContext{
Username: whois.NodeName,
Email: whois.LoginName,
Name: whois.DisplayName,
},
UserID: whois.UserID,
Tags: whois.Tags,
}
if !strings.ContainsAny(uctx.Email, "@") {
uctx.Email = utils.CompileUserEmail(uctx.Email+"-tailscale", m.runtime.CookieDomain)
}
return &uctx, nil
}
+40 -11
View File
@@ -5,7 +5,6 @@ import (
"encoding/base64" "encoding/base64"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"sync"
"testing" "testing"
"time" "time"
@@ -17,15 +16,35 @@ import (
"github.com/tinyauthapp/tinyauth/internal/repository" "github.com/tinyauthapp/tinyauth/internal/repository"
"github.com/tinyauthapp/tinyauth/internal/repository/memory" "github.com/tinyauthapp/tinyauth/internal/repository/memory"
"github.com/tinyauthapp/tinyauth/internal/service" "github.com/tinyauthapp/tinyauth/internal/service"
"github.com/tinyauthapp/tinyauth/internal/test" "github.com/tinyauthapp/tinyauth/internal/utils/tlog"
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
) )
func TestContextMiddleware(t *testing.T) { func TestContextMiddleware(t *testing.T) {
log := logger.NewLogger().WithTestConfig() tlog.NewTestLogger().Init()
log.Init()
cfg, runtime := test.CreateTestConfigs(t) authServiceCfg := service.AuthServiceConfig{
LocalUsers: &[]model.LocalUser{
{
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",
}
middlewareCfg := middleware.ContextMiddlewareConfig{
CookieDomain: "example.com",
SessionCookieName: "tinyauth-session",
}
basicAuthHeader := func(username, password string) string { basicAuthHeader := func(username, password string) string {
return "Basic " + base64.StdEncoding.EncodeToString([]byte(username+":"+password)) return "Basic " + base64.StdEncoding.EncodeToString([]byte(username+":"+password))
@@ -249,15 +268,25 @@ func TestContextMiddleware(t *testing.T) {
}, },
} }
ctx := context.TODO() oauthBrokerCfgs := make(map[string]model.OAuthServiceConfig)
wg := &sync.WaitGroup{}
store := memory.New() store := memory.New()
broker := service.NewOAuthBrokerService(log, map[string]model.OAuthServiceConfig{}, ctx) ldap := service.NewLdapService(service.LdapServiceConfig{})
authService := service.NewAuthService(log, cfg, runtime, ctx, wg, nil, store, broker, nil) err := ldap.Init()
require.NoError(t, err)
contextMiddleware := middleware.NewContextMiddleware(log, runtime, authService, broker, nil) broker := service.NewOAuthBrokerService(oauthBrokerCfgs)
err = broker.Init()
require.NoError(t, err)
authService := service.NewAuthService(authServiceCfg, ldap, store, broker)
err = authService.Init()
require.NoError(t, err)
contextMiddleware := middleware.NewContextMiddleware(middlewareCfg, authService, broker)
err = contextMiddleware.Init()
require.NoError(t, err)
for _, test := range tests { for _, test := range tests {
authService.ClearRateLimitsTestingOnly() authService.ClearRateLimitsTestingOnly()
+9 -4
View File
@@ -9,6 +9,7 @@ import (
"time" "time"
"github.com/tinyauthapp/tinyauth/internal/assets" "github.com/tinyauthapp/tinyauth/internal/assets"
"github.com/tinyauthapp/tinyauth/internal/utils/tlog"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -18,25 +19,29 @@ type UIMiddleware struct {
uiFileServer http.Handler uiFileServer http.Handler
} }
func NewUIMiddleware() (*UIMiddleware, error) { func NewUIMiddleware() *UIMiddleware {
m := &UIMiddleware{} return &UIMiddleware{}
}
func (m *UIMiddleware) Init() error {
ui, err := fs.Sub(assets.FrontendAssets, "dist") ui, err := fs.Sub(assets.FrontendAssets, "dist")
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to load ui assets: %w", err) return err
} }
m.uiFs = ui m.uiFs = ui
m.uiFileServer = http.FileServerFS(ui) m.uiFileServer = http.FileServerFS(ui)
return m, nil return nil
} }
func (m *UIMiddleware) Middleware() gin.HandlerFunc { func (m *UIMiddleware) Middleware() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
path := strings.TrimPrefix(c.Request.URL.Path, "/") path := strings.TrimPrefix(c.Request.URL.Path, "/")
tlog.App.Debug().Str("path", path).Msg("path")
switch strings.SplitN(path, "/", 2)[0] { switch strings.SplitN(path, "/", 2)[0] {
case "api", "resources", ".well-known": case "api", "resources", ".well-known":
c.Next() c.Next()
+8 -8
View File
@@ -5,7 +5,7 @@ import (
"time" "time"
"github.com/gin-gonic/gin" "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 // See context middleware for explanation of why we have to do this
@@ -17,14 +17,14 @@ var (
} }
) )
type ZerologMiddleware struct { type ZerologMiddleware struct{}
log *logger.Logger
func NewZerologMiddleware() *ZerologMiddleware {
return &ZerologMiddleware{}
} }
func NewZerologMiddleware(log *logger.Logger) *ZerologMiddleware { func (m *ZerologMiddleware) Init() error {
return &ZerologMiddleware{ return nil
log: log,
}
} }
func (m *ZerologMiddleware) logPath(path string) bool { func (m *ZerologMiddleware) logPath(path string) bool {
@@ -50,7 +50,7 @@ func (m *ZerologMiddleware) Middleware() gin.HandlerFunc {
latency := time.Since(tStart).String() latency := time.Since(tStart).String()
subLogger := m.log.HTTP.With().Str("method", method). subLogger := tlog.HTTP.With().Str("method", method).
Str("path", path). Str("path", path).
Str("address", address). Str("address", address).
Str("client_ip", clientIP). Str("client_ip", clientIP).
+12 -36
View File
@@ -15,9 +15,8 @@ func NewDefaultConfiguration() *Config {
Path: "./resources", Path: "./resources",
}, },
Server: ServerConfig{ Server: ServerConfig{
Port: 3000, Port: 3000,
Address: "0.0.0.0", Address: "0.0.0.0",
ConcurrentListenersEnabled: false,
}, },
Auth: AuthConfig{ Auth: AuthConfig{
SubdomainsEnabled: true, SubdomainsEnabled: true,
@@ -25,9 +24,6 @@ func NewDefaultConfiguration() *Config {
SessionMaxLifetime: 0, // disabled SessionMaxLifetime: 0, // disabled
LoginTimeout: 300, // 5 minutes LoginTimeout: 300, // 5 minutes
LoginMaxRetries: 3, LoginMaxRetries: 3,
ACLs: ACLsConfig{
Policy: "allow",
},
}, },
UI: UIConfig{ UI: UIConfig{
Title: "Tinyauth", Title: "Tinyauth",
@@ -65,9 +61,6 @@ func NewDefaultConfiguration() *Config {
Experimental: ExperimentalConfig{ Experimental: ExperimentalConfig{
ConfigFile: "", ConfigFile: "",
}, },
Tailscale: TailscaleConfig{
Dir: "./tailscale_state",
},
LabelProvider: "auto", LabelProvider: "auto",
} }
} }
@@ -85,14 +78,13 @@ type Config struct {
UI UIConfig `description:"UI customization." yaml:"ui"` UI UIConfig `description:"UI customization." yaml:"ui"`
LDAP LDAPConfig `description:"LDAP configuration." yaml:"ldap"` LDAP LDAPConfig `description:"LDAP configuration." yaml:"ldap"`
Experimental ExperimentalConfig `description:"Experimental features, use with caution." yaml:"experimental"` Experimental ExperimentalConfig `description:"Experimental features, use with caution." yaml:"experimental"`
LabelProvider string `description:"Label provider to use for ACLs (auto, docker, kubernetes or none to disable). auto detects the environment." yaml:"labelProvider"` 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"` Log LogConfig `description:"Logging configuration." yaml:"log"`
Tailscale TailscaleConfig `description:"Tailscale configuration." yaml:"tailscale"`
} }
type DatabaseConfig struct { type DatabaseConfig struct {
Driver string `description:"The database driver to use. Valid values: sqlite, postgres, memory." yaml:"driver"` Driver string `description:"The database driver to use. Valid values: sqlite, memory." yaml:"driver"`
Path string `description:"The path to the SQLite database file, or connection URL when driver is postgres." yaml:"path"` Path string `description:"The path to the SQLite database, including file name. Only used when driver is sqlite." yaml:"path"`
} }
type AnalyticsConfig struct { type AnalyticsConfig struct {
@@ -105,10 +97,9 @@ type ResourcesConfig struct {
} }
type ServerConfig struct { type ServerConfig struct {
Port int `description:"The port on which the server listens." yaml:"port"` Port int `description:"The port on which the server listens." yaml:"port"`
Address string `description:"The address on which the server listens." yaml:"address"` Address string `description:"The address on which the server listens." yaml:"address"`
SocketPath string `description:"The path to the Unix socket." yaml:"socketPath"` SocketPath string `description:"The path to the Unix socket." yaml:"socketPath"`
ConcurrentListenersEnabled bool `description:"Enable listening on both TCP and Unix socket at the same time." yaml:"concurrentListenersEnabled"`
} }
type AuthConfig struct { type AuthConfig struct {
@@ -123,7 +114,6 @@ type AuthConfig struct {
LoginTimeout int `description:"Login timeout in seconds." yaml:"loginTimeout"` LoginTimeout int `description:"Login timeout in seconds." yaml:"loginTimeout"`
LoginMaxRetries int `description:"Maximum login retries." yaml:"loginMaxRetries"` LoginMaxRetries int `description:"Maximum login retries." yaml:"loginMaxRetries"`
TrustedProxies []string `description:"Comma-separated list of trusted proxy addresses." yaml:"trustedProxies"` TrustedProxies []string `description:"Comma-separated list of trusted proxy addresses." yaml:"trustedProxies"`
ACLs ACLsConfig `description:"ACLs configuration." yaml:"acls"`
} }
type UserAttributes struct { type UserAttributes struct {
@@ -159,10 +149,10 @@ type IPConfig struct {
} }
type OAuthConfig struct { type OAuthConfig struct {
Whitelist []string `description:"Comma-separated list of allowed OAuth domains." yaml:"whitelist"` Whitelist []string `description:"Comma-separated list of allowed OAuth domains." yaml:"whitelist"`
WhitelistFile string `description:"Path to the OAuth whitelist file." yaml:"whitelistFile"` WhitelistFile string `description:"Path to the OAuth whitelist file." yaml:"whitelistFile"`
AutoRedirect string `description:"The OAuth provider to use for automatic redirection." yaml:"autoRedirect"` AutoRedirect string `description:"The OAuth provider to use for automatic redirection." yaml:"autoRedirect"`
Providers map[string]OAuthServiceConfig `description:"OAuth providers configuration." yaml:"providers"` Providers map[string]OAuthServiceConfig `description:"OAuth providers configuration." yaml:"providers"`
} }
type OIDCConfig struct { type OIDCConfig struct {
@@ -211,16 +201,6 @@ type ExperimentalConfig struct {
ConfigFile string `description:"Path to config file." yaml:"-"` ConfigFile string `description:"Path to config file." yaml:"-"`
} }
type TailscaleConfig struct {
Enabled bool `description:"Enable Tailscale integration." yaml:"enabled"`
Dir string `description:"Tailscale state directory." yaml:"dir"`
Hostname string `description:"Tailscale hostname." yaml:"hostname"`
AuthKey string `description:"Tailscale auth key." yaml:"authKey"`
Ephemeral bool `description:"Use ephemeral Tailscale node." yaml:"ephemeral"`
}
// OAuth/OIDC config
type OAuthServiceConfig struct { type OAuthServiceConfig struct {
ClientID string `description:"OAuth client ID." yaml:"clientId"` ClientID string `description:"OAuth client ID." yaml:"clientId"`
ClientSecret string `description:"OAuth client secret." yaml:"clientSecret"` ClientSecret string `description:"OAuth client secret." yaml:"clientSecret"`
@@ -243,10 +223,6 @@ type OIDCClientConfig struct {
Name string `description:"Client name in UI." yaml:"name"` Name string `description:"Client name in UI." yaml:"name"`
} }
type ACLsConfig struct {
Policy string `description:"ACL policy for allow-by-default or deny-by-default, available options are allow and deny, default is allow." yaml:"policy"`
}
// ACLs // ACLs
type Apps struct { type Apps struct {
-2
View File
@@ -21,5 +21,3 @@ const SessionCookieName = "tinyauth-session"
const CSRFCookieName = "tinyauth-csrf" const CSRFCookieName = "tinyauth-csrf"
const RedirectCookieName = "tinyauth-redirect" const RedirectCookieName = "tinyauth-redirect"
const OAuthSessionCookieName = "tinyauth-oauth" const OAuthSessionCookieName = "tinyauth-oauth"
const GracefulShutdownTimeout = 5 // seconds
+60 -65
View File
@@ -8,10 +8,6 @@ import (
"github.com/tinyauthapp/tinyauth/internal/repository" "github.com/tinyauthapp/tinyauth/internal/repository"
) )
var (
ErrUserContextNotFound = errors.New("user context not found")
)
type ProviderType int type ProviderType int
const ( const (
@@ -19,7 +15,6 @@ const (
ProviderBasicAuth ProviderBasicAuth
ProviderOAuth ProviderOAuth
ProviderLDAP ProviderLDAP
ProviderTailscale
) )
type UserContext struct { type UserContext struct {
@@ -28,7 +23,6 @@ type UserContext struct {
Local *LocalContext Local *LocalContext
OAuth *OAuthContext OAuth *OAuthContext
LDAP *LDAPContext LDAP *LDAPContext
Tailscale *TailscaleContext
} }
type BaseContext struct { type BaseContext struct {
@@ -56,13 +50,6 @@ type LDAPContext struct {
Groups []string Groups []string
} }
type TailscaleContext struct {
BaseContext
UserID string
// for future use
Tags []string
}
func (c *UserContext) IsAuthenticated() bool { func (c *UserContext) IsAuthenticated() bool {
return c.Authenticated return c.Authenticated
} }
@@ -83,15 +70,11 @@ func (c *UserContext) IsBasicAuth() bool {
return c.Provider == ProviderBasicAuth && c.Local != nil return c.Provider == ProviderBasicAuth && c.Local != nil
} }
func (c *UserContext) IsTailscale() bool {
return c.Provider == ProviderTailscale && c.Tailscale != nil
}
func (c *UserContext) NewFromGin(ginctx *gin.Context) (*UserContext, error) { func (c *UserContext) NewFromGin(ginctx *gin.Context) (*UserContext, error) {
userContextValue, exists := ginctx.Get("context") userContextValue, exists := ginctx.Get("context")
if !exists { if !exists {
return nil, ErrUserContextNotFound return nil, errors.New("failed to get user context")
} }
userContext, ok := userContextValue.(*UserContext) userContext, ok := userContextValue.(*UserContext)
@@ -100,7 +83,7 @@ func (c *UserContext) NewFromGin(ginctx *gin.Context) (*UserContext, error) {
return nil, errors.New("invalid user context type") return nil, errors.New("invalid user context type")
} }
if userContext.LDAP == nil && userContext.Local == nil && userContext.OAuth == nil && userContext.Tailscale == nil { if userContext.LDAP == nil && userContext.Local == nil && userContext.OAuth == nil {
return nil, errors.New("incomplete user context") return nil, errors.New("incomplete user context")
} }
@@ -134,16 +117,7 @@ func (c *UserContext) NewFromSession(session *repository.Session) (*UserContext,
Email: session.Email, Email: session.Email,
}, },
} }
case "tailscale": // By default we assume an unkown name which is oauth
c.Provider = ProviderTailscale
c.Tailscale = &TailscaleContext{
BaseContext: BaseContext{
Username: session.Username,
Name: session.Name,
Email: session.Email,
},
}
// By default we assume an unknown name which is oauth
default: default:
c.Provider = ProviderOAuth c.Provider = ProviderOAuth
c.OAuth = &OAuthContext{ c.OAuth = &OAuthContext{
@@ -167,55 +141,85 @@ func (c *UserContext) NewFromSession(session *repository.Session) (*UserContext,
return c, nil return c, nil
} }
func (c *UserContext) getBaseContext() *BaseContext { func (c *UserContext) GetUsername() string {
switch c.Provider { switch c.Provider {
case ProviderLocal, ProviderBasicAuth: case ProviderLocal:
if c.Local == nil { if c.Local == nil {
return nil return ""
} }
return &c.Local.BaseContext return c.Local.Username
case ProviderLDAP: case ProviderLDAP:
if c.LDAP == nil { if c.LDAP == nil {
return nil return ""
} }
return &c.LDAP.BaseContext return c.LDAP.Username
case ProviderBasicAuth:
if c.Local == nil {
return ""
}
return c.Local.Username
case ProviderOAuth: case ProviderOAuth:
if c.OAuth == nil { if c.OAuth == nil {
return nil return ""
} }
return &c.OAuth.BaseContext return c.OAuth.Username
case ProviderTailscale:
if c.Tailscale == nil {
return nil
}
return &c.Tailscale.BaseContext
default: default:
return nil
}
}
func (c *UserContext) GetUsername() string {
base := c.getBaseContext()
if base == nil {
return "" return ""
} }
return base.Username
} }
func (c *UserContext) GetEmail() string { func (c *UserContext) GetEmail() string {
base := c.getBaseContext() switch c.Provider {
if base == nil { case ProviderLocal:
if c.Local == nil {
return ""
}
return c.Local.Email
case ProviderLDAP:
if c.LDAP == nil {
return ""
}
return c.LDAP.Email
case ProviderBasicAuth:
if c.Local == nil {
return ""
}
return c.Local.Email
case ProviderOAuth:
if c.OAuth == nil {
return ""
}
return c.OAuth.Email
default:
return "" return ""
} }
return base.Email
} }
func (c *UserContext) GetName() string { func (c *UserContext) GetName() string {
base := c.getBaseContext() switch c.Provider {
if base == nil { case ProviderLocal:
if c.Local == nil {
return ""
}
return c.Local.Name
case ProviderLDAP:
if c.LDAP == nil {
return ""
}
return c.LDAP.Name
case ProviderBasicAuth:
if c.Local == nil {
return ""
}
return c.Local.Name
case ProviderOAuth:
if c.OAuth == nil {
return ""
}
return c.OAuth.Name
default:
return "" return ""
} }
return base.Name
} }
func (c *UserContext) GetProviderID() string { func (c *UserContext) GetProviderID() string {
@@ -226,8 +230,6 @@ func (c *UserContext) GetProviderID() string {
return "ldap" return "ldap"
case ProviderOAuth: case ProviderOAuth:
return c.OAuth.ID return c.OAuth.ID
case ProviderTailscale:
return "tailscale"
default: default:
return "unknown" return "unknown"
} }
@@ -246,10 +248,3 @@ func (c *UserContext) OAuthName() string {
} }
return "" return ""
} }
func (c *UserContext) TailscaleNodeName() string {
if c.Tailscale != nil {
return c.Tailscale.Username
}
return ""
}
+1 -1
View File
@@ -238,7 +238,7 @@ func TestContext(t *testing.T) {
_, err := c.NewFromGin(newGinCtx(nil, false)) _, err := c.NewFromGin(newGinCtx(nil, false))
return err.Error() return err.Error()
}, },
expected: model.ErrUserContextNotFound.Error(), expected: "failed to get user context",
}, },
{ {
description: "NewFromGin returns error when context value has wrong type", description: "NewFromGin returns error when context value has wrong type",
-23
View File
@@ -1,23 +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
TrustedDomains []string
}
type Provider struct {
Name string `json:"name"`
ID string `json:"id"`
OAuth bool `json:"oauth"`
}
-1
View File
@@ -21,6 +21,5 @@ type LocalUser struct {
type UserSearch struct { type UserSearch struct {
Username string Username string
Email string // used for LDAP, we can't throw it to LDAPUser because it would need another cache or an LDAP lookup every time
Type UserSearchType Type UserSearchType
} }
-472
View File
@@ -1,472 +0,0 @@
package memory_test
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tinyauthapp/tinyauth/internal/repository"
"github.com/tinyauthapp/tinyauth/internal/repository/memory"
)
var ctx = context.Background()
func TestMemoryStore(t *testing.T) {
type testCase struct {
description string
run func(t *testing.T, s repository.Store)
}
tests := []testCase{
{
description: "Create and get session",
run: func(t *testing.T, s repository.Store) {
sess, err := s.CreateSession(ctx, repository.CreateSessionParams{
UUID: "uuid-1",
Username: "alice",
Expiry: 9999,
})
require.NoError(t, err)
assert.Equal(t, "uuid-1", sess.UUID)
assert.Equal(t, "alice", sess.Username)
got, err := s.GetSession(ctx, "uuid-1")
require.NoError(t, err)
assert.Equal(t, sess, got)
},
},
{
description: "Get session not found",
run: func(t *testing.T, s repository.Store) {
_, err := s.GetSession(ctx, "missing")
assert.ErrorIs(t, err, repository.ErrNotFound)
},
},
{
description: "Update session",
run: func(t *testing.T, s repository.Store) {
_, err := s.CreateSession(ctx, repository.CreateSessionParams{UUID: "uuid-1", Username: "alice"})
require.NoError(t, err)
updated, err := s.UpdateSession(ctx, repository.UpdateSessionParams{
UUID: "uuid-1",
Username: "bob",
Email: "bob@example.com",
})
require.NoError(t, err)
assert.Equal(t, "bob", updated.Username)
assert.Equal(t, "bob@example.com", updated.Email)
got, err := s.GetSession(ctx, "uuid-1")
require.NoError(t, err)
assert.Equal(t, updated, got)
},
},
{
description: "Update session not found",
run: func(t *testing.T, s repository.Store) {
_, err := s.UpdateSession(ctx, repository.UpdateSessionParams{UUID: "missing"})
assert.ErrorIs(t, err, repository.ErrNotFound)
},
},
{
description: "Delete session",
run: func(t *testing.T, s repository.Store) {
_, err := s.CreateSession(ctx, repository.CreateSessionParams{UUID: "uuid-1"})
require.NoError(t, err)
require.NoError(t, s.DeleteSession(ctx, "uuid-1"))
_, err = s.GetSession(ctx, "uuid-1")
assert.ErrorIs(t, err, repository.ErrNotFound)
},
},
{
description: "Delete expired sessions",
run: func(t *testing.T, s repository.Store) {
_, err := s.CreateSession(ctx, repository.CreateSessionParams{UUID: "expired", Expiry: 10})
require.NoError(t, err)
_, err = s.CreateSession(ctx, repository.CreateSessionParams{UUID: "valid", Expiry: 100})
require.NoError(t, err)
require.NoError(t, s.DeleteExpiredSessions(ctx, 50))
_, err = s.GetSession(ctx, "expired")
assert.ErrorIs(t, err, repository.ErrNotFound)
_, err = s.GetSession(ctx, "valid")
assert.NoError(t, err)
},
},
{
description: "Create and get OIDC code",
run: func(t *testing.T, s repository.Store) {
code, err := s.CreateOidcCode(ctx, repository.CreateOidcCodeParams{
Sub: "sub-1",
CodeHash: "hash-1",
Scope: "openid",
})
require.NoError(t, err)
assert.Equal(t, "sub-1", code.Sub)
// destructive read removes the record
got, err := s.GetOidcCode(ctx, "hash-1")
require.NoError(t, err)
assert.Equal(t, code, got)
_, err = s.GetOidcCode(ctx, "hash-1")
assert.ErrorIs(t, err, repository.ErrNotFound)
},
},
{
description: "Get OIDC code not found",
run: func(t *testing.T, s repository.Store) {
_, err := s.GetOidcCode(ctx, "missing")
assert.ErrorIs(t, err, repository.ErrNotFound)
},
},
{
description: "Get OIDC code by sub",
run: func(t *testing.T, s repository.Store) {
_, err := s.CreateOidcCode(ctx, repository.CreateOidcCodeParams{Sub: "sub-1", CodeHash: "hash-1"})
require.NoError(t, err)
got, err := s.GetOidcCodeBySub(ctx, "sub-1")
require.NoError(t, err)
assert.Equal(t, "sub-1", got.Sub)
// destructive — gone after read
_, err = s.GetOidcCodeBySub(ctx, "sub-1")
assert.ErrorIs(t, err, repository.ErrNotFound)
},
},
{
description: "Get OIDC code by sub not found",
run: func(t *testing.T, s repository.Store) {
_, err := s.GetOidcCodeBySub(ctx, "missing")
assert.ErrorIs(t, err, repository.ErrNotFound)
},
},
{
description: "Get OIDC code unsafe",
run: func(t *testing.T, s repository.Store) {
_, err := s.CreateOidcCode(ctx, repository.CreateOidcCodeParams{Sub: "sub-1", CodeHash: "hash-1"})
require.NoError(t, err)
got, err := s.GetOidcCodeUnsafe(ctx, "hash-1")
require.NoError(t, err)
assert.Equal(t, "sub-1", got.Sub)
// non-destructive — still present
_, err = s.GetOidcCodeUnsafe(ctx, "hash-1")
assert.NoError(t, err)
},
},
{
description: "Get OIDC code unsafe not found",
run: func(t *testing.T, s repository.Store) {
_, err := s.GetOidcCodeUnsafe(ctx, "missing")
assert.ErrorIs(t, err, repository.ErrNotFound)
},
},
{
description: "Get OIDC code by sub unsafe",
run: func(t *testing.T, s repository.Store) {
_, err := s.CreateOidcCode(ctx, repository.CreateOidcCodeParams{Sub: "sub-1", CodeHash: "hash-1"})
require.NoError(t, err)
got, err := s.GetOidcCodeBySubUnsafe(ctx, "sub-1")
require.NoError(t, err)
assert.Equal(t, "hash-1", got.CodeHash)
// non-destructive — still present
_, err = s.GetOidcCodeBySubUnsafe(ctx, "sub-1")
assert.NoError(t, err)
},
},
{
description: "Get OIDC code by sub unsafe not found",
run: func(t *testing.T, s repository.Store) {
_, err := s.GetOidcCodeBySubUnsafe(ctx, "missing")
assert.ErrorIs(t, err, repository.ErrNotFound)
},
},
{
description: "Create OIDC code unique sub constraint",
run: func(t *testing.T, s repository.Store) {
_, err := s.CreateOidcCode(ctx, repository.CreateOidcCodeParams{Sub: "sub-1", CodeHash: "hash-1"})
require.NoError(t, err)
_, err = s.CreateOidcCode(ctx, repository.CreateOidcCodeParams{Sub: "sub-1", CodeHash: "hash-2"})
assert.ErrorContains(t, err, "UNIQUE constraint failed: oidc_codes.sub")
},
},
{
description: "Delete OIDC code",
run: func(t *testing.T, s repository.Store) {
_, err := s.CreateOidcCode(ctx, repository.CreateOidcCodeParams{Sub: "sub-1", CodeHash: "hash-1"})
require.NoError(t, err)
require.NoError(t, s.DeleteOidcCode(ctx, "hash-1"))
_, err = s.GetOidcCodeUnsafe(ctx, "hash-1")
assert.ErrorIs(t, err, repository.ErrNotFound)
},
},
{
description: "Delete OIDC code by sub",
run: func(t *testing.T, s repository.Store) {
_, err := s.CreateOidcCode(ctx, repository.CreateOidcCodeParams{Sub: "sub-1", CodeHash: "hash-1"})
require.NoError(t, err)
require.NoError(t, s.DeleteOidcCodeBySub(ctx, "sub-1"))
_, err = s.GetOidcCodeUnsafe(ctx, "hash-1")
assert.ErrorIs(t, err, repository.ErrNotFound)
},
},
{
description: "Delete expired OIDC codes",
run: func(t *testing.T, s repository.Store) {
_, err := s.CreateOidcCode(ctx, repository.CreateOidcCodeParams{Sub: "sub-1", CodeHash: "hash-1", ExpiresAt: 10})
require.NoError(t, err)
_, err = s.CreateOidcCode(ctx, repository.CreateOidcCodeParams{Sub: "sub-2", CodeHash: "hash-2", ExpiresAt: 100})
require.NoError(t, err)
deleted, err := s.DeleteExpiredOidcCodes(ctx, 50)
require.NoError(t, err)
require.Len(t, deleted, 1)
assert.Equal(t, "hash-1", deleted[0].CodeHash)
_, err = s.GetOidcCodeUnsafe(ctx, "hash-2")
assert.NoError(t, err)
},
},
{
description: "Create and get OIDC token",
run: func(t *testing.T, s repository.Store) {
tok, err := s.CreateOidcToken(ctx, repository.CreateOidcTokenParams{
Sub: "sub-1",
AccessTokenHash: "at-hash-1",
CodeHash: "code-hash-1",
})
require.NoError(t, err)
assert.Equal(t, "sub-1", tok.Sub)
got, err := s.GetOidcToken(ctx, "at-hash-1")
require.NoError(t, err)
assert.Equal(t, tok, got)
},
},
{
description: "Get OIDC token not found",
run: func(t *testing.T, s repository.Store) {
_, err := s.GetOidcToken(ctx, "missing")
assert.ErrorIs(t, err, repository.ErrNotFound)
},
},
{
description: "Create OIDC token unique sub constraint",
run: func(t *testing.T, s repository.Store) {
_, err := s.CreateOidcToken(ctx, repository.CreateOidcTokenParams{Sub: "sub-1", AccessTokenHash: "at-1"})
require.NoError(t, err)
_, err = s.CreateOidcToken(ctx, repository.CreateOidcTokenParams{Sub: "sub-1", AccessTokenHash: "at-2"})
assert.ErrorContains(t, err, "UNIQUE constraint failed: oidc_tokens.sub")
},
},
{
description: "Get OIDC token by refresh token",
run: func(t *testing.T, s repository.Store) {
_, err := s.CreateOidcToken(ctx, repository.CreateOidcTokenParams{
Sub: "sub-1",
AccessTokenHash: "at-1",
RefreshTokenHash: "rt-1",
})
require.NoError(t, err)
got, err := s.GetOidcTokenByRefreshToken(ctx, "rt-1")
require.NoError(t, err)
assert.Equal(t, "sub-1", got.Sub)
},
},
{
description: "Get OIDC token by refresh token not found",
run: func(t *testing.T, s repository.Store) {
_, err := s.GetOidcTokenByRefreshToken(ctx, "missing")
assert.ErrorIs(t, err, repository.ErrNotFound)
},
},
{
description: "Get OIDC token by sub",
run: func(t *testing.T, s repository.Store) {
_, err := s.CreateOidcToken(ctx, repository.CreateOidcTokenParams{
Sub: "sub-1",
AccessTokenHash: "at-1",
})
require.NoError(t, err)
got, err := s.GetOidcTokenBySub(ctx, "sub-1")
require.NoError(t, err)
assert.Equal(t, "at-1", got.AccessTokenHash)
},
},
{
description: "Get OIDC token by sub not found",
run: func(t *testing.T, s repository.Store) {
_, err := s.GetOidcTokenBySub(ctx, "missing")
assert.ErrorIs(t, err, repository.ErrNotFound)
},
},
{
description: "Update OIDC token by refresh token",
run: func(t *testing.T, s repository.Store) {
_, err := s.CreateOidcToken(ctx, repository.CreateOidcTokenParams{
Sub: "sub-1",
AccessTokenHash: "at-1",
RefreshTokenHash: "rt-1",
})
require.NoError(t, err)
updated, err := s.UpdateOidcTokenByRefreshToken(ctx, repository.UpdateOidcTokenByRefreshTokenParams{
RefreshTokenHash_2: "rt-1",
AccessTokenHash: "at-2",
RefreshTokenHash: "rt-2",
TokenExpiresAt: 200,
RefreshTokenExpiresAt: 400,
})
require.NoError(t, err)
assert.Equal(t, "at-2", updated.AccessTokenHash)
assert.Equal(t, "rt-2", updated.RefreshTokenHash)
// old key gone, new key present
_, err = s.GetOidcToken(ctx, "at-1")
assert.ErrorIs(t, err, repository.ErrNotFound)
got, err := s.GetOidcToken(ctx, "at-2")
require.NoError(t, err)
assert.Equal(t, "sub-1", got.Sub)
},
},
{
description: "Update OIDC token by refresh token not found",
run: func(t *testing.T, s repository.Store) {
_, err := s.UpdateOidcTokenByRefreshToken(ctx, repository.UpdateOidcTokenByRefreshTokenParams{
RefreshTokenHash_2: "missing",
})
assert.ErrorIs(t, err, repository.ErrNotFound)
},
},
{
description: "Delete OIDC token",
run: func(t *testing.T, s repository.Store) {
_, err := s.CreateOidcToken(ctx, repository.CreateOidcTokenParams{Sub: "sub-1", AccessTokenHash: "at-1"})
require.NoError(t, err)
require.NoError(t, s.DeleteOidcToken(ctx, "at-1"))
_, err = s.GetOidcToken(ctx, "at-1")
assert.ErrorIs(t, err, repository.ErrNotFound)
},
},
{
description: "Delete OIDC token by sub",
run: func(t *testing.T, s repository.Store) {
_, err := s.CreateOidcToken(ctx, repository.CreateOidcTokenParams{Sub: "sub-1", AccessTokenHash: "at-1"})
require.NoError(t, err)
require.NoError(t, s.DeleteOidcTokenBySub(ctx, "sub-1"))
_, err = s.GetOidcToken(ctx, "at-1")
assert.ErrorIs(t, err, repository.ErrNotFound)
},
},
{
description: "Delete OIDC token by code hash",
run: func(t *testing.T, s repository.Store) {
_, err := s.CreateOidcToken(ctx, repository.CreateOidcTokenParams{
Sub: "sub-1",
AccessTokenHash: "at-1",
CodeHash: "code-1",
})
require.NoError(t, err)
require.NoError(t, s.DeleteOidcTokenByCodeHash(ctx, "code-1"))
_, err = s.GetOidcToken(ctx, "at-1")
assert.ErrorIs(t, err, repository.ErrNotFound)
},
},
{
description: "Delete expired OIDC tokens",
run: func(t *testing.T, s repository.Store) {
// both expiries past
_, err := s.CreateOidcToken(ctx, repository.CreateOidcTokenParams{
Sub: "sub-1", AccessTokenHash: "at-1",
TokenExpiresAt: 10, RefreshTokenExpiresAt: 10,
})
require.NoError(t, err)
// valid
_, err = s.CreateOidcToken(ctx, repository.CreateOidcTokenParams{
Sub: "sub-3", AccessTokenHash: "at-3",
TokenExpiresAt: 100, RefreshTokenExpiresAt: 100,
})
require.NoError(t, err)
deleted, err := s.DeleteExpiredOidcTokens(ctx, repository.DeleteExpiredOidcTokensParams{
TokenExpiresAt: 50,
RefreshTokenExpiresAt: 50,
})
require.NoError(t, err)
assert.Len(t, deleted, 1)
_, err = s.GetOidcToken(ctx, "at-3")
assert.NoError(t, err)
},
},
{
description: "Create and get OIDC user info",
run: func(t *testing.T, s repository.Store) {
u, err := s.CreateOidcUserInfo(ctx, repository.CreateOidcUserInfoParams{
Sub: "sub-1",
Name: "Alice",
Email: "alice@example.com",
})
require.NoError(t, err)
assert.Equal(t, "sub-1", u.Sub)
got, err := s.GetOidcUserInfo(ctx, "sub-1")
require.NoError(t, err)
assert.Equal(t, u, got)
},
},
{
description: "Get OIDC user info not found",
run: func(t *testing.T, s repository.Store) {
_, err := s.GetOidcUserInfo(ctx, "missing")
assert.ErrorIs(t, err, repository.ErrNotFound)
},
},
{
description: "Delete OIDC user info",
run: func(t *testing.T, s repository.Store) {
_, err := s.CreateOidcUserInfo(ctx, repository.CreateOidcUserInfoParams{Sub: "sub-1"})
require.NoError(t, err)
require.NoError(t, s.DeleteOidcUserInfo(ctx, "sub-1"))
_, err = s.GetOidcUserInfo(ctx, "sub-1")
assert.ErrorIs(t, err, repository.ErrNotFound)
},
},
}
for _, test := range tests {
t.Run(test.description, func(t *testing.T) {
s := memory.New()
test.run(t, s)
})
}
}
+1 -1
View File
@@ -207,7 +207,7 @@ func (s *Store) DeleteExpiredOidcTokens(_ context.Context, arg repository.Delete
defer s.mu.Unlock() defer s.mu.Unlock()
var deleted []repository.OidcToken var deleted []repository.OidcToken
for k, t := range s.oidcTokens { for k, t := range s.oidcTokens {
if t.TokenExpiresAt < arg.TokenExpiresAt && t.RefreshTokenExpiresAt < arg.RefreshTokenExpiresAt { if t.TokenExpiresAt < arg.TokenExpiresAt || t.RefreshTokenExpiresAt < arg.RefreshTokenExpiresAt {
deleted = append(deleted, t) deleted = append(deleted, t)
delete(s.oidcTokens, k) delete(s.oidcTokens, k)
} }
-33
View File
@@ -1,33 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
package postgres
import (
"context"
"database/sql"
)
type DBTX interface {
ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
PrepareContext(context.Context, string) (*sql.Stmt, error)
QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
QueryRowContext(context.Context, string, ...interface{}) *sql.Row
}
// New returns a *Queries configured to use the provided DBTX for executing database operations.
// The returned *Queries will use db as its database handle for all query method calls.
func New(db DBTX) *Queries {
return &Queries{db: db}
}
type Queries struct {
db DBTX
}
func (q *Queries) WithTx(tx *sql.Tx) *Queries {
return &Queries{
db: tx,
}
}
-3
View File
@@ -1,3 +0,0 @@
package postgres
//go:generate go run github.com/tinyauthapp/tinyauth/gen/sqlc-wrapper -pkg github.com/tinyauthapp/tinyauth/internal/repository/postgres
-64
View File
@@ -1,64 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
package postgres
type OidcCode struct {
Sub string
CodeHash string
Scope string
RedirectURI string
ClientID string
ExpiresAt int64
Nonce string
CodeChallenge string
}
type OidcToken struct {
Sub string
AccessTokenHash string
RefreshTokenHash string
CodeHash string
Scope string
ClientID string
TokenExpiresAt int64
RefreshTokenExpiresAt int64
Nonce string
}
type OidcUserinfo struct {
Sub string
Name string
PreferredUsername string
Email string
Groups string
UpdatedAt int64
GivenName string
FamilyName string
MiddleName string
Nickname string
Profile string
Picture string
Website string
Gender string
Birthdate string
Zoneinfo string
Locale string
PhoneNumber string
Address string
}
type Session struct {
UUID string
Username string
Email string
Name string
Provider string
TotpPending bool
OAuthGroups string
Expiry int64
CreatedAt int64
OAuthName string
OAuthSub string
}
@@ -1,581 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: oidc_queries.sql
package postgres
import (
"context"
)
const createOidcCode = `-- name: CreateOidcCode :one
INSERT INTO "oidc_codes" (
"sub",
"code_hash",
"scope",
"redirect_uri",
"client_id",
"expires_at",
"nonce",
"code_challenge"
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8
)
RETURNING sub, code_hash, scope, redirect_uri, client_id, expires_at, nonce, code_challenge
`
type CreateOidcCodeParams struct {
Sub string
CodeHash string
Scope string
RedirectURI string
ClientID string
ExpiresAt int64
Nonce string
CodeChallenge string
}
func (q *Queries) CreateOidcCode(ctx context.Context, arg CreateOidcCodeParams) (OidcCode, error) {
row := q.db.QueryRowContext(ctx, createOidcCode,
arg.Sub,
arg.CodeHash,
arg.Scope,
arg.RedirectURI,
arg.ClientID,
arg.ExpiresAt,
arg.Nonce,
arg.CodeChallenge,
)
var i OidcCode
err := row.Scan(
&i.Sub,
&i.CodeHash,
&i.Scope,
&i.RedirectURI,
&i.ClientID,
&i.ExpiresAt,
&i.Nonce,
&i.CodeChallenge,
)
return i, err
}
const createOidcToken = `-- name: CreateOidcToken :one
INSERT INTO "oidc_tokens" (
"sub",
"access_token_hash",
"refresh_token_hash",
"scope",
"client_id",
"token_expires_at",
"refresh_token_expires_at",
"code_hash",
"nonce"
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9
)
RETURNING sub, access_token_hash, refresh_token_hash, code_hash, scope, client_id, token_expires_at, refresh_token_expires_at, nonce
`
type CreateOidcTokenParams struct {
Sub string
AccessTokenHash string
RefreshTokenHash string
Scope string
ClientID string
TokenExpiresAt int64
RefreshTokenExpiresAt int64
CodeHash string
Nonce string
}
func (q *Queries) CreateOidcToken(ctx context.Context, arg CreateOidcTokenParams) (OidcToken, error) {
row := q.db.QueryRowContext(ctx, createOidcToken,
arg.Sub,
arg.AccessTokenHash,
arg.RefreshTokenHash,
arg.Scope,
arg.ClientID,
arg.TokenExpiresAt,
arg.RefreshTokenExpiresAt,
arg.CodeHash,
arg.Nonce,
)
var i OidcToken
err := row.Scan(
&i.Sub,
&i.AccessTokenHash,
&i.RefreshTokenHash,
&i.CodeHash,
&i.Scope,
&i.ClientID,
&i.TokenExpiresAt,
&i.RefreshTokenExpiresAt,
&i.Nonce,
)
return i, err
}
const createOidcUserInfo = `-- name: CreateOidcUserInfo :one
INSERT INTO "oidc_userinfo" (
"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"
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19
)
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
`
type CreateOidcUserInfoParams struct {
Sub string
Name string
PreferredUsername string
Email string
Groups string
UpdatedAt int64
GivenName string
FamilyName string
MiddleName string
Nickname string
Profile string
Picture string
Website string
Gender string
Birthdate string
Zoneinfo string
Locale string
PhoneNumber string
Address string
}
func (q *Queries) CreateOidcUserInfo(ctx context.Context, arg CreateOidcUserInfoParams) (OidcUserinfo, error) {
row := q.db.QueryRowContext(ctx, createOidcUserInfo,
arg.Sub,
arg.Name,
arg.PreferredUsername,
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(
&i.Sub,
&i.Name,
&i.PreferredUsername,
&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
}
const deleteExpiredOidcCodes = `-- name: DeleteExpiredOidcCodes :many
DELETE FROM "oidc_codes"
WHERE "expires_at" < $1
RETURNING sub, code_hash, scope, redirect_uri, client_id, expires_at, nonce, code_challenge
`
func (q *Queries) DeleteExpiredOidcCodes(ctx context.Context, expiresAt int64) ([]OidcCode, error) {
rows, err := q.db.QueryContext(ctx, deleteExpiredOidcCodes, expiresAt)
if err != nil {
return nil, err
}
defer rows.Close()
var items []OidcCode
for rows.Next() {
var i OidcCode
if err := rows.Scan(
&i.Sub,
&i.CodeHash,
&i.Scope,
&i.RedirectURI,
&i.ClientID,
&i.ExpiresAt,
&i.Nonce,
&i.CodeChallenge,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const deleteExpiredOidcTokens = `-- name: DeleteExpiredOidcTokens :many
DELETE FROM "oidc_tokens"
WHERE "token_expires_at" < $1 AND "refresh_token_expires_at" < $2
RETURNING sub, access_token_hash, refresh_token_hash, code_hash, scope, client_id, token_expires_at, refresh_token_expires_at, nonce
`
type DeleteExpiredOidcTokensParams struct {
TokenExpiresAt int64
RefreshTokenExpiresAt int64
}
func (q *Queries) DeleteExpiredOidcTokens(ctx context.Context, arg DeleteExpiredOidcTokensParams) ([]OidcToken, error) {
rows, err := q.db.QueryContext(ctx, deleteExpiredOidcTokens, arg.TokenExpiresAt, arg.RefreshTokenExpiresAt)
if err != nil {
return nil, err
}
defer rows.Close()
var items []OidcToken
for rows.Next() {
var i OidcToken
if err := rows.Scan(
&i.Sub,
&i.AccessTokenHash,
&i.RefreshTokenHash,
&i.CodeHash,
&i.Scope,
&i.ClientID,
&i.TokenExpiresAt,
&i.RefreshTokenExpiresAt,
&i.Nonce,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const deleteOidcCode = `-- name: DeleteOidcCode :exec
DELETE FROM "oidc_codes"
WHERE "code_hash" = $1
`
func (q *Queries) DeleteOidcCode(ctx context.Context, codeHash string) error {
_, err := q.db.ExecContext(ctx, deleteOidcCode, codeHash)
return err
}
const deleteOidcCodeBySub = `-- name: DeleteOidcCodeBySub :exec
DELETE FROM "oidc_codes"
WHERE "sub" = $1
`
func (q *Queries) DeleteOidcCodeBySub(ctx context.Context, sub string) error {
_, err := q.db.ExecContext(ctx, deleteOidcCodeBySub, sub)
return err
}
const deleteOidcToken = `-- name: DeleteOidcToken :exec
DELETE FROM "oidc_tokens"
WHERE "access_token_hash" = $1
`
func (q *Queries) DeleteOidcToken(ctx context.Context, accessTokenHash string) error {
_, err := q.db.ExecContext(ctx, deleteOidcToken, accessTokenHash)
return err
}
const deleteOidcTokenByCodeHash = `-- name: DeleteOidcTokenByCodeHash :exec
DELETE FROM "oidc_tokens"
WHERE "code_hash" = $1
`
func (q *Queries) DeleteOidcTokenByCodeHash(ctx context.Context, codeHash string) error {
_, err := q.db.ExecContext(ctx, deleteOidcTokenByCodeHash, codeHash)
return err
}
const deleteOidcTokenBySub = `-- name: DeleteOidcTokenBySub :exec
DELETE FROM "oidc_tokens"
WHERE "sub" = $1
`
func (q *Queries) DeleteOidcTokenBySub(ctx context.Context, sub string) error {
_, err := q.db.ExecContext(ctx, deleteOidcTokenBySub, sub)
return err
}
const deleteOidcUserInfo = `-- name: DeleteOidcUserInfo :exec
DELETE FROM "oidc_userinfo"
WHERE "sub" = $1
`
func (q *Queries) DeleteOidcUserInfo(ctx context.Context, sub string) error {
_, err := q.db.ExecContext(ctx, deleteOidcUserInfo, sub)
return err
}
const getOidcCode = `-- name: GetOidcCode :one
DELETE FROM "oidc_codes"
WHERE "code_hash" = $1
RETURNING sub, code_hash, scope, redirect_uri, client_id, expires_at, nonce, code_challenge
`
func (q *Queries) GetOidcCode(ctx context.Context, codeHash string) (OidcCode, error) {
row := q.db.QueryRowContext(ctx, getOidcCode, codeHash)
var i OidcCode
err := row.Scan(
&i.Sub,
&i.CodeHash,
&i.Scope,
&i.RedirectURI,
&i.ClientID,
&i.ExpiresAt,
&i.Nonce,
&i.CodeChallenge,
)
return i, err
}
const getOidcCodeBySub = `-- name: GetOidcCodeBySub :one
DELETE FROM "oidc_codes"
WHERE "sub" = $1
RETURNING sub, code_hash, scope, redirect_uri, client_id, expires_at, nonce, code_challenge
`
func (q *Queries) GetOidcCodeBySub(ctx context.Context, sub string) (OidcCode, error) {
row := q.db.QueryRowContext(ctx, getOidcCodeBySub, sub)
var i OidcCode
err := row.Scan(
&i.Sub,
&i.CodeHash,
&i.Scope,
&i.RedirectURI,
&i.ClientID,
&i.ExpiresAt,
&i.Nonce,
&i.CodeChallenge,
)
return i, err
}
const getOidcCodeBySubUnsafe = `-- name: GetOidcCodeBySubUnsafe :one
SELECT sub, code_hash, scope, redirect_uri, client_id, expires_at, nonce, code_challenge FROM "oidc_codes"
WHERE "sub" = $1
`
func (q *Queries) GetOidcCodeBySubUnsafe(ctx context.Context, sub string) (OidcCode, error) {
row := q.db.QueryRowContext(ctx, getOidcCodeBySubUnsafe, sub)
var i OidcCode
err := row.Scan(
&i.Sub,
&i.CodeHash,
&i.Scope,
&i.RedirectURI,
&i.ClientID,
&i.ExpiresAt,
&i.Nonce,
&i.CodeChallenge,
)
return i, err
}
const getOidcCodeUnsafe = `-- name: GetOidcCodeUnsafe :one
SELECT sub, code_hash, scope, redirect_uri, client_id, expires_at, nonce, code_challenge FROM "oidc_codes"
WHERE "code_hash" = $1
`
func (q *Queries) GetOidcCodeUnsafe(ctx context.Context, codeHash string) (OidcCode, error) {
row := q.db.QueryRowContext(ctx, getOidcCodeUnsafe, codeHash)
var i OidcCode
err := row.Scan(
&i.Sub,
&i.CodeHash,
&i.Scope,
&i.RedirectURI,
&i.ClientID,
&i.ExpiresAt,
&i.Nonce,
&i.CodeChallenge,
)
return i, err
}
const getOidcToken = `-- name: GetOidcToken :one
SELECT sub, access_token_hash, refresh_token_hash, code_hash, scope, client_id, token_expires_at, refresh_token_expires_at, nonce FROM "oidc_tokens"
WHERE "access_token_hash" = $1
`
func (q *Queries) GetOidcToken(ctx context.Context, accessTokenHash string) (OidcToken, error) {
row := q.db.QueryRowContext(ctx, getOidcToken, accessTokenHash)
var i OidcToken
err := row.Scan(
&i.Sub,
&i.AccessTokenHash,
&i.RefreshTokenHash,
&i.CodeHash,
&i.Scope,
&i.ClientID,
&i.TokenExpiresAt,
&i.RefreshTokenExpiresAt,
&i.Nonce,
)
return i, err
}
const getOidcTokenByRefreshToken = `-- name: GetOidcTokenByRefreshToken :one
SELECT sub, access_token_hash, refresh_token_hash, code_hash, scope, client_id, token_expires_at, refresh_token_expires_at, nonce FROM "oidc_tokens"
WHERE "refresh_token_hash" = $1
`
func (q *Queries) GetOidcTokenByRefreshToken(ctx context.Context, refreshTokenHash string) (OidcToken, error) {
row := q.db.QueryRowContext(ctx, getOidcTokenByRefreshToken, refreshTokenHash)
var i OidcToken
err := row.Scan(
&i.Sub,
&i.AccessTokenHash,
&i.RefreshTokenHash,
&i.CodeHash,
&i.Scope,
&i.ClientID,
&i.TokenExpiresAt,
&i.RefreshTokenExpiresAt,
&i.Nonce,
)
return i, err
}
const getOidcTokenBySub = `-- name: GetOidcTokenBySub :one
SELECT sub, access_token_hash, refresh_token_hash, code_hash, scope, client_id, token_expires_at, refresh_token_expires_at, nonce FROM "oidc_tokens"
WHERE "sub" = $1
`
func (q *Queries) GetOidcTokenBySub(ctx context.Context, sub string) (OidcToken, error) {
row := q.db.QueryRowContext(ctx, getOidcTokenBySub, sub)
var i OidcToken
err := row.Scan(
&i.Sub,
&i.AccessTokenHash,
&i.RefreshTokenHash,
&i.CodeHash,
&i.Scope,
&i.ClientID,
&i.TokenExpiresAt,
&i.RefreshTokenExpiresAt,
&i.Nonce,
)
return i, err
}
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"
WHERE "sub" = $1
`
func (q *Queries) GetOidcUserInfo(ctx context.Context, sub string) (OidcUserinfo, error) {
row := q.db.QueryRowContext(ctx, getOidcUserInfo, sub)
var i OidcUserinfo
err := row.Scan(
&i.Sub,
&i.Name,
&i.PreferredUsername,
&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
}
const updateOidcTokenByRefreshToken = `-- name: UpdateOidcTokenByRefreshToken :one
UPDATE "oidc_tokens" SET
"access_token_hash" = $1,
"refresh_token_hash" = $2,
"token_expires_at" = $3,
"refresh_token_expires_at" = $4
WHERE "refresh_token_hash" = $5
RETURNING sub, access_token_hash, refresh_token_hash, code_hash, scope, client_id, token_expires_at, refresh_token_expires_at, nonce
`
type UpdateOidcTokenByRefreshTokenParams struct {
AccessTokenHash string
RefreshTokenHash string
TokenExpiresAt int64
RefreshTokenExpiresAt int64
RefreshTokenHash_2 string
}
func (q *Queries) UpdateOidcTokenByRefreshToken(ctx context.Context, arg UpdateOidcTokenByRefreshTokenParams) (OidcToken, error) {
row := q.db.QueryRowContext(ctx, updateOidcTokenByRefreshToken,
arg.AccessTokenHash,
arg.RefreshTokenHash,
arg.TokenExpiresAt,
arg.RefreshTokenExpiresAt,
arg.RefreshTokenHash_2,
)
var i OidcToken
err := row.Scan(
&i.Sub,
&i.AccessTokenHash,
&i.RefreshTokenHash,
&i.CodeHash,
&i.Scope,
&i.ClientID,
&i.TokenExpiresAt,
&i.RefreshTokenExpiresAt,
&i.Nonce,
)
return i, err
}
@@ -1,176 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: session_queries.sql
package postgres
import (
"context"
)
const createSession = `-- name: CreateSession :one
INSERT INTO "sessions" (
"uuid",
"username",
"email",
"name",
"provider",
"totp_pending",
"oauth_groups",
"expiry",
"created_at",
"oauth_name",
"oauth_sub"
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11
)
RETURNING uuid, username, email, name, provider, totp_pending, oauth_groups, expiry, created_at, oauth_name, oauth_sub
`
type CreateSessionParams struct {
UUID string
Username string
Email string
Name string
Provider string
TotpPending bool
OAuthGroups string
Expiry int64
CreatedAt int64
OAuthName string
OAuthSub string
}
func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error) {
row := q.db.QueryRowContext(ctx, createSession,
arg.UUID,
arg.Username,
arg.Email,
arg.Name,
arg.Provider,
arg.TotpPending,
arg.OAuthGroups,
arg.Expiry,
arg.CreatedAt,
arg.OAuthName,
arg.OAuthSub,
)
var i Session
err := row.Scan(
&i.UUID,
&i.Username,
&i.Email,
&i.Name,
&i.Provider,
&i.TotpPending,
&i.OAuthGroups,
&i.Expiry,
&i.CreatedAt,
&i.OAuthName,
&i.OAuthSub,
)
return i, err
}
const deleteExpiredSessions = `-- name: DeleteExpiredSessions :exec
DELETE FROM "sessions"
WHERE "expiry" < $1
`
func (q *Queries) DeleteExpiredSessions(ctx context.Context, expiry int64) error {
_, err := q.db.ExecContext(ctx, deleteExpiredSessions, expiry)
return err
}
const deleteSession = `-- name: DeleteSession :exec
DELETE FROM "sessions"
WHERE "uuid" = $1
`
func (q *Queries) DeleteSession(ctx context.Context, uuid string) error {
_, err := q.db.ExecContext(ctx, deleteSession, uuid)
return err
}
const getSession = `-- name: GetSession :one
SELECT uuid, username, email, name, provider, totp_pending, oauth_groups, expiry, created_at, oauth_name, oauth_sub FROM "sessions"
WHERE "uuid" = $1
`
func (q *Queries) GetSession(ctx context.Context, uuid string) (Session, error) {
row := q.db.QueryRowContext(ctx, getSession, uuid)
var i Session
err := row.Scan(
&i.UUID,
&i.Username,
&i.Email,
&i.Name,
&i.Provider,
&i.TotpPending,
&i.OAuthGroups,
&i.Expiry,
&i.CreatedAt,
&i.OAuthName,
&i.OAuthSub,
)
return i, err
}
const updateSession = `-- name: UpdateSession :one
UPDATE "sessions" SET
"username" = $1,
"email" = $2,
"name" = $3,
"provider" = $4,
"totp_pending" = $5,
"oauth_groups" = $6,
"expiry" = $7,
"oauth_name" = $8,
"oauth_sub" = $9
WHERE "uuid" = $10
RETURNING uuid, username, email, name, provider, totp_pending, oauth_groups, expiry, created_at, oauth_name, oauth_sub
`
type UpdateSessionParams struct {
Username string
Email string
Name string
Provider string
TotpPending bool
OAuthGroups string
Expiry int64
OAuthName string
OAuthSub string
UUID string
}
func (q *Queries) UpdateSession(ctx context.Context, arg UpdateSessionParams) (Session, error) {
row := q.db.QueryRowContext(ctx, updateSession,
arg.Username,
arg.Email,
arg.Name,
arg.Provider,
arg.TotpPending,
arg.OAuthGroups,
arg.Expiry,
arg.OAuthName,
arg.OAuthSub,
arg.UUID,
)
var i Session
err := row.Scan(
&i.UUID,
&i.Username,
&i.Email,
&i.Name,
&i.Provider,
&i.TotpPending,
&i.OAuthGroups,
&i.Expiry,
&i.CreatedAt,
&i.OAuthName,
&i.OAuthSub,
)
return i, err
}
-211
View File
@@ -1,211 +0,0 @@
// Code generated by cmd/gen/sqlc-wrapper. DO NOT EDIT.
package postgres
import (
"context"
"database/sql"
"errors"
"github.com/tinyauthapp/tinyauth/internal/repository"
)
// Store wraps *Queries and implements repository.Store.
type Store struct {
q *Queries
}
// NewStore returns a repository.Store backed by the provided *Queries.
func NewStore(q *Queries) repository.Store {
return &Store{q: q}
}
var errorMap = map[error]error{
sql.ErrNoRows: repository.ErrNotFound,
}
// mapErr maps known database errors to repository-level errors using the package-level errorMap.
// It uses errors.Is to match (so wrapped errors are recognized) and returns the original error if no mapping applies.
func mapErr(err error) error {
for from, to := range errorMap {
if errors.Is(err, from) {
return to
}
}
return err
}
func (s *Store) CreateOidcCode(ctx context.Context, arg repository.CreateOidcCodeParams) (repository.OidcCode, error) {
r, err := s.q.CreateOidcCode(ctx, CreateOidcCodeParams(arg))
if err != nil {
return repository.OidcCode{}, mapErr(err)
}
return repository.OidcCode(r), nil
}
func (s *Store) CreateOidcToken(ctx context.Context, arg repository.CreateOidcTokenParams) (repository.OidcToken, error) {
r, err := s.q.CreateOidcToken(ctx, CreateOidcTokenParams(arg))
if err != nil {
return repository.OidcToken{}, mapErr(err)
}
return repository.OidcToken(r), nil
}
func (s *Store) CreateOidcUserInfo(ctx context.Context, arg repository.CreateOidcUserInfoParams) (repository.OidcUserinfo, error) {
r, err := s.q.CreateOidcUserInfo(ctx, CreateOidcUserInfoParams(arg))
if err != nil {
return repository.OidcUserinfo{}, mapErr(err)
}
return repository.OidcUserinfo(r), nil
}
func (s *Store) CreateSession(ctx context.Context, arg repository.CreateSessionParams) (repository.Session, error) {
r, err := s.q.CreateSession(ctx, CreateSessionParams(arg))
if err != nil {
return repository.Session{}, mapErr(err)
}
return repository.Session(r), nil
}
func (s *Store) DeleteExpiredOidcCodes(ctx context.Context, expiresAt int64) ([]repository.OidcCode, error) {
rows, err := s.q.DeleteExpiredOidcCodes(ctx, expiresAt)
if err != nil {
return nil, mapErr(err)
}
out := make([]repository.OidcCode, len(rows))
for i, row := range rows {
out[i] = repository.OidcCode(row)
}
return out, nil
}
func (s *Store) DeleteExpiredOidcTokens(ctx context.Context, arg repository.DeleteExpiredOidcTokensParams) ([]repository.OidcToken, error) {
rows, err := s.q.DeleteExpiredOidcTokens(ctx, DeleteExpiredOidcTokensParams(arg))
if err != nil {
return nil, mapErr(err)
}
out := make([]repository.OidcToken, len(rows))
for i, row := range rows {
out[i] = repository.OidcToken(row)
}
return out, nil
}
func (s *Store) DeleteExpiredSessions(ctx context.Context, expiry int64) error {
return mapErr(s.q.DeleteExpiredSessions(ctx, expiry))
}
func (s *Store) DeleteOidcCode(ctx context.Context, codeHash string) error {
return mapErr(s.q.DeleteOidcCode(ctx, codeHash))
}
func (s *Store) DeleteOidcCodeBySub(ctx context.Context, sub string) error {
return mapErr(s.q.DeleteOidcCodeBySub(ctx, sub))
}
func (s *Store) DeleteOidcToken(ctx context.Context, accessTokenHash string) error {
return mapErr(s.q.DeleteOidcToken(ctx, accessTokenHash))
}
func (s *Store) DeleteOidcTokenByCodeHash(ctx context.Context, codeHash string) error {
return mapErr(s.q.DeleteOidcTokenByCodeHash(ctx, codeHash))
}
func (s *Store) DeleteOidcTokenBySub(ctx context.Context, sub string) error {
return mapErr(s.q.DeleteOidcTokenBySub(ctx, sub))
}
func (s *Store) DeleteOidcUserInfo(ctx context.Context, sub string) error {
return mapErr(s.q.DeleteOidcUserInfo(ctx, sub))
}
func (s *Store) DeleteSession(ctx context.Context, uuid string) error {
return mapErr(s.q.DeleteSession(ctx, uuid))
}
func (s *Store) GetOidcCode(ctx context.Context, codeHash string) (repository.OidcCode, error) {
r, err := s.q.GetOidcCode(ctx, codeHash)
if err != nil {
return repository.OidcCode{}, mapErr(err)
}
return repository.OidcCode(r), nil
}
func (s *Store) GetOidcCodeBySub(ctx context.Context, sub string) (repository.OidcCode, error) {
r, err := s.q.GetOidcCodeBySub(ctx, sub)
if err != nil {
return repository.OidcCode{}, mapErr(err)
}
return repository.OidcCode(r), nil
}
func (s *Store) GetOidcCodeBySubUnsafe(ctx context.Context, sub string) (repository.OidcCode, error) {
r, err := s.q.GetOidcCodeBySubUnsafe(ctx, sub)
if err != nil {
return repository.OidcCode{}, mapErr(err)
}
return repository.OidcCode(r), nil
}
func (s *Store) GetOidcCodeUnsafe(ctx context.Context, codeHash string) (repository.OidcCode, error) {
r, err := s.q.GetOidcCodeUnsafe(ctx, codeHash)
if err != nil {
return repository.OidcCode{}, mapErr(err)
}
return repository.OidcCode(r), nil
}
func (s *Store) GetOidcToken(ctx context.Context, accessTokenHash string) (repository.OidcToken, error) {
r, err := s.q.GetOidcToken(ctx, accessTokenHash)
if err != nil {
return repository.OidcToken{}, mapErr(err)
}
return repository.OidcToken(r), nil
}
func (s *Store) GetOidcTokenByRefreshToken(ctx context.Context, refreshTokenHash string) (repository.OidcToken, error) {
r, err := s.q.GetOidcTokenByRefreshToken(ctx, refreshTokenHash)
if err != nil {
return repository.OidcToken{}, mapErr(err)
}
return repository.OidcToken(r), nil
}
func (s *Store) GetOidcTokenBySub(ctx context.Context, sub string) (repository.OidcToken, error) {
r, err := s.q.GetOidcTokenBySub(ctx, sub)
if err != nil {
return repository.OidcToken{}, mapErr(err)
}
return repository.OidcToken(r), nil
}
func (s *Store) GetOidcUserInfo(ctx context.Context, sub string) (repository.OidcUserinfo, error) {
r, err := s.q.GetOidcUserInfo(ctx, sub)
if err != nil {
return repository.OidcUserinfo{}, mapErr(err)
}
return repository.OidcUserinfo(r), nil
}
func (s *Store) GetSession(ctx context.Context, uuid string) (repository.Session, error) {
r, err := s.q.GetSession(ctx, uuid)
if err != nil {
return repository.Session{}, mapErr(err)
}
return repository.Session(r), nil
}
func (s *Store) UpdateOidcTokenByRefreshToken(ctx context.Context, arg repository.UpdateOidcTokenByRefreshTokenParams) (repository.OidcToken, error) {
r, err := s.q.UpdateOidcTokenByRefreshToken(ctx, UpdateOidcTokenByRefreshTokenParams(arg))
if err != nil {
return repository.OidcToken{}, mapErr(err)
}
return repository.OidcToken(r), nil
}
func (s *Store) UpdateSession(ctx context.Context, arg repository.UpdateSessionParams) (repository.Session, error) {
r, err := s.q.UpdateSession(ctx, UpdateSessionParams(arg))
if err != nil {
return repository.Session{}, mapErr(err)
}
return repository.Session(r), nil
}
+1 -1
View File
@@ -1,3 +1,3 @@
package sqlite package sqlite
//go:generate go run github.com/tinyauthapp/tinyauth/gen/sqlc-wrapper -pkg github.com/tinyauthapp/tinyauth/internal/repository/sqlite //go:generate go run github.com/tinyauthapp/tinyauth/cmd/gen/sqlc-wrapper -pkg github.com/tinyauthapp/tinyauth/internal/repository/sqlite
+37 -22
View File
@@ -19,25 +19,40 @@ func NewStore(q *Queries) repository.Store {
return &Store{q: q} return &Store{q: q}
} }
var errorMap = map[error]error{ var errMap = []struct {
sql.ErrNoRows: repository.ErrNotFound, from error
to error
}{
{sql.ErrNoRows, repository.ErrNotFound},
} }
func mapErr(err error) error { func mapErr(err error) error {
for from, to := range errorMap { for _, e := range errMap {
if errors.Is(err, from) { if errors.Is(err, e.from) {
return to return e.to
} }
} }
return err return err
} }
func oidcCodeToRepo(v OidcCode) repository.OidcCode {
return repository.OidcCode(v)
}
func oidcTokenToRepo(v OidcToken) repository.OidcToken {
return repository.OidcToken(v)
}
func oidcUserinfoToRepo(v OidcUserinfo) repository.OidcUserinfo {
return repository.OidcUserinfo(v)
}
func sessionToRepo(v Session) repository.Session {
return repository.Session(v)
}
func (s *Store) CreateOidcCode(ctx context.Context, arg repository.CreateOidcCodeParams) (repository.OidcCode, error) { func (s *Store) CreateOidcCode(ctx context.Context, arg repository.CreateOidcCodeParams) (repository.OidcCode, error) {
r, err := s.q.CreateOidcCode(ctx, CreateOidcCodeParams(arg)) r, err := s.q.CreateOidcCode(ctx, CreateOidcCodeParams(arg))
if err != nil { if err != nil {
return repository.OidcCode{}, mapErr(err) return repository.OidcCode{}, mapErr(err)
} }
return repository.OidcCode(r), nil return oidcCodeToRepo(r), nil
} }
func (s *Store) CreateOidcToken(ctx context.Context, arg repository.CreateOidcTokenParams) (repository.OidcToken, error) { func (s *Store) CreateOidcToken(ctx context.Context, arg repository.CreateOidcTokenParams) (repository.OidcToken, error) {
@@ -45,7 +60,7 @@ func (s *Store) CreateOidcToken(ctx context.Context, arg repository.CreateOidcTo
if err != nil { if err != nil {
return repository.OidcToken{}, mapErr(err) return repository.OidcToken{}, mapErr(err)
} }
return repository.OidcToken(r), nil return oidcTokenToRepo(r), nil
} }
func (s *Store) CreateOidcUserInfo(ctx context.Context, arg repository.CreateOidcUserInfoParams) (repository.OidcUserinfo, error) { func (s *Store) CreateOidcUserInfo(ctx context.Context, arg repository.CreateOidcUserInfoParams) (repository.OidcUserinfo, error) {
@@ -53,7 +68,7 @@ func (s *Store) CreateOidcUserInfo(ctx context.Context, arg repository.CreateOid
if err != nil { if err != nil {
return repository.OidcUserinfo{}, mapErr(err) return repository.OidcUserinfo{}, mapErr(err)
} }
return repository.OidcUserinfo(r), nil return oidcUserinfoToRepo(r), nil
} }
func (s *Store) CreateSession(ctx context.Context, arg repository.CreateSessionParams) (repository.Session, error) { func (s *Store) CreateSession(ctx context.Context, arg repository.CreateSessionParams) (repository.Session, error) {
@@ -61,7 +76,7 @@ func (s *Store) CreateSession(ctx context.Context, arg repository.CreateSessionP
if err != nil { if err != nil {
return repository.Session{}, mapErr(err) return repository.Session{}, mapErr(err)
} }
return repository.Session(r), nil return sessionToRepo(r), nil
} }
func (s *Store) DeleteExpiredOidcCodes(ctx context.Context, expiresAt int64) ([]repository.OidcCode, error) { func (s *Store) DeleteExpiredOidcCodes(ctx context.Context, expiresAt int64) ([]repository.OidcCode, error) {
@@ -71,7 +86,7 @@ func (s *Store) DeleteExpiredOidcCodes(ctx context.Context, expiresAt int64) ([]
} }
out := make([]repository.OidcCode, len(rows)) out := make([]repository.OidcCode, len(rows))
for i, row := range rows { for i, row := range rows {
out[i] = repository.OidcCode(row) out[i] = oidcCodeToRepo(row)
} }
return out, nil return out, nil
} }
@@ -83,7 +98,7 @@ func (s *Store) DeleteExpiredOidcTokens(ctx context.Context, arg repository.Dele
} }
out := make([]repository.OidcToken, len(rows)) out := make([]repository.OidcToken, len(rows))
for i, row := range rows { for i, row := range rows {
out[i] = repository.OidcToken(row) out[i] = oidcTokenToRepo(row)
} }
return out, nil return out, nil
} }
@@ -125,7 +140,7 @@ func (s *Store) GetOidcCode(ctx context.Context, codeHash string) (repository.Oi
if err != nil { if err != nil {
return repository.OidcCode{}, mapErr(err) return repository.OidcCode{}, mapErr(err)
} }
return repository.OidcCode(r), nil return oidcCodeToRepo(r), nil
} }
func (s *Store) GetOidcCodeBySub(ctx context.Context, sub string) (repository.OidcCode, error) { func (s *Store) GetOidcCodeBySub(ctx context.Context, sub string) (repository.OidcCode, error) {
@@ -133,7 +148,7 @@ func (s *Store) GetOidcCodeBySub(ctx context.Context, sub string) (repository.Oi
if err != nil { if err != nil {
return repository.OidcCode{}, mapErr(err) return repository.OidcCode{}, mapErr(err)
} }
return repository.OidcCode(r), nil return oidcCodeToRepo(r), nil
} }
func (s *Store) GetOidcCodeBySubUnsafe(ctx context.Context, sub string) (repository.OidcCode, error) { func (s *Store) GetOidcCodeBySubUnsafe(ctx context.Context, sub string) (repository.OidcCode, error) {
@@ -141,7 +156,7 @@ func (s *Store) GetOidcCodeBySubUnsafe(ctx context.Context, sub string) (reposit
if err != nil { if err != nil {
return repository.OidcCode{}, mapErr(err) return repository.OidcCode{}, mapErr(err)
} }
return repository.OidcCode(r), nil return oidcCodeToRepo(r), nil
} }
func (s *Store) GetOidcCodeUnsafe(ctx context.Context, codeHash string) (repository.OidcCode, error) { func (s *Store) GetOidcCodeUnsafe(ctx context.Context, codeHash string) (repository.OidcCode, error) {
@@ -149,7 +164,7 @@ func (s *Store) GetOidcCodeUnsafe(ctx context.Context, codeHash string) (reposit
if err != nil { if err != nil {
return repository.OidcCode{}, mapErr(err) return repository.OidcCode{}, mapErr(err)
} }
return repository.OidcCode(r), nil return oidcCodeToRepo(r), nil
} }
func (s *Store) GetOidcToken(ctx context.Context, accessTokenHash string) (repository.OidcToken, error) { func (s *Store) GetOidcToken(ctx context.Context, accessTokenHash string) (repository.OidcToken, error) {
@@ -157,7 +172,7 @@ func (s *Store) GetOidcToken(ctx context.Context, accessTokenHash string) (repos
if err != nil { if err != nil {
return repository.OidcToken{}, mapErr(err) return repository.OidcToken{}, mapErr(err)
} }
return repository.OidcToken(r), nil return oidcTokenToRepo(r), nil
} }
func (s *Store) GetOidcTokenByRefreshToken(ctx context.Context, refreshTokenHash string) (repository.OidcToken, error) { func (s *Store) GetOidcTokenByRefreshToken(ctx context.Context, refreshTokenHash string) (repository.OidcToken, error) {
@@ -165,7 +180,7 @@ func (s *Store) GetOidcTokenByRefreshToken(ctx context.Context, refreshTokenHash
if err != nil { if err != nil {
return repository.OidcToken{}, mapErr(err) return repository.OidcToken{}, mapErr(err)
} }
return repository.OidcToken(r), nil return oidcTokenToRepo(r), nil
} }
func (s *Store) GetOidcTokenBySub(ctx context.Context, sub string) (repository.OidcToken, error) { func (s *Store) GetOidcTokenBySub(ctx context.Context, sub string) (repository.OidcToken, error) {
@@ -173,7 +188,7 @@ func (s *Store) GetOidcTokenBySub(ctx context.Context, sub string) (repository.O
if err != nil { if err != nil {
return repository.OidcToken{}, mapErr(err) return repository.OidcToken{}, mapErr(err)
} }
return repository.OidcToken(r), nil return oidcTokenToRepo(r), nil
} }
func (s *Store) GetOidcUserInfo(ctx context.Context, sub string) (repository.OidcUserinfo, error) { func (s *Store) GetOidcUserInfo(ctx context.Context, sub string) (repository.OidcUserinfo, error) {
@@ -181,7 +196,7 @@ func (s *Store) GetOidcUserInfo(ctx context.Context, sub string) (repository.Oid
if err != nil { if err != nil {
return repository.OidcUserinfo{}, mapErr(err) return repository.OidcUserinfo{}, mapErr(err)
} }
return repository.OidcUserinfo(r), nil return oidcUserinfoToRepo(r), nil
} }
func (s *Store) GetSession(ctx context.Context, uuid string) (repository.Session, error) { func (s *Store) GetSession(ctx context.Context, uuid string) (repository.Session, error) {
@@ -189,7 +204,7 @@ func (s *Store) GetSession(ctx context.Context, uuid string) (repository.Session
if err != nil { if err != nil {
return repository.Session{}, mapErr(err) return repository.Session{}, mapErr(err)
} }
return repository.Session(r), nil return sessionToRepo(r), nil
} }
func (s *Store) UpdateOidcTokenByRefreshToken(ctx context.Context, arg repository.UpdateOidcTokenByRefreshTokenParams) (repository.OidcToken, error) { func (s *Store) UpdateOidcTokenByRefreshToken(ctx context.Context, arg repository.UpdateOidcTokenByRefreshTokenParams) (repository.OidcToken, error) {
@@ -197,7 +212,7 @@ func (s *Store) UpdateOidcTokenByRefreshToken(ctx context.Context, arg repositor
if err != nil { if err != nil {
return repository.OidcToken{}, mapErr(err) return repository.OidcToken{}, mapErr(err)
} }
return repository.OidcToken(r), nil return oidcTokenToRepo(r), nil
} }
func (s *Store) UpdateSession(ctx context.Context, arg repository.UpdateSessionParams) (repository.Session, error) { func (s *Store) UpdateSession(ctx context.Context, arg repository.UpdateSessionParams) (repository.Session, error) {
@@ -205,5 +220,5 @@ func (s *Store) UpdateSession(ctx context.Context, arg repository.UpdateSessionP
if err != nil { if err != nil {
return repository.Session{}, mapErr(err) return repository.Session{}, mapErr(err)
} }
return repository.Session(r), nil return sessionToRepo(r), nil
} }
-249
View File
@@ -1,249 +0,0 @@
package service
import (
"regexp"
"strings"
"github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/utils"
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
)
type RuleName string
const (
RuleUserAllowed RuleName = "rule-user-allowed"
RuleOAuthGroup RuleName = "rule-oauth-group"
RuleLDAPGroup RuleName = "rule-ldap-group"
RuleAuthEnabled RuleName = "rule-auth-enabled"
RuleIPAllowed RuleName = "rule-ip-allowed"
RuleIPBypassed RuleName = "rule-ip-bypassed"
)
type UserAllowedRule struct {
Log *logger.Logger
}
func (rule *UserAllowedRule) Evaluate(ctx *ACLContext) Effect {
if ctx.ACLs == nil || ctx.UserContext == nil {
return EffectAbstain
}
if ctx.UserContext.Provider == model.ProviderOAuth {
rule.Log.App.Debug().Msg("User is an OAuth user, checking OAuth whitelist")
match, err := utils.CheckFilter(ctx.ACLs.OAuth.Whitelist, ctx.UserContext.OAuth.Email)
if err != nil {
rule.Log.App.Warn().Err(err).Str("item", ctx.UserContext.OAuth.Email).Msg("Invalid entry in OAuth whitelist")
return EffectAbstain
}
if match {
rule.Log.App.Debug().Str("email", ctx.UserContext.OAuth.Email).Msg("User is in OAuth whitelist, allowing access")
return EffectAllow
}
return EffectDeny
}
if ctx.ACLs.Users.Block != "" {
rule.Log.App.Debug().Msg("Checking users block list")
match, err := utils.CheckFilter(ctx.ACLs.Users.Block, ctx.UserContext.GetUsername())
if err != nil {
rule.Log.App.Warn().Err(err).Str("item", ctx.UserContext.GetUsername()).Msg("Invalid entry in users block list")
return EffectAbstain
}
if match {
rule.Log.App.Debug().Str("username", ctx.UserContext.GetUsername()).Msg("User is in users block list, denying access")
return EffectDeny
}
return EffectAllow
}
rule.Log.App.Debug().Msg("Checking users allow list")
match, err := utils.CheckFilter(ctx.ACLs.Users.Allow, ctx.UserContext.GetUsername())
if err != nil {
rule.Log.App.Warn().Err(err).Str("item", ctx.UserContext.GetUsername()).Msg("Invalid entry in users allow list")
return EffectAbstain
}
if match {
rule.Log.App.Debug().Str("username", ctx.UserContext.GetUsername()).Msg("User is in users allow list, allowing access")
return EffectAllow
}
rule.Log.App.Debug().Str("username", ctx.UserContext.GetUsername()).Msg("User is not in users allow list, denying access")
return EffectDeny
}
type OAuthGroupRule struct {
Log *logger.Logger
}
func (rule *OAuthGroupRule) Evaluate(ctx *ACLContext) Effect {
if ctx.ACLs == nil || ctx.UserContext == nil {
return EffectAbstain
}
if !ctx.UserContext.IsOAuth() {
rule.Log.App.Debug().Msg("User is not an OAuth user, skipping OAuth group check")
return EffectAbstain
}
if _, ok := model.OverrideProviders[ctx.UserContext.OAuth.ID]; ok {
rule.Log.App.Debug().Str("provider", ctx.UserContext.OAuth.ID).Msg("Provider override detected, skipping group check")
return EffectAllow
}
for _, group := range ctx.UserContext.OAuth.Groups {
match, err := utils.CheckFilter(ctx.ACLs.OAuth.Groups, strings.TrimSpace(group))
if err != nil {
return EffectAbstain
}
if match {
rule.Log.App.Trace().Str("group", group).Str("required", ctx.ACLs.OAuth.Groups).Msg("User group matched, allowing access")
return EffectAllow
}
}
rule.Log.App.Debug().Msg("No groups matched")
return EffectDeny
}
type LDAPGroupRule struct {
Log *logger.Logger
}
func (rule *LDAPGroupRule) Evaluate(ctx *ACLContext) Effect {
if ctx == nil || ctx.UserContext == nil {
return EffectAbstain
}
if !ctx.UserContext.IsLDAP() {
rule.Log.App.Debug().Msg("User is not an LDAP user, skipping LDAP group check")
return EffectAbstain
}
for _, group := range ctx.UserContext.LDAP.Groups {
match, err := utils.CheckFilter(ctx.ACLs.LDAP.Groups, strings.TrimSpace(group))
if err != nil {
return EffectAbstain
}
if match {
rule.Log.App.Trace().Str("group", group).Str("required", ctx.ACLs.LDAP.Groups).Msg("User group matched, allowing access")
return EffectAllow
}
}
rule.Log.App.Debug().Msg("No groups matched")
return EffectDeny
}
type AuthEnabledRule struct {
Log *logger.Logger
}
func (rule *AuthEnabledRule) Evaluate(ctx *ACLContext) Effect {
if ctx.ACLs == nil {
return EffectDeny
}
if ctx.ACLs.Path.Block != "" {
regex, err := regexp.Compile(ctx.ACLs.Path.Block)
if err != nil {
rule.Log.App.Error().Err(err).Msg("Failed to compile block regex")
return EffectDeny
}
if !regex.MatchString(ctx.Path) {
return EffectAllow
}
}
if ctx.ACLs.Path.Allow != "" {
regex, err := regexp.Compile(ctx.ACLs.Path.Allow)
if err != nil {
rule.Log.App.Error().Err(err).Msg("Failed to compile allow regex")
return EffectDeny
}
if regex.MatchString(ctx.Path) {
return EffectAllow
}
}
return EffectDeny
}
type IPAllowedRule struct {
Log *logger.Logger
Config model.Config
}
func (rule *IPAllowedRule) Evaluate(ctx *ACLContext) Effect {
if ctx.ACLs == nil {
return EffectAbstain
}
// Merge the global and app IP filter
blockedIps := append(ctx.ACLs.IP.Block, rule.Config.Auth.IP.Block...)
allowedIPs := append(ctx.ACLs.IP.Allow, rule.Config.Auth.IP.Allow...)
for _, blocked := range blockedIps {
match, err := utils.CheckIPFilter(blocked, ctx.IP.String())
if err != nil {
rule.Log.App.Warn().Err(err).Str("item", blocked).Msg("Invalid IP/CIDR in block list")
continue
}
if match {
rule.Log.App.Debug().Str("ip", ctx.IP.String()).Str("item", blocked).Msg("IP is in block list, denying access")
return EffectDeny
}
}
for _, allowed := range allowedIPs {
match, err := utils.CheckIPFilter(allowed, ctx.IP.String())
if err != nil {
rule.Log.App.Warn().Err(err).Str("item", allowed).Msg("Invalid IP/CIDR in allow list")
continue
}
if match {
rule.Log.App.Debug().Str("ip", ctx.IP.String()).Str("item", allowed).Msg("IP is in allow list, allowing access")
return EffectAllow
}
}
if len(allowedIPs) > 0 {
rule.Log.App.Debug().Str("ip", ctx.IP.String()).Msg("IP not in allow list, denying access")
return EffectDeny
}
rule.Log.App.Debug().Str("ip", ctx.IP.String()).Msg("IP not in block or allow list, allowing access")
return EffectAllow
}
type IPBypassedRule struct {
Log *logger.Logger
}
func (rule *IPBypassedRule) Evaluate(ctx *ACLContext) Effect {
if ctx.ACLs == nil {
return EffectDeny
}
for _, bypassed := range ctx.ACLs.IP.Bypass {
match, err := utils.CheckIPFilter(bypassed, ctx.IP.String())
if err != nil {
rule.Log.App.Warn().Err(err).Str("item", bypassed).Msg("Invalid IP/CIDR in bypass list")
continue
}
if match {
rule.Log.App.Debug().Str("ip", ctx.IP.String()).Str("item", bypassed).Msg("IP is in bypass list, skipping authentication")
return EffectAllow
}
}
rule.Log.App.Debug().Str("ip", ctx.IP.String()).Msg("IP not in bypass list, proceeding with authentication")
return EffectDeny
}
@@ -1,732 +0,0 @@
package service
import (
"net"
"testing"
"github.com/stretchr/testify/assert"
"github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
)
func TestUserAllowedRule(t *testing.T) {
log := logger.NewLogger().WithTestConfig()
log.Init()
rule := &UserAllowedRule{Log: log}
tests := []struct {
name string
ctx *ACLContext
expected Effect
}{
{
name: "abstains when ACLs are nil",
ctx: &ACLContext{
ACLs: nil,
UserContext: &model.UserContext{
Provider: model.ProviderLocal,
Local: &model.LocalContext{
BaseContext: model.BaseContext{Username: "alice"},
},
},
},
expected: EffectAbstain,
},
{
name: "abstains when user context is nil",
ctx: &ACLContext{
ACLs: &model.App{
OAuth: model.AppOAuth{Whitelist: "alice"},
},
UserContext: nil,
},
expected: EffectAbstain,
},
{
name: "allows OAuth user when email matches whitelist",
ctx: &ACLContext{
ACLs: &model.App{
OAuth: model.AppOAuth{Whitelist: "allowed@example.com"},
},
UserContext: &model.UserContext{
Provider: model.ProviderOAuth,
OAuth: &model.OAuthContext{
BaseContext: model.BaseContext{
Username: "different-username",
Email: "allowed@example.com",
},
},
},
},
expected: EffectAllow,
},
{
name: "denies OAuth user when email does not match whitelist",
ctx: &ACLContext{
ACLs: &model.App{
OAuth: model.AppOAuth{Whitelist: "allowed@example.com"},
},
UserContext: &model.UserContext{
Provider: model.ProviderOAuth,
OAuth: &model.OAuthContext{
BaseContext: model.BaseContext{Email: "denied@example.com"},
},
},
},
expected: EffectDeny,
},
{
name: "abstains for OAuth user when whitelist filter is invalid",
ctx: &ACLContext{
ACLs: &model.App{
OAuth: model.AppOAuth{Whitelist: "/[/"},
},
UserContext: &model.UserContext{
Provider: model.ProviderOAuth,
OAuth: &model.OAuthContext{
BaseContext: model.BaseContext{Email: "allowed@example.com"},
},
},
},
expected: EffectAbstain,
},
{
name: "denies local user when username matches block list",
ctx: &ACLContext{
ACLs: &model.App{
Users: model.AppUsers{Block: "alice,bob"},
},
UserContext: &model.UserContext{
Provider: model.ProviderLocal,
Local: &model.LocalContext{
BaseContext: model.BaseContext{Username: "alice"},
},
},
},
expected: EffectDeny,
},
{
name: "allows local user when username does not match block list",
ctx: &ACLContext{
ACLs: &model.App{
Users: model.AppUsers{Block: "alice,bob"},
},
UserContext: &model.UserContext{
Provider: model.ProviderLocal,
Local: &model.LocalContext{
BaseContext: model.BaseContext{Username: "charlie"},
},
},
},
expected: EffectAllow,
},
{
name: "abstains when block list filter is invalid",
ctx: &ACLContext{
ACLs: &model.App{
Users: model.AppUsers{Block: "/[/"},
},
UserContext: &model.UserContext{
Provider: model.ProviderLocal,
Local: &model.LocalContext{
BaseContext: model.BaseContext{Username: "alice"},
},
},
},
expected: EffectAbstain,
},
{
name: "allows local user when username matches allow list",
ctx: &ACLContext{
ACLs: &model.App{
Users: model.AppUsers{Allow: "alice,bob"},
},
UserContext: &model.UserContext{
Provider: model.ProviderLocal,
Local: &model.LocalContext{
BaseContext: model.BaseContext{Username: "alice"},
},
},
},
expected: EffectAllow,
},
{
name: "denies local user when username does not match allow list",
ctx: &ACLContext{
ACLs: &model.App{
Users: model.AppUsers{Allow: "alice,bob"},
},
UserContext: &model.UserContext{
Provider: model.ProviderLocal,
Local: &model.LocalContext{
BaseContext: model.BaseContext{Username: "charlie"},
},
},
},
expected: EffectDeny,
},
{
name: "abstains when allow list filter is invalid",
ctx: &ACLContext{
ACLs: &model.App{
Users: model.AppUsers{Allow: "/[/"},
},
UserContext: &model.UserContext{
Provider: model.ProviderLocal,
Local: &model.LocalContext{
BaseContext: model.BaseContext{Username: "alice"},
},
},
},
expected: EffectAbstain,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expected, rule.Evaluate(tt.ctx))
})
}
}
func TestOAuthGroupRule(t *testing.T) {
log := logger.NewLogger().WithTestConfig()
log.Init()
rule := &OAuthGroupRule{Log: log}
tests := []struct {
name string
ctx *ACLContext
expected Effect
}{
{
name: "abstains when ACLs are nil",
ctx: &ACLContext{
ACLs: nil,
UserContext: &model.UserContext{
Provider: model.ProviderOAuth,
OAuth: &model.OAuthContext{
Groups: []string{"admins"},
},
},
},
expected: EffectAbstain,
},
{
name: "abstains when user context is nil",
ctx: &ACLContext{
ACLs: &model.App{
OAuth: model.AppOAuth{Whitelist: "alice"},
},
UserContext: nil,
},
expected: EffectAbstain,
},
{
name: "abstains when user is not OAuth",
ctx: &ACLContext{
ACLs: &model.App{
OAuth: model.AppOAuth{Groups: "admins"},
},
UserContext: &model.UserContext{
Provider: model.ProviderLocal,
Local: &model.LocalContext{
BaseContext: model.BaseContext{Username: "alice"},
},
},
},
expected: EffectAbstain,
},
{
name: "allows when provider is an override provider regardless of groups",
ctx: &ACLContext{
ACLs: &model.App{
OAuth: model.AppOAuth{Groups: "admins"},
},
UserContext: &model.UserContext{
Provider: model.ProviderOAuth,
OAuth: &model.OAuthContext{
ID: "google",
Groups: []string{"unrelated"},
},
},
},
expected: EffectAllow,
},
{
name: "allows OAuth user when a group matches",
ctx: &ACLContext{
ACLs: &model.App{
OAuth: model.AppOAuth{Groups: "admins,users"},
},
UserContext: &model.UserContext{
Provider: model.ProviderOAuth,
OAuth: &model.OAuthContext{
ID: "custom",
Groups: []string{"users"},
},
},
},
expected: EffectAllow,
},
{
name: "denies OAuth user when no group matches",
ctx: &ACLContext{
ACLs: &model.App{
OAuth: model.AppOAuth{Groups: "admins"},
},
UserContext: &model.UserContext{
Provider: model.ProviderOAuth,
OAuth: &model.OAuthContext{
ID: "custom",
Groups: []string{"users", "guests"},
},
},
},
expected: EffectDeny,
},
{
name: "denies OAuth user when user has no groups",
ctx: &ACLContext{
ACLs: &model.App{
OAuth: model.AppOAuth{Groups: "admins"},
},
UserContext: &model.UserContext{
Provider: model.ProviderOAuth,
OAuth: &model.OAuthContext{
ID: "custom",
Groups: nil,
},
},
},
expected: EffectDeny,
},
{
name: "abstains when groups filter is invalid",
ctx: &ACLContext{
ACLs: &model.App{
OAuth: model.AppOAuth{Groups: "/[/"},
},
UserContext: &model.UserContext{
Provider: model.ProviderOAuth,
OAuth: &model.OAuthContext{
ID: "custom",
Groups: []string{"admins"},
},
},
},
expected: EffectAbstain,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expected, rule.Evaluate(tt.ctx))
})
}
}
func TestLDAPGroupRule(t *testing.T) {
log := logger.NewLogger().WithTestConfig()
log.Init()
rule := &LDAPGroupRule{Log: log}
tests := []struct {
name string
ctx *ACLContext
expected Effect
}{
{
name: "abstains when context is nil",
ctx: nil,
expected: EffectAbstain,
},
{
name: "abstains when user context is nil",
ctx: &ACLContext{
ACLs: &model.App{
OAuth: model.AppOAuth{Whitelist: "alice"},
},
UserContext: nil,
},
expected: EffectAbstain,
},
{
name: "abstains when user is not LDAP",
ctx: &ACLContext{
ACLs: &model.App{
LDAP: model.AppLDAP{Groups: "admins"},
},
UserContext: &model.UserContext{
Provider: model.ProviderLocal,
Local: &model.LocalContext{
BaseContext: model.BaseContext{Username: "alice"},
},
},
},
expected: EffectAbstain,
},
{
name: "allows LDAP user when a group matches",
ctx: &ACLContext{
ACLs: &model.App{
LDAP: model.AppLDAP{Groups: "admins,users"},
},
UserContext: &model.UserContext{
Provider: model.ProviderLDAP,
LDAP: &model.LDAPContext{
Groups: []string{"users"},
},
},
},
expected: EffectAllow,
},
{
name: "denies LDAP user when no group matches",
ctx: &ACLContext{
ACLs: &model.App{
LDAP: model.AppLDAP{Groups: "admins"},
},
UserContext: &model.UserContext{
Provider: model.ProviderLDAP,
LDAP: &model.LDAPContext{
Groups: []string{"users", "guests"},
},
},
},
expected: EffectDeny,
},
{
name: "denies LDAP user when user has no groups",
ctx: &ACLContext{
ACLs: &model.App{
LDAP: model.AppLDAP{Groups: "admins"},
},
UserContext: &model.UserContext{
Provider: model.ProviderLDAP,
LDAP: &model.LDAPContext{
Groups: nil,
},
},
},
expected: EffectDeny,
},
{
name: "abstains when groups filter is invalid",
ctx: &ACLContext{
ACLs: &model.App{
LDAP: model.AppLDAP{Groups: "/[/"},
},
UserContext: &model.UserContext{
Provider: model.ProviderLDAP,
LDAP: &model.LDAPContext{
Groups: []string{"admins"},
},
},
},
expected: EffectAbstain,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expected, rule.Evaluate(tt.ctx))
})
}
}
func TestAuthEnabledRule(t *testing.T) {
log := logger.NewLogger().WithTestConfig()
log.Init()
rule := &AuthEnabledRule{Log: log}
tests := []struct {
name string
ctx *ACLContext
expected Effect
}{
{
name: "deny when ACLs are nil",
ctx: &ACLContext{
ACLs: nil,
Path: "/anything",
},
expected: EffectDeny,
},
{
name: "allows when path does not match block regex",
ctx: &ACLContext{
ACLs: &model.App{
Path: model.AppPath{Block: "^/admin"},
},
Path: "/public",
},
expected: EffectAllow,
},
{
name: "denies when path matches block regex and no allow regex",
ctx: &ACLContext{
ACLs: &model.App{
Path: model.AppPath{Block: "^/admin"},
},
Path: "/admin/users",
},
expected: EffectDeny,
},
{
name: "allows when path matches allow regex",
ctx: &ACLContext{
ACLs: &model.App{
Path: model.AppPath{Allow: "^/public"},
},
Path: "/public/index",
},
expected: EffectAllow,
},
{
name: "denies when path does not match allow regex",
ctx: &ACLContext{
ACLs: &model.App{
Path: model.AppPath{Allow: "^/public"},
},
Path: "/private",
},
expected: EffectDeny,
},
{
name: "allows when blocked path is also explicitly allowed",
ctx: &ACLContext{
ACLs: &model.App{
Path: model.AppPath{
Block: "^/admin",
Allow: "^/admin/public",
},
},
Path: "/admin/public/page",
},
expected: EffectAllow,
},
{
name: "denies when block regex fails to compile",
ctx: &ACLContext{
ACLs: &model.App{
Path: model.AppPath{Block: "[invalid"},
},
Path: "/anything",
},
expected: EffectDeny,
},
{
name: "denies when allow regex fails to compile",
ctx: &ACLContext{
ACLs: &model.App{
Path: model.AppPath{Allow: "[invalid"},
},
Path: "/anything",
},
expected: EffectDeny,
},
{
name: "denies when no path rules are configured",
ctx: &ACLContext{
ACLs: &model.App{},
Path: "/anything",
},
expected: EffectDeny,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expected, rule.Evaluate(tt.ctx))
})
}
}
func TestIPAllowedRule(t *testing.T) {
log := logger.NewLogger().WithTestConfig()
log.Init()
tests := []struct {
name string
config model.Config
ctx *ACLContext
expected Effect
}{
{
name: "abstains when ACLs are nil",
ctx: &ACLContext{
ACLs: nil,
IP: net.ParseIP("10.0.0.1"),
},
expected: EffectAbstain,
},
{
name: "denies when IP matches app block list",
ctx: &ACLContext{
ACLs: &model.App{
IP: model.AppIP{Block: []string{"10.0.0.1"}},
},
IP: net.ParseIP("10.0.0.1"),
},
expected: EffectDeny,
},
{
name: "denies when IP matches global block list",
config: model.Config{
Auth: model.AuthConfig{
IP: model.IPConfig{Block: []string{"10.0.0.0/24"}},
},
},
ctx: &ACLContext{
ACLs: &model.App{},
IP: net.ParseIP("10.0.0.5"),
},
expected: EffectDeny,
},
{
name: "allows when IP matches app allow list",
ctx: &ACLContext{
ACLs: &model.App{
IP: model.AppIP{Allow: []string{"192.168.1.0/24"}},
},
IP: net.ParseIP("192.168.1.10"),
},
expected: EffectAllow,
},
{
name: "allows when IP matches global allow list",
config: model.Config{
Auth: model.AuthConfig{
IP: model.IPConfig{Allow: []string{"192.168.1.10"}},
},
},
ctx: &ACLContext{
ACLs: &model.App{},
IP: net.ParseIP("192.168.1.10"),
},
expected: EffectAllow,
},
{
name: "denies when allow list is set and IP does not match",
ctx: &ACLContext{
ACLs: &model.App{
IP: model.AppIP{Allow: []string{"192.168.1.0/24"}},
},
IP: net.ParseIP("10.0.0.1"),
},
expected: EffectDeny,
},
{
name: "allows when no block or allow lists are configured",
ctx: &ACLContext{
ACLs: &model.App{},
IP: net.ParseIP("10.0.0.1"),
},
expected: EffectAllow,
},
{
name: "block list takes precedence over allow list",
ctx: &ACLContext{
ACLs: &model.App{
IP: model.AppIP{
Block: []string{"10.0.0.1"},
Allow: []string{"10.0.0.1"},
},
},
IP: net.ParseIP("10.0.0.1"),
},
expected: EffectDeny,
},
{
name: "skips invalid block entries and continues evaluation",
ctx: &ACLContext{
ACLs: &model.App{
IP: model.AppIP{
Block: []string{"not-an-ip"},
Allow: []string{"10.0.0.1"},
},
},
IP: net.ParseIP("10.0.0.1"),
},
expected: EffectAllow,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rule := &IPAllowedRule{Log: log, Config: tt.config}
assert.Equal(t, tt.expected, rule.Evaluate(tt.ctx))
})
}
}
func TestIPBypassedRule(t *testing.T) {
log := logger.NewLogger().WithTestConfig()
log.Init()
rule := &IPBypassedRule{Log: log}
tests := []struct {
name string
ctx *ACLContext
expected Effect
}{
{
name: "deny when ACLs are nil",
ctx: &ACLContext{
ACLs: nil,
IP: net.ParseIP("10.0.0.1"),
},
expected: EffectDeny,
},
{
name: "allows when IP matches bypass list",
ctx: &ACLContext{
ACLs: &model.App{
IP: model.AppIP{Bypass: []string{"10.0.0.0/24"}},
},
IP: net.ParseIP("10.0.0.5"),
},
expected: EffectAllow,
},
{
name: "denies when IP does not match bypass list",
ctx: &ACLContext{
ACLs: &model.App{
IP: model.AppIP{Bypass: []string{"10.0.0.0/24"}},
},
IP: net.ParseIP("192.168.1.1"),
},
expected: EffectDeny,
},
{
name: "denies when bypass list is empty",
ctx: &ACLContext{
ACLs: &model.App{},
IP: net.ParseIP("10.0.0.1"),
},
expected: EffectDeny,
},
{
name: "skips invalid bypass entries and allows on later match",
ctx: &ACLContext{
ACLs: &model.App{
IP: model.AppIP{Bypass: []string{"not-an-ip", "10.0.0.1"}},
},
IP: net.ParseIP("10.0.0.1"),
},
expected: EffectAllow,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expected, rule.Evaluate(tt.ctx))
})
}
}
+25 -31
View File
@@ -4,7 +4,7 @@ import (
"strings" "strings"
"github.com/tinyauthapp/tinyauth/internal/model" "github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/utils/logger" "github.com/tinyauthapp/tinyauth/internal/utils/tlog"
) )
type LabelProvider interface { type LabelProvider interface {
@@ -12,55 +12,49 @@ type LabelProvider interface {
} }
type AccessControlsService struct { type AccessControlsService struct {
log *logger.Logger labelProvider LabelProvider
config model.Config static map[string]model.App
labelProvider *LabelProvider
} }
func NewAccessControlsService( func NewAccessControlsService(labelProvider LabelProvider, static map[string]model.App) *AccessControlsService {
log *logger.Logger,
config model.Config,
labelProvider *LabelProvider) *AccessControlsService {
return &AccessControlsService{ return &AccessControlsService{
log: log,
config: config,
labelProvider: labelProvider, labelProvider: labelProvider,
static: static,
} }
} }
func (service *AccessControlsService) lookupStaticACLs(domain string) *model.App { func (acls *AccessControlsService) Init() error {
var nameMatch *model.App return nil // No initialization needed
}
// First try to find a matching app by domain, then fallback to matching by app name (subdomain) func (acls *AccessControlsService) lookupStaticACLs(domain string) *model.App {
for app, config := range service.config.Apps { var appAcls *model.App
for app, config := range acls.static {
if config.Config.Domain == domain { if config.Config.Domain == domain {
service.log.App.Debug().Str("name", app).Msg("Found matching container by domain") tlog.App.Debug().Str("name", app).Msg("Found matching container by domain")
return &config appAcls = &config
break // If we find a match by domain, we can stop searching
} }
if strings.SplitN(domain, ".", 2)[0] == app { if strings.SplitN(domain, ".", 2)[0] == app {
service.log.App.Debug().Str("name", app).Msg("Found matching container by app name") tlog.App.Debug().Str("name", app).Msg("Found matching container by app name")
nameMatch = &config appAcls = &config
break // If we find a match by app name, we can stop searching
} }
} }
return appAcls
return nameMatch
} }
func (service *AccessControlsService) GetAccessControls(domain string) (*model.App, error) { func (acls *AccessControlsService) GetAccessControls(domain string) (*model.App, error) {
// First check in the static config // First check in the static config
app := service.lookupStaticACLs(domain) app := acls.lookupStaticACLs(domain)
if app != nil { if app != nil {
service.log.App.Debug().Msg("Using static ACLs for app") tlog.App.Debug().Msg("Using ACls from static configuration")
return app, nil return app, nil
} }
// If we have a label provider configured, try to get ACLs from it // Fallback to label provider
if service.labelProvider != nil && *service.labelProvider != nil { tlog.App.Debug().Msg("Falling back to label provider for ACLs")
return (*service.labelProvider).GetLabels(domain) return acls.labelProvider.GetLabels(domain)
}
// no labels
return nil, nil
} }
@@ -1,199 +0,0 @@
package service
import (
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
)
type mockLabelProvider struct {
getLabelsFn func(appDomain string) (*model.App, error)
calledWith string
callCount int
}
func (m *mockLabelProvider) GetLabels(appDomain string) (*model.App, error) {
m.calledWith = appDomain
m.callCount++
if m.getLabelsFn != nil {
return m.getLabelsFn(appDomain)
}
return nil, nil
}
func TestLookupStaticACLs(t *testing.T) {
log := logger.NewLogger().WithTestConfig()
log.Init()
tests := []struct {
name string
apps map[string]model.App
domain string
expectNil bool
expectedDomain string
}{
{
name: "returns nil when no apps are configured",
apps: nil,
domain: "foo.example.com",
expectNil: true,
},
{
name: "returns nil when no app matches",
apps: map[string]model.App{
"foo": {Config: model.AppConfig{Domain: "foo.example.com"}},
},
domain: "bar.example.com",
expectNil: true,
},
{
name: "matches by exact domain",
apps: map[string]model.App{
"foo": {Config: model.AppConfig{Domain: "foo.example.com"}},
},
domain: "foo.example.com",
expectedDomain: "foo.example.com",
},
{
name: "matches by app name when domain does not match any app",
apps: map[string]model.App{
"foo": {Config: model.AppConfig{Domain: "configured.example.com"}},
},
domain: "foo.example.com",
expectedDomain: "configured.example.com",
},
{
name: "matches by app name for nested subdomains",
apps: map[string]model.App{
"foo": {Config: model.AppConfig{Domain: "configured.example.com"}},
},
domain: "foo.sub.example.com",
expectedDomain: "configured.example.com",
},
{
name: "selects the app matching by domain among multiple apps",
apps: map[string]model.App{
"unrelated": {Config: model.AppConfig{Domain: "other.example.com"}},
"target": {Config: model.AppConfig{Domain: "foo.example.com"}},
},
domain: "foo.example.com",
expectedDomain: "foo.example.com",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svc := NewAccessControlsService(log, model.Config{Apps: tt.apps}, nil)
got := svc.lookupStaticACLs(tt.domain)
if tt.expectNil {
assert.Nil(t, got)
return
}
require.NotNil(t, got)
assert.Equal(t, tt.expectedDomain, got.Config.Domain)
})
}
}
func TestGetAccessControls(t *testing.T) {
log := logger.NewLogger().WithTestConfig()
log.Init()
t.Run("returns static ACLs when domain matches", func(t *testing.T) {
config := model.Config{
Apps: map[string]model.App{
"foo": {
Config: model.AppConfig{Domain: "foo.example.com"},
Users: model.AppUsers{Allow: "alice"},
},
},
}
svc := NewAccessControlsService(log, config, nil)
got, err := svc.GetAccessControls("foo.example.com")
require.NoError(t, err)
require.NotNil(t, got)
assert.Equal(t, "foo.example.com", got.Config.Domain)
assert.Equal(t, "alice", got.Users.Allow)
})
t.Run("returns nil when no static match and no label provider", func(t *testing.T) {
svc := NewAccessControlsService(log, model.Config{}, nil)
got, err := svc.GetAccessControls("unknown.example.com")
require.NoError(t, err)
assert.Nil(t, got)
})
t.Run("returns nil when label provider pointer wraps a nil interface", func(t *testing.T) {
var provider LabelProvider
svc := NewAccessControlsService(log, model.Config{}, &provider)
got, err := svc.GetAccessControls("unknown.example.com")
require.NoError(t, err)
assert.Nil(t, got)
})
t.Run("falls back to label provider when no static match", func(t *testing.T) {
expected := &model.App{
Config: model.AppConfig{Domain: "dynamic.example.com"},
Users: model.AppUsers{Allow: "bob"},
}
mock := &mockLabelProvider{
getLabelsFn: func(appDomain string) (*model.App, error) {
return expected, nil
},
}
var provider LabelProvider = mock
svc := NewAccessControlsService(log, model.Config{}, &provider)
got, err := svc.GetAccessControls("dynamic.example.com")
require.NoError(t, err)
assert.Same(t, expected, got)
assert.Equal(t, "dynamic.example.com", mock.calledWith)
assert.Equal(t, 1, mock.callCount)
})
t.Run("does not call label provider when static match found", func(t *testing.T) {
mock := &mockLabelProvider{}
var provider LabelProvider = mock
config := model.Config{
Apps: map[string]model.App{
"foo": {Config: model.AppConfig{Domain: "foo.example.com"}},
},
}
svc := NewAccessControlsService(log, config, &provider)
got, err := svc.GetAccessControls("foo.example.com")
require.NoError(t, err)
require.NotNil(t, got)
assert.Equal(t, "foo.example.com", got.Config.Domain)
assert.Equal(t, 0, mock.callCount)
})
t.Run("propagates label provider errors", func(t *testing.T) {
providerErr := errors.New("provider boom")
mock := &mockLabelProvider{
getLabelsFn: func(appDomain string) (*model.App, error) {
return nil, providerErr
},
}
var provider LabelProvider = mock
svc := NewAccessControlsService(log, model.Config{}, &provider)
got, err := svc.GetAccessControls("dynamic.example.com")
assert.Nil(t, got)
assert.ErrorIs(t, err, providerErr)
assert.Equal(t, 1, mock.callCount)
})
}
+275 -150
View File
@@ -5,6 +5,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
"regexp"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -12,10 +13,11 @@ import (
"github.com/tinyauthapp/tinyauth/internal/model" "github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/repository" "github.com/tinyauthapp/tinyauth/internal/repository"
"github.com/tinyauthapp/tinyauth/internal/utils" "github.com/tinyauthapp/tinyauth/internal/utils"
"github.com/tinyauthapp/tinyauth/internal/utils/logger" "github.com/tinyauthapp/tinyauth/internal/utils/tlog"
"slices" "slices"
"github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"golang.org/x/oauth2" "golang.org/x/oauth2"
@@ -69,43 +71,39 @@ type Lockdown struct {
ActiveUntil time.Time ActiveUntil time.Time
} }
type AuthServiceConfig struct {
LocalUsers *[]model.LocalUser
OauthWhitelist []string
SessionExpiry int
SessionMaxLifetime int
SecureCookie bool
CookieDomain string
LoginTimeout int
LoginMaxRetries int
SessionCookieName string
IP model.IPConfig
LDAPGroupsCacheTTL int
SubdomainsEnabled bool
}
type AuthService struct { type AuthService struct {
log *logger.Logger config AuthServiceConfig
config model.Config
runtime model.RuntimeConfig
context context.Context
ldap *LdapService
queries repository.Store
oauthBroker *OAuthBrokerService
tailscale *TailscaleService
loginAttempts map[string]*LoginAttempt loginAttempts map[string]*LoginAttempt
ldapGroupsCache map[string]*LdapGroupsCache ldapGroupsCache map[string]*LdapGroupsCache
oauthPendingSessions map[string]*OAuthPendingSession oauthPendingSessions map[string]*OAuthPendingSession
oauthMutex sync.RWMutex oauthMutex sync.RWMutex
loginMutex sync.RWMutex loginMutex sync.RWMutex
ldapGroupsMutex sync.RWMutex ldapGroupsMutex sync.RWMutex
ldap *LdapService
queries repository.Store
oauthBroker *OAuthBrokerService
lockdown *Lockdown lockdown *Lockdown
lockdownCtx context.Context lockdownCtx context.Context
lockdownCancelFunc context.CancelFunc lockdownCancelFunc context.CancelFunc
} }
func NewAuthService( func NewAuthService(config AuthServiceConfig, ldap *LdapService, queries repository.Store, oauthBroker *OAuthBrokerService) *AuthService {
log *logger.Logger, return &AuthService{
config model.Config,
runtime model.RuntimeConfig,
ctx context.Context,
wg *sync.WaitGroup,
ldap *LdapService,
queries repository.Store,
oauthBroker *OAuthBrokerService,
tailscale *TailscaleService,
) *AuthService {
service := &AuthService{
log: log,
runtime: runtime,
context: ctx,
config: config, config: config,
loginAttempts: make(map[string]*LoginAttempt), loginAttempts: make(map[string]*LoginAttempt),
ldapGroupsCache: make(map[string]*LdapGroupsCache), ldapGroupsCache: make(map[string]*LdapGroupsCache),
@@ -113,12 +111,12 @@ func NewAuthService(
ldap: ldap, ldap: ldap,
queries: queries, queries: queries,
oauthBroker: oauthBroker, oauthBroker: oauthBroker,
tailscale: tailscale,
} }
}
wg.Go(service.CleanupOAuthSessionsRoutine) func (auth *AuthService) Init() error {
go auth.CleanupOAuthSessionsRoutine()
return service return nil
} }
func (auth *AuthService) SearchUser(username string) (*model.UserSearch, error) { func (auth *AuthService) SearchUser(username string) (*model.UserSearch, error) {
@@ -129,8 +127,8 @@ func (auth *AuthService) SearchUser(username string) (*model.UserSearch, error)
}, nil }, nil
} }
if auth.ldap != nil { if auth.ldap.IsConfigured() {
userDN, email, err := auth.ldap.GetUserInfo(username) userDN, err := auth.ldap.GetUserDN(username)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get ldap user: %w", err) return nil, fmt.Errorf("failed to get ldap user: %w", err)
@@ -138,7 +136,6 @@ func (auth *AuthService) SearchUser(username string) (*model.UserSearch, error)
return &model.UserSearch{ return &model.UserSearch{
Username: userDN, Username: userDN,
Email: email,
Type: model.UserLDAP, Type: model.UserLDAP,
}, nil }, nil
} }
@@ -155,7 +152,7 @@ func (auth *AuthService) CheckUserPassword(search model.UserSearch, password str
} }
return bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) return bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password))
case model.UserLDAP: case model.UserLDAP:
if auth.ldap != nil { if auth.ldap.IsConfigured() {
err := auth.ldap.Bind(search.Username, password) err := auth.ldap.Bind(search.Username, password)
if err != nil { if err != nil {
return fmt.Errorf("failed to bind to ldap user: %w", err) return fmt.Errorf("failed to bind to ldap user: %w", err)
@@ -175,10 +172,10 @@ func (auth *AuthService) CheckUserPassword(search model.UserSearch, password str
} }
func (auth *AuthService) GetLocalUser(username string) *model.LocalUser { func (auth *AuthService) GetLocalUser(username string) *model.LocalUser {
if auth.runtime.LocalUsers == nil { if auth.config.LocalUsers == nil {
return nil return nil
} }
for _, user := range auth.runtime.LocalUsers { for _, user := range *auth.config.LocalUsers {
if user.Username == username { if user.Username == username {
return &user return &user
} }
@@ -187,7 +184,7 @@ func (auth *AuthService) GetLocalUser(username string) *model.LocalUser {
} }
func (auth *AuthService) GetLDAPUser(userDN string) (*model.LDAPUser, error) { func (auth *AuthService) GetLDAPUser(userDN string) (*model.LDAPUser, error) {
if auth.ldap == nil { if !auth.ldap.IsConfigured() {
return nil, errors.New("ldap service not configured") return nil, errors.New("ldap service not configured")
} }
@@ -211,7 +208,7 @@ func (auth *AuthService) GetLDAPUser(userDN string) (*model.LDAPUser, error) {
auth.ldapGroupsMutex.Lock() auth.ldapGroupsMutex.Lock()
auth.ldapGroupsCache[userDN] = &LdapGroupsCache{ auth.ldapGroupsCache[userDN] = &LdapGroupsCache{
Groups: groups, 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() auth.ldapGroupsMutex.Unlock()
@@ -230,7 +227,7 @@ func (auth *AuthService) IsAccountLocked(identifier string) (bool, int) {
return true, remaining 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 return false, 0
} }
@@ -248,7 +245,7 @@ func (auth *AuthService) IsAccountLocked(identifier string) (bool, int) {
} }
func (auth *AuthService) RecordLoginAttempt(identifier string, success bool) { 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 return
} }
@@ -279,26 +276,17 @@ func (auth *AuthService) RecordLoginAttempt(identifier string, success bool) {
attempt.FailedAttempts++ attempt.FailedAttempts++
if attempt.FailedAttempts >= auth.config.Auth.LoginMaxRetries { if attempt.FailedAttempts >= auth.config.LoginMaxRetries {
attempt.LockedUntil = time.Now().Add(time.Duration(auth.config.Auth.LoginTimeout) * time.Second) attempt.LockedUntil = time.Now().Add(time.Duration(auth.config.LoginTimeout) * time.Second)
auth.log.App.Warn().Str("identifier", identifier).Int("failedAttempts", attempt.FailedAttempts).Msg("Account locked due to too many failed login attempts") 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 { func (auth *AuthService) IsEmailWhitelisted(email string) bool {
match, err := utils.CheckFilter(strings.Join(auth.runtime.OAuthWhitelist, ","), email) return utils.CheckFilter(strings.Join(auth.config.OauthWhitelist, ","), email)
if err != nil {
auth.log.App.Warn().Err(err).Str("email", email).Msg("Invalid email filter pattern")
return false
}
return match
} }
func (auth *AuthService) CreateSession(ctx context.Context, data repository.Session) (*http.Cookie, error) { func (auth *AuthService) CreateSession(ctx context.Context, data repository.Session) (*http.Cookie, error) {
if data.Provider == "tailscale" && auth.tailscale == nil {
return nil, fmt.Errorf("tailscale service not configured, cannot create session for tailscale user")
}
uuid, err := uuid.NewRandom() uuid, err := uuid.NewRandom()
if err != nil { if err != nil {
@@ -310,7 +298,7 @@ func (auth *AuthService) CreateSession(ctx context.Context, data repository.Sess
if data.TotpPending { if data.TotpPending {
expiry = 3600 expiry = 3600
} else { } else {
expiry = auth.config.Auth.SessionExpiry expiry = auth.config.SessionExpiry
} }
expiresAt := time.Now().Add(time.Duration(expiry) * time.Second) expiresAt := time.Now().Add(time.Duration(expiry) * time.Second)
@@ -335,36 +323,14 @@ func (auth *AuthService) CreateSession(ctx context.Context, data repository.Sess
return nil, fmt.Errorf("failed to create session entry: %w", err) return nil, fmt.Errorf("failed to create session entry: %w", err)
} }
if data.Provider == "tailscale" {
auth.log.App.Trace().Str("url", fmt.Sprintf("https://%s", auth.tailscale.GetHostname())).Msg("Extracting root domain from Tailscale hostname")
tsCookieDomain, err := utils.GetCookieDomain(fmt.Sprintf("https://%s", auth.tailscale.GetHostname()))
if err != nil {
return nil, fmt.Errorf("failed to get cookie domain for tailscale user: %w", err)
}
return &http.Cookie{
Name: auth.runtime.SessionCookieName,
Value: session.UUID,
Path: "/",
Domain: fmt.Sprintf(".%s", tsCookieDomain),
Expires: expiresAt,
MaxAge: int(time.Until(expiresAt).Seconds()),
Secure: auth.config.Auth.SecureCookie,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
}, nil
}
return &http.Cookie{ return &http.Cookie{
Name: auth.runtime.SessionCookieName, Name: auth.config.SessionCookieName,
Value: session.UUID, Value: session.UUID,
Path: "/", Path: "/",
Domain: fmt.Sprintf(".%s", auth.runtime.CookieDomain), Domain: fmt.Sprintf(".%s", auth.config.CookieDomain),
Expires: expiresAt, Expires: expiresAt,
MaxAge: int(time.Until(expiresAt).Seconds()), MaxAge: int(time.Until(expiresAt).Seconds()),
Secure: auth.config.Auth.SecureCookie, Secure: auth.config.SecureCookie,
HttpOnly: true, HttpOnly: true,
SameSite: http.SameSiteLaxMode, SameSite: http.SameSiteLaxMode,
}, nil }, nil
@@ -381,8 +347,8 @@ func (auth *AuthService) RefreshSession(ctx context.Context, uuid string) (*http
var refreshThreshold int64 var refreshThreshold int64
if auth.config.Auth.SessionExpiry <= int(time.Hour.Seconds()) { if auth.config.SessionExpiry <= int(time.Hour.Seconds()) {
refreshThreshold = int64(auth.config.Auth.SessionExpiry / 2) refreshThreshold = int64(auth.config.SessionExpiry / 2)
} else { } else {
refreshThreshold = int64(time.Hour.Seconds()) refreshThreshold = int64(time.Hour.Seconds())
} }
@@ -411,13 +377,13 @@ func (auth *AuthService) RefreshSession(ctx context.Context, uuid string) (*http
} }
return &http.Cookie{ return &http.Cookie{
Name: auth.runtime.SessionCookieName, Name: auth.config.SessionCookieName,
Value: session.UUID, Value: session.UUID,
Path: "/", Path: "/",
Domain: fmt.Sprintf(".%s", auth.runtime.CookieDomain), Domain: fmt.Sprintf(".%s", auth.config.CookieDomain),
Expires: time.Now().Add(time.Duration(newExpiry-currentTime) * time.Second), Expires: time.Now().Add(time.Duration(newExpiry-currentTime) * time.Second),
MaxAge: int(newExpiry - currentTime), MaxAge: int(newExpiry - currentTime),
Secure: auth.config.Auth.SecureCookie, Secure: auth.config.SecureCookie,
HttpOnly: true, HttpOnly: true,
SameSite: http.SameSiteLaxMode, SameSite: http.SameSiteLaxMode,
}, nil }, nil
@@ -428,17 +394,23 @@ func (auth *AuthService) DeleteSession(ctx context.Context, uuid string) (*http.
err := auth.queries.DeleteSession(ctx, uuid) err := auth.queries.DeleteSession(ctx, uuid)
if err != nil { if err != nil {
auth.log.App.Error().Err(err).Str("uuid", uuid).Msg("Failed to delete session from database") tlog.App.Warn().Err(err).Msg("Failed to delete session from database, proceeding to clear cookie anyway")
}
err = auth.queries.DeleteSession(ctx, uuid)
if err != nil {
return nil, err
} }
return &http.Cookie{ return &http.Cookie{
Name: auth.runtime.SessionCookieName, Name: auth.config.SessionCookieName,
Value: "", Value: "",
Path: "/", Path: "/",
Domain: fmt.Sprintf(".%s", auth.runtime.CookieDomain), Domain: fmt.Sprintf(".%s", auth.config.CookieDomain),
Expires: time.Now(), Expires: time.Now(),
MaxAge: -1, MaxAge: -1,
Secure: auth.config.Auth.SecureCookie, Secure: auth.config.SecureCookie,
HttpOnly: true, HttpOnly: true,
SameSite: http.SameSiteLaxMode, SameSite: http.SameSiteLaxMode,
}, nil }, nil
@@ -456,8 +428,8 @@ func (auth *AuthService) GetSession(ctx context.Context, uuid string) (*reposito
currentTime := time.Now().Unix() currentTime := time.Now().Unix()
if auth.config.Auth.SessionMaxLifetime != 0 && session.CreatedAt != 0 { if auth.config.SessionMaxLifetime != 0 && session.CreatedAt != 0 {
if currentTime-session.CreatedAt > int64(auth.config.Auth.SessionMaxLifetime) { if currentTime-session.CreatedAt > int64(auth.config.SessionMaxLifetime) {
err = auth.queries.DeleteSession(ctx, uuid) err = auth.queries.DeleteSession(ctx, uuid)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to delete expired session: %w", err) return nil, fmt.Errorf("failed to delete expired session: %w", err)
@@ -478,11 +450,176 @@ func (auth *AuthService) GetSession(ctx context.Context, uuid string) (*reposito
} }
func (auth *AuthService) LocalAuthConfigured() bool { func (auth *AuthService) LocalAuthConfigured() bool {
return len(auth.runtime.LocalUsers) > 0 return auth.config.LocalUsers != nil && len(*auth.config.LocalUsers) > 0
} }
func (auth *AuthService) LDAPAuthConfigured() bool { func (auth *AuthService) LDAPAuthConfigured() bool {
return auth.ldap != nil 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 {
tlog.App.Debug().Msg("Checking OAuth whitelist")
return utils.CheckFilter(acls.OAuth.Whitelist, context.OAuth.Email)
}
if acls.Users.Block != "" {
tlog.App.Debug().Msg("Checking blocked users")
if utils.CheckFilter(acls.Users.Block, context.GetUsername()) {
return false
}
}
tlog.App.Debug().Msg("Checking users")
return utils.CheckFilter(acls.Users.Allow, context.GetUsername())
}
func (auth *AuthService) IsInOAuthGroup(c *gin.Context, context model.UserContext, acls *model.App) bool {
if acls == nil {
return true
}
if !context.IsOAuth() {
tlog.App.Debug().Msg("User is not an OAuth user, skipping OAuth group check")
return false
}
if _, ok := model.OverrideProviders[context.OAuth.ID]; ok {
tlog.App.Debug().Msg("Provider override for OAuth groups enabled, skipping group check")
return true
}
for _, userGroup := range context.OAuth.Groups {
if utils.CheckFilter(acls.OAuth.Groups, strings.TrimSpace(userGroup)) {
tlog.App.Trace().Str("group", userGroup).Str("required", acls.OAuth.Groups).Msg("User group matched")
return true
}
}
tlog.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() {
tlog.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)) {
tlog.App.Trace().Str("group", userGroup).Str("required", acls.LDAP.Groups).Msg("User group matched")
return true
}
}
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
}
// Check for block list
if acls.Path.Block != "" {
regex, err := regexp.Compile(acls.Path.Block)
if err != nil {
return true, err
}
if !regex.MatchString(uri) {
return false, nil
}
}
// Check for allow list
if acls.Path.Allow != "" {
regex, err := regexp.Compile(acls.Path.Allow)
if err != nil {
return true, err
}
if regex.MatchString(uri) {
return false, nil
}
}
return true, nil
}
func (auth *AuthService) CheckIP(ip string, acls *model.App) bool {
if acls == nil {
return true
}
// Merge the global and app IP filter
blockedIps := append(auth.config.IP.Block, acls.IP.Block...)
allowedIPs := append(auth.config.IP.Allow, acls.IP.Allow...)
for _, blocked := range blockedIps {
res, err := utils.FilterIP(blocked, ip)
if err != nil {
tlog.App.Warn().Err(err).Str("item", blocked).Msg("Invalid IP/CIDR in block list")
continue
}
if res {
tlog.App.Debug().Str("ip", ip).Str("item", blocked).Msg("IP is in blocked list, denying access")
return false
}
}
for _, allowed := range allowedIPs {
res, err := utils.FilterIP(allowed, ip)
if err != nil {
tlog.App.Warn().Err(err).Str("item", allowed).Msg("Invalid IP/CIDR in allow list")
continue
}
if res {
tlog.App.Debug().Str("ip", ip).Str("item", allowed).Msg("IP is in allowed list, allowing access")
return true
}
}
if len(allowedIPs) > 0 {
tlog.App.Debug().Str("ip", ip).Msg("IP not in allow list, denying access")
return false
}
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 {
res, err := utils.FilterIP(bypassed, ip)
if err != nil {
tlog.App.Warn().Err(err).Str("item", bypassed).Msg("Invalid IP/CIDR in bypass list")
continue
}
if res {
tlog.App.Debug().Str("ip", ip).Str("item", bypassed).Msg("IP is in bypass list, allowing access")
return true
}
}
tlog.App.Debug().Str("ip", ip).Msg("IP not in bypass list, continuing with authentication")
return false
} }
func (auth *AuthService) NewOAuthSession(serviceName string, params OAuthURLParams) (string, OAuthPendingSession, error) { func (auth *AuthService) NewOAuthSession(serviceName string, params OAuthURLParams) (string, OAuthPendingSession, error) {
@@ -585,32 +722,21 @@ func (auth *AuthService) EndOAuthSession(sessionId string) {
} }
func (auth *AuthService) CleanupOAuthSessionsRoutine() { func (auth *AuthService) CleanupOAuthSessionsRoutine() {
auth.log.App.Debug().Msg("Starting OAuth session cleanup routine")
ticker := time.NewTicker(30 * time.Minute) ticker := time.NewTicker(30 * time.Minute)
defer ticker.Stop() defer ticker.Stop()
for { for range ticker.C {
select { auth.oauthMutex.Lock()
case <-ticker.C:
auth.log.App.Debug().Msg("Running OAuth session cleanup")
auth.oauthMutex.Lock() now := time.Now()
now := time.Now() for sessionId, session := range auth.oauthPendingSessions {
if now.After(session.ExpiresAt) {
for sessionId, session := range auth.oauthPendingSessions { delete(auth.oauthPendingSessions, sessionId)
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()
} }
} }
@@ -639,54 +765,51 @@ func (auth *AuthService) ensureOAuthSessionLimit() {
auth.oauthMutex.Lock() auth.oauthMutex.Lock()
defer auth.oauthMutex.Unlock() defer auth.oauthMutex.Unlock()
if len(auth.oauthPendingSessions) <= MaxOAuthPendingSessions { if len(auth.oauthPendingSessions) >= MaxOAuthPendingSessions {
return
}
type entry struct { cleanupIds := make([]string, 0, OAuthCleanupCount)
id string
expiresAt int64
}
entries := make([]entry, 0, len(auth.oauthPendingSessions)) for range OAuthCleanupCount {
for id, session := range auth.oauthPendingSessions { oldestId := ""
entries = append(entries, entry{id, session.ExpiresAt.Unix()}) oldestTime := int64(0)
}
slices.SortFunc(entries, func(a, b entry) int { for id, session := range auth.oauthPendingSessions {
if a.expiresAt < b.expiresAt { if oldestTime == 0 {
return -1 oldestId = id
oldestTime = session.ExpiresAt.Unix()
continue
}
if slices.Contains(cleanupIds, id) {
continue
}
if session.ExpiresAt.Unix() < oldestTime {
oldestId = id
oldestTime = session.ExpiresAt.Unix()
}
}
cleanupIds = append(cleanupIds, oldestId)
} }
if a.expiresAt > b.expiresAt {
return 1
}
return 0
})
for _, e := range entries[:OAuthCleanupCount] { for _, id := range cleanupIds {
delete(auth.oauthPendingSessions, e.id) delete(auth.oauthPendingSessions, id)
}
} }
} }
func (auth *AuthService) lockdownMode() { func (auth *AuthService) lockdownMode() {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel()
auth.loginMutex.Lock()
if auth.lockdown != nil && auth.lockdown.Active {
auth.loginMutex.Unlock()
cancel()
return
}
auth.lockdownCtx = ctx auth.lockdownCtx = ctx
auth.lockdownCancelFunc = cancel auth.lockdownCancelFunc = cancel
auth.log.App.Warn().Msg("Too many failed login attempts, entering lockdown mode") auth.loginMutex.Lock()
tlog.App.Warn().Msg("Multiple login attempts detected, possibly DDOS attack. Activating temporary lockdown.")
auth.lockdown = &Lockdown{ auth.lockdown = &Lockdown{
Active: true, 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, // At this point all login attemps will also expire so,
@@ -694,25 +817,20 @@ func (auth *AuthService) lockdownMode() {
auth.loginAttempts = make(map[string]*LoginAttempt) auth.loginAttempts = make(map[string]*LoginAttempt)
timer := time.NewTimer(time.Until(auth.lockdown.ActiveUntil)) timer := time.NewTimer(time.Until(auth.lockdown.ActiveUntil))
defer timer.Stop()
auth.loginMutex.Unlock() auth.loginMutex.Unlock()
defer cancel()
defer timer.Stop()
select { select {
case <-timer.C: case <-timer.C:
// Timer expired, end lockdown // Timer expired, end lockdown
case <-ctx.Done(): case <-ctx.Done():
// Context cancelled, end lockdown // Context cancelled, end lockdown
case <-auth.context.Done():
// Service is shutting down, end lockdown
} }
auth.loginMutex.Lock() 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.lockdown = nil
auth.loginMutex.Unlock() auth.loginMutex.Unlock()
} }
@@ -726,3 +844,10 @@ func (auth *AuthService) ClearRateLimitsTestingOnly() {
} }
auth.loginMutex.Unlock() auth.loginMutex.Unlock()
} }
func (auth *AuthService) getCookieDomain() string {
if auth.config.SubdomainsEnabled {
return "." + auth.config.CookieDomain
}
return auth.config.CookieDomain
}
+27 -49
View File
@@ -3,56 +3,51 @@ package service
import ( import (
"context" "context"
"strings" "strings"
"sync"
"github.com/tinyauthapp/tinyauth/internal/model" "github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/utils/decoders" "github.com/tinyauthapp/tinyauth/internal/utils/decoders"
"github.com/tinyauthapp/tinyauth/internal/utils/logger" "github.com/tinyauthapp/tinyauth/internal/utils/tlog"
container "github.com/docker/docker/api/types/container" container "github.com/docker/docker/api/types/container"
"github.com/docker/docker/client" "github.com/docker/docker/client"
) )
type DockerService struct { type DockerService struct {
log *logger.Logger client *client.Client
client *client.Client context context.Context
context context.Context
isConnected bool isConnected bool
} }
func NewDockerService( func NewDockerService() *DockerService {
log *logger.Logger, return &DockerService{}
ctx context.Context, }
wg *sync.WaitGroup,
) (*DockerService, error) {
func (docker *DockerService) Init() error {
client, err := client.NewClientWithOpts(client.FromEnv) client, err := client.NewClientWithOpts(client.FromEnv)
if err != nil { if err != nil {
return nil, err return err
} }
ctx := context.Background()
client.NegotiateAPIVersion(ctx) client.NegotiateAPIVersion(ctx)
_, err = client.Ping(ctx) docker.client = client
docker.context = ctx
_, err = docker.client.Ping(docker.context)
if err != nil { if err != nil {
log.App.Debug().Err(err).Msg("Docker not connected") tlog.App.Debug().Err(err).Msg("Docker not connected")
return nil, nil docker.isConnected = false
docker.client = nil
docker.context = nil
return nil
} }
service := &DockerService{ docker.isConnected = true
log: log, tlog.App.Debug().Msg("Docker connected")
client: client,
context: ctx,
}
service.isConnected = true return nil
service.log.App.Debug().Msg("Docker connected successfully")
wg.Go(service.watchAndClose)
return service, nil
} }
func (docker *DockerService) getContainers() ([]container.Summary, error) { func (docker *DockerService) getContainers() ([]container.Summary, error) {
@@ -65,7 +60,7 @@ func (docker *DockerService) inspectContainer(containerId string) (container.Ins
func (docker *DockerService) GetLabels(appDomain string) (*model.App, error) { func (docker *DockerService) GetLabels(appDomain string) (*model.App, error) {
if !docker.isConnected { if !docker.isConnected {
docker.log.App.Debug().Msg("Docker service not connected, returning empty labels") tlog.App.Debug().Msg("Docker not connected, returning empty labels")
return nil, nil return nil, nil
} }
@@ -85,36 +80,19 @@ func (docker *DockerService) GetLabels(appDomain string) (*model.App, error) {
return nil, err return nil, err
} }
var nameMatch *model.App
// First try to find a matching app by domain, then fallback to matching by app name (subdomain)
for appName, appLabels := range labels.Apps { for appName, appLabels := range labels.Apps {
if appLabels.Config.Domain == appDomain { if appLabels.Config.Domain == appDomain {
docker.log.App.Debug().Str("id", inspect.ID).Str("name", inspect.Name).Msg("Found matching container by domain") tlog.App.Debug().Str("id", inspect.ID).Str("name", inspect.Name).Msg("Found matching container by domain")
return &appLabels, nil return &appLabels, nil
} }
if strings.SplitN(appDomain, ".", 2)[0] == appName { 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") tlog.App.Debug().Str("id", inspect.ID).Str("name", inspect.Name).Msg("Found matching container by app name")
nameMatch = &appLabels return &appLabels, nil
} }
} }
if nameMatch != nil {
return nameMatch, nil
}
} }
docker.log.App.Debug().Str("domain", appDomain).Msg("No matching container found for domain") tlog.App.Debug().Msg("No matching container found, returning empty labels")
return nil, nil 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")
}
}
}
+60 -64
View File
@@ -9,7 +9,7 @@ import (
"github.com/tinyauthapp/tinyauth/internal/model" "github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/utils/decoders" "github.com/tinyauthapp/tinyauth/internal/utils/decoders"
"github.com/tinyauthapp/tinyauth/internal/utils/logger" "github.com/tinyauthapp/tinyauth/internal/utils/tlog"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
@@ -36,10 +36,9 @@ type ingressApp struct {
} }
type KubernetesService struct { type KubernetesService struct {
log *logger.Logger
ctx context.Context
client dynamic.Interface client dynamic.Interface
ctx context.Context
cancel context.CancelFunc
started bool started bool
mu sync.RWMutex mu sync.RWMutex
ingressApps map[ingressKey][]ingressApp ingressApps map[ingressKey][]ingressApp
@@ -47,55 +46,12 @@ type KubernetesService struct {
appNameIndex map[string]ingressAppKey appNameIndex map[string]ingressAppKey
} }
func NewKubernetesService( func NewKubernetesService() *KubernetesService {
log *logger.Logger, return &KubernetesService{
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), ingressApps: make(map[ingressKey][]ingressApp),
domainIndex: make(map[string]ingressAppKey), domainIndex: make(map[string]ingressAppKey),
appNameIndex: 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) { func (k *KubernetesService) addIngressApps(namespace, name string, apps []ingressApp) {
@@ -177,7 +133,7 @@ func (k *KubernetesService) updateFromItem(item *unstructured.Unstructured) {
} }
labels, err := decoders.DecodeLabels[model.Apps](annotations, "apps") labels, err := decoders.DecodeLabels[model.Apps](annotations, "apps")
if err != nil { if err != nil {
k.log.App.Warn().Err(err).Str("namespace", namespace).Str("name", name).Msg("Failed to decode ingress labels, skipping") tlog.App.Debug().Err(err).Msg("Failed to decode labels from annotations")
k.removeIngress(namespace, name) k.removeIngress(namespace, name)
return return
} }
@@ -205,13 +161,13 @@ func (k *KubernetesService) resyncGVR(gvr schema.GroupVersionResource) error {
list, err := k.client.Resource(gvr).List(ctx, metav1.ListOptions{}) list, err := k.client.Resource(gvr).List(ctx, metav1.ListOptions{})
if err != nil { if err != nil {
k.log.App.Warn().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Failed to list resources for resync") tlog.App.Debug().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Failed to list ingresses during resync")
return err return err
} }
for i := range list.Items { for i := range list.Items {
k.updateFromItem(&list.Items[i]) k.updateFromItem(&list.Items[i])
} }
k.log.App.Debug().Str("api", gvr.GroupVersion().String()).Int("count", len(list.Items)).Msg("Resync complete") tlog.App.Debug().Str("api", gvr.GroupVersion().String()).Int("count", len(list.Items)).Msg("Resynced ingress cache")
return nil return nil
} }
@@ -225,14 +181,14 @@ func (k *KubernetesService) runWatcher(gvr schema.GroupVersionResource, w watch.
return false return false
case event, ok := <-w.ResultChan(): case event, ok := <-w.ResultChan():
if !ok { if !ok {
k.log.App.Warn().Str("api", gvr.GroupVersion().String()).Msg("Watcher channel closed, restarting watcher") tlog.App.Debug().Str("api", gvr.GroupVersion().String()).Msg("Watcher channel closed, restarting in 5 seconds")
w.Stop() w.Stop()
time.Sleep(5 * time.Second) time.Sleep(5 * time.Second)
return true return true
} }
item, ok := event.Object.(*unstructured.Unstructured) item, ok := event.Object.(*unstructured.Unstructured)
if !ok { if !ok {
k.log.App.Warn().Str("api", gvr.GroupVersion().String()).Msg("Received unexpected event object, skipping") tlog.App.Warn().Str("api", gvr.GroupVersion().String()).Msg("Failed to cast watched object")
continue continue
} }
switch event.Type { switch event.Type {
@@ -243,7 +199,7 @@ func (k *KubernetesService) runWatcher(gvr schema.GroupVersionResource, w watch.
} }
case <-resyncTicker.C: case <-resyncTicker.C:
if err := k.resyncGVR(gvr); err != nil { 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") tlog.App.Warn().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Periodic resync failed")
} }
} }
} }
@@ -254,29 +210,29 @@ func (k *KubernetesService) watchGVR(gvr schema.GroupVersionResource) {
defer resyncTicker.Stop() defer resyncTicker.Stop()
if err := k.resyncGVR(gvr); err != nil { if err := k.resyncGVR(gvr); err != nil {
k.log.App.Warn().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Initial resync failed, will retry") tlog.App.Error().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Initial resync failed, retrying in 30 seconds")
time.Sleep(30 * time.Second) time.Sleep(30 * time.Second)
} }
for { for {
select { select {
case <-k.ctx.Done(): case <-k.ctx.Done():
k.log.App.Debug().Str("api", gvr.GroupVersion().String()).Msg("Shutting down kubernetes watcher") tlog.App.Debug().Str("api", gvr.GroupVersion().String()).Msg("Stopping watcher")
return return
case <-resyncTicker.C: case <-resyncTicker.C:
if err := k.resyncGVR(gvr); err != nil { if err := k.resyncGVR(gvr); err != nil {
k.log.App.Warn().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Periodic resync failed, will retry") tlog.App.Warn().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Periodic resync failed")
} }
default: default:
ctx, cancel := context.WithCancel(k.ctx) ctx, cancel := context.WithCancel(k.ctx)
watcher, err := k.client.Resource(gvr).Watch(ctx, metav1.ListOptions{}) watcher, err := k.client.Resource(gvr).Watch(ctx, metav1.ListOptions{})
if err != nil { if err != nil {
k.log.App.Warn().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Failed to start watcher, will retry") tlog.App.Error().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Failed to start watcher")
cancel() cancel()
time.Sleep(10 * time.Second) time.Sleep(10 * time.Second)
continue continue
} }
k.log.App.Debug().Str("api", gvr.GroupVersion().String()).Msg("Watcher started successfully") tlog.App.Debug().Str("api", gvr.GroupVersion().String()).Msg("Watcher started")
if !k.runWatcher(gvr, watcher, resyncTicker) { if !k.runWatcher(gvr, watcher, resyncTicker) {
cancel() cancel()
return return
@@ -286,25 +242,65 @@ func (k *KubernetesService) watchGVR(gvr schema.GroupVersionResource) {
} }
} }
func (k *KubernetesService) Init() error {
var cfg *rest.Config
var err error
cfg, err = rest.InClusterConfig()
if err != nil {
return fmt.Errorf("failed to get in-cluster Kubernetes config: %w", err)
}
client, err := dynamic.NewForConfig(cfg)
if err != nil {
return fmt.Errorf("failed to create Kubernetes client: %w", err)
}
k.client = client
k.ctx, k.cancel = context.WithCancel(context.Background())
gvr := schema.GroupVersionResource{
Group: "networking.k8s.io",
Version: "v1",
Resource: "ingresses",
}
accessCtx, accessCancel := context.WithTimeout(k.ctx, 5*time.Second)
defer accessCancel()
_, err = k.client.Resource(gvr).List(accessCtx, metav1.ListOptions{Limit: 1})
if err != nil {
tlog.App.Warn().Err(err).Msg("Insufficient permissions for networking.k8s.io/v1 Ingress, Kubernetes label provider will not work")
k.started = false
return nil
}
tlog.App.Debug().Msg("networking.k8s.io/v1 Ingress API accessible")
go k.watchGVR(gvr)
k.started = true
tlog.App.Info().Msg("Kubernetes label provider initialized")
return nil
}
func (k *KubernetesService) GetLabels(appDomain string) (*model.App, error) { func (k *KubernetesService) GetLabels(appDomain string) (*model.App, error) {
if !k.started { if !k.started {
k.log.App.Debug().Str("domain", appDomain).Msg("Kubernetes label provider not started, skipping") tlog.App.Debug().Msg("Kubernetes not connected, returning empty labels")
return nil, nil return nil, nil
} }
// First check cache // First check cache
app := k.getByDomain(appDomain) app := k.getByDomain(appDomain)
if app != nil { if app != nil {
k.log.App.Debug().Str("domain", appDomain).Msg("Found labels in cache by domain") tlog.App.Debug().Str("domain", appDomain).Msg("Found labels in cache by domain")
return app, nil return app, nil
} }
appName := strings.SplitN(appDomain, ".", 2)[0] appName := strings.SplitN(appDomain, ".", 2)[0]
app = k.getByAppName(appName) app = k.getByAppName(appName)
if app != nil { if app != nil {
k.log.App.Debug().Str("domain", appDomain).Str("appName", appName).Msg("Found labels in cache by app name") tlog.App.Debug().Str("domain", appDomain).Str("appName", appName).Msg("Found labels in cache by app name")
return app, nil return app, nil
} }
k.log.App.Debug().Str("domain", appDomain).Msg("No labels found for domain") tlog.App.Debug().Str("domain", appDomain).Msg("Cache miss, no matching ingress found")
return nil, nil return nil, nil
} }
@@ -8,13 +8,9 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/tinyauthapp/tinyauth/internal/model" "github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
) )
func TestKubernetesService(t *testing.T) { func TestKubernetesService(t *testing.T) {
log := logger.NewLogger().WithTestConfig()
log.Init()
type testCase struct { type testCase struct {
description string description string
run func(t *testing.T, svc *KubernetesService) run func(t *testing.T, svc *KubernetesService)
@@ -183,7 +179,6 @@ func TestKubernetesService(t *testing.T) {
ingressApps: make(map[ingressKey][]ingressApp), ingressApps: make(map[ingressKey][]ingressApp),
domainIndex: make(map[string]ingressAppKey), domainIndex: make(map[string]ingressAppKey),
appNameIndex: make(map[string]ingressAppKey), appNameIndex: make(map[string]ingressAppKey),
log: log,
} }
test.run(t, svc) test.run(t, svc)
}) })

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