Compare commits

..

11 Commits

Author SHA1 Message Date
Stavros 35cd3b9ce5 refactor: simplify listener logic 2026-05-11 16:32:30 +03:00
Stavros 90145dd774 i18n: i18n tailscale frontend features 2026-05-11 16:13:03 +03:00
Stavros 9c0fe751cc refactor: use better names for context in login page 2026-05-11 15:52:17 +03:00
Stavros bc7c604a7d feat: add option to disable tailscale integration 2026-05-11 15:48:35 +03:00
Stavros 8b28e0c3e4 feat: extract tags from tailscale node 2026-05-10 19:23:34 +03:00
Stavros 8fad270379 refactor: simplify context getter functions 2026-05-10 19:20:27 +03:00
Stavros 32595e351d refactor: rework backend to frontend context 2026-05-10 19:17:22 +03:00
Stavros 25017a76c9 feat: add basic login functionality back after main merge 2026-05-10 17:50:01 +03:00
Stavros 312671c795 Merge branch 'main' into feat/tailscale 2026-05-10 16:44:39 +03:00
Stavros 3971710e87 feat: initial wip frontend 2026-04-28 19:11:36 +03:00
Stavros a5677d2558 feat: initial tailscale backend 2026-04-28 18:16:55 +03:00
113 changed files with 1994 additions and 9848 deletions
-4
View File
@@ -101,10 +101,6 @@ TINYAUTH_OAUTH_PROVIDERS_name_CLIENTID=
TINYAUTH_OAUTH_PROVIDERS_name_CLIENTSECRET=
# Path to the file containing the OAuth client secret.
TINYAUTH_OAUTH_PROVIDERS_name_CLIENTSECRETFILE=
# Comma-separated list of allowed OAuth domains for this provider.
TINYAUTH_OAUTH_PROVIDERS_name_WHITELIST=
# Path to the OAuth whitelist file for this provider.
TINYAUTH_OAUTH_PROVIDERS_name_WHITELISTFILE=
# OAuth scopes.
TINYAUTH_OAUTH_PROVIDERS_name_SCOPES=
# OAuth redirect URL.
+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
updates:
- package-ecosystem: "npm"
- package-ecosystem: "bun"
directory: "/frontend"
groups:
minor-patch:
+16 -25
View File
@@ -15,10 +15,8 @@ jobs:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup pnpm
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
with:
package_json_file: ./frontend/package.json
- name: Setup bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
- name: Setup go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
@@ -28,40 +26,33 @@ jobs:
- name: Go dependencies
run: go mod download
- name: Setup sqlc
uses: sqlc-dev/setup-sqlc@v5
with:
sqlc-version: "1.31.1"
- name: Check codegen is up to date
run: |
sqlc generate
go generate ./internal/repository/...
git diff --exit-code -- internal/repository/
git status --porcelain -- internal/repository/ | grep -q . && echo "untracked files in internal/repository/" && exit 1 || true
- name: Install frontend dependencies
working-directory: ./frontend
run: pnpm ci
run: |
cd frontend
bun install --frozen-lockfile
- name: Set version
run: echo testing > internal/assets/version
run: |
echo testing > internal/assets/version
- name: Lint frontend
working-directory: ./frontend
run: pnpm run lint
run: |
cd frontend
bun run lint
- name: Build frontend
working-directory: ./frontend
run: pnpm run build
run: |
cd frontend
bun run build
- name: Copy frontend
run: cp -r frontend/dist internal/assets/dist
run: |
cp -r frontend/dist internal/assets/dist
- name: Run tests
run: go test -coverprofile=coverage.txt -v ./...
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6
with:
token: ${{ secrets.CODECOV_TOKEN }}
+24 -22
View File
@@ -59,10 +59,8 @@ jobs:
with:
ref: nightly
- name: Setup pnpm
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
with:
package_json_file: ./frontend/package.json
- name: Install bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
- name: Install go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
@@ -70,15 +68,18 @@ jobs:
go-version: "^1.26.0"
- name: Install frontend dependencies
working-directory: ./frontend
run: pnpm ci
run: |
cd frontend
bun install --frozen-lockfile
- name: Install backend dependencies
run: go mod download
run: |
go mod download
- name: Build frontend
working-directory: ./frontend
run: pnpm run build
run: |
cd frontend
bun run build
- name: Build
run: |
@@ -104,10 +105,8 @@ jobs:
with:
ref: nightly
- name: Setup pnpm
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
with:
package_json_file: ./frontend/package.json
- name: Install bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
- name: Install go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
@@ -115,15 +114,18 @@ jobs:
go-version: "^1.26.0"
- name: Install frontend dependencies
working-directory: ./frontend
run: pnpm ci
run: |
cd frontend
bun install --frozen-lockfile
- name: Install backend dependencies
run: go mod download
run: |
go mod download
- name: Build frontend
working-directory: ./frontend
run: pnpm run build
run: |
cd frontend
bun run build
- name: Build
run: |
@@ -166,7 +168,7 @@ jobs:
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
- name: Build and push
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7
id: build
with:
platforms: linux/amd64
@@ -224,7 +226,7 @@ jobs:
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
- name: Build and push
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7
id: build
with:
platforms: linux/amd64
@@ -282,7 +284,7 @@ jobs:
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
- name: Build and push
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7
id: build
with:
platforms: linux/arm64
@@ -340,7 +342,7 @@ jobs:
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
- name: Build and push
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7
id: build
with:
platforms: linux/arm64
+24 -22
View File
@@ -35,10 +35,8 @@ jobs:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup pnpm
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
with:
package_json_file: ./frontend/package.json
- name: Install bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
- name: Install go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
@@ -46,15 +44,18 @@ jobs:
go-version: "^1.26.0"
- name: Install frontend dependencies
working-directory: ./frontend
run: pnpm ci
run: |
cd frontend
bun install --frozen-lockfile
- name: Install backend dependencies
run: go mod download
run: |
go mod download
- name: Build frontend
working-directory: ./frontend
run: pnpm run build
run: |
cd frontend
bun run build
- name: Build
run: |
@@ -77,10 +78,8 @@ jobs:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup pnpm
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
with:
package_json_file: ./frontend/package.json
- name: Install bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
- name: Install go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
@@ -88,15 +87,18 @@ jobs:
go-version: "^1.26.0"
- name: Install frontend dependencies
working-directory: ./frontend
run: pnpm ci
run: |
cd frontend
bun install --frozen-lockfile
- name: Install backend dependencies
run: go mod download
run: |
go mod download
- name: Build frontend
working-directory: ./frontend
run: pnpm run build
run: |
cd frontend
bun run build
- name: Build
run: |
@@ -136,7 +138,7 @@ jobs:
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
- name: Build and push
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7
id: build
with:
platforms: linux/amd64
@@ -191,7 +193,7 @@ jobs:
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
- name: Build and push
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7
id: build
with:
platforms: linux/amd64
@@ -246,7 +248,7 @@ jobs:
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
- name: Build and push
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7
id: build
with:
platforms: linux/arm64
@@ -301,7 +303,7 @@ jobs:
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
- name: Build and push
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7
id: build
with:
platforms: linux/arm64
+1 -1
View File
@@ -38,6 +38,6 @@ jobs:
retention-days: 5
- name: Upload to code-scanning
uses: github/codeql-action/upload-sarif@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4
uses: github/codeql-action/upload-sarif@e46ed2cbd01164d986452f91f178727624ae40d7 # v4
with:
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
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
- pnpm
- Bun
- Golang v1.24.0 or later
- Git
- Docker
@@ -34,7 +34,7 @@ Frontend dependencies can be installed as follows:
```sh
cd frontend/
pnpm ci
bun install
```
## Create the `.env` file
+4 -6
View File
@@ -1,14 +1,12 @@
# Site builder
FROM node:26.2-alpine3.23 AS frontend-builder
FROM oven/bun:1.3.13-alpine AS frontend-builder
WORKDIR /frontend
RUN npm install -g pnpm@11.1.2
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/src ./src
@@ -19,7 +17,7 @@ COPY ./frontend/tsconfig.app.json ./
COPY ./frontend/tsconfig.node.json ./
COPY ./frontend/vite.config.ts ./
RUN pnpm run build
RUN bun run build
# 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 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 ./internal ./internal
+4 -6
View File
@@ -1,14 +1,12 @@
# Site builder
FROM node:26.2-alpine3.23 AS frontend-builder
FROM oven/bun:1.3.13-alpine AS frontend-builder
WORKDIR /frontend
RUN npm install -g pnpm@11.1.2
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/src ./src
@@ -19,7 +17,7 @@ COPY ./frontend/tsconfig.app.json ./
COPY ./frontend/tsconfig.node.json ./
COPY ./frontend/vite.config.ts ./
RUN pnpm run build
RUN bun run build
# Builder
FROM golang:1.26-alpine3.23 AS builder
+77 -64
View File
@@ -1,5 +1,5 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
@@ -7,15 +7,17 @@
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
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
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
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
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
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.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
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.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
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
modification follow.
@@ -60,7 +72,7 @@ modification follow.
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
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
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU 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.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
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
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
3 of the GNU General Public License.
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
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
will be similar in spirit to the present version, but may differ in detail to
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
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
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
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.
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
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>
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
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
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/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
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
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
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,
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/>.
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:
cd frontend && pnpm ci
bun install --frozen-lockfile --cwd frontend
go mod download
# Clean data
@@ -31,7 +31,7 @@ clean-webui:
# Build the web UI
webui: clean-webui
cd frontend && pnpm run build
bun run --cwd frontend build
cp -r frontend/dist internal/assets
# Build the binary
@@ -85,4 +85,3 @@ sql:
# Go gen
generate:
go run ./gen
go generate ./internal/repository/...
+4 -1
View File
@@ -28,6 +28,9 @@ Tinyauth is the simplest and tiniest authentication and authorization server you
> [!NOTE]
> 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
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
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
+2 -50
View File
@@ -2,56 +2,8 @@
## 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
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.
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.
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.
+1 -4
View File
@@ -1,10 +1,9 @@
services:
traefik:
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:
- 80:80
- 443:443
volumes:
- /var/run/docker.sock:/var/run/docker.sock
@@ -26,8 +25,6 @@ services:
labels:
traefik.enable: true
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:
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
RUN npm install -g pnpm@11.1.2
FROM oven/bun:1.2.16-alpine
WORKDIR /frontend
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/src ./src
@@ -21,4 +19,4 @@ COPY ./frontend/vite.config.ts ./
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",
"tsc": "tsc -b"
},
"packageManager": "pnpm@11.1.2",
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@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
+1 -1
View File
@@ -86,7 +86,7 @@
"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?",
"loginTailscaleDescription": "We detected that you are 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",
+1 -1
View File
@@ -86,7 +86,7 @@
"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?",
"loginTailscaleDescription": "We detected that you are 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",
+2 -2
View File
@@ -58,8 +58,8 @@
"invalidInput": "Input non valido",
"domainWarningTitle": "Dominio non valido",
"domainWarningSubtitle": "Stai accedendo a questa istanza da un dominio errato. Scegliendo di procedere, potresti incontrare problemi con l'autenticazione.",
"domainWarningCurrent": "Attuale:",
"domainWarningExpected": "Previsto:",
"domainWarningCurrent": "Current:",
"domainWarningExpected": "Expected:",
"ignoreTitle": "Ignora",
"goToCorrectDomainTitle": "Vai al dominio corretto",
"authorizeTitle": "Autorizza",
+1 -1
View File
@@ -57,7 +57,7 @@
"fieldRequired": "Ово поље је неопходно",
"invalidInput": "Неисправан унос",
"domainWarningTitle": "Неисправан домен",
"domainWarningSubtitle": "Приступате овој инстанци са неисправног домена. Ако наставите, можете наићи на проблеме са аутентификацијом.",
"domainWarningSubtitle": "You are accessing this instance from an incorrect domain. If you proceed, you may encounter issues with authentication.",
"domainWarningCurrent": "Тренутни:",
"domainWarningExpected": "Очекивани:",
"ignoreTitle": "Игнориши",
+1 -1
View File
@@ -46,7 +46,7 @@ export const LoginPage = () => {
const { t } = useTranslation();
const [showRedirectButton, setShowRedirectButton] = useState(false);
const [useTailscale, setUseTailscale] = useState(tailscale.nodeName !== undefined);
const [useTailscale, setUseTailscale] = useState(tailscale.nodeName !== "");
const hasAutoRedirectedRef = useRef(false);
+1 -1
View File
@@ -18,7 +18,7 @@ const totpSchema = z.object({
});
const tailscaleSchema = z.object({
nodeName: z.string().optional(),
nodeName: z.string(),
});
export const userContextSchema = z.object({
-473
View File
@@ -1,473 +0,0 @@
// gen/sqlc-wrapper generates store.go wrapper files for each sqlc driver package under
// internal/repository/<driver>/. Run via:
//
// go generate ./internal/repository/...
//
// The generator introspects *Queries methods and the model/params types in the
// driver package, then emits a store.go that wraps *Queries so it satisfies
// repository.Store using the canonical shared types in the parent package.
// This generator is specific to sqlc-generated drivers. Non-sqlc drivers should
// implement repository.Store directly by hand.
package main
import (
"bytes"
_ "embed"
"flag"
"fmt"
"go/format"
"go/types"
"log"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"text/template"
"golang.org/x/tools/go/packages"
)
//go:embed store.tmpl
var storeSrc string
func main() {
fmt.Println("sqlc-wrapper: generating store.go files for sqlc driver packages...")
if err := run(); err != nil {
log.Fatal(err)
}
}
func run() error {
driverPkg := flag.String("pkg", "", "import path of the driver package")
out := flag.String("out", "store.go", "output filename relative to driver package directory")
flag.Parse()
if *driverPkg == "" {
return fmt.Errorf("-pkg is required")
}
// Resolve the driver package directory so we can overlay the output file
// with a valid stub. This prevents a stale store.go from poisoning the
// type-checker and producing cryptic "undefined" errors.
driverDir, err := pkgDir(*driverPkg)
if err != nil {
return fmt.Errorf("resolve driver dir: %w", err)
}
outPath := filepath.Join(driverDir, *out)
if filepath.IsAbs(*out) {
outPath = *out
}
// Stub replaces the output file during load so stale generated code is ignored.
stub := []byte("package " + filepath.Base(driverDir) + "\n")
cfg := &packages.Config{
Mode: packages.NeedName | packages.NeedTypes | packages.NeedSyntax | packages.NeedImports,
Overlay: map[string][]byte{outPath: stub},
}
driverTypePkg, err := loadOnePkg(cfg, *driverPkg)
if err != nil {
return fmt.Errorf("load driver package: %w", err)
}
repoPkgPath := parentPkg(*driverPkg)
repoTypePkg, err := loadOnePkg(cfg, repoPkgPath)
if err != nil {
return fmt.Errorf("load repo package: %w", err)
}
if err := validateStructShapes(driverTypePkg, repoTypePkg); err != nil {
return fmt.Errorf("struct shape mismatch: %w", err)
}
if err := validateStoreCoverage(driverTypePkg, repoTypePkg); err != nil {
return err
}
methods, err := collectMethods(driverTypePkg)
if err != nil {
return err
}
src, err := render(tmplData{
PkgName: driverTypePkg.Name(),
RepoPkg: repoPkgPath,
Methods: renderMethods(methods),
})
if err != nil {
return fmt.Errorf("render: %w", err)
}
if err := os.WriteFile(outPath, src, 0644); err != nil {
return fmt.Errorf("write %s: %w", outPath, err)
}
fmt.Printf("wrote %s\n", outPath)
return nil
}
// loadOnePkg loads a single package via cfg and returns its *types.Package,
// or an error if the package fails to load or has type errors.
func loadOnePkg(cfg *packages.Config, importPath string) (*types.Package, error) {
pkgs, err := packages.Load(cfg, importPath)
if err != nil {
return nil, fmt.Errorf("load %s: %w", importPath, err)
}
if len(pkgs) != 1 {
return nil, fmt.Errorf("expected 1 package for %s, got %d", importPath, len(pkgs))
}
pkg := pkgs[0]
if len(pkg.Errors) > 0 {
msgs := make([]string, len(pkg.Errors))
for i, e := range pkg.Errors {
msgs[i] = e.Error()
}
return nil, fmt.Errorf("package %s has errors:\n %s", importPath, strings.Join(msgs, "\n "))
}
return pkg.Types, nil
}
// parentPkg returns the parent import path (everything before the last /).
// Panics if imp contains no slash — callers are expected to pass driver sub-packages.
func parentPkg(imp string) string {
i := strings.LastIndex(imp, "/")
if i < 0 {
panic(fmt.Sprintf("parentPkg: import path %q has no parent", imp))
}
return imp[:i]
}
// pkgDir returns the on-disk directory for an import path using `go list`.
func pkgDir(importPath string) (string, error) {
out, err := exec.Command("go", "list", "-f", "{{.Dir}}", importPath).Output()
if err != nil {
return "", fmt.Errorf("go list %s: %w", importPath, err)
}
return strings.TrimSpace(string(out)), nil
}
// scopeStructs returns all named struct types in pkg, excluding the internal
// sqlc types Queries, DBTX, and Store. Names are returned in sorted order.
func scopeStructs(pkg *types.Package) (names []string, byName map[string]*types.Struct) {
byName = make(map[string]*types.Struct)
for _, name := range pkg.Scope().Names() { // Names() is already sorted
switch name {
case "Queries", "DBTX", "Store":
continue
}
obj, ok := pkg.Scope().Lookup(name).(*types.TypeName)
if !ok {
continue
}
named, ok := obj.Type().(*types.Named)
if !ok {
continue
}
s, ok := named.Underlying().(*types.Struct)
if !ok {
continue
}
names = append(names, name)
byName[name] = s
}
return
}
// validateStoreCoverage checks that every method declared in repository.Store
// exists on *Queries in the driver package. Missing methods are reported by
// name so the developer knows exactly which SQL queries need to be added.
func validateStoreCoverage(driverPkg, repoPkg *types.Package) error {
queriesObj := driverPkg.Scope().Lookup("Queries")
if queriesObj == nil {
return fmt.Errorf("queries type not found in driver package")
}
queriesNamed := queriesObj.Type().(*types.Named)
queriesMS := types.NewMethodSet(types.NewPointer(queriesNamed))
queriesMethods := make(map[string]bool)
for m := range queriesMS.Methods() {
queriesMethods[m.Obj().Name()] = true
}
storeObj := repoPkg.Scope().Lookup("Store")
if storeObj == nil {
return fmt.Errorf("store type not found in repository package")
}
storeIface, ok := storeObj.Type().Underlying().(*types.Interface)
if !ok {
return fmt.Errorf("repository.Store is not an interface")
}
var missing []string
for method := range storeIface.Methods() {
if name := method.Name(); !queriesMethods[name] {
missing = append(missing, name)
}
}
if len(missing) > 0 {
sort.Strings(missing)
return fmt.Errorf(
"driver *Queries is missing %d method(s) required by repository.Store:\n - %s\n\nRun sqlc generate to regenerate query methods, or add the missing SQL queries",
len(missing), strings.Join(missing, "\n - "),
)
}
return nil
}
// validateStructShapes checks that every model/params struct in the driver
// package has fields that exactly match the corresponding type in the repo
// (parent) package. This catches drift between sqlc-generated types and the
// canonical repository types before a broken cast reaches the compiler.
func validateStructShapes(driverPkg, repoPkg *types.Package) error {
_, repoStructs := scopeStructs(repoPkg)
driverNames, driverStructs := scopeStructs(driverPkg)
var errs []string
for _, name := range driverNames {
repoStruct, ok := repoStructs[name]
if !ok {
// Driver has a type not in repo — fine (e.g. internal helpers).
continue
}
if err := compareStructs(name, driverStructs[name], repoStruct); err != nil {
errs = append(errs, err.Error())
}
}
if len(errs) > 0 {
sort.Strings(errs)
return fmt.Errorf("%s", strings.Join(errs, "\n "))
}
return nil
}
func compareStructs(name string, driver, repo *types.Struct) error {
if driver.NumFields() != repo.NumFields() {
return fmt.Errorf("%s: field count mismatch (driver=%d, repo=%d)",
name, driver.NumFields(), repo.NumFields())
}
for i := range driver.NumFields() {
df := driver.Field(i)
rf := repo.Field(i)
if df.Name() != rf.Name() {
return fmt.Errorf("%s: field %d name mismatch (driver=%q, repo=%q)",
name, i, df.Name(), rf.Name())
}
if !types.Identical(df.Type(), rf.Type()) {
return fmt.Errorf("%s.%s: type mismatch (driver=%s, repo=%s)",
name, df.Name(), df.Type(), rf.Type())
}
}
return nil
}
type methodInfo struct {
Name string
Params []paramInfo
Results []resultInfo
}
type paramInfo struct {
Name string
TypeStr string // local (unqualified) type name
RepoType string // "repository.X" if this is a driver model/params type; else ""
}
type resultInfo struct {
TypeStr string
IsSlice bool
RepoType string // "repository.X" if driver type; else ""
}
func collectMethods(pkg *types.Package) ([]methodInfo, error) {
obj := pkg.Scope().Lookup("Queries")
if obj == nil {
return nil, fmt.Errorf("queries type not found in %s", pkg.Path())
}
named, ok := obj.Type().(*types.Named)
if !ok {
return nil, fmt.Errorf("queries is not a named type")
}
ms := types.NewMethodSet(types.NewPointer(named))
var out []methodInfo
for method := range ms.Methods() {
fn, ok := method.Obj().(*types.Func)
if !ok || fn.Name() == "WithTx" {
continue
}
sig := fn.Type().(*types.Signature)
mi := methodInfo{Name: fn.Name()}
// params: skip receiver + first (context.Context)
for i := 1; i < sig.Params().Len(); i++ {
p := sig.Params().At(i)
mi.Params = append(mi.Params, makeParam(p.Name(), p.Type(), pkg.Path()))
}
// results: skip error
for r := range sig.Results().Variables() {
if r.Type().String() == "error" {
continue
}
mi.Results = append(mi.Results, makeResult(r.Type(), pkg.Path()))
}
out = append(out, mi)
}
return out, nil
}
func makeParam(name string, t types.Type, driverPath string) paramInfo {
return paramInfo{
Name: name,
TypeStr: localName(t, driverPath),
RepoType: repoName(t, driverPath),
}
}
func makeResult(t types.Type, driverPath string) resultInfo {
ri := resultInfo{}
if sl, ok := t.(*types.Slice); ok {
ri.IsSlice = true
t = sl.Elem()
}
ri.TypeStr = localName(t, driverPath)
ri.RepoType = repoName(t, driverPath)
return ri
}
func localName(t types.Type, driverPath string) string {
named, ok := t.(*types.Named)
if !ok {
return types.TypeString(t, nil)
}
if named.Obj().Pkg() != nil && named.Obj().Pkg().Path() == driverPath {
return named.Obj().Name()
}
return types.TypeString(t, func(p *types.Package) string { return p.Name() })
}
func repoName(t types.Type, driverPath string) string {
named, ok := t.(*types.Named)
if !ok {
return ""
}
if named.Obj().Pkg() != nil && named.Obj().Pkg().Path() == driverPath {
return "repository." + named.Obj().Name()
}
return ""
}
// renderedMethod holds pre-built signature and body strings passed to the template.
type renderedMethod struct {
Signature string
Body string
}
func renderMethods(methods []methodInfo) []renderedMethod {
out := make([]renderedMethod, len(methods))
for i, m := range methods {
out[i] = renderedMethod{
Signature: buildSig(m),
Body: buildBody(m),
}
}
return out
}
func buildSig(m methodInfo) string {
var sb strings.Builder
sb.WriteString("func (s *Store) ")
sb.WriteString(m.Name)
sb.WriteString("(ctx context.Context")
for _, p := range m.Params {
sb.WriteString(", ")
sb.WriteString(p.Name)
sb.WriteString(" ")
if p.RepoType != "" {
sb.WriteString(p.RepoType)
} else {
sb.WriteString(p.TypeStr)
}
}
sb.WriteString(") (")
for _, r := range m.Results {
if r.IsSlice {
sb.WriteString("[]")
}
if r.RepoType != "" {
sb.WriteString(r.RepoType)
} else {
sb.WriteString(r.TypeStr)
}
sb.WriteString(", ")
}
sb.WriteString("error)")
return sb.String()
}
func callArgs(m methodInfo) string {
args := make([]string, 0, len(m.Params))
for _, p := range m.Params {
if p.RepoType != "" {
// convert repo type → driver type: DriverType(arg)
args = append(args, p.TypeStr+"("+p.Name+")")
} else {
args = append(args, p.Name)
}
}
if len(args) == 0 {
return "ctx"
}
return "ctx, " + strings.Join(args, ", ")
}
var bodyTmpl = template.Must(template.New("store").Parse(storeSrc))
type bodyData struct {
Call string
RepoType string
}
func buildBody(m methodInfo) string {
call := "s.q." + m.Name + "(" + callArgs(m) + ")"
var (
name string
data bodyData
)
switch {
case len(m.Results) == 0 || m.Results[0].RepoType == "":
name = "void"
data = bodyData{Call: call}
case m.Results[0].IsSlice:
name = "slice"
data = bodyData{Call: call, RepoType: m.Results[0].RepoType}
default:
name = "scalar"
data = bodyData{Call: call, RepoType: m.Results[0].RepoType}
}
var buf bytes.Buffer
if err := bodyTmpl.ExecuteTemplate(&buf, name, data); err != nil {
panic(fmt.Sprintf("buildBody %s: %v", name, err))
}
return buf.String()
}
type tmplData struct {
PkgName string
RepoPkg string
Methods []renderedMethod
}
func render(data tmplData) ([]byte, error) {
var buf bytes.Buffer
if err := bodyTmpl.Execute(&buf, data); err != nil {
return nil, fmt.Errorf("execute template: %w", err)
}
formatted, err := format.Source(buf.Bytes())
if err != nil {
return buf.Bytes(), fmt.Errorf("format source: %w\nraw:\n%s", err, buf.String())
}
return formatted, nil
}
-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}}
+30 -18
View File
@@ -1,6 +1,6 @@
module github.com/tinyauthapp/tinyauth
go 1.26.3
go 1.26.1
require (
charm.land/huh/v2 v2.0.3
@@ -18,13 +18,12 @@ require (
github.com/stretchr/testify v1.11.1
github.com/tinyauthapp/paerser v0.0.0-20260410140347-85c3740d6298
github.com/weppos/publicsuffix-go v0.50.3
golang.org/x/crypto v0.52.0
golang.org/x/crypto v0.50.0
golang.org/x/oauth2 v0.36.0
golang.org/x/tools v0.44.0
k8s.io/apimachinery v0.36.1
k8s.io/client-go v0.36.1
modernc.org/sqlite v1.50.1
tailscale.com v1.98.3
k8s.io/apimachinery v0.36.0
k8s.io/client-go v0.36.0
modernc.org/sqlite v1.50.0
tailscale.com v1.96.5
)
require (
@@ -42,6 +41,19 @@ require (
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/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/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
@@ -66,7 +78,7 @@ require (
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/docker/go-connections v0.6.0 // indirect
github.com/docker/go-connections v0.5.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
@@ -92,7 +104,7 @@ require (
github.com/huin/goupnp v1.3.0 // indirect
github.com/jsimonetti/rtnetlink v1.4.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/klauspost/compress v1.18.2 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
@@ -119,6 +131,7 @@ require (
github.com/pires/go-proxyproto v0.8.1 // indirect
github.com/pkg/errors v0.9.1 // 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/quic-go v0.59.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
@@ -126,12 +139,12 @@ require (
github.com/safchain/ethtool v0.3.0 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d // 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-20260302212456-ecc657c15afd // 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-20260427181203-e3ac4a0afb4e // indirect
github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/x448/float16 v0.8.4 // indirect
@@ -150,12 +163,11 @@ require (
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/mod v0.35.0 // indirect
golang.org/x/net v0.54.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/term v0.43.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/term v0.42.0 // indirect
golang.org/x/text v0.36.0 // indirect
golang.org/x/time v0.14.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
golang.zx2c4.com/wireguard/windows v0.5.3 // indirect
@@ -167,7 +179,7 @@ require (
k8s.io/klog/v2 v2.140.0 // indirect
k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect
modernc.org/libc v1.72.3 // indirect
modernc.org/libc v1.72.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
rsc.io/qr v0.2.0 // indirect
+50 -50
View File
@@ -151,8 +151,8 @@ 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/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
@@ -273,8 +273,8 @@ github.com/jsimonetti/rtnetlink v1.4.0 h1:Z1BF0fRgcETPEa0Kt0MRk3yV5+kF1FWTni6KUF
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/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
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/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a h1:+RR6SqnTkDLWyICxS1xpjCi/3dhyV+TgZwA6Ww3KncQ=
@@ -364,6 +364,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
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/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=
@@ -384,12 +386,12 @@ github.com/safchain/ethtool v0.3.0 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP
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/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -402,16 +404,14 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl
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/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d h1:JcGKBZAL7ePLwOhUdN8qGQZlP5GueEiIZwY7R62pejE=
github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d/go.mod h1:XrBNfAFN+pwoWuksbFS9Ccxnopa15zJGgXRFN90l3K4=
github.com/tailscale/gliderssh v0.3.4-0.20260330083525-c1389c70ff89 h1:glgVc1ZYMjwN1Q/ITWeuSQyl029uayagaR2sjsifehc=
github.com/tailscale/gliderssh v0.3.4-0.20260330083525-c1389c70ff89/go.mod h1:wn16Km1EZOX4UEAyaZa3dBwfFGOJ7neck40NcwosJUw=
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-20260302212456-ecc657c15afd h1:Rf9uhF1+VJ7ZHqxrG8pJ6YacmHvVCmByDmGbAWCc/gA=
github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd/go.mod h1:EbW0wDK/qEUYI0A5bqq0C2kF8JTQwWONmGDBbzsxxHo=
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=
@@ -420,8 +420,8 @@ github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 h1:U
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-20260427181203-e3ac4a0afb4e h1:GexFR7ak1iz26fxg8HWCpOEqAOL8UEZJ7J3JxeCalDs=
github.com/tailscale/wireguard-go v0.0.0-20260427181203-e3ac4a0afb4e/go.mod h1:6SerzcvHWQchKO2BfNdmquA77CHSECZuFl+D9fp4RnI=
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=
@@ -478,18 +478,18 @@ go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBs
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/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
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/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/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.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
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/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
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/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -497,16 +497,16 @@ 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/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.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
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.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=
@@ -534,26 +534,26 @@ gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
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 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU=
honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc=
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.1 h1:XbL/EMj8K2aJpJtePmqUyQMsM0D4QI2pvl7YKJ20FTY=
k8s.io/api v0.36.1/go.mod h1:KOWo4ey3TINlXjeHVuwB3i+tXXnu+UcwFBHlI/9dvEo=
k8s.io/apimachinery v0.36.1 h1:G63Gjx2W+q0YD+72Vo8oY0nDnePVwnuzTmmy5ENrVSA=
k8s.io/apimachinery v0.36.1/go.mod h1:ibYOR00vW/I1kzvi5SF0dRuJ52BvKtfvRdOn35GPQ+8=
k8s.io/client-go v0.36.1 h1:FN/K8QIT2CEDt+2WB2HnWrUANZ50AP5GII43/SP2JR0=
k8s.io/client-go v0.36.1/go.mod h1:s6rAnCtTGYDQnpNjEhSaISV+2O8jwruZ6m3QOYBFbtU=
k8s.io/api v0.36.0 h1:SgqDhZzHdOtMk40xVSvCXkP9ME0H05hPM3p9AB1kL80=
k8s.io/api v0.36.0/go.mod h1:m1LVrGPNYax5NBHdO+QuAedXyuzTt4RryI/qnmNvs34=
k8s.io/apimachinery v0.36.0 h1:jZyPzhd5Z+3h9vJLt0z9XdzW9VzNzWAUw+P1xZ9PXtQ=
k8s.io/apimachinery v0.36.0/go.mod h1:FklypaRJt6n5wUIwWXIP6GJlIpUizTgfo1T/As+Tyxc=
k8s.io/client-go v0.36.0 h1:pOYi7C4RHChYjMiHpZSpSbIM6ZxVbRXBy7CuiIwqA3c=
k8s.io/client-go v0.36.0/go.mod h1:ZKKcpwF0aLYfkHFCjillCKaTK/yBkEDHTDXCFY6AS9Y=
k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc=
k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0=
k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a h1:xCeOEAOoGYl2jnJoHkC3hkbPJgdATINPMAxaynU2Ovg=
k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0=
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU=
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
modernc.org/cc/v4 v4.27.3 h1:uNCgn37E5U09mTv1XgskEVUJ8ADKpmFMPxzGJ0TSo+U=
modernc.org/cc/v4 v4.27.3/go.mod h1:3YjcbCqhoTTHPycJDRl2WZKKFj0nwcOIPBfEZK0Hdk8=
modernc.org/ccgo/v4 v4.32.4 h1:L5OB8rpEX4ZsXEQwGozRfJyJSFHbbNVOoQ59DU9/KuU=
modernc.org/ccgo/v4 v4.32.4/go.mod h1:lY7f+fiTDHfcv6YlRgSkxYfhs+UvOEEzj49jAn2TOx0=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
@@ -562,18 +562,18 @@ modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
modernc.org/libc v1.72.0 h1:IEu559v9a0XWjw0DPoVKtXpO2qt5NVLAnFaBbjq+n8c=
modernc.org/libc v1.72.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
modernc.org/sqlite v1.50.0 h1:eMowQSWLK0MeiQTdmz3lqoF5dqclujdlIKeJA11+7oM=
modernc.org/sqlite v1.50.0/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
@@ -590,5 +590,5 @@ sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
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.98.3 h1:caAbG4UfkKfKPE6b1fj5t4ep5qrwEis5AJu91ruvePw=
tailscale.com v1.98.3/go.mod h1:U23ZwbZlKJMNU7CScy+lCVVlece/S5n09q0nyudncBI=
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
//
//go:embed migrations/sqlite/*.sql
//go:embed migrations/*.sql
var Migrations embed.FS
+22 -23
View File
@@ -18,7 +18,6 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/repository"
"github.com/tinyauthapp/tinyauth/internal/service"
@@ -35,7 +34,6 @@ type Services struct {
oauthBrokerService *service.OAuthBrokerService
oidcService *service.OIDCService
tailscaleService *service.TailscaleService
policyEngine *service.PolicyEngine
}
type BootstrapApp struct {
@@ -45,7 +43,7 @@ type BootstrapApp struct {
log *logger.Logger
ctx context.Context
cancel context.CancelFunc
queries repository.Store
queries *repository.Queries
router *gin.Engine
db *sql.DB
wg sync.WaitGroup
@@ -97,12 +95,7 @@ func (app *BootstrapApp) Setup() error {
return fmt.Errorf("failed to load users: %w", err)
}
if users != nil {
app.runtime.LocalUsers = *users
} else {
log.App.Debug().Msg("No local users found, local authentication will not be available")
app.runtime.LocalUsers = []model.LocalUser{}
}
app.runtime.LocalUsers = *users
// load oauth whitelist
oauthWhitelist, err := utils.GetStringList(app.config.OAuth.Whitelist, app.config.OAuth.WhitelistFile)
@@ -117,13 +110,6 @@ func (app *BootstrapApp) Setup() error {
app.runtime.OAuthProviders = app.config.OAuth.Providers
for id, provider := range app.runtime.OAuthProviders {
providerWhitelist, err := utils.GetStringList(provider.Whitelist, provider.WhitelistFile)
if err != nil {
return fmt.Errorf("failed to load oauth whitelist for provider %s: %w", id, err)
}
provider.Whitelist = providerWhitelist
secret := utils.GetSecret(provider.ClientSecret, provider.ClientSecretFile)
provider.ClientSecret = secret
provider.ClientSecretFile = ""
@@ -180,7 +166,7 @@ func (app *BootstrapApp) Setup() error {
app.runtime.OAuthSessionCookieName = fmt.Sprintf("%s-%s", model.OAuthSessionCookieName, cookieId)
// database
store, err := app.SetupStore()
err = app.SetupDatabase()
if err != nil {
return fmt.Errorf("failed to setup database: %w", err)
@@ -191,13 +177,12 @@ func (app *BootstrapApp) Setup() error {
defer func() {
app.cancel()
app.wg.Wait()
if app.db != nil {
app.db.Close()
}
app.db.Close()
}()
// store
app.queries = store
// queries
queries := repository.New(app.db)
app.queries = queries
// services
err = app.setupServices()
@@ -270,7 +255,21 @@ func (app *BootstrapApp) Setup() error {
}
// setup listeners
app.listeners = app.calculateListenerPolicy()
runUnix := app.config.Server.SocketPath != ""
runHTTP := app.config.Server.SocketPath == "" || app.config.Server.ConcurrentListenersEnabled
runTailscale := app.services.tailscaleService != nil
if runHTTP {
app.listeners = append(app.listeners, ListenerHTTP)
}
if runUnix {
app.listeners = append(app.listeners, ListenerUnix)
}
if runTailscale {
app.listeners = append(app.listeners, ListenerTailscale)
}
if app.config.Server.ConcurrentListenersEnabled {
app.log.App.Info().Msg("Concurrent listeners enabled, will run on all available listeners")
+15 -26
View File
@@ -7,9 +7,6 @@ import (
"path/filepath"
"github.com/tinyauthapp/tinyauth/internal/assets"
"github.com/tinyauthapp/tinyauth/internal/repository"
"github.com/tinyauthapp/tinyauth/internal/repository/memory"
"github.com/tinyauthapp/tinyauth/internal/repository/sqlite"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/sqlite3"
@@ -17,28 +14,17 @@ import (
_ "modernc.org/sqlite"
)
func (app *BootstrapApp) SetupStore() (repository.Store, error) {
switch app.config.Database.Driver {
case "memory":
return memory.New(), nil
case "sqlite", "":
return app.setupSQLite(app.config.Database.Path)
default:
return nil, fmt.Errorf("unknown database driver %q: valid values are sqlite, memory", app.config.Database.Driver)
}
}
func (app *BootstrapApp) setupSQLite(databasePath string) (repository.Store, error) {
dir := filepath.Dir(databasePath)
func (app *BootstrapApp) SetupDatabase() error {
dir := filepath.Dir(app.config.Database.Path)
if err := os.MkdirAll(dir, 0750); err != nil {
return nil, fmt.Errorf("failed to create database directory %s: %w", dir, err)
return fmt.Errorf("failed to create database directory %s: %w", dir, err)
}
db, err := sql.Open("sqlite", databasePath)
db, err := sql.Open("sqlite", app.config.Database.Path)
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
return fmt.Errorf("failed to open database: %w", err)
}
// Close the database if there is an error during migration
@@ -52,29 +38,32 @@ func (app *BootstrapApp) setupSQLite(databasePath string) (repository.Store, err
// if the sqlite connection starts being a bottleneck
db.SetMaxOpenConns(1)
migrations, err := iofs.New(assets.Migrations, "migrations/sqlite")
migrations, err := iofs.New(assets.Migrations, "migrations")
if err != nil {
return nil, fmt.Errorf("failed to create migrations: %w", err)
return fmt.Errorf("failed to create migrations: %w", err)
}
target, err := sqlite3.WithInstance(db, &sqlite3.Config{})
if err != nil {
return nil, fmt.Errorf("failed to create sqlite3 instance: %w", err)
return fmt.Errorf("failed to create sqlite3 instance: %w", err)
}
migrator, err := migrate.NewWithInstance("iofs", migrations, "sqlite3", target)
if err != nil {
return nil, fmt.Errorf("failed to create migrator: %w", err)
return 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)
return fmt.Errorf("failed to migrate database: %w", err)
}
app.db = db
return sqlite.NewStore(sqlite.New(db)), nil
return nil
}
func (app *BootstrapApp) GetDB() *sql.DB {
return app.db
}
-117
View File
@@ -1,117 +0,0 @@
package bootstrap
import (
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tinyauthapp/tinyauth/internal/model"
)
func TestSetupStore_UnknownDriver(t *testing.T) {
tests := []struct {
driver string
wantErr string
}{
{
driver: "mysql",
wantErr: `unknown database driver "mysql": valid values are sqlite, postgres, memory`,
},
{
driver: "redis",
wantErr: `unknown database driver "redis": valid values are sqlite, postgres, memory`,
},
{
driver: "baddriver",
wantErr: `unknown database driver "baddriver": valid values are sqlite, postgres, memory`,
},
}
for _, tt := range tests {
t.Run("driver_"+tt.driver, func(t *testing.T) {
app := NewBootstrapApp(model.Config{
Database: model.DatabaseConfig{
Driver: tt.driver,
},
})
store, err := app.SetupStore()
assert.Nil(t, store)
require.Error(t, err)
assert.Equal(t, tt.wantErr, err.Error())
})
}
}
func TestSetupStore_Memory(t *testing.T) {
app := NewBootstrapApp(model.Config{
Database: model.DatabaseConfig{
Driver: "memory",
},
})
store, err := app.SetupStore()
require.NoError(t, err)
assert.NotNil(t, store)
}
func TestSetupStore_SQLite_ExplicitDriver(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "test.db")
app := NewBootstrapApp(model.Config{
Database: model.DatabaseConfig{
Driver: "sqlite",
Path: dbPath,
},
})
store, err := app.SetupStore()
require.NoError(t, err)
assert.NotNil(t, store)
}
func TestSetupStore_SQLite_DefaultDriver(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "default.db")
app := NewBootstrapApp(model.Config{
Database: model.DatabaseConfig{
Driver: "",
Path: dbPath,
},
})
store, err := app.SetupStore()
require.NoError(t, err)
assert.NotNil(t, store)
}
func TestSetupStore_Postgres_InvalidURL(t *testing.T) {
app := NewBootstrapApp(model.Config{
Database: model.DatabaseConfig{
Driver: "postgres",
Path: "not-a-valid-postgres-url",
},
})
store, err := app.SetupStore()
// sql.Open does not fail on a bad URL for pgx — it only fails on first use.
// The error should come from pgxmigrate.WithInstance when the DB is actually
// pinged / connected, so we expect either success-with-error or an error here.
// What matters is that the postgres case is reached (i.e., no "unknown driver" error).
if err != nil {
assert.False(t, strings.Contains(err.Error(), "unknown database driver"))
assert.Nil(t, store)
}
}
func TestSetupStore_ErrorMessageIncludesPostgres(t *testing.T) {
app := NewBootstrapApp(model.Config{
Database: model.DatabaseConfig{
Driver: "oracle",
},
})
_, err := app.SetupStore()
require.Error(t, err)
assert.Contains(t, err.Error(), "postgres")
assert.Contains(t, err.Error(), "sqlite")
assert.Contains(t, err.Error(), "memory")
}
+2 -52
View File
@@ -1,17 +1,14 @@
package bootstrap
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"os"
"time"
"github.com/tinyauthapp/tinyauth/internal/controller"
"github.com/tinyauthapp/tinyauth/internal/middleware"
"github.com/tinyauthapp/tinyauth/internal/model"
"github.com/gin-gonic/gin"
)
@@ -59,7 +56,7 @@ func (app *BootstrapApp) setupRouter() error {
controller.NewContextController(app.log, app.config, app.runtime, apiRouter)
controller.NewOAuthController(app.log, app.config, app.runtime, apiRouter, app.services.authService)
controller.NewOIDCController(app.log, app.services.oidcService, app.runtime, apiRouter)
controller.NewProxyController(app.log, app.runtime, apiRouter, app.services.accessControlService, app.services.authService, app.services.policyEngine)
controller.NewProxyController(app.log, app.runtime, apiRouter, app.services.accessControlService, app.services.authService)
controller.NewUserController(app.log, app.runtime, apiRouter, app.services.authService)
controller.NewResourcesController(app.config, &engine.RouterGroup)
controller.NewHealthController(apiRouter)
@@ -88,43 +85,6 @@ func (app *BootstrapApp) runListeners() (chan error, error) {
return lec, nil
}
// The way we calculate listeners is as follows:
// If concurrent listeners are disabled, we pick the first available listener, so:
// 1. If tailscale is enabled, we use tailscale
// 2. If socket path is configured, we use unix socket
// 3. Finally if none is configured we use http
// If concurrent listeners are enabled, we add all available listeners in the following order
func (app *BootstrapApp) calculateListenerPolicy() []Listener {
l := []Listener{}
if !app.config.Server.ConcurrentListenersEnabled {
if app.services.tailscaleService != nil {
l = append(l, ListenerTailscale)
return l
}
if app.config.Server.SocketPath != "" {
l = append(l, ListenerUnix)
return l
}
l = append(l, ListenerHTTP)
return l
}
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:
@@ -202,17 +162,7 @@ func (app *BootstrapApp) serveTailscale() error {
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)
}
server.Shutdown(app.ctx)
listener.Close()
}
+30 -89
View File
@@ -2,6 +2,7 @@ package bootstrap
import (
"fmt"
"os"
"github.com/tinyauthapp/tinyauth/internal/service"
@@ -16,29 +17,46 @@ func (app *BootstrapApp) setupServices() error {
app.services.ldapService = ldapService
labelProvider, err := app.getLabelProvider()
useKubernetes := app.config.LabelProvider == "kubernetes" ||
(app.config.LabelProvider == "auto" && os.Getenv("KUBERNETES_SERVICE_HOST") != "")
if err != nil {
return fmt.Errorf("failed to initialize label provider: %w", err)
var labelProvider service.LabelProvider
if useKubernetes {
app.log.App.Debug().Msg("Using Kubernetes label provider")
kubernetesService, err := service.NewKubernetesService(app.log, app.ctx, &app.wg)
if err != nil {
return fmt.Errorf("failed to initialize kubernetes service: %w", err)
}
app.services.kubernetesService = kubernetesService
labelProvider = kubernetesService
} else {
app.log.App.Debug().Msg("Using Docker label provider")
dockerService, err := service.NewDockerService(app.log, app.ctx, &app.wg)
if err != nil {
return fmt.Errorf("failed to initialize docker service: %w", err)
}
app.services.dockerService = dockerService
labelProvider = dockerService
}
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")
} else {
app.services.tailscaleService = tailscaleService
}
app.services.tailscaleService = tailscaleService
accessControlsService := service.NewAccessControlsService(app.log, app.config, &labelProvider)
accessControlsService := service.NewAccessControlsService(app.log, &labelProvider, app.config.Apps)
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
@@ -55,80 +73,3 @@ func (app *BootstrapApp) setupServices() error {
return nil
}
func (app *BootstrapApp) getLabelProvider() (service.LabelProvider, error) {
switch app.config.LabelProvider {
case "none", "docker", "kubernetes", "auto":
if app.config.LabelProvider == "none" {
return nil, nil
}
useKubernetes := app.config.LabelProvider == "kubernetes" ||
(app.config.LabelProvider == "auto" && os.Getenv("KUBERNETES_SERVICE_HOST") != "")
if useKubernetes {
app.log.App.Debug().Msg("Using Kubernetes label provider")
kubernetesService, err := service.NewKubernetesService(app.log, app.ctx, &app.wg)
if err != nil {
return nil, fmt.Errorf("failed to initialize kubernetes service: %w", err)
}
app.services.kubernetesService = kubernetesService
return kubernetesService, nil
}
app.log.App.Debug().Msg("Using Docker label provider")
dockerService, err := service.NewDockerService(app.log, app.ctx, &app.wg)
if err != nil {
return nil, fmt.Errorf("failed to initialize docker service: %w", err)
}
if dockerService == nil {
if app.config.LabelProvider == "docker" {
app.log.App.Warn().Msg("Docker label provider selected but Docker is not available, will continue without it")
}
return nil, nil
}
app.services.dockerService = dockerService
return dockerService, nil
default:
return nil, fmt.Errorf("invalid label provider: %s", app.config.LabelProvider)
}
}
func (app *BootstrapApp) setupPolicyEngine() error {
policyEngine, err := service.NewPolicyEngine(app.config, app.log)
if err != nil {
return fmt.Errorf("failed to initialize policy engine: %w", err)
}
policyEngine.RegisterRule(service.RuleUserAllowed, &service.UserAllowedRule{
Log: app.log,
})
policyEngine.RegisterRule(service.RuleOAuthGroup, &service.OAuthGroupRule{
Log: app.log,
})
policyEngine.RegisterRule(service.RuleLDAPGroup, &service.LDAPGroupRule{
Log: app.log,
})
policyEngine.RegisterRule(service.RuleAuthEnabled, &service.AuthEnabledRule{
Log: app.log,
})
policyEngine.RegisterRule(service.RuleIPAllowed, &service.IPAllowedRule{
Log: app.log,
Config: app.config,
})
policyEngine.RegisterRule(service.RuleIPBypassed, &service.IPBypassedRule{
Log: app.log,
Config: app.config,
})
app.services.policyEngine = policyEngine
return nil
}
+17 -22
View File
@@ -183,23 +183,9 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
return
}
svc, err := controller.auth.GetOAuthService(sessionIdCookie)
if err != nil {
controller.log.App.Error().Err(err).Msg("Failed to get OAuth service for session")
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.runtime.AppURL))
return
}
if svc.ID() != req.Provider {
controller.log.App.Warn().Msgf("OAuth provider mismatch: expected %s, got %s", req.Provider, svc.ID())
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.runtime.AppURL))
return
}
if !controller.auth.IsEmailWhitelisted(svc.ID(), user.Email) {
if !controller.auth.IsEmailWhitelisted(user.Email) {
controller.log.App.Warn().Str("email", user.Email).Msg("Email not whitelisted, denying access")
controller.log.AuditLoginFailure(user.Email, svc.ID(), c.ClientIP(), "email not whitelisted")
controller.log.AuditLoginFailure(user.Email, req.Provider, c.ClientIP(), "email not whitelisted")
queries, err := query.Values(UnauthorizedQuery{
Username: user.Email,
@@ -222,12 +208,7 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
name = user.Name
} else {
controller.log.App.Debug().Msg("No name from OAuth provider, generating from email")
parts := strings.SplitN(user.Email, "@", 2)
if len(parts) == 2 {
name = fmt.Sprintf("%s (%s)", utils.Capitalize(parts[0]), parts[1])
} else {
name = utils.Capitalize(user.Email)
}
name = fmt.Sprintf("%s (%s)", utils.Capitalize(strings.Split(user.Email, "@")[0]), strings.Split(user.Email, "@")[1])
}
var username string
@@ -240,6 +221,20 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
username = strings.Replace(user.Email, "@", "_", 1)
}
svc, err := controller.auth.GetOAuthService(sessionIdCookie)
if err != nil {
controller.log.App.Error().Err(err).Msg("Failed to get OAuth service for session")
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.runtime.AppURL))
return
}
if svc.ID() != req.Provider {
controller.log.App.Warn().Msgf("OAuth provider mismatch: expected %s, got %s", req.Provider, svc.ID())
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.runtime.AppURL))
return
}
sessionCookie := repository.Session{
Username: username,
Name: name,
+2 -2
View File
@@ -146,7 +146,7 @@ func (controller *OIDCController) Authorize(c *gin.Context) {
client, ok := controller.oidc.GetClient(req.ClientID)
if !ok {
controller.authorizeError(c, fmt.Errorf("client not found: %s", req.ClientID), "Client not found", "The client ID is invalid", "", "", "")
controller.authorizeError(c, err, "Client not found", "The client ID is invalid", "", "", "")
return
}
@@ -288,7 +288,7 @@ func (controller *OIDCController) Token(c *gin.Context) {
entry, err := controller.oidc.GetCodeEntry(c, controller.oidc.Hash(req.Code), client.ClientID)
if err != nil {
if err := controller.oidc.DeleteTokenByCodeHash(c, controller.oidc.Hash(req.Code)); err != nil {
controller.log.App.Error().Err(err).Msg("Failed to revoke tokens for replayed code")
controller.log.App.Error().Err(err).Msg("Failed to delete code")
}
if errors.Is(err, service.ErrCodeNotFound) {
controller.log.App.Warn().Msg("Code not found")
+13 -3
View File
@@ -15,9 +15,10 @@ import (
"github.com/google/go-querystring/query"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tinyauthapp/tinyauth/internal/bootstrap"
"github.com/tinyauthapp/tinyauth/internal/controller"
"github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/repository/memory"
"github.com/tinyauthapp/tinyauth/internal/repository"
"github.com/tinyauthapp/tinyauth/internal/service"
"github.com/tinyauthapp/tinyauth/internal/test"
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
@@ -838,11 +839,16 @@ func TestOIDCController(t *testing.T) {
},
}
store := memory.New()
app := bootstrap.NewBootstrapApp(cfg)
err := app.SetupDatabase()
require.NoError(t, err)
queries := repository.New(app.GetDB())
wg := &sync.WaitGroup{}
oidcService, err := service.NewOIDCService(log, cfg, runtime, store, context.TODO(), wg)
oidcService, err := service.NewOIDCService(log, cfg, runtime, queries, context.TODO(), wg)
require.NoError(t, err)
for _, test := range tests {
@@ -863,4 +869,8 @@ func TestOIDCController(t *testing.T) {
test.run(t, router, recorder)
})
}
t.Cleanup(func() {
app.GetDB().Close()
})
}
+28 -33
View File
@@ -3,7 +3,6 @@ package controller
import (
"errors"
"fmt"
"net"
"net/http"
"net/url"
"regexp"
@@ -52,11 +51,10 @@ type ProxyContext struct {
}
type ProxyController struct {
log *logger.Logger
runtime model.RuntimeConfig
acls *service.AccessControlsService
auth *service.AuthService
policyEngine *service.PolicyEngine
log *logger.Logger
runtime model.RuntimeConfig
acls *service.AccessControlsService
auth *service.AuthService
}
func NewProxyController(
@@ -65,14 +63,12 @@ func NewProxyController(
router *gin.RouterGroup,
acls *service.AccessControlsService,
auth *service.AuthService,
policyEngine *service.PolicyEngine,
) *ProxyController {
controller := &ProxyController{
log: log,
runtime: runtime,
acls: acls,
auth: auth,
policyEngine: policyEngine,
log: log,
runtime: runtime,
acls: acls,
auth: auth,
}
proxyGroup := router.Group("/auth")
@@ -105,13 +101,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
clientIP := c.ClientIP()
aclsCtx := &service.ACLContext{
ACLs: acls,
IP: net.ParseIP(clientIP),
Path: proxyCtx.Path,
}
if controller.policyEngine.Evaluate(service.RuleIPBypassed, aclsCtx) {
if controller.auth.IsBypassedIP(clientIP, acls) {
controller.setHeaders(c, acls)
c.JSON(200, gin.H{
"status": 200,
@@ -120,7 +110,15 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
return
}
if controller.policyEngine.Evaluate(service.RuleAuthEnabled, aclsCtx) {
authEnabled, err := controller.auth.IsAuthEnabled(proxyCtx.Path, acls)
if err != nil {
controller.log.App.Error().Err(err).Msg("Failed to determine if authentication is enabled for resource")
controller.handleError(c, proxyCtx)
return
}
if !authEnabled {
controller.log.App.Debug().Msg("Authentication is disabled for this resource, allowing access without authentication")
controller.setHeaders(c, acls)
c.JSON(200, gin.H{
@@ -130,7 +128,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
return
}
if !controller.policyEngine.Evaluate(service.RuleIPAllowed, aclsCtx) {
if !controller.auth.CheckIP(clientIP, acls) {
queries, err := query.Values(UnauthorizedQuery{
Resource: strings.Split(proxyCtx.Host, ".")[0],
IP: clientIP,
@@ -146,9 +144,9 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
if !controller.useBrowserResponse(proxyCtx) {
c.Header("x-tinyauth-location", redirectURL)
c.JSON(403, gin.H{
"status": 403,
"message": "Forbidden",
c.JSON(401, gin.H{
"status": 401,
"message": "Unauthorized",
})
return
}
@@ -160,19 +158,16 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
userContext, err := new(model.UserContext).NewFromGin(c)
if err != nil {
// No user context found is not an issue
if !errors.Is(err, model.ErrUserContextNotFound) {
controller.log.App.Error().Err(err).Msg("Failed to create user context from request, treating as unauthenticated")
}
controller.log.App.Debug().Err(err).Msg("Failed to create user context from request, treating as unauthenticated")
userContext = &model.UserContext{
Authenticated: false,
}
}
aclsCtx.UserContext = userContext
if userContext.Authenticated {
if !controller.policyEngine.Evaluate(service.RuleUserAllowed, aclsCtx) {
userAllowed := controller.auth.IsUserAllowed(c, *userContext, acls)
if !userAllowed {
controller.log.App.Warn().Str("user", userContext.GetUsername()).Str("resource", strings.Split(proxyCtx.Host, ".")[0]).Msg("User is not allowed to access resource")
queries, err := query.Values(UnauthorizedQuery{
@@ -210,9 +205,9 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
var groupOK bool
if userContext.IsOAuth() {
groupOK = controller.policyEngine.Evaluate(service.RuleOAuthGroup, aclsCtx)
groupOK = controller.auth.IsInOAuthGroup(c, *userContext, acls)
} else {
groupOK = controller.policyEngine.Evaluate(service.RuleLDAPGroup, aclsCtx)
groupOK = controller.auth.IsInLDAPGroup(c, *userContext, acls)
}
if !groupOK {
+42 -28
View File
@@ -9,9 +9,10 @@ import (
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tinyauthapp/tinyauth/internal/bootstrap"
"github.com/tinyauthapp/tinyauth/internal/controller"
"github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/repository/memory"
"github.com/tinyauthapp/tinyauth/internal/repository"
"github.com/tinyauthapp/tinyauth/internal/service"
"github.com/tinyauthapp/tinyauth/internal/test"
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
@@ -23,6 +24,33 @@ func TestProxyController(t *testing.T) {
cfg, runtime := test.CreateTestConfigs(t)
acls := map[string]model.App{
"app_path_allow": {
Config: model.AppConfig{
Domain: "path-allow.example.com",
},
Path: model.AppPath{
Allow: "/allowed",
},
},
"app_user_allow": {
Config: model.AppConfig{
Domain: "user-allow.example.com",
},
Users: model.AppUsers{
Allow: "testuser",
},
},
"ip_bypass": {
Config: model.AppConfig{
Domain: "ip-bypass.example.com",
},
IP: model.AppIP{
Bypass: []string{"10.10.10.10"},
},
},
}
const browserUserAgent = `
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 +379,19 @@ func TestProxyController(t *testing.T) {
},
}
store := memory.New()
app := bootstrap.NewBootstrapApp(cfg)
err := app.SetupDatabase()
require.NoError(t, err)
queries := repository.New(app.GetDB())
wg := &sync.WaitGroup{}
ctx := context.TODO()
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)
policyEngine.RegisterRule(service.RuleUserAllowed, &service.UserAllowedRule{
Log: log,
})
policyEngine.RegisterRule(service.RuleOAuthGroup, &service.OAuthGroupRule{
Log: log,
})
policyEngine.RegisterRule(service.RuleLDAPGroup, &service.LDAPGroupRule{
Log: log,
})
policyEngine.RegisterRule(service.RuleAuthEnabled, &service.AuthEnabledRule{
Log: log,
})
policyEngine.RegisterRule(service.RuleIPAllowed, &service.IPAllowedRule{
Log: log,
Config: cfg,
})
policyEngine.RegisterRule(service.RuleIPBypassed, &service.IPBypassedRule{
Log: log,
})
authService := service.NewAuthService(log, cfg, runtime, ctx, wg, nil, queries, broker, nil)
aclsService := service.NewAccessControlsService(log, nil, acls)
for _, test := range tests {
t.Run(test.description, func(t *testing.T) {
@@ -396,9 +406,13 @@ func TestProxyController(t *testing.T) {
recorder := httptest.NewRecorder()
controller.NewProxyController(log, runtime, group, aclsService, authService, policyEngine)
controller.NewProxyController(log, runtime, group, aclsService, authService)
test.run(t, router, recorder)
})
}
t.Cleanup(func() {
app.GetDB().Close()
})
}
+1 -1
View File
@@ -32,7 +32,7 @@ func (controller *ResourcesController) resourcesHandler(c *gin.Context) {
if controller.config.Resources.Path == "" {
c.JSON(404, gin.H{
"status": 404,
"message": "Resource not found",
"message": "Resources not found",
})
return
}
+1 -4
View File
@@ -190,9 +190,6 @@ func (controller *UserController) loginHandler(c *gin.Context) {
if search.Type == model.UserLDAP {
sessionCookie.Provider = "ldap"
if search.Email != "" {
sessionCookie.Email = search.Email
}
}
cookie, err := controller.auth.CreateSession(c, sessionCookie)
@@ -427,7 +424,7 @@ func (controller *UserController) tailscaleHandler(c *gin.Context) {
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")
controller.log.App.Error().Err(err).Str("username", context.GetUsername()).Msg("Failed to create session cookie after successful TOTP verification")
c.JSON(500, gin.H{
"status": 500,
"message": "Internal Server Error",
+14 -5
View File
@@ -14,10 +14,10 @@ import (
"github.com/pquerna/otp/totp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tinyauthapp/tinyauth/internal/bootstrap"
"github.com/tinyauthapp/tinyauth/internal/controller"
"github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/repository"
"github.com/tinyauthapp/tinyauth/internal/repository/memory"
"github.com/tinyauthapp/tinyauth/internal/service"
"github.com/tinyauthapp/tinyauth/internal/test"
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
@@ -73,7 +73,12 @@ func TestUserController(t *testing.T) {
})
}
store := memory.New()
app := bootstrap.NewBootstrapApp(cfg)
err := app.SetupDatabase()
require.NoError(t, err)
queries := repository.New(app.GetDB())
type testCase struct {
description string
@@ -249,7 +254,7 @@ func TestUserController(t *testing.T) {
totpCtx,
},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
_, err := store.CreateSession(context.TODO(), repository.CreateSessionParams{
_, err := queries.CreateSession(context.TODO(), repository.CreateSessionParams{
UUID: "test-totp-login-uuid",
Username: "test",
Email: "test@example.com",
@@ -373,7 +378,7 @@ func TestUserController(t *testing.T) {
totpAttrCtx,
},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
_, err := store.CreateSession(context.TODO(), repository.CreateSessionParams{
_, err := queries.CreateSession(context.TODO(), repository.CreateSessionParams{
UUID: "test-totp-login-attributes-uuid",
Username: "test",
Email: "test@example.com",
@@ -415,7 +420,7 @@ func TestUserController(t *testing.T) {
wg := &sync.WaitGroup{}
broker := service.NewOAuthBrokerService(log, map[string]model.OAuthServiceConfig{}, ctx)
authService := service.NewAuthService(log, cfg, runtime, ctx, wg, nil, store, broker, nil)
authService := service.NewAuthService(log, cfg, runtime, ctx, wg, nil, queries, broker, nil)
beforeEach := func() {
// Clear failed login attempts before each test
@@ -441,4 +446,8 @@ func TestUserController(t *testing.T) {
test.run(t, router, recorder)
})
}
t.Cleanup(func() {
app.GetDB().Close()
})
}
@@ -11,8 +11,9 @@ import (
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tinyauthapp/tinyauth/internal/bootstrap"
"github.com/tinyauthapp/tinyauth/internal/controller"
"github.com/tinyauthapp/tinyauth/internal/repository/memory"
"github.com/tinyauthapp/tinyauth/internal/repository"
"github.com/tinyauthapp/tinyauth/internal/service"
"github.com/tinyauthapp/tinyauth/internal/test"
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
@@ -91,9 +92,14 @@ func TestWellKnownController(t *testing.T) {
ctx := context.TODO()
wg := &sync.WaitGroup{}
store := memory.New()
app := bootstrap.NewBootstrapApp(cfg)
oidcService, err := service.NewOIDCService(log, cfg, runtime, store, ctx, wg)
err := app.SetupDatabase()
require.NoError(t, err)
queries := repository.New(app.GetDB())
oidcService, err := service.NewOIDCService(log, cfg, runtime, queries, ctx, wg)
require.NoError(t, err)
for _, test := range tests {
@@ -108,4 +114,8 @@ func TestWellKnownController(t *testing.T) {
test.run(t, router, recorder)
})
}
t.Cleanup(func() {
app.GetDB().Close()
})
}
+2 -16
View File
@@ -116,7 +116,6 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc {
if tailscaleContext != nil {
c.Set("context", &model.UserContext{
Authenticated: false,
Provider: model.ProviderTailscale,
Tailscale: tailscaleContext,
})
}
@@ -192,12 +191,7 @@ func (m *ContextMiddleware) cookieAuth(ctx context.Context, uuid string, ip stri
userContext.LDAP.Groups = user.Groups
userContext.LDAP.Name = utils.Capitalize(userContext.LDAP.Username)
userContext.LDAP.Email = utils.CompileUserEmail(userContext.LDAP.Username, m.runtime.CookieDomain)
if search.Email != "" {
userContext.LDAP.Email = search.Email
}
case model.ProviderOAuth:
_, exists := m.broker.GetService(userContext.OAuth.ID)
@@ -205,7 +199,7 @@ func (m *ContextMiddleware) cookieAuth(ctx context.Context, uuid string, ip stri
return nil, nil, fmt.Errorf("oauth provider from session cookie not found: %s", userContext.OAuth.ID)
}
if !m.auth.IsEmailWhitelisted(userContext.OAuth.ID, userContext.OAuth.Email) {
if !m.auth.IsEmailWhitelisted(userContext.OAuth.Email) {
m.auth.DeleteSession(ctx, uuid)
return nil, nil, fmt.Errorf("email from session cookie not whitelisted: %s", userContext.OAuth.Email)
}
@@ -251,10 +245,6 @@ func (m *ContextMiddleware) basicAuth(username string, password string) (*model.
case model.UserLocal:
user := m.auth.GetLocalUser(username)
if user == nil {
return nil, nil, fmt.Errorf("user not found locally: %s", username)
}
if user.TOTPSecret != "" {
return nil, nil, fmt.Errorf("user with totp not allowed to login via basic auth: %s", username)
}
@@ -279,15 +269,11 @@ func (m *ContextMiddleware) basicAuth(username string, password string) (*model.
BaseContext: model.BaseContext{
Username: username,
Name: utils.Capitalize(username),
Email: utils.CompileUserEmail(username, m.runtime.CookieDomain),
},
Groups: user.Groups,
}
userContext.Provider = model.ProviderLDAP
userContext.LDAP.Email = utils.CompileUserEmail(username, m.runtime.CookieDomain)
if search.Email != "" {
userContext.LDAP.Email = search.Email
}
}
userContext.Authenticated = true
+15 -6
View File
@@ -12,10 +12,10 @@ import (
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tinyauthapp/tinyauth/internal/bootstrap"
"github.com/tinyauthapp/tinyauth/internal/middleware"
"github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/repository"
"github.com/tinyauthapp/tinyauth/internal/repository/memory"
"github.com/tinyauthapp/tinyauth/internal/service"
"github.com/tinyauthapp/tinyauth/internal/test"
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
@@ -31,7 +31,7 @@ func TestContextMiddleware(t *testing.T) {
return "Basic " + base64.StdEncoding.EncodeToString([]byte(username+":"+password))
}
seedSession := func(t *testing.T, queries repository.Store, params repository.CreateSessionParams) {
seedSession := func(t *testing.T, queries *repository.Queries, params repository.CreateSessionParams) {
t.Helper()
_, err := queries.CreateSession(context.Background(), params)
require.NoError(t, err)
@@ -39,7 +39,7 @@ func TestContextMiddleware(t *testing.T) {
type runArgs struct {
do func(req *http.Request) (*model.UserContext, *httptest.ResponseRecorder)
queries repository.Store
queries *repository.Queries
}
type testCase struct {
@@ -252,10 +252,15 @@ func TestContextMiddleware(t *testing.T) {
ctx := context.TODO()
wg := &sync.WaitGroup{}
store := memory.New()
app := bootstrap.NewBootstrapApp(cfg)
err := app.SetupDatabase()
require.NoError(t, err)
queries := repository.New(app.GetDB())
broker := service.NewOAuthBrokerService(log, map[string]model.OAuthServiceConfig{}, ctx)
authService := service.NewAuthService(log, cfg, runtime, ctx, wg, nil, store, broker, nil)
authService := service.NewAuthService(log, cfg, runtime, ctx, wg, nil, queries, broker, nil)
contextMiddleware := middleware.NewContextMiddleware(log, runtime, authService, broker, nil)
@@ -281,7 +286,11 @@ func TestContextMiddleware(t *testing.T) {
return captured, recorder
}
test.run(t, runArgs{do: do, queries: store})
test.run(t, runArgs{do: do, queries: queries})
})
}
t.Cleanup(func() {
app.GetDB().Close()
})
}
+11 -18
View File
@@ -4,8 +4,7 @@ package model
func NewDefaultConfiguration() *Config {
return &Config{
Database: DatabaseConfig{
Driver: "sqlite",
Path: "./tinyauth.db",
Path: "./tinyauth.db",
},
Analytics: AnalyticsConfig{
Enabled: true,
@@ -25,9 +24,6 @@ func NewDefaultConfiguration() *Config {
SessionMaxLifetime: 0, // disabled
LoginTimeout: 300, // 5 minutes
LoginMaxRetries: 3,
ACLs: ACLsConfig{
Policy: "allow",
},
},
UI: UIConfig{
Title: "Tinyauth",
@@ -85,14 +81,13 @@ type Config struct {
UI UIConfig `description:"UI customization." yaml:"ui"`
LDAP LDAPConfig `description:"LDAP configuration." yaml:"ldap"`
Experimental ExperimentalConfig `description:"Experimental features, use with caution." yaml:"experimental"`
LabelProvider string `description:"Label provider to use for ACLs (auto, docker, kubernetes or none to disable). auto detects the environment." yaml:"labelProvider"`
Log LogConfig `description:"Logging configuration." yaml:"log"`
Tailscale TailscaleConfig `description:"Tailscale configuration." yaml:"tailscale"`
LabelProvider string `description:"Label provider to use (docker, kubernetes, auto)." yaml:"labelProvider"`
}
type DatabaseConfig struct {
Driver string `description:"The database driver to use. Valid values: sqlite, memory." yaml:"driver"`
Path string `description:"The path to the SQLite database, including file name. Only used when driver is sqlite." yaml:"path"`
Path string `description:"The path to the database, including file name." yaml:"path"`
}
type AnalyticsConfig struct {
@@ -123,7 +118,6 @@ type AuthConfig struct {
LoginTimeout int `description:"Login timeout in seconds." yaml:"loginTimeout"`
LoginMaxRetries int `description:"Maximum login retries." yaml:"loginMaxRetries"`
TrustedProxies []string `description:"Comma-separated list of trusted proxy addresses." yaml:"trustedProxies"`
ACLs ACLsConfig `description:"ACLs configuration." yaml:"acls"`
}
type UserAttributes struct {
@@ -154,9 +148,8 @@ type AddressClaim struct {
}
type IPConfig struct {
Allow []string `description:"List of allowed IPs or CIDR ranges." yaml:"allow"`
Block []string `description:"List of blocked IPs or CIDR ranges." yaml:"block"`
Bypass []string `description:"List of IPs or CIDR ranges that bypass authentication entirely." yaml:"bypass"`
Allow []string `description:"List of allowed IPs or CIDR ranges." yaml:"allow"`
Block []string `description:"List of blocked IPs or CIDR ranges." yaml:"block"`
}
type OAuthConfig struct {
@@ -226,8 +219,6 @@ type OAuthServiceConfig struct {
ClientID string `description:"OAuth client ID." yaml:"clientId"`
ClientSecret string `description:"OAuth client secret." yaml:"clientSecret"`
ClientSecretFile string `description:"Path to the file containing the OAuth client secret." yaml:"clientSecretFile"`
Whitelist []string `description:"Comma-separated list of allowed OAuth domains for this provider." yaml:"whitelist"`
WhitelistFile string `description:"Path to the OAuth whitelist file for this provider." yaml:"whitelistFile"`
Scopes []string `description:"OAuth scopes." yaml:"scopes"`
RedirectURL string `description:"OAuth redirect URL." yaml:"redirectUrl"`
AuthURL string `description:"OAuth authorization URL." yaml:"authUrl"`
@@ -246,12 +237,14 @@ type OIDCClientConfig struct {
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"`
type TailscaleWhoisResponse struct {
UserID string
LoginName string
DisplayName string
NodeName string
Tags []string
}
// ACLs
type Apps struct {
Apps map[string]App `description:"App ACLs configuration." yaml:"apps"`
}
-90
View File
@@ -1,90 +0,0 @@
package model
import (
"reflect"
"testing"
"github.com/stretchr/testify/assert"
)
// TestDatabaseConfig_DescriptionMentionsPostgres verifies that the DatabaseConfig
// Driver field description explicitly lists "postgres" as a valid value, reflecting
// the newly added PostgreSQL support.
func TestDatabaseConfig_DescriptionMentionsPostgres(t *testing.T) {
rt := reflect.TypeOf(DatabaseConfig{})
driverField, ok := rt.FieldByName("Driver")
assert.True(t, ok, "DatabaseConfig should have a Driver field")
description := driverField.Tag.Get("description")
assert.Contains(t, description, "postgres", "DatabaseConfig.Driver description should mention postgres as a valid value")
assert.Contains(t, description, "sqlite", "DatabaseConfig.Driver description should mention sqlite as a valid value")
assert.Contains(t, description, "memory", "DatabaseConfig.Driver description should mention memory as a valid value")
}
// TestDatabaseConfig_PathDescriptionMentionsConnectionURL verifies that the Path
// field description covers both SQLite file path and PostgreSQL connection URL usage.
func TestDatabaseConfig_PathDescriptionMentionsConnectionURL(t *testing.T) {
rt := reflect.TypeOf(DatabaseConfig{})
pathField, ok := rt.FieldByName("Path")
assert.True(t, ok, "DatabaseConfig should have a Path field")
description := pathField.Tag.Get("description")
assert.Contains(t, description, "postgres",
"DatabaseConfig.Path description should mention postgres to clarify connection URL usage")
}
// TestIPConfig_NoBypassField verifies that the Bypass field has been removed
// from IPConfig as part of the PR changes. IP bypass lists are now only
// configured at the per-app ACL level.
func TestIPConfig_NoBypassField(t *testing.T) {
rt := reflect.TypeOf(IPConfig{})
_, hasBypass := rt.FieldByName("Bypass")
assert.False(t, hasBypass, "IPConfig should not have a Bypass field after PR changes")
}
// TestIPConfig_HasAllowAndBlock ensures the remaining Allow and Block fields
// are still present in IPConfig after the Bypass removal.
func TestIPConfig_HasAllowAndBlock(t *testing.T) {
rt := reflect.TypeOf(IPConfig{})
_, hasAllow := rt.FieldByName("Allow")
assert.True(t, hasAllow, "IPConfig should still have an Allow field")
_, hasBlock := rt.FieldByName("Block")
assert.True(t, hasBlock, "IPConfig should still have a Block field")
}
// TestOAuthServiceConfig_NoWhitelistField verifies that the per-provider Whitelist
// and WhitelistFile fields have been removed from OAuthServiceConfig. The global
// OAuthWhitelist on OAuthConfig/RuntimeConfig is now the only whitelist.
func TestOAuthServiceConfig_NoWhitelistField(t *testing.T) {
rt := reflect.TypeOf(OAuthServiceConfig{})
_, hasWhitelist := rt.FieldByName("Whitelist")
assert.False(t, hasWhitelist, "OAuthServiceConfig should not have a Whitelist field after PR changes")
_, hasWhitelistFile := rt.FieldByName("WhitelistFile")
assert.False(t, hasWhitelistFile, "OAuthServiceConfig should not have a WhitelistFile field after PR changes")
}
// TestOAuthServiceConfig_CoreFieldsPreserved ensures that removing the whitelist
// fields did not inadvertently drop unrelated fields.
func TestOAuthServiceConfig_CoreFieldsPreserved(t *testing.T) {
rt := reflect.TypeOf(OAuthServiceConfig{})
for _, fieldName := range []string{"ClientID", "ClientSecret", "ClientSecretFile", "Scopes", "RedirectURL", "AuthURL", "TokenURL", "UserinfoURL"} {
_, ok := rt.FieldByName(fieldName)
assert.True(t, ok, "OAuthServiceConfig should still have a %s field", fieldName)
}
}
// TestDatabaseConfig_ZeroValue ensures DatabaseConfig is usable as a zero value
// with the expected default (empty string) driver, which falls back to sqlite.
func TestDatabaseConfig_ZeroValue(t *testing.T) {
var cfg DatabaseConfig
assert.Equal(t, "", cfg.Driver, "zero-value Driver should be an empty string (defaults to sqlite)")
assert.Equal(t, "", cfg.Path, "zero-value Path should be an empty string")
}
-2
View File
@@ -21,5 +21,3 @@ const SessionCookieName = "tinyauth-session"
const CSRFCookieName = "tinyauth-csrf"
const RedirectCookieName = "tinyauth-redirect"
const OAuthSessionCookieName = "tinyauth-oauth"
const GracefulShutdownTimeout = 5 // seconds
-1
View File
@@ -21,6 +21,5 @@ type LocalUser struct {
type UserSearch struct {
Username string
Email string // used for LDAP, we can't throw it to LDAPUser because it would need another cache or an LDAP lookup every time
Type UserSearchType
}
@@ -1,8 +1,8 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// sqlc v1.30.0
package sqlite
package repository
import (
"context"
-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)
})
}
}
-241
View File
@@ -1,241 +0,0 @@
package memory
import (
"context"
"fmt"
"github.com/tinyauthapp/tinyauth/internal/repository"
)
func (s *Store) CreateOidcCode(_ context.Context, arg repository.CreateOidcCodeParams) (repository.OidcCode, error) {
s.mu.Lock()
defer s.mu.Unlock()
// Enforce sub UNIQUE constraint
for _, c := range s.oidcCodes {
if c.Sub == arg.Sub {
return repository.OidcCode{}, fmt.Errorf("UNIQUE constraint failed: oidc_codes.sub")
}
}
code := repository.OidcCode(arg)
s.oidcCodes[arg.CodeHash] = code
return code, nil
}
// GetOidcCode is a destructive read: it deletes and returns the code (mirrors SQLite's DELETE...RETURNING).
func (s *Store) GetOidcCode(_ context.Context, codeHash string) (repository.OidcCode, error) {
s.mu.Lock()
defer s.mu.Unlock()
c, ok := s.oidcCodes[codeHash]
if !ok {
return repository.OidcCode{}, repository.ErrNotFound
}
delete(s.oidcCodes, codeHash)
return c, nil
}
// GetOidcCodeBySub is a destructive read: it deletes and returns the code (mirrors SQLite's DELETE...RETURNING).
func (s *Store) GetOidcCodeBySub(_ context.Context, sub string) (repository.OidcCode, error) {
s.mu.Lock()
defer s.mu.Unlock()
for k, c := range s.oidcCodes {
if c.Sub == sub {
delete(s.oidcCodes, k)
return c, nil
}
}
return repository.OidcCode{}, repository.ErrNotFound
}
// GetOidcCodeUnsafe is a non-destructive read (mirrors SQLite's SELECT).
func (s *Store) GetOidcCodeUnsafe(_ context.Context, codeHash string) (repository.OidcCode, error) {
s.mu.RLock()
defer s.mu.RUnlock()
c, ok := s.oidcCodes[codeHash]
if !ok {
return repository.OidcCode{}, repository.ErrNotFound
}
return c, nil
}
// GetOidcCodeBySubUnsafe is a non-destructive read (mirrors SQLite's SELECT).
func (s *Store) GetOidcCodeBySubUnsafe(_ context.Context, sub string) (repository.OidcCode, error) {
s.mu.RLock()
defer s.mu.RUnlock()
for _, c := range s.oidcCodes {
if c.Sub == sub {
return c, nil
}
}
return repository.OidcCode{}, repository.ErrNotFound
}
func (s *Store) DeleteOidcCode(_ context.Context, codeHash string) error {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.oidcCodes, codeHash)
return nil
}
func (s *Store) DeleteOidcCodeBySub(_ context.Context, sub string) error {
s.mu.Lock()
defer s.mu.Unlock()
for k, c := range s.oidcCodes {
if c.Sub == sub {
delete(s.oidcCodes, k)
}
}
return nil
}
func (s *Store) DeleteExpiredOidcCodes(_ context.Context, expiresAt int64) ([]repository.OidcCode, error) {
s.mu.Lock()
defer s.mu.Unlock()
var deleted []repository.OidcCode
for k, c := range s.oidcCodes {
if c.ExpiresAt < expiresAt {
deleted = append(deleted, c)
delete(s.oidcCodes, k)
}
}
return deleted, nil
}
func (s *Store) CreateOidcToken(_ context.Context, arg repository.CreateOidcTokenParams) (repository.OidcToken, error) {
s.mu.Lock()
defer s.mu.Unlock()
// Enforce sub UNIQUE constraint
for _, t := range s.oidcTokens {
if t.Sub == arg.Sub {
return repository.OidcToken{}, fmt.Errorf("UNIQUE constraint failed: oidc_tokens.sub")
}
}
tok := repository.OidcToken{
Sub: arg.Sub,
AccessTokenHash: arg.AccessTokenHash,
RefreshTokenHash: arg.RefreshTokenHash,
CodeHash: arg.CodeHash,
Scope: arg.Scope,
ClientID: arg.ClientID,
TokenExpiresAt: arg.TokenExpiresAt,
RefreshTokenExpiresAt: arg.RefreshTokenExpiresAt,
Nonce: arg.Nonce,
}
s.oidcTokens[arg.AccessTokenHash] = tok
return tok, nil
}
func (s *Store) GetOidcToken(_ context.Context, accessTokenHash string) (repository.OidcToken, error) {
s.mu.RLock()
defer s.mu.RUnlock()
t, ok := s.oidcTokens[accessTokenHash]
if !ok {
return repository.OidcToken{}, repository.ErrNotFound
}
return t, nil
}
func (s *Store) GetOidcTokenByRefreshToken(_ context.Context, refreshTokenHash string) (repository.OidcToken, error) {
s.mu.RLock()
defer s.mu.RUnlock()
for _, t := range s.oidcTokens {
if t.RefreshTokenHash == refreshTokenHash {
return t, nil
}
}
return repository.OidcToken{}, repository.ErrNotFound
}
func (s *Store) GetOidcTokenBySub(_ context.Context, sub string) (repository.OidcToken, error) {
s.mu.RLock()
defer s.mu.RUnlock()
for _, t := range s.oidcTokens {
if t.Sub == sub {
return t, nil
}
}
return repository.OidcToken{}, repository.ErrNotFound
}
func (s *Store) UpdateOidcTokenByRefreshToken(_ context.Context, arg repository.UpdateOidcTokenByRefreshTokenParams) (repository.OidcToken, error) {
s.mu.Lock()
defer s.mu.Unlock()
for k, t := range s.oidcTokens {
if t.RefreshTokenHash == arg.RefreshTokenHash_2 {
delete(s.oidcTokens, k)
t.AccessTokenHash = arg.AccessTokenHash
t.RefreshTokenHash = arg.RefreshTokenHash
t.TokenExpiresAt = arg.TokenExpiresAt
t.RefreshTokenExpiresAt = arg.RefreshTokenExpiresAt
s.oidcTokens[arg.AccessTokenHash] = t
return t, nil
}
}
return repository.OidcToken{}, repository.ErrNotFound
}
func (s *Store) DeleteOidcToken(_ context.Context, accessTokenHash string) error {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.oidcTokens, accessTokenHash)
return nil
}
func (s *Store) DeleteOidcTokenBySub(_ context.Context, sub string) error {
s.mu.Lock()
defer s.mu.Unlock()
for k, t := range s.oidcTokens {
if t.Sub == sub {
delete(s.oidcTokens, k)
}
}
return nil
}
func (s *Store) DeleteOidcTokenByCodeHash(_ context.Context, codeHash string) error {
s.mu.Lock()
defer s.mu.Unlock()
for k, t := range s.oidcTokens {
if t.CodeHash == codeHash {
delete(s.oidcTokens, k)
}
}
return nil
}
func (s *Store) DeleteExpiredOidcTokens(_ context.Context, arg repository.DeleteExpiredOidcTokensParams) ([]repository.OidcToken, error) {
s.mu.Lock()
defer s.mu.Unlock()
var deleted []repository.OidcToken
for k, t := range s.oidcTokens {
if t.TokenExpiresAt < arg.TokenExpiresAt && t.RefreshTokenExpiresAt < arg.RefreshTokenExpiresAt {
deleted = append(deleted, t)
delete(s.oidcTokens, k)
}
}
return deleted, nil
}
func (s *Store) CreateOidcUserInfo(_ context.Context, arg repository.CreateOidcUserInfoParams) (repository.OidcUserinfo, error) {
s.mu.Lock()
defer s.mu.Unlock()
u := repository.OidcUserinfo(arg)
s.oidcUsers[arg.Sub] = u
return u, nil
}
func (s *Store) GetOidcUserInfo(_ context.Context, sub string) (repository.OidcUserinfo, error) {
s.mu.RLock()
defer s.mu.RUnlock()
u, ok := s.oidcUsers[sub]
if !ok {
return repository.OidcUserinfo{}, repository.ErrNotFound
}
return u, nil
}
func (s *Store) DeleteOidcUserInfo(_ context.Context, sub string) error {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.oidcUsers, sub)
return nil
}
@@ -1,63 +0,0 @@
package memory
import (
"context"
"github.com/tinyauthapp/tinyauth/internal/repository"
)
func (s *Store) CreateSession(_ context.Context, arg repository.CreateSessionParams) (repository.Session, error) {
s.mu.Lock()
defer s.mu.Unlock()
sess := repository.Session(arg)
s.sessions[arg.UUID] = sess
return sess, nil
}
func (s *Store) GetSession(_ context.Context, uuid string) (repository.Session, error) {
s.mu.RLock()
defer s.mu.RUnlock()
sess, ok := s.sessions[uuid]
if !ok {
return repository.Session{}, repository.ErrNotFound
}
return sess, nil
}
func (s *Store) UpdateSession(_ context.Context, arg repository.UpdateSessionParams) (repository.Session, error) {
s.mu.Lock()
defer s.mu.Unlock()
sess, ok := s.sessions[arg.UUID]
if !ok {
return repository.Session{}, repository.ErrNotFound
}
sess.Username = arg.Username
sess.Email = arg.Email
sess.Name = arg.Name
sess.Provider = arg.Provider
sess.TotpPending = arg.TotpPending
sess.OAuthGroups = arg.OAuthGroups
sess.Expiry = arg.Expiry
sess.OAuthName = arg.OAuthName
sess.OAuthSub = arg.OAuthSub
s.sessions[arg.UUID] = sess
return sess, nil
}
func (s *Store) DeleteSession(_ context.Context, uuid string) error {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.sessions, uuid)
return nil
}
func (s *Store) DeleteExpiredSessions(_ context.Context, expiry int64) error {
s.mu.Lock()
defer s.mu.Unlock()
for k, v := range s.sessions {
if v.Expiry < expiry {
delete(s.sessions, k)
}
}
return nil
}
-27
View File
@@ -1,27 +0,0 @@
// Package memory provides an in-memory implementation of repository.Store for use in tests.
package memory
import (
"sync"
"github.com/tinyauthapp/tinyauth/internal/repository"
)
// Store is a thread-safe in-memory implementation of repository.Store.
type Store struct {
mu sync.RWMutex
sessions map[string]repository.Session
oidcCodes map[string]repository.OidcCode
oidcTokens map[string]repository.OidcToken
oidcUsers map[string]repository.OidcUserinfo
}
// New returns a new empty in-memory Store.
func New() repository.Store {
return &Store{
sessions: make(map[string]repository.Session),
oidcCodes: make(map[string]repository.OidcCode),
oidcTokens: make(map[string]repository.OidcToken),
oidcUsers: make(map[string]repository.OidcUserinfo),
}
}
+5 -89
View File
@@ -1,22 +1,9 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
package repository
// Shared model and parameter types for all storage drivers.
// sqlc-generated driver packages use these via the conversion layer in their store.go.
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
}
type OidcCode struct {
Sub string
CodeHash string
@@ -62,7 +49,7 @@ type OidcUserinfo struct {
Address string
}
type CreateSessionParams struct {
type Session struct {
UUID string
Username string
Email string
@@ -75,74 +62,3 @@ type CreateSessionParams struct {
OAuthName string
OAuthSub string
}
type UpdateSessionParams struct {
Username string
Email string
Name string
Provider string
TotpPending bool
OAuthGroups string
Expiry int64
OAuthName string
OAuthSub string
UUID string
}
type CreateOidcCodeParams struct {
Sub string
CodeHash string
Scope string
RedirectURI string
ClientID string
ExpiresAt int64
Nonce string
CodeChallenge string
}
type CreateOidcTokenParams struct {
Sub string
AccessTokenHash string
RefreshTokenHash string
Scope string
ClientID string
TokenExpiresAt int64
RefreshTokenExpiresAt int64
CodeHash string
Nonce string
}
type UpdateOidcTokenByRefreshTokenParams struct {
AccessTokenHash string
RefreshTokenHash string
TokenExpiresAt int64
RefreshTokenExpiresAt int64
RefreshTokenHash_2 string
}
type DeleteExpiredOidcTokensParams struct {
TokenExpiresAt int64
RefreshTokenExpiresAt int64
}
type CreateOidcUserInfoParams struct {
Sub string
Name string
PreferredUsername string
Email string
Groups string
UpdatedAt int64
GivenName string
FamilyName string
MiddleName string
Nickname string
Profile string
Picture string
Website string
Gender string
Birthdate string
Zoneinfo string
Locale string
PhoneNumber string
Address string
}
@@ -1,9 +1,9 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// sqlc v1.30.0
// source: oidc_queries.sql
package sqlite
package repository
import (
"context"
-162
View File
@@ -1,162 +0,0 @@
package postgres
import (
"context"
"database/sql"
"errors"
"fmt"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tinyauthapp/tinyauth/internal/repository"
)
// TestMapErr verifies that mapErr translates known sentinel errors and
// passes through all other errors unchanged.
func TestMapErr(t *testing.T) {
sentinel := errors.New("some other error")
tests := []struct {
name string
input error
want error
isWant bool // use errors.Is check
}{
{
name: "nil passes through unchanged",
input: nil,
want: nil,
isWant: false,
},
{
name: "sql.ErrNoRows maps to repository.ErrNotFound",
input: sql.ErrNoRows,
want: repository.ErrNotFound,
isWant: true,
},
{
name: "wrapped sql.ErrNoRows maps to repository.ErrNotFound",
input: fmt.Errorf("wrapped: %w", sql.ErrNoRows),
want: repository.ErrNotFound,
isWant: true,
},
{
name: "arbitrary error passes through unchanged",
input: sentinel,
want: sentinel,
isWant: true,
},
{
name: "wrapped arbitrary error passes through unchanged",
input: fmt.Errorf("outer: %w", sentinel),
want: fmt.Errorf("outer: %w", sentinel),
isWant: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := mapErr(tt.input)
if tt.input == nil {
assert.Nil(t, got)
return
}
if tt.isWant {
assert.True(t, errors.Is(got, tt.want), "expected errors.Is(%v, %v) to be true, got %v", got, tt.want, got)
} else {
// For wrapped-arbitrary-error passthrough: the original wrapped error is returned as-is
assert.Equal(t, tt.input, got)
}
})
}
}
// TestMapErr_ErrNoRows_IsRepositoryErrNotFound specifically asserts the contract
// that callers outside the package can detect repository.ErrNotFound using errors.Is.
func TestMapErr_ErrNoRows_IsRepositoryErrNotFound(t *testing.T) {
result := mapErr(sql.ErrNoRows)
require.NotNil(t, result)
assert.True(t, errors.Is(result, repository.ErrNotFound))
// Must NOT still be sql.ErrNoRows after mapping
assert.False(t, errors.Is(result, sql.ErrNoRows))
}
// TestMapErr_OtherError_IsNotRepositoryErrNotFound ensures unrecognised errors
// are NOT silently converted to ErrNotFound.
func TestMapErr_OtherError_IsNotRepositoryErrNotFound(t *testing.T) {
someErr := errors.New("connection refused")
result := mapErr(someErr)
require.NotNil(t, result)
assert.False(t, errors.Is(result, repository.ErrNotFound))
assert.True(t, errors.Is(result, someErr))
}
// TestNewStore ensures that NewStore returns a value satisfying the
// repository.Store interface (compile-time verified) and is not nil.
func TestNewStore(t *testing.T) {
q := New(nil) // Queries with a nil DBTX — adequate for construction checks
var store repository.Store = NewStore(q)
assert.NotNil(t, store)
}
// mockDBTX is a minimal DBTX implementation that returns a configurable error.
type mockDBTX struct {
err error
rowErr error
}
func (m *mockDBTX) ExecContext(_ context.Context, _ string, _ ...interface{}) (sql.Result, error) {
return nil, m.err
}
func (m *mockDBTX) PrepareContext(_ context.Context, _ string) (*sql.Stmt, error) {
return nil, m.err
}
func (m *mockDBTX) QueryContext(_ context.Context, _ string, _ ...interface{}) (*sql.Rows, error) {
return nil, m.err
}
func (m *mockDBTX) QueryRowContext(_ context.Context, _ string, _ ...interface{}) *sql.Row {
// *sql.Row cannot be constructed without internals; returning nil causes a
// nil-dereference in callers, so we can only test ExecContext-backed methods.
return nil
}
// TestStore_DeleteSession_PropagatesError verifies that an error returned by the
// underlying DBTX is forwarded (possibly mapped) by the Store wrapper.
func TestStore_DeleteSession_PropagatesError(t *testing.T) {
customErr := errors.New("exec error")
mock := &mockDBTX{err: customErr}
store := NewStore(New(mock))
err := store.DeleteSession(context.Background(), "some-uuid")
require.Error(t, err)
// The error is not ErrNoRows, so it must be passed through as-is.
assert.True(t, errors.Is(err, customErr))
}
// TestStore_DeleteOidcCode_PropagatesError verifies error propagation for a
// different delete method.
func TestStore_DeleteOidcCode_PropagatesError(t *testing.T) {
customErr := errors.New("exec error")
mock := &mockDBTX{err: customErr}
store := NewStore(New(mock))
err := store.DeleteOidcCode(context.Background(), "some-hash")
require.Error(t, err)
assert.True(t, errors.Is(err, customErr))
}
// TestStore_DeleteExpiredSessions_PropagatesErrNoRowsAsNotFound verifies that
// sql.ErrNoRows is mapped to repository.ErrNotFound through the Store wrapper.
func TestStore_DeleteExpiredSessions_PropagatesError(t *testing.T) {
customErr := errors.New("db unavailable")
mock := &mockDBTX{err: customErr}
store := NewStore(New(mock))
err := store.DeleteExpiredSessions(context.Background(), 0)
require.Error(t, err)
assert.True(t, errors.Is(err, customErr))
}
@@ -1,9 +1,9 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// sqlc v1.30.0
// source: session_queries.sql
package sqlite
package repository
import (
"context"
-3
View File
@@ -1,3 +0,0 @@
package sqlite
//go:generate go run github.com/tinyauthapp/tinyauth/gen/sqlc-wrapper -pkg github.com/tinyauthapp/tinyauth/internal/repository/sqlite
-64
View File
@@ -1,64 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
package sqlite
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
}
-209
View File
@@ -1,209 +0,0 @@
// Code generated by cmd/gen/sqlc-wrapper. DO NOT EDIT.
package sqlite
import (
"context"
"database/sql"
"errors"
"github.com/tinyauthapp/tinyauth/internal/repository"
)
// 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
}
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
}
-47
View File
@@ -1,47 +0,0 @@
package repository
import (
"context"
"errors"
)
// ErrNotFound is returned by Store methods when the requested record does not exist.
var ErrNotFound = errors.New("not found")
// Store is the interface that all storage drivers must implement.
// The sqlc-generated *Queries struct satisfies this interface for SQLite.
// Future drivers (postgres, etc.) must return the shared types defined in this package.
type Store interface {
// Sessions
CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error)
GetSession(ctx context.Context, uuid string) (Session, error)
UpdateSession(ctx context.Context, arg UpdateSessionParams) (Session, error)
DeleteSession(ctx context.Context, uuid string) error
DeleteExpiredSessions(ctx context.Context, expiry int64) error
// OIDC codes
CreateOidcCode(ctx context.Context, arg CreateOidcCodeParams) (OidcCode, error)
GetOidcCode(ctx context.Context, codeHash string) (OidcCode, error)
GetOidcCodeBySub(ctx context.Context, sub string) (OidcCode, error)
GetOidcCodeUnsafe(ctx context.Context, codeHash string) (OidcCode, error)
GetOidcCodeBySubUnsafe(ctx context.Context, sub string) (OidcCode, error)
DeleteOidcCode(ctx context.Context, codeHash string) error
DeleteOidcCodeBySub(ctx context.Context, sub string) error
DeleteExpiredOidcCodes(ctx context.Context, expiresAt int64) ([]OidcCode, error)
// OIDC tokens
CreateOidcToken(ctx context.Context, arg CreateOidcTokenParams) (OidcToken, error)
GetOidcToken(ctx context.Context, accessTokenHash string) (OidcToken, error)
GetOidcTokenByRefreshToken(ctx context.Context, refreshTokenHash string) (OidcToken, error)
GetOidcTokenBySub(ctx context.Context, sub string) (OidcToken, error)
UpdateOidcTokenByRefreshToken(ctx context.Context, arg UpdateOidcTokenByRefreshTokenParams) (OidcToken, error)
DeleteOidcToken(ctx context.Context, accessTokenHash string) error
DeleteOidcTokenBySub(ctx context.Context, sub string) error
DeleteOidcTokenByCodeHash(ctx context.Context, codeHash string) error
DeleteExpiredOidcTokens(ctx context.Context, arg DeleteExpiredOidcTokensParams) ([]OidcToken, error)
// OIDC userinfo
CreateOidcUserInfo(ctx context.Context, arg CreateOidcUserInfoParams) (OidcUserinfo, error)
GetOidcUserInfo(ctx context.Context, sub string) (OidcUserinfo, error)
DeleteOidcUserInfo(ctx context.Context, sub string) error
}
-253
View File
@@ -1,253 +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 || ctx.ACLs == 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 {
// merge global and per-app block/allow lists
blockedIps := append([]string{}, rule.Config.Auth.IP.Block...)
allowedIPs := append([]string{}, rule.Config.Auth.IP.Allow...)
if ctx.ACLs != nil {
blockedIps = append(blockedIps, ctx.ACLs.IP.Block...)
allowedIPs = append(allowedIPs, ctx.ACLs.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
Config model.Config
}
func (rule *IPBypassedRule) Evaluate(ctx *ACLContext) Effect {
// merge global and per-app bypass lists
bypassList := append([]string{}, rule.Config.Auth.IP.Bypass...)
if ctx.ACLs != nil {
bypassList = append(bypassList, ctx.ACLs.IP.Bypass...)
}
for _, bypassed := range bypassList {
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,783 +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: "allows when ACLs are nil and no global lists configured",
ctx: &ACLContext{
ACLs: nil,
IP: net.ParseIP("10.0.0.1"),
},
expected: EffectAllow,
},
{
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()
defaultIPBR := &IPBypassedRule{Log: log}
globBypassIPBR := &IPBypassedRule{
Log: log,
Config: model.Config{Auth: model.AuthConfig{IP: model.IPConfig{Bypass: []string{"10.0.0.0/24"}}}},
}
tests := []struct {
name string
rule *IPBypassedRule
ctx *ACLContext
expected Effect
}{
{
name: "deny when ACLs are nil and no global bypass",
rule: defaultIPBR,
ctx: &ACLContext{
ACLs: nil,
IP: net.ParseIP("10.0.0.1"),
},
expected: EffectDeny,
},
{
name: "allows when ACLs are nil but IP matches global bypass",
rule: globBypassIPBR,
ctx: &ACLContext{
ACLs: nil,
IP: net.ParseIP("10.0.0.5"),
},
expected: EffectAllow,
},
{
name: "denies when ACLs are nil and IP does not match global bypass",
rule: globBypassIPBR,
ctx: &ACLContext{
ACLs: nil,
IP: net.ParseIP("192.168.1.1"),
},
expected: EffectDeny,
},
{
name: "allows when IP matches per-app bypass but not global bypass",
rule: defaultIPBR,
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: "allows when IP matches global bypass but not per-app bypass",
rule: globBypassIPBR,
ctx: &ACLContext{
ACLs: &model.App{
IP: model.AppIP{Bypass: []string{"172.16.0.0/24"}},
},
IP: net.ParseIP("10.0.0.5"),
},
expected: EffectAllow,
},
{
name: "allows when IP matches bypass list",
rule: defaultIPBR,
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",
rule: defaultIPBR,
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",
rule: defaultIPBR,
ctx: &ACLContext{
ACLs: &model.App{},
IP: net.ParseIP("10.0.0.1"),
},
expected: EffectDeny,
},
{
name: "skips invalid bypass entries and allows on later match",
rule: defaultIPBR,
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, tt.rule.Evaluate(tt.ctx))
})
}
}
+20 -21
View File
@@ -13,52 +13,51 @@ type LabelProvider interface {
type AccessControlsService struct {
log *logger.Logger
config model.Config
labelProvider *LabelProvider
static map[string]model.App
}
func NewAccessControlsService(
log *logger.Logger,
config model.Config,
labelProvider *LabelProvider) *AccessControlsService {
labelProvider *LabelProvider,
static map[string]model.App) *AccessControlsService {
return &AccessControlsService{
log: log,
config: config,
labelProvider: labelProvider,
static: static,
}
}
func (service *AccessControlsService) lookupStaticACLs(domain string) *model.App {
var nameMatch *model.App
// First try to find a matching app by domain, then fallback to matching by app name (subdomain)
for app, config := range service.config.Apps {
func (acls *AccessControlsService) lookupStaticACLs(domain string) *model.App {
var appAcls *model.App
for app, config := range acls.static {
if config.Config.Domain == domain {
service.log.App.Debug().Str("name", app).Msg("Found matching container by domain")
return &config
acls.log.App.Debug().Str("name", app).Msg("Found matching container by domain")
appAcls = &config
break // If we find a match by domain, we can stop searching
}
if strings.SplitN(domain, ".", 2)[0] == app {
service.log.App.Debug().Str("name", app).Msg("Found matching container by app name")
nameMatch = &config
acls.log.App.Debug().Str("name", app).Msg("Found matching container by app name")
appAcls = &config
break // If we find a match by app name, we can stop searching
}
}
return nameMatch
return appAcls
}
func (service *AccessControlsService) GetAccessControls(domain string) (*model.App, error) {
func (acls *AccessControlsService) GetAccessControls(domain string) (*model.App, error) {
// First check in the static config
app := service.lookupStaticACLs(domain)
app := acls.lookupStaticACLs(domain)
if app != nil {
service.log.App.Debug().Msg("Using static ACLs for app")
acls.log.App.Debug().Msg("Using static ACLs for app")
return app, nil
}
// If we have a label provider configured, try to get ACLs from it
if service.labelProvider != nil && *service.labelProvider != nil {
return (*service.labelProvider).GetLabels(domain)
if acls.labelProvider != nil {
return (*acls.labelProvider).GetLabels(domain)
}
// no labels
@@ -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)
})
}
+206 -54
View File
@@ -2,9 +2,11 @@ package service
import (
"context"
"database/sql"
"errors"
"fmt"
"net/http"
"regexp"
"strings"
"sync"
"time"
@@ -16,6 +18,7 @@ import (
"slices"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
"golang.org/x/oauth2"
@@ -76,7 +79,7 @@ type AuthService struct {
context context.Context
ldap *LdapService
queries repository.Store
queries *repository.Queries
oauthBroker *OAuthBrokerService
tailscale *TailscaleService
@@ -98,7 +101,7 @@ func NewAuthService(
ctx context.Context,
wg *sync.WaitGroup,
ldap *LdapService,
queries repository.Store,
queries *repository.Queries,
oauthBroker *OAuthBrokerService,
tailscale *TailscaleService,
) *AuthService {
@@ -130,7 +133,7 @@ func (auth *AuthService) SearchUser(username string) (*model.UserSearch, error)
}
if auth.ldap != nil {
userDN, email, err := auth.ldap.GetUserInfo(username)
userDN, err := auth.ldap.GetUserDN(username)
if err != nil {
return nil, fmt.Errorf("failed to get ldap user: %w", err)
@@ -138,7 +141,6 @@ func (auth *AuthService) SearchUser(username string) (*model.UserSearch, error)
return &model.UserSearch{
Username: userDN,
Email: email,
Type: model.UserLDAP,
}, nil
}
@@ -285,25 +287,11 @@ func (auth *AuthService) RecordLoginAttempt(identifier string, success bool) {
}
}
func (auth *AuthService) IsEmailWhitelisted(provider string, email string) bool {
whitelist := auth.runtime.OAuthWhitelist
if providerConfig, ok := auth.runtime.OAuthProviders[provider]; ok && len(providerConfig.Whitelist) > 0 {
whitelist = providerConfig.Whitelist
}
match, err := utils.CheckFilter(strings.Join(whitelist, ","), email)
if err != nil {
auth.log.App.Warn().Err(err).Str("provider", provider).Str("email", email).Msg("Invalid email filter pattern")
return false
}
return match
func (auth *AuthService) IsEmailWhitelisted(email string) bool {
return utils.CheckFilter(strings.Join(auth.runtime.OAuthWhitelist, ","), email)
}
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()
if err != nil {
@@ -341,6 +329,10 @@ func (auth *AuthService) CreateSession(ctx context.Context, data repository.Sess
}
if data.Provider == "tailscale" {
if auth.tailscale == nil {
return nil, fmt.Errorf("tailscale service not configured, cannot create session for tailscale user")
}
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()))
@@ -453,7 +445,7 @@ func (auth *AuthService) GetSession(ctx context.Context, uuid string) (*reposito
session, err := auth.queries.GetSession(ctx, uuid)
if err != nil {
if errors.Is(err, repository.ErrNotFound) {
if errors.Is(err, sql.ErrNoRows) {
return nil, errors.New("session not found")
}
return nil, err
@@ -490,6 +482,171 @@ func (auth *AuthService) LDAPAuthConfigured() bool {
return auth.ldap != nil
}
func (auth *AuthService) IsUserAllowed(c *gin.Context, context model.UserContext, acls *model.App) bool {
if acls == nil {
return true
}
if context.Provider == model.ProviderOAuth {
auth.log.App.Debug().Msg("User is an OAuth user, checking OAuth whitelist")
return utils.CheckFilter(acls.OAuth.Whitelist, context.OAuth.Email)
}
if acls.Users.Block != "" {
auth.log.App.Debug().Msg("Checking users block list")
if utils.CheckFilter(acls.Users.Block, context.GetUsername()) {
return false
}
}
auth.log.App.Debug().Msg("Checking users allow list")
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() {
auth.log.App.Debug().Msg("User is not an OAuth user, skipping OAuth group check")
return false
}
if _, ok := model.OverrideProviders[context.OAuth.ID]; ok {
auth.log.App.Debug().Str("provider", context.OAuth.ID).Msg("Provider override detected, skipping group check")
return true
}
for _, userGroup := range context.OAuth.Groups {
if utils.CheckFilter(acls.OAuth.Groups, strings.TrimSpace(userGroup)) {
auth.log.App.Trace().Str("group", userGroup).Str("required", acls.OAuth.Groups).Msg("User group matched")
return true
}
}
auth.log.App.Debug().Msg("No groups matched")
return false
}
func (auth *AuthService) IsInLDAPGroup(c *gin.Context, context model.UserContext, acls *model.App) bool {
if acls == nil {
return true
}
if !context.IsLDAP() {
auth.log.App.Debug().Msg("User is not an LDAP user, skipping LDAP group check")
return false
}
for _, userGroup := range context.LDAP.Groups {
if utils.CheckFilter(acls.LDAP.Groups, strings.TrimSpace(userGroup)) {
auth.log.App.Trace().Str("group", userGroup).Str("required", acls.LDAP.Groups).Msg("User group matched")
return true
}
}
auth.log.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.Auth.IP.Block, acls.IP.Block...)
allowedIPs := append(auth.config.Auth.IP.Allow, acls.IP.Allow...)
for _, blocked := range blockedIps {
res, err := utils.FilterIP(blocked, ip)
if err != nil {
auth.log.App.Warn().Err(err).Str("item", blocked).Msg("Invalid IP/CIDR in block list")
continue
}
if res {
auth.log.App.Debug().Str("ip", ip).Str("item", blocked).Msg("IP is in block list, denying access")
return false
}
}
for _, allowed := range allowedIPs {
res, err := utils.FilterIP(allowed, ip)
if err != nil {
auth.log.App.Warn().Err(err).Str("item", allowed).Msg("Invalid IP/CIDR in allow list")
continue
}
if res {
auth.log.App.Debug().Str("ip", ip).Str("item", allowed).Msg("IP is in allow list, allowing access")
return true
}
}
if len(allowedIPs) > 0 {
auth.log.App.Debug().Str("ip", ip).Msg("IP not in allow list, denying access")
return false
}
auth.log.App.Debug().Str("ip", ip).Msg("IP not in any block or allow list, allowing access by default")
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 {
auth.log.App.Warn().Err(err).Str("item", bypassed).Msg("Invalid IP/CIDR in bypass list")
continue
}
if res {
auth.log.App.Debug().Str("ip", ip).Str("item", bypassed).Msg("IP is in bypass list, skipping authentication")
return true
}
}
auth.log.App.Debug().Str("ip", ip).Msg("IP not in bypass list, proceeding with authentication")
return false
}
func (auth *AuthService) NewOAuthSession(serviceName string, params OAuthURLParams) (string, OAuthPendingSession, error) {
auth.ensureOAuthSessionLimit()
@@ -644,49 +801,46 @@ func (auth *AuthService) ensureOAuthSessionLimit() {
auth.oauthMutex.Lock()
defer auth.oauthMutex.Unlock()
if len(auth.oauthPendingSessions) <= MaxOAuthPendingSessions {
return
}
if len(auth.oauthPendingSessions) >= MaxOAuthPendingSessions {
type entry struct {
id string
expiresAt int64
}
cleanupIds := make([]string, 0, OAuthCleanupCount)
entries := make([]entry, 0, len(auth.oauthPendingSessions))
for id, session := range auth.oauthPendingSessions {
entries = append(entries, entry{id, session.ExpiresAt.Unix()})
}
for range OAuthCleanupCount {
oldestId := ""
oldestTime := int64(0)
slices.SortFunc(entries, func(a, b entry) int {
if a.expiresAt < b.expiresAt {
return -1
for id, session := range auth.oauthPendingSessions {
if oldestTime == 0 {
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] {
delete(auth.oauthPendingSessions, e.id)
for _, id := range cleanupIds {
delete(auth.oauthPendingSessions, id)
}
}
}
func (auth *AuthService) lockdownMode() {
ctx, cancel := context.WithCancel(context.Background())
auth.loginMutex.Lock()
if auth.lockdown != nil && auth.lockdown.Active {
auth.loginMutex.Unlock()
cancel()
return
}
defer cancel()
auth.lockdownCtx = ctx
auth.lockdownCancelFunc = cancel
auth.loginMutex.Lock()
auth.log.App.Warn().Msg("Too many failed login attempts, entering lockdown mode")
auth.lockdown = &Lockdown{
@@ -699,12 +853,10 @@ func (auth *AuthService) lockdownMode() {
auth.loginAttempts = make(map[string]*LoginAttempt)
timer := time.NewTimer(time.Until(auth.lockdown.ActiveUntil))
defer timer.Stop()
auth.loginMutex.Unlock()
defer cancel()
defer timer.Stop()
select {
case <-timer.C:
// Timer expired, end lockdown
-131
View File
@@ -1,131 +0,0 @@
package service
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
)
func newTestAuthService(whitelist []string) *AuthService {
log := logger.NewLogger().WithTestConfig()
log.Init()
return &AuthService{
log: log,
runtime: model.RuntimeConfig{
OAuthWhitelist: whitelist,
},
}
}
func TestIsEmailWhitelisted(t *testing.T) {
tests := []struct {
name string
whitelist []string
email string
expected bool
}{
{
name: "empty whitelist denies all",
whitelist: []string{},
email: "user@example.com",
expected: false,
},
{
name: "nil whitelist denies all",
whitelist: nil,
email: "user@example.com",
expected: false,
},
{
name: "matching email is allowed",
whitelist: []string{"user@example.com"},
email: "user@example.com",
expected: true,
},
{
name: "non-matching email is denied",
whitelist: []string{"user@example.com"},
email: "other@example.com",
expected: false,
},
{
name: "multiple entries, matching email is allowed",
whitelist: []string{"alice@example.com", "bob@example.com"},
email: "bob@example.com",
expected: true,
},
{
name: "multiple entries, non-matching email is denied",
whitelist: []string{"alice@example.com", "bob@example.com"},
email: "charlie@example.com",
expected: false,
},
{
name: "regex pattern matches email",
whitelist: []string{"/@example\\.com$/"},
email: "anyone@example.com",
expected: true,
},
{
name: "regex pattern does not match different domain",
whitelist: []string{"/@example\\.com$/"},
email: "anyone@other.com",
expected: false,
},
{
name: "wildcard domain pattern with regex",
whitelist: []string{"/^.+@mycompany\\.org$/"},
email: "employee@mycompany.org",
expected: true,
},
{
name: "only global whitelist is used, not any per-provider list",
whitelist: []string{"global@example.com"},
email: "global@example.com",
expected: true,
},
{
name: "whitespace-only entries are handled gracefully",
whitelist: []string{" "},
email: "user@example.com",
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
auth := newTestAuthService(tt.whitelist)
result := auth.IsEmailWhitelisted(tt.email)
assert.Equal(t, tt.expected, result)
})
}
}
// TestIsEmailWhitelistedNoPerProviderList verifies the new behaviour where
// per-provider whitelist overrides are no longer applied; only the global
// OAuthWhitelist is consulted regardless of which OAuth provider was used.
func TestIsEmailWhitelistedNoPerProviderList(t *testing.T) {
log := logger.NewLogger().WithTestConfig()
log.Init()
auth := &AuthService{
log: log,
runtime: model.RuntimeConfig{
OAuthWhitelist: []string{"global@example.com"},
// OAuthProviders still present but their Whitelist field has been removed
OAuthProviders: map[string]model.OAuthServiceConfig{
"github": {
ClientID: "github-client-id",
},
},
},
}
// Global whitelist allows this email regardless of provider
assert.True(t, auth.IsEmailWhitelisted("global@example.com"))
// Global whitelist denies this email even though it was previously
// allowed by a provider-specific list in the old implementation
assert.False(t, auth.IsEmailWhitelisted("provider-only@example.com"))
}
+2 -8
View File
@@ -85,23 +85,17 @@ func (docker *DockerService) GetLabels(appDomain string) (*model.App, error) {
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 {
if appLabels.Config.Domain == appDomain {
docker.log.App.Debug().Str("id", inspect.ID).Str("name", inspect.Name).Msg("Found matching container by domain")
return &appLabels, nil
}
if strings.SplitN(appDomain, ".", 2)[0] == appName {
docker.log.App.Debug().Str("id", inspect.ID).Str("name", inspect.Name).Msg("Found matching container by app name")
nameMatch = &appLabels
return &appLabels, nil
}
}
if nameMatch != nil {
return nameMatch, nil
}
}
docker.log.App.Debug().Str("domain", appDomain).Msg("No matching container found for domain")
-72
View File
@@ -3,7 +3,6 @@ package service
import (
"context"
"fmt"
"slices"
"strings"
"sync"
"time"
@@ -168,68 +167,6 @@ func (k *KubernetesService) getByAppName(appName string) *model.App {
return nil
}
func (k *KubernetesService) extractPaths(rule map[string]any) ([]string, error) {
http, found, err := unstructured.NestedMap(rule, "http")
if err != nil {
return nil, fmt.Errorf("reading http from rule: %w", err)
}
if !found {
return nil, nil
}
paths, found, err := unstructured.NestedSlice(http, "paths")
if err != nil {
return nil, fmt.Errorf("reading http.paths: %w", err)
}
if !found {
return nil, nil
}
var result []string
for _, p := range paths {
path, ok := p.(map[string]any)
if !ok {
continue
}
if p, ok := path["path"].(string); ok && p != "" {
result = append(result, p)
}
}
return result, nil
}
func (k *KubernetesService) extractHosts(item *unstructured.Unstructured) ([]string, error) {
rules, found, err := unstructured.NestedSlice(item.Object, "spec", "rules")
if err != nil {
return nil, fmt.Errorf("reading spec.rules: %w", err)
}
if !found {
return nil, nil
}
var hosts []string
for _, r := range rules {
rule, ok := r.(map[string]any)
if !ok {
continue
}
if host, ok := rule["host"].(string); ok && host != "" {
hosts = append(hosts, host)
}
paths, err := k.extractPaths(rule)
if err != nil {
// This is purely to warn users, it doesn't affect our ability to extract hosts so we won't fail the whole operation
k.log.App.Warn().Err(err).Str("namespace", item.GetNamespace()).Str("name", item.GetName()).Msg("Failed to extract paths from ingress rule")
continue
}
if len(paths) == 0 {
continue
}
if !slices.Contains(paths, "/") {
k.log.App.Warn().Str("namespace", item.GetNamespace()).Str("name", item.GetName()).Strs("paths", paths).Msg("Ingress rule does not contain a catch-all path, another ingress may be able to bypass auth checks if it routes the same host with a different path. Consider adding a catch-all path to this rule to ensure auth checks are applied to all paths for this host.")
}
}
k.log.App.Trace().Strs("hosts", hosts).Msg("Extracted hosts from ingress rules")
return hosts, nil
}
func (k *KubernetesService) updateFromItem(item *unstructured.Unstructured) {
namespace := item.GetNamespace()
name := item.GetName()
@@ -238,11 +175,6 @@ func (k *KubernetesService) updateFromItem(item *unstructured.Unstructured) {
k.removeIngress(namespace, name)
return
}
hosts, err := k.extractHosts(item)
if err != nil {
k.removeIngress(namespace, name)
return
}
labels, err := decoders.DecodeLabels[model.Apps](annotations, "apps")
if err != nil {
k.log.App.Warn().Err(err).Str("namespace", namespace).Str("name", name).Msg("Failed to decode ingress labels, skipping")
@@ -254,10 +186,6 @@ func (k *KubernetesService) updateFromItem(item *unstructured.Unstructured) {
if appLabels.Config.Domain == "" {
continue
}
if len(hosts) > 0 && !slices.Contains(hosts, appLabels.Config.Domain) {
k.log.App.Warn().Str("namespace", namespace).Str("name", name).Str("appName", appName).Str("domain", appLabels.Config.Domain).Msg("App domain does not match any hosts defined in ingress rules, skipping")
continue
}
apps = append(apps, ingressApp{
domain: appLabels.Config.Domain,
appName: appName,
+7 -6
View File
@@ -134,7 +134,8 @@ func (ldap *LdapService) connect() (*ldapgo.Conn, error) {
return ldap.conn, nil
}
func (ldap *LdapService) GetUserInfo(username string) (dn string, email string, err error) {
func (ldap *LdapService) GetUserDN(username string) (string, error) {
// Escape the username to prevent LDAP injection
escapedUsername := ldapgo.EscapeFilter(username)
filter := fmt.Sprintf(ldap.config.LDAP.SearchFilter, escapedUsername)
@@ -142,7 +143,7 @@ func (ldap *LdapService) GetUserInfo(username string) (dn string, email string,
ldap.config.LDAP.BaseDN,
ldapgo.ScopeWholeSubtree, ldapgo.NeverDerefAliases, 0, 0, false,
filter,
[]string{"dn", "mail"},
[]string{"dn"},
nil,
)
@@ -151,15 +152,15 @@ func (ldap *LdapService) GetUserInfo(username string) (dn string, email string,
searchResult, err := ldap.conn.Search(searchRequest)
if err != nil {
return "", "", err
return "", err
}
if len(searchResult.Entries) != 1 {
return "", "", fmt.Errorf("multiple or no entries found for user %s", username)
return "", fmt.Errorf("multiple or no entries found for user %s", username)
}
entry := searchResult.Entries[0]
return entry.DN, entry.GetAttributeValue("mail"), nil
userDN := searchResult.Entries[0].DN
return userDN, nil
}
func (ldap *LdapService) GetUserGroups(userDN string) ([]string, error) {

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