Compare commits

...

46 Commits

Author SHA1 Message Date
Stavros 2454ba58ea refactor: use ticket approach for oidc flow 2026-06-01 17:04:08 +03:00
Stavros 97e0e0dfff wip: backend 2026-06-01 16:26:42 +03:00
Stavros b3c152fa1c chore: rabbit comments 2026-06-01 15:47:19 +03:00
Stavros 5caee887de fix: ensure no oidc code reuse 2026-06-01 12:22:49 +03:00
Stavros b5770ef305 fix: add memory back in the db bootstrap 2026-06-01 12:10:59 +03:00
Stavros 1c4ca8f436 chore: differentiate oauth userinfo from oidc userinfo 2026-06-01 12:02:11 +03:00
Stavros a72300484b tests: fix oidc service tests 2026-06-01 12:00:50 +03:00
Stavros 4fe5de241b chore: fix memory store 2026-06-01 11:55:47 +03:00
Stavros 83ed9ece57 feat: add db cleanup routine back 2026-06-01 11:47:17 +03:00
Stavros faa3156672 Merge branch 'main' into refactor/oidc-store 2026-05-31 20:11:37 +03:00
Stavros 695feca71c refactor: rework oidc session storage 2026-05-31 20:10:53 +03:00
Stavros dac844595d refactor: use new cache store in services (#912) 2026-05-31 18:55:06 +03:00
Stavros 82d21c3b28 Merge branch 'refactor/service-cache' into refactor/oidc-codes 2026-05-31 18:34:52 +03:00
Stavros fe8463890a fix: fix bugs in cache order 2026-05-31 18:29:14 +03:00
Stavros 940ba6dff7 fix: don't allow tagged devices in tailscale integration 2026-05-31 12:42:00 +03:00
Stavros ac9689dc9b tests: add cache store tests 2026-05-30 15:18:23 +03:00
Stavros 3e5757cfc9 fix: fix race conditions 2026-05-30 15:04:53 +03:00
Stavros ed94490efd refactor: use new cache store in auth service 2026-05-29 23:33:35 +03:00
Stavros faee58ca8e feat: use ding for ordered go routine shutdown order (#896) 2026-05-27 12:46:28 +03:00
Stavros e9b8ca3cf8 fix: cleanup acl logic to match stable one 2026-05-27 12:11:17 +03:00
Stavros f2c4e7932d chore: include debug symbols in nightly images (#908) 2026-05-27 11:43:43 +03:00
Stavros 4538922caf refactor: simplify error handling in oidc authorize handler (#907) 2026-05-27 11:27:10 +03:00
Stavros 672db84200 feat: make config file a stable feature (#897) 2026-05-27 11:26:09 +03:00
Scott McKendry 359000f731 feat(db): add postgresql support (#892) 2026-05-26 00:08:59 +03:00
Stavros 0a3e7bf265 fix: use policy engine in oauth whitelist check (#904) 2026-05-26 00:07:46 +03:00
Puneet Dixit c3461131f5 feat: support provider-specific OAuth whitelists (#882)
Co-authored-by: Puneet Dixit <236133619+puneetdixit200@users.noreply.github.com>
2026-05-24 20:18:33 +03:00
Stavros 3f584ca741 New Crowdin updates (#798) 2026-05-24 19:16:29 +03:00
dependabot[bot] 36d0ffc2b5 chore(deps): bump sqlc-dev/setup-sqlc from 4 to 5 (#880)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-24 19:14:15 +03:00
dependabot[bot] 37b79735f0 chore(deps): bump the minor-patch group across 1 directory with 6 updates (#881)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-24 19:13:53 +03:00
dependabot[bot] 09540fbe6e chore(deps): bump docker/build-push-action from 7.1.0 to 7.2.0 (#891)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-24 19:13:15 +03:00
dependabot[bot] 8e60a2e28e chore(deps): bump codecov/codecov-action from 6.0.0 to 6.0.1 (#879)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-24 19:12:51 +03:00
dependabot[bot] 9619024c37 chore(deps): bump github/codeql-action from 4.35.4 to 4.35.5 (#871)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-24 19:12:20 +03:00
dependabot[bot] 1c305bacca chore(deps): bump node from 26.1-alpine3.23 to 26.2-alpine3.23 (#883)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-24 19:11:55 +03:00
Scott McKendry e532cde2b6 fix: potential nil pointer dereferences (#893) 2026-05-24 17:23:48 +03:00
Stavros 2737a25227 fix: don't point to nil local users in bootstrap app 2026-05-23 20:24:54 +03:00
Scott McKendry 7aa25210f5 feat(config): allow global bypass by ip (#889) 2026-05-23 19:58:48 +03:00
Stavros 55bef72639 fix: ensure domain defined in acls is included in host rules (#884) 2026-05-23 17:13:41 +03:00
Stavros ae17bd3b66 fix: do not log user context not found errors in proxy controller 2026-05-23 16:43:03 +03:00
Scott McKendry 8849d7e00f fix(frontend): tailscale prompt on every login (#888) 2026-05-21 21:02:46 +03:00
Stavros c9e90547d4 chore: change project license to AGPL (#876) 2026-05-21 12:16:57 +03:00
Stavros 3194f4b987 chore: remove stale error from tailscale service 2026-05-20 23:04:38 +03:00
Stavros 9b50670925 fix: handle panics in tailscale service 2026-05-20 23:01:14 +03:00
Stavros 1166a15aa7 feat: tailscale integration (#847) 2026-05-20 20:10:38 +03:00
Stavros c855f9b8ac feat: add support for deny by default access controls (#852) 2026-05-19 18:07:55 +03:00
Scott McKendry a56c349525 refactor(db): use new store interface (#831) 2026-05-18 22:33:09 +03:00
Stavros 8b4ba23328 chore: remove stale bot 2026-05-18 14:32:50 +03:00
132 changed files with 7301 additions and 2555 deletions
+70 -1
View File
@@ -7,7 +7,9 @@ TINYAUTH_APPURL=
# database config
# The path to the database, including file name.
# The database driver to use. Valid values: sqlite, postgres, memory.
TINYAUTH_DATABASE_DRIVER="sqlite"
# The path to the SQLite database file, or connection URL when driver is postgres.
TINYAUTH_DATABASE_PATH="./tinyauth.db"
# analytics config
@@ -30,6 +32,8 @@ TINYAUTH_SERVER_PORT=3000
TINYAUTH_SERVER_ADDRESS="0.0.0.0"
# The path to the Unix socket.
TINYAUTH_SERVER_SOCKETPATH=
# Enable listening on both TCP and Unix socket at the same time.
TINYAUTH_SERVER_CONCURRENTLISTENERSENABLED=false
# auth config
@@ -37,8 +41,52 @@ TINYAUTH_SERVER_SOCKETPATH=
TINYAUTH_AUTH_IP_ALLOW=
# List of blocked IPs or CIDR ranges.
TINYAUTH_AUTH_IP_BLOCK=
# List of IPs or CIDR ranges that bypass authentication entirely.
TINYAUTH_AUTH_IP_BYPASS=
# Comma-separated list of users (username:hashed_password).
TINYAUTH_AUTH_USERS=
# Enable subdomains support.
TINYAUTH_AUTH_SUBDOMAINSENABLED=true
# Full name of the user.
TINYAUTH_AUTH_USERATTRIBUTES_name_NAME=
# Given (first) name of the user.
TINYAUTH_AUTH_USERATTRIBUTES_name_GIVENNAME=
# Family (last) name of the user.
TINYAUTH_AUTH_USERATTRIBUTES_name_FAMILYNAME=
# Middle name of the user.
TINYAUTH_AUTH_USERATTRIBUTES_name_MIDDLENAME=
# Nickname of the user.
TINYAUTH_AUTH_USERATTRIBUTES_name_NICKNAME=
# URL of the user's profile page.
TINYAUTH_AUTH_USERATTRIBUTES_name_PROFILE=
# URL of the user's profile picture.
TINYAUTH_AUTH_USERATTRIBUTES_name_PICTURE=
# URL of the user's website.
TINYAUTH_AUTH_USERATTRIBUTES_name_WEBSITE=
# Email address of the user.
TINYAUTH_AUTH_USERATTRIBUTES_name_EMAIL=
# Gender of the user.
TINYAUTH_AUTH_USERATTRIBUTES_name_GENDER=
# Birthdate of the user (YYYY-MM-DD).
TINYAUTH_AUTH_USERATTRIBUTES_name_BIRTHDATE=
# Time zone of the user (e.g. Europe/Athens).
TINYAUTH_AUTH_USERATTRIBUTES_name_ZONEINFO=
# Locale of the user (e.g. en-US).
TINYAUTH_AUTH_USERATTRIBUTES_name_LOCALE=
# Phone number of the user.
TINYAUTH_AUTH_USERATTRIBUTES_name_PHONENUMBER=
# Full mailing address, formatted for display.
TINYAUTH_AUTH_USERATTRIBUTES_name_ADDRESS_FORMATTED=
# Street address.
TINYAUTH_AUTH_USERATTRIBUTES_name_ADDRESS_STREETADDRESS=
# City or locality.
TINYAUTH_AUTH_USERATTRIBUTES_name_ADDRESS_LOCALITY=
# State, province, or region.
TINYAUTH_AUTH_USERATTRIBUTES_name_ADDRESS_REGION=
# Zip or postal code.
TINYAUTH_AUTH_USERATTRIBUTES_name_ADDRESS_POSTALCODE=
# Country.
TINYAUTH_AUTH_USERATTRIBUTES_name_ADDRESS_COUNTRY=
# Path to the users file.
TINYAUTH_AUTH_USERSFILE=
# Enable secure cookies.
@@ -53,6 +101,8 @@ TINYAUTH_AUTH_LOGINTIMEOUT=300
TINYAUTH_AUTH_LOGINMAXRETRIES=3
# Comma-separated list of trusted proxy addresses.
TINYAUTH_AUTH_TRUSTEDPROXIES=
# ACL policy for allow-by-default or deny-by-default, available options are allow and deny, default is allow.
TINYAUTH_AUTH_ACLS_POLICY="allow"
# apps config
@@ -101,6 +151,10 @@ 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.
@@ -164,6 +218,8 @@ TINYAUTH_LDAP_AUTHCERT=
TINYAUTH_LDAP_AUTHKEY=
# Cache duration for LDAP group membership in seconds.
TINYAUTH_LDAP_GROUPCACHETTL=900
# Label provider to use for ACLs (auto, docker, kubernetes or none to disable). auto detects the environment.
TINYAUTH_LABELPROVIDER="auto"
# log config
@@ -183,3 +239,16 @@ TINYAUTH_LOG_STREAMS_APP_LEVEL=
TINYAUTH_LOG_STREAMS_AUDIT_ENABLED=false
# Log level for this stream. Use global if empty.
TINYAUTH_LOG_STREAMS_AUDIT_LEVEL=
# tailscale config
# Enable Tailscale integration.
TINYAUTH_TAILSCALE_ENABLED=false
# Tailscale state directory.
TINYAUTH_TAILSCALE_DIR="./tailscale_state"
# Tailscale hostname.
TINYAUTH_TAILSCALE_HOSTNAME=
# Tailscale auth key.
TINYAUTH_TAILSCALE_AUTHKEY=
# Use ephemeral Tailscale node.
TINYAUTH_TAILSCALE_EPHEMERAL=false
+13 -1
View File
@@ -28,6 +28,18 @@ 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
@@ -50,6 +62,6 @@ jobs:
run: go test -coverprofile=coverage.txt -v ./...
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6
with:
token: ${{ secrets.CODECOV_TOKEN }}
+6 -6
View File
@@ -83,7 +83,7 @@ jobs:
- name: Build
run: |
cp -r frontend/dist internal/assets/dist
go build -ldflags "-s -w -X github.com/tinyauthapp/tinyauth/internal/model.Version=${{ needs.generate-metadata.outputs.VERSION }} -X github.com/tinyauthapp/tinyauth/internal/model.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} -X github.com/tinyauthapp/tinyauth/internal/model.BuildTimestamp=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}" -o tinyauth-amd64 ./cmd/tinyauth
go build -ldflags "-X github.com/tinyauthapp/tinyauth/internal/model.Version=${{ needs.generate-metadata.outputs.VERSION }} -X github.com/tinyauthapp/tinyauth/internal/model.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} -X github.com/tinyauthapp/tinyauth/internal/model.BuildTimestamp=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}" -o tinyauth-amd64 ./cmd/tinyauth
env:
CGO_ENABLED: 0
@@ -128,7 +128,7 @@ jobs:
- name: Build
run: |
cp -r frontend/dist internal/assets/dist
go build -ldflags "-s -w -X github.com/tinyauthapp/tinyauth/internal/model.Version=${{ needs.generate-metadata.outputs.VERSION }} -X github.com/tinyauthapp/tinyauth/internal/model.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} -X github.com/tinyauthapp/tinyauth/internal/model.BuildTimestamp=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}" -o tinyauth-arm64 ./cmd/tinyauth
go build -ldflags "-X github.com/tinyauthapp/tinyauth/internal/model.Version=${{ needs.generate-metadata.outputs.VERSION }} -X github.com/tinyauthapp/tinyauth/internal/model.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} -X github.com/tinyauthapp/tinyauth/internal/model.BuildTimestamp=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}" -o tinyauth-arm64 ./cmd/tinyauth
env:
CGO_ENABLED: 0
@@ -166,7 +166,7 @@ jobs:
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
- name: Build and push
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
id: build
with:
platforms: linux/amd64
@@ -224,7 +224,7 @@ jobs:
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
- name: Build and push
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
id: build
with:
platforms: linux/amd64
@@ -282,7 +282,7 @@ jobs:
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
- name: Build and push
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
id: build
with:
platforms: linux/arm64
@@ -340,7 +340,7 @@ jobs:
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
- name: Build and push
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
id: build
with:
platforms: linux/arm64
+8 -4
View File
@@ -136,7 +136,7 @@ jobs:
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
- name: Build and push
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
id: build
with:
platforms: linux/amd64
@@ -150,6 +150,7 @@ jobs:
VERSION=${{ needs.generate-metadata.outputs.VERSION }}
COMMIT_HASH=${{ needs.generate-metadata.outputs.COMMIT_HASH }}
BUILD_TIMESTAMP=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}
LDFLAGS="-s -w"
- name: Export digest
run: |
@@ -191,7 +192,7 @@ jobs:
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
- name: Build and push
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
id: build
with:
platforms: linux/amd64
@@ -206,6 +207,7 @@ jobs:
VERSION=${{ needs.generate-metadata.outputs.VERSION }}
COMMIT_HASH=${{ needs.generate-metadata.outputs.COMMIT_HASH }}
BUILD_TIMESTAMP=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}
LDFLAGS="-s -w"
- name: Export digest
run: |
@@ -246,7 +248,7 @@ jobs:
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
- name: Build and push
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
id: build
with:
platforms: linux/arm64
@@ -260,6 +262,7 @@ jobs:
VERSION=${{ needs.generate-metadata.outputs.VERSION }}
COMMIT_HASH=${{ needs.generate-metadata.outputs.COMMIT_HASH }}
BUILD_TIMESTAMP=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}
LDFLAGS="-s -w"
- name: Export digest
run: |
@@ -301,7 +304,7 @@ jobs:
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
- name: Build and push
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
id: build
with:
platforms: linux/arm64
@@ -316,6 +319,7 @@ jobs:
VERSION=${{ needs.generate-metadata.outputs.VERSION }}
COMMIT_HASH=${{ needs.generate-metadata.outputs.COMMIT_HASH }}
BUILD_TIMESTAMP=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}
LDFLAGS="-s -w"
- name: Export digest
run: |
+1 -1
View File
@@ -38,6 +38,6 @@ jobs:
retention-days: 5
- name: Upload to code-scanning
uses: github/codeql-action/upload-sarif@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4
uses: github/codeql-action/upload-sarif@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4
with:
sarif_file: results.sarif
-24
View File
@@ -1,24 +0,0 @@
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 -2
View File
@@ -1,5 +1,5 @@
# Site builder
FROM node:26.1-alpine3.23 AS frontend-builder
FROM node:26.2-alpine3.23 AS frontend-builder
WORKDIR /frontend
@@ -27,6 +27,7 @@ FROM golang:1.26-alpine3.23 AS builder
ARG VERSION
ARG COMMIT_HASH
ARG BUILD_TIMESTAMP
ARG LDFLAGS
WORKDIR /tinyauth
@@ -39,7 +40,7 @@ COPY ./cmd ./cmd
COPY ./internal ./internal
COPY --from=frontend-builder /frontend/dist ./internal/assets/dist
RUN CGO_ENABLED=0 go build -ldflags "-s -w \
RUN CGO_ENABLED=0 go build -ldflags "${LDFLAGS} \
-X github.com/tinyauthapp/tinyauth/internal/model.Version=${VERSION} \
-X github.com/tinyauthapp/tinyauth/internal/model.CommitHash=${COMMIT_HASH} \
-X github.com/tinyauthapp/tinyauth/internal/model.BuildTimestamp=${BUILD_TIMESTAMP}" ./cmd/tinyauth
+3 -2
View File
@@ -1,5 +1,5 @@
# Site builder
FROM node:26.1-alpine3.23 AS frontend-builder
FROM node:26.2-alpine3.23 AS frontend-builder
WORKDIR /frontend
@@ -27,6 +27,7 @@ FROM golang:1.26-alpine3.23 AS builder
ARG VERSION
ARG COMMIT_HASH
ARG BUILD_TIMESTAMP
ARG LDFLAGS
WORKDIR /tinyauth
@@ -41,7 +42,7 @@ COPY --from=frontend-builder /frontend/dist ./internal/assets/dist
RUN mkdir -p data
RUN CGO_ENABLED=0 go build -ldflags "-s -w \
RUN CGO_ENABLED=0 go build -ldflags "${LDFLAGS} \
-X github.com/tinyauthapp/tinyauth/internal/model.Version=${VERSION} \
-X github.com/tinyauthapp/tinyauth/internal/model.CommitHash=${COMMIT_HASH} \
-X github.com/tinyauthapp/tinyauth/internal/model.BuildTimestamp=${BUILD_TIMESTAMP}" ./cmd/tinyauth
+64 -77
View File
@@ -1,5 +1,5 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
@@ -7,17 +7,15 @@
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
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 licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
our General Public Licenses are 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. 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.
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
@@ -26,44 +24,34 @@ 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.
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.
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.
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.
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.
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.
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.
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.
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.
The precise terms and conditions for copying, distribution and
modification follow.
@@ -72,7 +60,7 @@ modification follow.
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
@@ -549,35 +537,45 @@ 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. Use with the GNU Affero General Public License.
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.
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 Affero General Public License into a single
under version 3 of the GNU 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 special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
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
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
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 General
Program specifies that a certain numbered version of the GNU Affero 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 General Public License, you may choose any version ever published
GNU Affero 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 General Public License can be used, that proxy's
versions of the GNU Affero 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.
@@ -635,40 +633,29 @@ 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 General Public License as published by
it under the terms of the GNU Affero 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 General Public License for more details.
GNU Affero General Public License for more details.
You should have received a copy of the GNU General Public License
You should have received a copy of the GNU Affero 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 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".
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.
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 GPL, see
For more information on this, and how to apply and follow the GNU AGPL, 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>.
+12 -1
View File
@@ -8,6 +8,7 @@ TAG_NAME := $(shell git describe --abbrev=0 --exact-match 2> /dev/null || echo "
COMMIT_HASH := $(shell git rev-parse HEAD)
BUILD_TIMESTAMP := $(shell date '+%Y-%m-%dT%H:%M:%S')
BIN_NAME := tinyauth-$(GOARCH)
LDFLAGS := -s -w
# Development vars
DEV_COMPOSE := $(shell test -f "docker-compose.test.yml" && echo "docker-compose.test.yml" || echo "docker-compose.dev.yml" )
@@ -36,7 +37,7 @@ webui: clean-webui
# Build the binary
binary: webui
CGO_ENABLED=$(CGO_ENABLED) go build -ldflags "-s -w \
CGO_ENABLED=$(CGO_ENABLED) go build -ldflags "${LDFLAGS} \
-X github.com/tinyauthapp/tinyauth/internal/model.Version=${TAG_NAME} \
-X github.com/tinyauthapp/tinyauth/internal/model.CommitHash=${COMMIT_HASH} \
-X github.com/tinyauthapp/tinyauth/internal/model.BuildTimestamp=${BUILD_TIMESTAMP}" \
@@ -61,6 +62,15 @@ binary-linux-arm64:
test:
go test -v ./...
# Go vet
.PHONY: vet
vet:
go vet ./...
# Go race
test-race:
go test -race ./...
# Development
dev:
docker compose -f $(DEV_COMPOSE) up --force-recreate --pull=always --remove-orphans --build
@@ -85,3 +95,4 @@ sql:
# Go gen
generate:
go run ./gen
go generate ./internal/repository/...
+1 -4
View File
@@ -28,9 +28,6 @@ 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).
@@ -59,7 +56,7 @@ If you like, you can help translate Tinyauth into more languages by visiting the
## License
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.
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.
## Sponsors
+4 -1
View File
@@ -1,9 +1,10 @@
services:
traefik:
image: traefik:v3.6
command: --api.insecure=true --providers.docker
command: --api.insecure=true --providers.docker --entrypoints.web.address=:80 --entrypoints.websecure.address=:443
ports:
- 80:80
- 443:443
volumes:
- /var/run/docker.sock:/var/run/docker.sock
@@ -25,6 +26,8 @@ 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:
+2 -2
View File
@@ -2,9 +2,9 @@ import { Navigate } from "react-router";
import { useUserContext } from "./context/user-context";
export const App = () => {
const { isLoggedIn } = useUserContext();
const { auth } = useUserContext();
if (isLoggedIn) {
if (auth.authenticated) {
return <Navigate to="/logout" replace />;
}
+11 -7
View File
@@ -6,17 +6,17 @@ import { DomainWarning } from "../domain-warning/domain-warning";
import { ThemeToggle } from "../theme-toggle/theme-toggle";
const BaseLayout = ({ children }: { children: React.ReactNode }) => {
const { backgroundImage, title } = useAppContext();
const { ui } = useAppContext();
useEffect(() => {
document.title = title;
}, [title]);
document.title = ui.title;
}, [ui.title]);
return (
<div
className="flex flex-col justify-center items-center min-h-svh px-4"
style={{
backgroundImage: `url(${backgroundImage})`,
backgroundImage: `url(${ui.backgroundImage})`,
backgroundSize: "cover",
backgroundPosition: "center",
}}
@@ -31,7 +31,7 @@ const BaseLayout = ({ children }: { children: React.ReactNode }) => {
};
export const Layout = () => {
const { appUrl, warningsEnabled } = useAppContext();
const { app, ui } = useAppContext();
const [ignoreDomainWarning, setIgnoreDomainWarning] = useState(() => {
return window.sessionStorage.getItem("ignoreDomainWarning") === "true";
});
@@ -42,11 +42,15 @@ export const Layout = () => {
setIgnoreDomainWarning(true);
}, [setIgnoreDomainWarning]);
if (!ignoreDomainWarning && warningsEnabled && appUrl !== currentUrl) {
if (
!ignoreDomainWarning &&
ui.warningsEnabled &&
!app.trustedDomains.includes(currentUrl)
) {
return (
<BaseLayout>
<DomainWarning
appUrl={appUrl}
appUrl={app.appUrl}
currentUrl={currentUrl}
onClick={() => handleIgnore()}
/>
-76
View File
@@ -1,76 +0,0 @@
import { z } from "zod";
export const oidcParamsSchema = z.object({
scope: z.string().min(1),
response_type: z.string().min(1),
client_id: z.string().min(1),
redirect_uri: z.string().min(1),
state: z.string().optional(),
nonce: z.string().optional(),
code_challenge: z.string().optional(),
code_challenge_method: z.string().optional(),
});
function b64urlDecode(s: string): string {
const base64 = s.replace(/-/g, "+").replace(/_/g, "/");
return atob(base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), "="));
}
function decodeRequestObject(jwt: string): Record<string, string> {
try {
// Must have exactly 3 parts: header, payload, signature
const parts = jwt.split(".");
if (parts.length !== 3) return {};
// Header must specify "alg": "none" and signature must be empty string
const header = JSON.parse(b64urlDecode(parts[0]));
if (!header || typeof header !== "object" || header.alg !== "none" || parts[2] !== "") return {};
const payload = JSON.parse(b64urlDecode(parts[1]));
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return {};
const result: Record<string, string> = {};
for (const [k, v] of Object.entries(payload)) {
if (typeof v === "string") result[k] = v;
}
return result;
} catch {
return {};
}
}
export const useOIDCParams = (
params: URLSearchParams,
): {
values: z.infer<typeof oidcParamsSchema>;
issues: string[];
isOidc: boolean;
compiled: string;
} => {
const obj = Object.fromEntries(params.entries());
// RFC 9101 / OIDC Core 6.1: if `request` param present, decode JWT payload
// and merge claims over top-level params (JWT claims take precedence)
const requestJwt = params.get("request");
if (requestJwt) {
const claims = decodeRequestObject(requestJwt);
Object.assign(obj, claims);
}
const parsed = oidcParamsSchema.safeParse(obj);
if (parsed.success) {
return {
values: parsed.data,
issues: [],
isOidc: true,
compiled: new URLSearchParams(parsed.data).toString(),
};
}
return {
issues: parsed.error.issues.map((issue) => issue.path.toString()),
values: {} as z.infer<typeof oidcParamsSchema>,
isOidc: false,
compiled: "",
};
};
+40
View File
@@ -0,0 +1,40 @@
import { z } from "zod";
type ScreenParams = {
login_for?: "oidc" | "app";
redirect_url?: string;
oidc_ticket?: string;
oidc_scope?: string;
oidc_name?: string;
};
const zodScreenParams = z.object({
login_for: z.enum(["oidc", "app"]).optional(),
redirect_url: z.string().optional(),
oidc_ticket: z.string().optional(),
oidc_scope: z.string().optional(),
oidc_name: z.string().optional(),
});
export function useScreenParams(params: URLSearchParams): ScreenParams {
const paramsObj = Object.fromEntries(params.entries());
const parsed = zodScreenParams.safeParse(paramsObj);
if (!parsed.success) {
return {};
}
return parsed.data;
}
export function recompileScreenParams(params: ScreenParams): string {
const p = new URLSearchParams(
Object.fromEntries(
Object.entries(params).filter(([, v]) => v !== null),
) as Record<string, string>,
).toString();
if (p.length > 0) {
return "?" + p;
}
return "";
}
+13 -1
View File
@@ -80,5 +80,17 @@
"profileScopeDescription": "Allows the app to access your profile information.",
"groupsScopeName": "Groups",
"groupsScopeDescription": "Allows the app to access your group information.",
"backToLoginButton": "Back to login"
"backToLoginButton": "Back to login",
"phoneScopeName": "Phone",
"phoneScopeDescription": "Allows the app to access your phone number.",
"addressScopeName": "Address",
"addressScopeDescription": "Allows the app to access your address.",
"loginTailscaleTitle": "Continue with Tailscale",
"loginTailscaleDescription": "You appear to be accessing Tinyauth from an authorized Tailscale device. Would you like to continue with your Tailscale connection?",
"loginTailscaleDeviceName": "Device name:",
"loginTailscaleSubmit": "Continue with Tailscale",
"loginTailscaleOtherMethod": "Login with another method",
"loginTailscaleSuccess": "Successfully authenticated with Tailscale.",
"loginTailscaleFail": "Failed to authenticate with Tailscale. Please try again or use another login method.",
"logoutTailscaleSubtitle": "You are currently logged in with Tailscale on your device <code>{{deviceName}}</code>. Click the button below to logout."
}
+9 -1
View File
@@ -84,5 +84,13 @@
"phoneScopeName": "Phone",
"phoneScopeDescription": "Allows the app to access your phone number.",
"addressScopeName": "Address",
"addressScopeDescription": "Allows the app to access your address."
"addressScopeDescription": "Allows the app to access your address.",
"loginTailscaleTitle": "Continue with Tailscale",
"loginTailscaleDescription": "You appear to be accessing Tinyauth from an authorized Tailscale device. Would you like to continue with your Tailscale connection?",
"loginTailscaleDeviceName": "Device name:",
"loginTailscaleSubmit": "Continue with Tailscale",
"loginTailscaleOtherMethod": "Login with another method",
"loginTailscaleSuccess": "Successfully authenticated with Tailscale.",
"loginTailscaleFail": "Failed to authenticate with Tailscale. Please try again or use another login method.",
"logoutTailscaleSubtitle": "You are currently logged in with Tailscale on your device <code>{{deviceName}}</code>. Click the button below to logout."
}
+2 -2
View File
@@ -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": "Current:",
"domainWarningExpected": "Expected:",
"domainWarningCurrent": "Attuale:",
"domainWarningExpected": "Previsto:",
"ignoreTitle": "Ignora",
"goToCorrectDomainTitle": "Vai al dominio corretto",
"authorizeTitle": "Autorizza",
+1 -1
View File
@@ -57,7 +57,7 @@
"fieldRequired": "Ово поље је неопходно",
"invalidInput": "Неисправан унос",
"domainWarningTitle": "Неисправан домен",
"domainWarningSubtitle": "You are accessing this instance from an incorrect domain. If you proceed, you may encounter issues with authentication.",
"domainWarningSubtitle": "Приступате овој инстанци са неисправног домена. Ако наставите, можете наићи на проблеме са аутентификацијом.",
"domainWarningCurrent": "Тренутни:",
"domainWarningExpected": "Очекивани:",
"ignoreTitle": "Игнориши",
+4 -1
View File
@@ -35,7 +35,10 @@ createRoot(document.getElementById("root")!).render(
<Route element={<Layout />} errorElement={<ErrorPage />}>
<Route path="/" element={<App />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/authorize" element={<AuthorizePage />} />
<Route
path="/oidc/authorize"
element={<AuthorizePage />}
/>
<Route path="/logout" element={<LogoutPage />} />
<Route path="/continue" element={<ContinuePage />} />
<Route path="/totp" element={<TotpPage />} />
+25 -51
View File
@@ -1,5 +1,5 @@
import { useUserContext } from "@/context/user-context";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useMutation } from "@tanstack/react-query";
import { Navigate, useNavigate } from "react-router";
import { useLocation } from "react-router";
import {
@@ -10,11 +10,9 @@ import {
CardFooter,
CardContent,
} from "@/components/ui/card";
import { getOidcClientInfoSchema } from "@/schemas/oidc-schemas";
import { Button } from "@/components/ui/button";
import axios from "axios";
import { toast } from "sonner";
import { useOIDCParams } from "@/lib/hooks/oidc";
import { useTranslation } from "react-i18next";
import { TFunction } from "i18next";
import { Mail, MapPin, Phone, Shield, User, Users } from "lucide-react";
@@ -23,6 +21,10 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
recompileScreenParams,
useScreenParams,
} from "@/lib/hooks/screen-params";
type Scope = {
id: string;
@@ -77,34 +79,24 @@ const createScopeMap = (t: TFunction<"translation", undefined>): Scope[] => {
};
export const AuthorizePage = () => {
const { isLoggedIn } = useUserContext();
const { auth } = useUserContext();
const { search } = useLocation();
const { t } = useTranslation();
const navigate = useNavigate();
const scopeMap = createScopeMap(t);
const searchParams = new URLSearchParams(search);
const oidcParams = useOIDCParams(searchParams);
const getClientInfo = useQuery({
queryKey: ["client", oidcParams.values.client_id],
queryFn: async () => {
const res = await fetch(
`/api/oidc/clients/${encodeURIComponent(oidcParams.values.client_id)}`,
);
const data = await getOidcClientInfoSchema.parseAsync(await res.json());
return data;
},
enabled: oidcParams.isOidc,
});
const screenParams = useScreenParams(searchParams);
const isOidc = screenParams.login_for === "oidc";
const compiledParams = recompileScreenParams(screenParams);
const authorizeMutation = useMutation({
mutationFn: () => {
return axios.post("/api/oidc/authorize", {
...oidcParams.values,
return axios.post("/api/oidc/authorize-complete", {
ticket: screenParams.oidc_ticket,
});
},
mutationKey: ["authorize", oidcParams.values.client_id],
mutationKey: ["authorize", screenParams.oidc_ticket],
onSuccess: (data) => {
toast.info(t("authorizeSuccessTitle"), {
description: t("authorizeSuccessSubtitle"),
@@ -118,56 +110,38 @@ export const AuthorizePage = () => {
},
});
if (oidcParams.issues.length > 0) {
if (
!isOidc ||
screenParams.oidc_ticket === undefined ||
screenParams.oidc_scope === undefined
) {
return (
<Navigate
to={`/error?error=${encodeURIComponent(t("authorizeErrorMissingParams", { missingParams: oidcParams.issues.join(", ") }))}`}
to={`/error?error=${encodeURIComponent(t("authorizeErrorInvalidParams"))}`}
replace
/>
);
}
if (!isLoggedIn) {
return <Navigate to={`/login?${oidcParams.compiled}`} replace />;
}
if (getClientInfo.isLoading) {
return (
<Card className="gap-0">
<CardHeader>
<CardTitle className="text-xl">
{t("authorizeLoadingTitle")}
</CardTitle>
</CardHeader>
<CardContent>
<CardDescription>{t("authorizeLoadingSubtitle")}</CardDescription>
</CardContent>
</Card>
);
}
if (getClientInfo.isError) {
return (
<Navigate
to={`/error?error=${encodeURIComponent(t("authorizeErrorClientInfo"))}`}
replace
/>
);
if (!auth.authenticated) {
return <Navigate to={`/login${compiledParams}`} replace />;
}
const scopes =
oidcParams.values.scope.split(" ").filter((s) => s.trim() !== "") || [];
screenParams.oidc_scope.split(" ").filter((s) => s.trim() !== "") || [];
return (
<Card>
<CardHeader className="mb-2">
<div className="flex flex-col gap-3 items-center justify-center text-center">
<div className="bg-accent-foreground box-content text-muted text-xl font-bold font-sans rounded-lg size-8 p-2 flex items-center justify-center">
{getClientInfo.data?.name.slice(0, 1) || "U"}
{screenParams.oidc_name !== undefined
? screenParams.oidc_name.slice(0, 1)
: "U"}
</div>
<CardTitle className="text-xl">
{t("authorizeCardTitle", {
app: getClientInfo.data?.name || "Unknown",
app: screenParams.oidc_name || "Unknown",
})}
</CardTitle>
<CardDescription className="text-sm max-w-sm">
+9 -8
View File
@@ -14,8 +14,8 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { useRedirectUri } from "@/lib/hooks/redirect-uri";
export const ContinuePage = () => {
const { cookieDomain, warningsEnabled } = useAppContext();
const { isLoggedIn } = useUserContext();
const { app, ui } = useAppContext();
const { auth } = useUserContext();
const { search } = useLocation();
const { t } = useTranslation();
const navigate = useNavigate();
@@ -29,17 +29,18 @@ export const ContinuePage = () => {
const { url, valid, trusted, allowedProto, httpsDowngrade } = useRedirectUri(
redirectUri,
cookieDomain,
app.cookieDomain,
);
const urlHref = url?.href;
const hasValidRedirect = valid && allowedProto;
const showUntrustedWarning = hasValidRedirect && !trusted && warningsEnabled;
const showUntrustedWarning =
hasValidRedirect && !trusted && ui.warningsEnabled;
const showInsecureWarning =
hasValidRedirect && httpsDowngrade && warningsEnabled;
hasValidRedirect && httpsDowngrade && ui.warningsEnabled;
const shouldAutoRedirect =
isLoggedIn &&
auth.authenticated &&
hasValidRedirect &&
!showUntrustedWarning &&
!showInsecureWarning;
@@ -77,7 +78,7 @@ export const ContinuePage = () => {
};
}, [shouldAutoRedirect, redirectToTarget]);
if (!isLoggedIn) {
if (!auth.authenticated) {
return (
<Navigate
to={`/login${redirectUri ? `?redirect_uri=${encodeURIComponent(redirectUri)}` : ""}`}
@@ -104,7 +105,7 @@ export const ContinuePage = () => {
components={{
code: <code />,
}}
values={{ cookieDomain }}
values={{ cookieDomain: app.cookieDomain }}
shouldUnescape={true}
/>
</CardDescription>
+3 -3
View File
@@ -13,7 +13,7 @@ import Markdown from "react-markdown";
import { useLocation } from "react-router";
export const ForgotPasswordPage = () => {
const { forgotPasswordMessage } = useAppContext();
const { ui } = useAppContext();
const { t } = useTranslation();
const { search } = useLocation();
const searchParams = new URLSearchParams(search);
@@ -26,8 +26,8 @@ export const ForgotPasswordPage = () => {
<CardContent>
<CardDescription>
<Markdown>
{forgotPasswordMessage !== ""
? forgotPasswordMessage
{ui.forgotPasswordMessage !== ""
? ui.forgotPasswordMessage
: t("forgotPasswordMessage")}
</Markdown>
</CardDescription>
+93 -48
View File
@@ -18,7 +18,6 @@ import { OAuthButton } from "@/components/ui/oauth-button";
import { SeperatorWithChildren } from "@/components/ui/separator";
import { useAppContext } from "@/context/app-context";
import { useUserContext } from "@/context/user-context";
import { useOIDCParams } from "@/lib/hooks/oidc";
import { LoginSchema } from "@/schemas/login-schema";
import { useMutation } from "@tanstack/react-query";
import axios, { AxiosError } from "axios";
@@ -26,6 +25,10 @@ import { useEffect, useId, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Navigate, useLocation } from "react-router";
import { toast } from "sonner";
import {
recompileScreenParams,
useScreenParams,
} from "@/lib/hooks/screen-params";
const iconMap: Record<string, React.ReactNode> = {
google: <GoogleIcon />,
@@ -36,12 +39,19 @@ const iconMap: Record<string, React.ReactNode> = {
};
export const LoginPage = () => {
const { isLoggedIn } = useUserContext();
const { providers, title, oauthAutoRedirect } = useAppContext();
const { auth, tailscale } = useUserContext();
const {
ui,
oauth,
auth: { providers },
} = useAppContext();
const { search } = useLocation();
const { t } = useTranslation();
const [showRedirectButton, setShowRedirectButton] = useState(false);
const [useTailscale, setUseTailscale] = useState(
tailscale.nodeName !== undefined,
);
const hasAutoRedirectedRef = useRef(false);
@@ -51,17 +61,19 @@ export const LoginPage = () => {
const formId = useId();
const searchParams = new URLSearchParams(search);
const redirectUri = searchParams.get("redirect_uri") || undefined;
const oidcParams = useOIDCParams(searchParams);
const screenParams = useScreenParams(searchParams);
const isOidc = screenParams.login_for === "oidc";
const compiledParams = recompileScreenParams(screenParams);
const [isOauthAutoRedirect, setIsOauthAutoRedirect] = useState(
providers.find((provider) => provider.id === oauthAutoRedirect) !==
undefined && redirectUri !== undefined,
providers.find((provider) => provider.id === oauth.autoRedirect) !==
undefined && screenParams.redirect_url !== undefined,
);
const oauthProviders = providers.filter(
(provider) => provider.id !== "local" && provider.id !== "ldap",
);
const userAuthConfigured =
providers.find(
(provider) => provider.id === "local" || provider.id === "ldap",
@@ -74,16 +86,7 @@ export const LoginPage = () => {
variables: oauthVariables,
} = useMutation({
mutationFn: (provider: string) => {
const getParams = function (): string {
if (oidcParams.isOidc) {
return `?${oidcParams.compiled}`;
}
if (redirectUri) {
return `?redirect_uri=${encodeURIComponent(redirectUri)}`;
}
return "";
};
return axios.get(`/api/oauth/url/${provider}${getParams()}`);
return axios.get(`/api/oauth/url/${provider}${compiledParams}`);
},
mutationKey: ["oauth"],
onSuccess: (data) => {
@@ -114,13 +117,7 @@ export const LoginPage = () => {
mutationKey: ["login"],
onSuccess: (data) => {
if (data.data.totpPending) {
if (oidcParams.isOidc) {
window.location.replace(`/totp?${oidcParams.compiled}`);
return;
}
window.location.replace(
`/totp${redirectUri ? `?redirect_uri=${encodeURIComponent(redirectUri)}` : ""}`,
);
window.location.replace(`/totp${compiledParams}`);
return;
}
@@ -129,13 +126,7 @@ export const LoginPage = () => {
});
redirectTimer.current = window.setTimeout(() => {
if (oidcParams.isOidc) {
window.location.replace(`/authorize?${oidcParams.compiled}`);
return;
}
window.location.replace(
`/continue${redirectUri ? `?redirect_uri=${encodeURIComponent(redirectUri)}` : ""}`,
);
window.location.replace(`/continue${compiledParams}`);
}, 500);
},
onError: (error: AxiosError) => {
@@ -148,23 +139,43 @@ export const LoginPage = () => {
},
});
const { mutate: tailscaleMutate, isPending: tailscaleIsPending } =
useMutation({
mutationFn: () => axios.post("/api/user/tailscale"),
mutationKey: ["tailscale"],
onSuccess: () => {
toast.success(t("loginSuccessTitle"), {
description: t("loginTailscaleSuccess"),
});
redirectTimer.current = window.setTimeout(() => {
window.location.replace(`/continue${compiledParams}`);
}, 500);
},
onError: () => {
toast.error(t("loginFailTitle"), {
description: t("loginTailscaleFail"),
});
},
});
useEffect(() => {
if (
!isLoggedIn &&
!auth.authenticated &&
isOauthAutoRedirect &&
!hasAutoRedirectedRef.current &&
redirectUri !== undefined
screenParams.redirect_url !== undefined
) {
hasAutoRedirectedRef.current = true;
oauthMutate(oauthAutoRedirect);
oauthMutate(oauth.autoRedirect);
}
}, [
isLoggedIn,
auth.authenticated,
oauthMutate,
hasAutoRedirectedRef,
oauthAutoRedirect,
oauth.autoRedirect,
isOauthAutoRedirect,
redirectUri,
screenParams.redirect_url,
]);
useEffect(() => {
@@ -179,20 +190,15 @@ export const LoginPage = () => {
};
}, [redirectTimer, redirectButtonTimer]);
if (isLoggedIn && oidcParams.isOidc) {
return <Navigate to={`/authorize?${oidcParams.compiled}`} replace />;
if (auth.authenticated && isOidc) {
return <Navigate to={`/authorize${compiledParams}`} replace />;
}
if (isLoggedIn && redirectUri !== undefined) {
return (
<Navigate
to={`/continue${redirectUri ? `?redirect_uri=${encodeURIComponent(redirectUri)}` : ""}`}
replace
/>
);
if (auth.authenticated && screenParams.redirect_url !== undefined) {
return <Navigate to={`/continue${compiledParams}`} replace />;
}
if (isLoggedIn) {
if (auth.authenticated) {
return <Navigate to="/logout" replace />;
}
@@ -228,10 +234,49 @@ export const LoginPage = () => {
</Card>
);
}
if (useTailscale) {
return (
<Card>
<CardHeader className="gap-3">
<TailscaleIcon className="mx-auto h-8 w-8" />
<CardTitle className="text-center text-xl">
{t("loginTailscaleTitle")}
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="text-muted-foreground text-sm">
{t("loginTailscaleDescription")}
</div>
<div className="text-muted-foreground text-sm">
{t("loginTailscaleDeviceName")} <code>{tailscale.nodeName}</code>
</div>
</CardContent>
<CardFooter className="flex flex-col items-stretch gap-3">
<Button
className="w-full"
onClick={() => tailscaleMutate()}
loading={tailscaleIsPending}
>
{t("loginTailscaleSubmit")}
</Button>
<Button
className="w-full"
variant="outline"
onClick={() => setUseTailscale(false)}
disabled={tailscaleIsPending}
>
{t("loginTailscaleOtherMethod")}
</Button>
</CardFooter>
</Card>
);
}
return (
<Card>
<CardHeader className="gap-1.5">
<CardTitle className="text-center text-xl">{title}</CardTitle>
<CardTitle className="text-center text-xl">{ui.title}</CardTitle>
{providers.length > 0 && (
<CardDescription className="text-center">
{oauthProviders.length !== 0
+73 -31
View File
@@ -13,9 +13,11 @@ import { useEffect, useRef } from "react";
import { Trans, useTranslation } from "react-i18next";
import { Navigate } from "react-router";
import { toast } from "sonner";
import { type UseMutationResult } from "@tanstack/react-query";
import { type AxiosResponse } from "axios";
export const LogoutPage = () => {
const { provider, username, isLoggedIn, email, oauthName } = useUserContext();
const { auth, oauth, tailscale } = useUserContext();
const { t } = useTranslation();
const redirectTimer = useRef<number | null>(null);
@@ -47,42 +49,82 @@ export const LogoutPage = () => {
};
}, [redirectTimer]);
if (!isLoggedIn) {
if (!auth.authenticated) {
return <Navigate to="/login" replace />;
}
if (oauth.active) {
return (
<LogoutLayout logoutMutation={logoutMutation}>
<Trans
i18nKey="logoutOauthSubtitle"
t={t}
components={{
code: <code />,
}}
values={{
username: auth.email,
provider: oauth.displayName,
}}
shouldUnescape={true}
/>
</LogoutLayout>
);
}
if (auth.providerId === "tailscale") {
return (
<LogoutLayout logoutMutation={logoutMutation}>
<Trans
i18nKey="logoutTailscaleSubtitle"
t={t}
components={{
code: <code />,
}}
values={{
deviceName: tailscale.nodeName,
}}
shouldUnescape={true}
/>
</LogoutLayout>
);
}
return (
<LogoutLayout logoutMutation={logoutMutation}>
<Trans
i18nKey="logoutUsernameSubtitle"
t={t}
components={{
code: <code />,
}}
values={{
username: auth.username,
}}
shouldUnescape={true}
/>
</LogoutLayout>
);
};
interface LogoutLayoutProps {
children: React.ReactNode;
logoutMutation: UseMutationResult<
//eslint-disable-next-line @typescript-eslint/no-explicit-any,@typescript-eslint/no-empty-object-type
AxiosResponse<any, any, {}>,
Error,
void,
unknown
>;
}
function LogoutLayout({ children, logoutMutation }: LogoutLayoutProps) {
const { t } = useTranslation();
return (
<Card>
<CardHeader className="gap-1.5">
<CardTitle className="text-xl">{t("logoutTitle")}</CardTitle>
<CardDescription>
{provider !== "local" && provider !== "ldap" ? (
<Trans
i18nKey="logoutOauthSubtitle"
t={t}
components={{
code: <code />,
}}
values={{
username: email,
provider: oauthName,
}}
shouldUnescape={true}
/>
) : (
<Trans
i18nKey="logoutUsernameSubtitle"
t={t}
components={{
code: <code />,
}}
values={{
username,
}}
shouldUnescape={true}
/>
)}
</CardDescription>
<CardDescription>{children}</CardDescription>
</CardHeader>
<CardFooter>
<Button
@@ -96,4 +138,4 @@ export const LogoutPage = () => {
</CardFooter>
</Card>
);
};
}
+9 -13
View File
@@ -16,10 +16,13 @@ import { useEffect, useId, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Navigate, useLocation } from "react-router";
import { toast } from "sonner";
import { useOIDCParams } from "@/lib/hooks/oidc";
import {
recompileScreenParams,
useScreenParams,
} from "@/lib/hooks/screen-params";
export const TotpPage = () => {
const { totpPending } = useUserContext();
const { totp } = useUserContext();
const { t } = useTranslation();
const { search } = useLocation();
const formId = useId();
@@ -27,8 +30,8 @@ export const TotpPage = () => {
const redirectTimer = useRef<number | null>(null);
const searchParams = new URLSearchParams(search);
const redirectUri = searchParams.get("redirect_uri") || undefined;
const oidcParams = useOIDCParams(searchParams);
const screenParams = useScreenParams(searchParams);
const compiledParams = recompileScreenParams(screenParams);
const totpMutation = useMutation({
mutationFn: (values: TotpSchema) => axios.post("/api/user/totp", values),
@@ -39,14 +42,7 @@ export const TotpPage = () => {
});
redirectTimer.current = window.setTimeout(() => {
if (oidcParams.isOidc) {
window.location.replace(`/authorize?${oidcParams.compiled}`);
return;
}
window.location.replace(
`/continue${redirectUri ? `?redirect_uri=${encodeURIComponent(redirectUri)}` : ""}`,
);
window.location.replace(`/continue${compiledParams}`);
}, 500);
},
onError: () => {
@@ -64,7 +60,7 @@ export const TotpPage = () => {
};
}, [redirectTimer]);
if (!totpPending) {
if (!totp.pending) {
return <Navigate to="/" replace />;
}
+21 -4
View File
@@ -6,15 +6,32 @@ export const providerSchema = z.object({
oauth: z.boolean(),
});
export const appContextSchema = z.object({
const authSchema = z.object({
providers: z.array(providerSchema),
});
const oauthSchema = z.object({
autoRedirect: z.string(),
});
const uiSchema = z.object({
title: z.string(),
appUrl: z.string(),
cookieDomain: z.string(),
forgotPasswordMessage: z.string(),
backgroundImage: z.string(),
oauthAutoRedirect: z.string(),
warningsEnabled: z.boolean(),
});
const appSchema = z.object({
appUrl: z.string(),
cookieDomain: z.string(),
trustedDomains: z.array(z.string()),
});
export const appContextSchema = z.object({
auth: authSchema,
oauth: oauthSchema,
ui: uiSchema,
app: appSchema,
});
export type AppContextSchema = z.infer<typeof appContextSchema>;
-5
View File
@@ -1,5 +0,0 @@
import { z } from "zod";
export const getOidcClientInfoSchema = z.object({
name: z.string(),
});
+23 -6
View File
@@ -1,14 +1,31 @@
import { z } from "zod";
export const userContextSchema = z.object({
isLoggedIn: z.boolean(),
const authSchema = z.object({
authenticated: z.boolean(),
username: z.string(),
name: z.string(),
email: z.string(),
provider: z.string(),
oauth: z.boolean(),
totpPending: z.boolean(),
oauthName: z.string(),
providerId: z.string(),
});
const oauthSchema = z.object({
active: z.boolean(),
displayName: z.string(),
});
const totpSchema = z.object({
pending: z.boolean(),
});
const tailscaleSchema = z.object({
nodeName: z.string().optional(),
});
export const userContextSchema = z.object({
auth: authSchema,
oauth: oauthSchema,
totp: totpSchema,
tailscale: tailscaleSchema,
});
export type UserContextSchema = z.infer<typeof userContextSchema>;
+5
View File
@@ -57,6 +57,11 @@ export default defineConfig({
changeOrigin: true,
rewrite: (path) => path.replace(/^\/robots.txt/, ""),
},
"/authorize": {
target: "http://tinyauth-backend:3000/authorize",
changeOrigin: true,
rewrite: (path) => path.replace(/^\/authorize/, ""),
},
},
allowedHosts: true,
},
+473
View File
@@ -0,0 +1,473 @@
// 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
@@ -0,0 +1,59 @@
// 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}}
+55 -16
View File
@@ -1,6 +1,6 @@
module github.com/tinyauthapp/tinyauth
go 1.26.0
go 1.26.3
require (
charm.land/huh/v2 v2.0.3
@@ -12,17 +12,21 @@ require (
github.com/golang-migrate/migrate/v4 v4.19.1
github.com/google/go-querystring v1.2.0
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.9.2
github.com/mdp/qrterminal/v3 v3.2.1
github.com/pquerna/otp v1.5.0
github.com/rs/zerolog v1.35.1
github.com/steveiliop56/ding v0.2.0
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.50.0
golang.org/x/crypto v0.52.0
golang.org/x/oauth2 v0.36.0
k8s.io/apimachinery v0.36.0
k8s.io/client-go v0.36.0
modernc.org/sqlite v1.50.0
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
)
require (
@@ -30,12 +34,15 @@ require (
charm.land/bubbletea/v2 v2.0.2 // indirect
charm.land/lipgloss/v2 v2.0.1 // indirect
dario.cat/mergo v1.0.1 // indirect
filippo.io/edwards25519 v1.2.0 // indirect
github.com/Azure/go-ntlmssp v0.1.1 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/Masterminds/goutils v1.1.1 // indirect
github.com/Masterminds/semver/v3 v3.3.0 // indirect
github.com/Masterminds/semver/v3 v3.4.0 // indirect
github.com/Masterminds/sprig/v3 v3.3.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/akutz/memconn v0.1.0 // indirect
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/boombuler/barcode v1.0.2 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
@@ -54,19 +61,23 @@ require (
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/coder/websocket v1.8.12 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/creachadair/msync v0.7.1 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.5.0 // indirect
github.com/docker/go-connections v0.6.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
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gaissmai/bart v0.26.1 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
@@ -74,8 +85,20 @@ require (
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/btree v1.1.3 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/hdevalence/ed25519consensus v0.2.0 // indirect
github.com/huandu/xstrings v1.5.0 // indirect
github.com/huin/goupnp v1.3.0 // indirect
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jsimonetti/rtnetlink v1.4.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.18.5 // 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
@@ -83,35 +106,45 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.32 // indirect
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect
github.com/mdlayher/socket v0.5.0 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/go-ps v1.0.0 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/sys/atomicwriter v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pires/go-proxyproto v0.8.1 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // 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
github.com/rivo/uniseg v0.4.7 // indirect
github.com/safchain/ethtool v0.3.0 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d // 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/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/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
@@ -119,22 +152,28 @@ require (
go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/mod v0.35.0 // indirect
golang.org/x/net v0.54.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/term v0.42.0 // indirect
golang.org/x/text v0.36.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/term v0.43.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/time v0.14.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
golang.zx2c4.com/wireguard/windows v0.5.3 // indirect
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gotest.tools/v3 v3.5.2 // indirect
gvisor.dev/gvisor v0.0.0-20260224225140-573d5e7127a8 // indirect
k8s.io/klog/v2 v2.140.0 // indirect
k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect
modernc.org/libc v1.72.0 // indirect
modernc.org/libc v1.72.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
rsc.io/qr v0.2.0 // indirect
+240 -44
View File
@@ -1,3 +1,5 @@
9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f h1:1C7nZuxUMNz7eiQALRfiqNOm04+m3edWlRff/BYHf0Q=
9fans.net/go v0.0.8-0.20250307142834-96bdba94b63f/go.mod h1:hHyrZRryGqVdqrknjq5OWDLGCTJ2NeEvtrpR96mjraM=
charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s=
charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI=
charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0=
@@ -8,6 +10,10 @@ charm.land/lipgloss/v2 v2.0.1 h1:6Xzrn49+Py1Um5q/wZG1gWgER2+7dUyZ9XMEufqPSys=
charm.land/lipgloss/v2 v2.0.1/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM=
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
filippo.io/mkcert v1.4.4 h1:8eVbbwfVlaqUM7OwuftKc2nuYOoTDQWqsoXmzoXZdbc=
filippo.io/mkcert v1.4.4/go.mod h1:VyvOchVuAye3BoUsPUOOofKygVwLV2KQMVFJNRq+1dA=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw=
@@ -18,16 +24,50 @@ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+hmvYS0=
github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs=
github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A=
github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4=
github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
github.com/aws/aws-sdk-go-v2/config v1.29.5 h1:4lS2IB+wwkj5J43Tq/AwvnscBerBJtQQ6YS7puzCI1k=
github.com/aws/aws-sdk-go-v2/config v1.29.5/go.mod h1:SNzldMlDVbN6nWxM7XsUiNXPSa1LWlqiXtvh/1PrJGg=
github.com/aws/aws-sdk-go-v2/credentials v1.17.58 h1:/d7FUpAPU8Lf2KUdjniQvfNdlMID0Sd9pS23FJ3SS9Y=
github.com/aws/aws-sdk-go-v2/credentials v1.17.58/go.mod h1:aVYW33Ow10CyMQGFgC0ptMRIqJWvJ4nxZb0sUiuQT/A=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.27 h1:7lOW8NUwE9UZekS1DYoiPdVAqZ6A+LheHWb+mHbNOq8=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.27/go.mod h1:w1BASFIPOPUae7AgaH4SbjNbfdkxuggLyGfNFTn8ITY=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16/go.mod h1:L/UxsGeKpGoIj6DxfhOWHWQ/kGKcd4I1VncE4++IyKA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 h1:1jtGzuV7c82xnqOVfx2F0xmJcOw5374L7N6juGW6x6U=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16/go.mod h1:M2E5OQf+XLe+SZGmmpaI2yy+J326aFf6/+54PoxSANc=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2 h1:Pg9URiobXy85kgFev3og2CuOZ8JZUBENF+dcgWBaYNk=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy0ImIV0bsrX0X91GkV5nJAyv1l1CC9lnO0TI=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM=
github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7 h1:a8HvP/+ew3tKwSXqL3BCSjiuicr+XTU2eFYeogV9GJE=
github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7/go.mod h1:Q7XIWsMo0JcMpI/6TGD6XXcXcV1DbTj6e9BKNntIMIM=
github.com/aws/aws-sdk-go-v2/service/sso v1.24.14 h1:c5WJ3iHz7rLIgArznb3JCSQT3uUMiz9DLZhIX+1G8ok=
github.com/aws/aws-sdk-go-v2/service/sso v1.24.14/go.mod h1:+JJQTxB6N4niArC14YNtxcQtwEqzS3o9Z32n7q33Rfs=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.13 h1:f1L/JtUkVODD+k1+IiSJUUv8A++2qVr+Xvb3xWXETMU=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.13/go.mod h1:tvqlFoja8/s0o+UruA1Nrezo/df0PzdunMDDurUfg6U=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX1s+lFTg4+4DOy70=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk=
github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
github.com/axiomhq/hyperloglog v0.0.0-20240319100328-84253e514e02 h1:bXAPYSbdYbS5VTy92NIUbeDI1qyggi+JYh5op9IFlcQ=
github.com/axiomhq/hyperloglog v0.0.0-20240319100328-84253e514e02/go.mod h1:k08r+Yj1PRAmuayFiRK6MYuR5Ve4IuZtTfxErMIh0+c=
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
@@ -69,30 +109,52 @@ github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
github.com/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcOdFSsw=
github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y=
github.com/cilium/ebpf v0.16.0 h1:+BiEnHL6Z7lXnlGUsXQPPAE7+kenAd4ES8MQ5min0Ok=
github.com/cilium/ebpf v0.16.0/go.mod h1:L7u2Blt2jMM/vLAVgjxluxtBKlz3/GWjB0dMOEngfwE=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 h1:8h5+bWd7R6AYUslN6c6iuZWTKsKxUFDlpnmilO6R2n0=
github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q=
github.com/creachadair/mds v0.25.9 h1:080Hr8laN2h+l3NeVCGMBpXtIPnl9mz8e4HLraGPqtA=
github.com/creachadair/mds v0.25.9/go.mod h1:4hatI3hRM+qhzuAmqPRFvaBM8mONkS7nsLxkcuTYUIs=
github.com/creachadair/msync v0.7.1 h1:SeZmuEBXQPe5GqV/C94ER7QIZPwtvFbeQiykzt/7uho=
github.com/creachadair/msync v0.7.1/go.mod h1:8CcFlLsSujfHE5wWm19uUBLHIPDAUr6LXDwneVMO008=
github.com/creachadair/taskgroup v0.13.2 h1:3KyqakBuFsm3KkXi/9XIb0QcA8tEzLHLgaoidf0MdVc=
github.com/creachadair/taskgroup v0.13.2/go.mod h1:i3V1Zx7H8RjwljUEeUWYT30Lmb9poewSb2XI1yTwD0g=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa h1:h8TfIT1xc8FWbwwpmHn1J5i43Y0uZP97GqasGCzSRJk=
github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa/go.mod h1:Nx87SkVqTKd8UtT+xu7sM/l+LgXs6c0aHrlKusR+2EQ=
github.com/dgryski/go-metro v0.0.0-20180109044635-280f6062b5bc h1:8WFBn63wegobsYAX0YjD+8suexZDga5CctH4CCTx2+8=
github.com/dgryski/go-metro v0.0.0-20180109044635-280f6062b5bc/go.mod h1:c9O8+fpSOX1DM8cPNSkX/qsBWdkD4yd2dpciOWQjpBw=
github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e h1:vUmf0yezR0y7jJ5pceLHthLaYf4bA5T14B6q39S4q2Q=
github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e/go.mod h1:YTIHhz/QFSYnu/EhlF2SpU2Uk+32abacUYA5ZPljz1A=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=
github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
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-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=
@@ -107,14 +169,20 @@ github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sa
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gaissmai/bart v0.26.1 h1:+w4rnLGNlA2GDVn382Tfe3jOsK5vOr5n4KmigJ9lbTo=
github.com/gaissmai/bart v0.26.1/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I=
github.com/github/fakeca v0.1.0/go.mod h1:+bormgoGMMuamOscx7N91aOuUST7wdaJ2rNjeohylyo=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced h1:Q311OHjMh/u5E2TITc++WlTP5We0xNseRMkHDyvhW7I=
github.com/go-json-experiment/json v0.0.0-20250813024750-ebf49471dced/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
github.com/go-ldap/ldap/v3 v3.4.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ=
github.com/go-ldap/ldap/v3 v3.4.13/go.mod h1:LxsGZV6vbaK0sIvYfsv47rfh4ca0JXokCoKjZxsszv0=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
@@ -122,10 +190,12 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=
github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=
github.com/go-openapi/jsonreference v0.20.4 h1:bKlDxQxQJgwpUSgOENiMPzCTBVuc7vTdXSSgNeAhojU=
github.com/go-openapi/jsonreference v0.20.4/go.mod h1:5pZJyJP2MnYCpoeoMAql78cCHauHj0V9Lhc506VOpw4=
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
@@ -136,12 +206,20 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737 h1:cf60tHxREO3g1nroKr2osU3JWZsJzkfi7rEg+oAB0Lo=
github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737/go.mod h1:MIS0jDzbU/vuM9MC4YnBITCv+RYuTRq8dJzmCrFsK9g=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 h1:sQspH8M4niEijh3PFscJRLDnkL547IeP7kpPe3uUhEg=
github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466/go.mod h1:ZiQxhyQ+bbbfxUKVvjfO498oPYvtYhZzycal3G/NHmU=
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
@@ -149,7 +227,11 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=
github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU=
github.com/google/go-tpm v0.9.4 h1:awZRf9FwOeTunQmHoDYSHJps3ie6f1UlhS1fOdPEt1I=
github.com/google/go-tpm v0.9.4/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 h1:wG8RYIyctLhdFk6Vl1yPGtSRtwGpVkWyZww1OCil2MI=
github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806/go.mod h1:Beg6V6zZ3oEn0JuiUQ4wqwuyqqzasOltcoXPtgLbFp4=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -158,10 +240,29 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/golang-lru v0.6.0 h1:uL2shRDx7RTrOrTCUZEGP/wJUFiUI8QT6E7z5o8jga4=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU=
github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo=
github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=
github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc=
github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8=
github.com/illarion/gonotify/v3 v3.0.2 h1:O7S6vcopHexutmpObkeWsnzMJt/r1hONIEogeVNmJMk=
github.com/illarion/gonotify/v3 v3.0.2/go.mod h1:HWGPdPe817GfvY3w7cx6zkbzNZfi3QjcBm/wgVvEL1U=
github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2 h1:9K06NfxkBh25x56yVhWWlKFE8YpicaSfHwoV8SFbueA=
github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2/go.mod h1:3A9PQ1cunSDF/1rbTq99Ts4pVnycWg+vlPkfeD2NLFI=
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa h1:s+4MhCQ6YrzisK6hFJUX53drDT4UsSW3DEhKn0ifuHw=
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
@@ -174,12 +275,24 @@ github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh6
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/jellydator/ttlcache/v3 v3.1.0 h1:0gPFG0IHHP6xyUyXq+JaD8fwkDCqgqwohXNJBcYE71g=
github.com/jellydator/ttlcache/v3 v3.1.0/go.mod h1:hi7MGFdMAwZna5n2tuvh63DvFLzVKySzCVW6+0gA2n4=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/jsimonetti/rtnetlink v1.4.0 h1:Z1BF0fRgcETPEa0Kt0MRk3yV5+kF1FWTni6KUFKrq2I=
github.com/jsimonetti/rtnetlink v1.4.0/go.mod h1:5W1jDvWdnthFJ7fxYX1GMK07BUpI4oskfOqvPteYS6E=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/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/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=
github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a/go.mod h1:YTtCCM3ryyfiu4F7t8HQ1mxvp1UBdWM2r6Xa+nGWvDk=
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@@ -200,10 +313,22 @@ github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjc
github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw=
github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o=
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0tpKMbdCXCIfBclan4oHk1Jb+Hrejirg=
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42/go.mod h1:BB4YCPDOzfy7FniQ/lxuYQ3dgmM2cZumHbK8RpTjN2o=
github.com/mdlayher/sdnotify v1.0.0 h1:Ma9XeLVN/l0qpyx1tNeMSeTjCPH6NtuD6/N9XdTlQ3c=
github.com/mdlayher/sdnotify v1.0.0/go.mod h1:HQUmpM4XgYkhDLtd+Uad8ZFK1T9D5+pNxnXQjCeJlGE=
github.com/mdlayher/socket v0.5.0 h1:ilICZmJcQz70vrWVes1MFera4jGiWNocSkykwwoy3XI=
github.com/mdlayher/socket v0.5.0/go.mod h1:WkcBFfvyG8QENs5+hfQPl1X6Jpd2yeLIYgrGFmJiJxI=
github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4=
github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU=
github.com/miekg/dns v1.1.58 h1:ca2Hdkz+cDg/7eNF6V56jjzuZ4aCAE+DbVkILdQWG/4=
github.com/miekg/dns v1.1.58/go.mod h1:Ypv+3b/KadlvW9vJfXOTf300O4UqaHFzFCuHz+rPkBY=
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc=
github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg=
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
@@ -230,19 +355,31 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0=
github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pires/go-proxyproto v0.8.1 h1:9KEixbdJfhrbtjpz/ZwCdWDD2Xem0NZ38qMYaASJgp0=
github.com/pires/go-proxyproto v0.8.1/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/sftp v1.13.6 h1:JFZT4XbOU7l77xGSpOdW+pwIMqP044IyjXX6FGyEKFo=
github.com/pkg/sftp v1.13.6/go.mod h1:tz1ryNURKu77RL+GuCzmoJYxQczL3wLNNpPWagdg4Qk=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
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/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
@@ -255,32 +392,67 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
github.com/safchain/ethtool v0.3.0 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP0=
github.com/safchain/ethtool v0.3.0/go.mod h1:SA9BwrgyAqNo7M+uaL6IYbxpm5wk3L7Mm6ocLW+CJUs=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/steveiliop56/ding v0.2.0 h1:m/Fj99wBpVVLHlpqb2RDJkWubOc5cWJ11ZYCHya3Sk0=
github.com/steveiliop56/ding v0.2.0/go.mod h1:bE2u2XH7CjhPzbb/0Ems+D8YZlf2Ae+eKhj00UR1iAY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.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/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/netlink v1.1.1-0.20240822203006-4d49adab4de7 h1:uFsXVBE9Qr4ZoF094vE6iYTLDl0qCiKzYXlL6UeWObU=
github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0=
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc h1:24heQPtnFR+yfntqhI3oAu9i27nEojcQ4NuBQOo5ZFA=
github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc/go.mod h1:f93CXfllFsO9ZQVq+Zocb1Gp4G5Fz0b0rXHLOzt/Djc=
github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 h1:UBPHPtv8+nEAy2PD8RyAhOYvau1ek0HDJqLS/Pysi14=
github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ=
github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6 h1:l10Gi6w9jxvinoiq15g8OToDdASBni4CyJOdHY1Hr8M=
github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6/go.mod h1:ZXRML051h7o4OcI0d3AaILDIad/Xw0IkXaHM17dic1Y=
github.com/tailscale/wireguard-go v0.0.0-20260427181203-e3ac4a0afb4e h1:GexFR7ak1iz26fxg8HWCpOEqAOL8UEZJ7J3JxeCalDs=
github.com/tailscale/wireguard-go v0.0.0-20260427181203-e3ac4a0afb4e/go.mod h1:6SerzcvHWQchKO2BfNdmquA77CHSECZuFl+D9fp4RnI=
github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e h1:zOGKqN5D5hHhiYUp091JqK7DPCqSARyUfduhGUY8Bek=
github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg=
github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA=
github.com/tc-hib/winres v0.2.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk=
github.com/tinyauthapp/paerser v0.0.0-20260410140347-85c3740d6298 h1:EYSb5jv8ZL/0/NVFZtY7Ejplk0QG5+3lrdL3mSrjFZQ=
github.com/tinyauthapp/paerser v0.0.0-20260410140347-85c3740d6298/go.mod h1:TlUDoCF66hMqFZqoBym9bUdJ0bKAWYMir6hLJeYN5z0=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/u-root/u-root v0.14.0 h1:Ka4T10EEML7dQ5XDvO9c3MBN8z4nuSnGjcd1jmU2ivg=
github.com/u-root/u-root v0.14.0/go.mod h1:hAyZorapJe4qzbLWlAkmSVCJGbfoU9Pu4jpJ1WMluqE=
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM=
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
github.com/weppos/publicsuffix-go v0.50.3 h1:eT5dcjHQcVDNc0igpFEsGHKIip30feuB2zuuI9eJxiE=
github.com/weppos/publicsuffix-go v0.50.3/go.mod h1:/rOa781xBykZhHK/I3QeHo92qdDKVmKZKF7s8qAEM/4=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
@@ -291,8 +463,8 @@ go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
@@ -315,31 +487,45 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek=
go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g=
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
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/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/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/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=
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.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/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/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.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE=
golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI=
google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M=
@@ -361,22 +547,28 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
k8s.io/api v0.36.0 h1:SgqDhZzHdOtMk40xVSvCXkP9ME0H05hPM3p9AB1kL80=
k8s.io/api v0.36.0/go.mod h1:m1LVrGPNYax5NBHdO+QuAedXyuzTt4RryI/qnmNvs34=
k8s.io/apimachinery v0.36.0 h1:jZyPzhd5Z+3h9vJLt0z9XdzW9VzNzWAUw+P1xZ9PXtQ=
k8s.io/apimachinery v0.36.0/go.mod h1:FklypaRJt6n5wUIwWXIP6GJlIpUizTgfo1T/As+Tyxc=
k8s.io/client-go v0.36.0 h1:pOYi7C4RHChYjMiHpZSpSbIM6ZxVbRXBy7CuiIwqA3c=
k8s.io/client-go v0.36.0/go.mod h1:ZKKcpwF0aLYfkHFCjillCKaTK/yBkEDHTDXCFY6AS9Y=
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=
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/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc=
k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0=
k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a h1:xCeOEAOoGYl2jnJoHkC3hkbPJgdATINPMAxaynU2Ovg=
k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0=
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU=
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
modernc.org/cc/v4 v4.27.3 h1:uNCgn37E5U09mTv1XgskEVUJ8ADKpmFMPxzGJ0TSo+U=
modernc.org/cc/v4 v4.27.3/go.mod h1:3YjcbCqhoTTHPycJDRl2WZKKFj0nwcOIPBfEZK0Hdk8=
modernc.org/ccgo/v4 v4.32.4 h1:L5OB8rpEX4ZsXEQwGozRfJyJSFHbbNVOoQ59DU9/KuU=
modernc.org/ccgo/v4 v4.32.4/go.mod h1:lY7f+fiTDHfcv6YlRgSkxYfhs+UvOEEzj49jAn2TOx0=
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/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=
@@ -385,18 +577,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.0 h1:IEu559v9a0XWjw0DPoVKtXpO2qt5NVLAnFaBbjq+n8c=
modernc.org/libc v1.72.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ=
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/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.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.50.0 h1:eMowQSWLK0MeiQTdmz3lqoF5dqclujdlIKeJA11+7oM=
modernc.org/sqlite v1.50.0/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
@@ -411,3 +603,7 @@ sigs.k8s.io/structured-merge-diff/v6 v6.3.2 h1:kwVWMx5yS1CrnFWA/2QHyRVJ8jM6dBA80
sigs.k8s.io/structured-merge-diff/v6 v6.3.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
sigs.k8s.io/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=
+1 -1
View File
@@ -11,5 +11,5 @@ var FrontendAssets embed.FS
// Migrations
//
//go:embed migrations/*.sql
//go:embed migrations/sqlite/*.sql migrations/postgres/*.sql
var Migrations embed.FS
@@ -0,0 +1,4 @@
DROP TABLE IF EXISTS "oidc_tokens";
DROP TABLE IF EXISTS "oidc_userinfo";
DROP TABLE IF EXISTS "oidc_codes";
DROP TABLE IF EXISTS "sessions";
@@ -0,0 +1,60 @@
CREATE TABLE "sessions" (
"uuid" TEXT NOT NULL PRIMARY KEY,
"username" TEXT NOT NULL,
"email" TEXT NOT NULL,
"name" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"totp_pending" BOOLEAN NOT NULL,
"oauth_groups" TEXT NOT NULL DEFAULT '',
"expiry" BIGINT NOT NULL,
"created_at" BIGINT NOT NULL,
"oauth_name" TEXT NOT NULL DEFAULT '',
"oauth_sub" TEXT NOT NULL DEFAULT ''
);
CREATE TABLE "oidc_codes" (
"sub" TEXT NOT NULL UNIQUE,
"code_hash" TEXT NOT NULL PRIMARY KEY,
"scope" TEXT NOT NULL,
"redirect_uri" TEXT NOT NULL,
"client_id" TEXT NOT NULL,
"expires_at" BIGINT NOT NULL,
"nonce" TEXT NOT NULL DEFAULT '',
"code_challenge" TEXT NOT NULL DEFAULT ''
);
CREATE TABLE "oidc_tokens" (
"sub" TEXT NOT NULL UNIQUE,
"access_token_hash" TEXT NOT NULL PRIMARY KEY,
"refresh_token_hash" TEXT NOT NULL,
"code_hash" TEXT NOT NULL,
"scope" TEXT NOT NULL,
"client_id" TEXT NOT NULL,
"token_expires_at" BIGINT NOT NULL,
"refresh_token_expires_at" BIGINT NOT NULL,
"nonce" TEXT NOT NULL DEFAULT ''
);
CREATE TABLE "oidc_userinfo" (
"sub" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"preferred_username" TEXT NOT NULL,
"email" TEXT NOT NULL,
"groups" TEXT NOT NULL,
"updated_at" BIGINT NOT NULL,
"given_name" TEXT NOT NULL,
"family_name" TEXT NOT NULL,
"middle_name" TEXT NOT NULL,
"nickname" TEXT NOT NULL,
"profile" TEXT NOT NULL,
"picture" TEXT NOT NULL,
"website" TEXT NOT NULL,
"gender" TEXT NOT NULL,
"birthdate" TEXT NOT NULL,
"zoneinfo" TEXT NOT NULL,
"locale" TEXT NOT NULL,
"phone_number" TEXT NOT NULL,
"address" TEXT NOT NULL
);
CREATE INDEX idx_sessions_expiry ON "sessions" ("expiry");
@@ -0,0 +1,46 @@
DROP TABLE IF EXISTS "oidc_sessions";
CREATE TABLE "oidc_codes" (
"sub" TEXT NOT NULL UNIQUE,
"code_hash" TEXT NOT NULL PRIMARY KEY,
"scope" TEXT NOT NULL,
"redirect_uri" TEXT NOT NULL,
"client_id" TEXT NOT NULL,
"expires_at" BIGINT NOT NULL,
"nonce" TEXT NOT NULL DEFAULT '',
"code_challenge" TEXT NOT NULL DEFAULT ''
);
CREATE TABLE "oidc_tokens" (
"sub" TEXT NOT NULL UNIQUE,
"access_token_hash" TEXT NOT NULL PRIMARY KEY,
"refresh_token_hash" TEXT NOT NULL,
"code_hash" TEXT NOT NULL,
"scope" TEXT NOT NULL,
"client_id" TEXT NOT NULL,
"token_expires_at" BIGINT NOT NULL,
"refresh_token_expires_at" BIGINT NOT NULL,
"nonce" TEXT NOT NULL DEFAULT ''
);
CREATE TABLE "oidc_userinfo" (
"sub" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"preferred_username" TEXT NOT NULL,
"email" TEXT NOT NULL,
"groups" TEXT NOT NULL,
"updated_at" BIGINT NOT NULL,
"given_name" TEXT NOT NULL,
"family_name" TEXT NOT NULL,
"middle_name" TEXT NOT NULL,
"nickname" TEXT NOT NULL,
"profile" TEXT NOT NULL,
"picture" TEXT NOT NULL,
"website" TEXT NOT NULL,
"gender" TEXT NOT NULL,
"birthdate" TEXT NOT NULL,
"zoneinfo" TEXT NOT NULL,
"locale" TEXT NOT NULL,
"phone_number" TEXT NOT NULL,
"address" TEXT NOT NULL
);
@@ -0,0 +1,28 @@
/*
This migration will nuke the entire setup of OIDC sessions and merge everything
into one table.
*/
/*
Drop all the old tables. Yes, we will log out all OIDC users, but not really a big deal
*/
DROP TABLE IF EXISTS "oidc_tokens";
DROP TABLE IF EXISTS "oidc_userinfo";
DROP TABLE IF EXISTS "oidc_codes";
/*
Create a new simple OIDC sessions table that will hold tokens + userinfo.
*/
CREATE TABLE IF NOT EXISTS "oidc_sessions" (
"sub" TEXT NOT NULL UNIQUE PRIMARY KEY,
"access_token_hash" TEXT NOT NULL UNIQUE,
"refresh_token_hash" TEXT NOT NULL UNIQUE,
"scope" TEXT NOT NULL,
"client_id" TEXT NOT NULL,
"token_expires_at" BIGINT NOT NULL,
"refresh_token_expires_at" BIGINT NOT NULL,
"nonce" TEXT NOT NULL DEFAULT '',
"userinfo_json" TEXT NOT NULL
);
@@ -1,3 +1,5 @@
DROP TABLE IF EXISTS "oidc_sessions";
CREATE TABLE IF NOT EXISTS "oidc_codes" (
"sub" TEXT NOT NULL UNIQUE,
"code_hash" TEXT NOT NULL PRIMARY KEY UNIQUE,
@@ -0,0 +1,28 @@
/*
This migration will nuke the entire setup of OIDC sessions and merge everything
into one table.
*/
/*
Drop all the old tables. Yes, we will log out all OIDC users, but not really a big deal
*/
DROP TABLE IF EXISTS "oidc_tokens";
DROP TABLE IF EXISTS "oidc_userinfo";
DROP TABLE IF EXISTS "oidc_codes";
/*
Create a new simple OIDC sessions table that will hold tokens + userinfo.
*/
CREATE TABLE IF NOT EXISTS "oidc_sessions" (
"sub" TEXT NOT NULL UNIQUE PRIMARY KEY,
"access_token_hash" TEXT NOT NULL UNIQUE,
"refresh_token_hash" TEXT NOT NULL UNIQUE,
"scope" TEXT NOT NULL,
"client_id" TEXT NOT NULL,
"token_expires_at" INTEGER NOT NULL,
"refresh_token_expires_at" INTEGER NOT NULL,
"nonce" TEXT DEFAULT "",
"userinfo_json" TEXT NOT NULL
);
+76 -138
View File
@@ -7,18 +7,18 @@ import (
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"os"
"os/signal"
"sort"
"strings"
"sync"
"syscall"
"time"
"github.com/gin-gonic/gin"
"github.com/steveiliop56/ding"
"github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/repository"
"github.com/tinyauthapp/tinyauth/internal/service"
@@ -26,6 +26,12 @@ import (
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
)
// Shutdown order for go routines
// 1. Janitor routines (e.g. database cleanup, heartbeat) - ding.RingMinor
// 2. HTTP server listeners - ding.RingNormal
// 3. Networking layers, user and label providers (e.g. ailscale service, kubernetes service) - ding.RingMajor
// 4. Database connection - ding.RingCritical
type Services struct {
accessControlService *service.AccessControlsService
authService *service.AuthService
@@ -34,19 +40,22 @@ type Services struct {
ldapService *service.LdapService
oauthBrokerService *service.OAuthBrokerService
oidcService *service.OIDCService
tailscaleService *service.TailscaleService
policyEngine *service.PolicyEngine
}
type BootstrapApp struct {
config model.Config
runtime model.RuntimeConfig
services Services
log *logger.Logger
ctx context.Context
cancel context.CancelFunc
queries *repository.Queries
router *gin.Engine
db *sql.DB
wg sync.WaitGroup
config model.Config
runtime model.RuntimeConfig
services Services
log *logger.Logger
ctx context.Context
cancel context.CancelFunc
queries repository.Store
router *gin.Engine
db *sql.DB
ding *ding.Ding
listeners []Listener
}
func NewBootstrapApp(config model.Config) *BootstrapApp {
@@ -61,11 +70,17 @@ func (app *BootstrapApp) Setup() error {
app.ctx = ctx
app.cancel = cancel
// Create a ding instance
dg := ding.New(ctx)
app.ding = dg
// setup logger
log := logger.NewLogger().WithConfig(app.config.Log)
log.Init()
app.log = log
app.log.App.Info().Msgf("Starting Tinyauth version: %s", model.Version)
// get app url
if app.config.AppURL == "" {
return errors.New("app url cannot be empty, perhaps config loading failed")
@@ -78,6 +93,7 @@ func (app *BootstrapApp) Setup() error {
}
app.runtime.AppURL = appUrl.Scheme + "://" + appUrl.Host
app.runtime.TrustedDomains = append(app.runtime.TrustedDomains, app.runtime.AppURL)
// validate session config
if app.config.Auth.SessionMaxLifetime != 0 && app.config.Auth.SessionMaxLifetime < app.config.Auth.SessionExpiry {
@@ -91,7 +107,12 @@ func (app *BootstrapApp) Setup() error {
return fmt.Errorf("failed to load users: %w", err)
}
app.runtime.LocalUsers = *users
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{}
}
// load oauth whitelist
oauthWhitelist, err := utils.GetStringList(app.config.OAuth.Whitelist, app.config.OAuth.WhitelistFile)
@@ -106,6 +127,13 @@ 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 = ""
@@ -162,23 +190,26 @@ func (app *BootstrapApp) Setup() error {
app.runtime.OAuthSessionCookieName = fmt.Sprintf("%s-%s", model.OAuthSessionCookieName, cookieId)
// database
err = app.SetupDatabase()
store, err := app.SetupStore()
if err != nil {
return fmt.Errorf("failed to setup database: %w", err)
}
// after this point, we start initializing dependencies so it's a good time to setup a defer
// to ensure that resources are cleaned up properly in case of an error during initialization
defer func() {
app.cancel()
app.wg.Wait()
app.db.Close()
}()
app.ding.Go(func(ctx context.Context) {
<-ctx.Done()
app.log.App.Debug().Msg("Shutting down database connection")
if app.db == nil {
// using memory store, no db instance
return
}
if err := app.db.Close(); err != nil {
app.log.App.Error().Err(err).Msg("Failed to close database connection")
}
}, ding.RingCritical)
// queries
queries := repository.New(app.db)
app.queries = queries
// store
app.queries = store
// services
err = app.setupServices()
@@ -228,6 +259,11 @@ func (app *BootstrapApp) Setup() error {
app.runtime.ConfiguredProviders = configuredProviders
// throw in tailscale if it's configured just before setting up the controllers
if app.services.tailscaleService != nil {
app.runtime.TrustedDomains = append(app.runtime.TrustedDomains, "https://"+app.services.tailscaleService.GetHostname())
}
// setup router
err = app.setupRouter()
@@ -237,142 +273,44 @@ func (app *BootstrapApp) Setup() error {
// start db cleanup routine
app.log.App.Debug().Msg("Starting database cleanup routine")
app.wg.Go(app.dbCleanupRoutine)
app.ding.Go(app.dbCleanupRoutine, ding.RingMinor)
// if analytics are not disabled, start heartbeat
if app.config.Analytics.Enabled {
app.log.App.Debug().Msg("Starting heartbeat routine")
app.wg.Go(app.heartbeatRoutine)
app.ding.Go(app.heartbeatRoutine, ding.RingMinor)
}
// create err channel to listen for server errors
errChanLen := 0
runUnix := app.config.Server.SocketPath != ""
runHTTP := app.config.Server.SocketPath == "" || app.config.Server.ConcurrentListenersEnabled
if runUnix {
errChanLen++
}
if runHTTP {
errChanLen++
}
errChan := make(chan error, errChanLen)
// setup listeners
app.listeners = app.calculateListenerPolicy()
if app.config.Server.ConcurrentListenersEnabled {
app.log.App.Info().Msg("Concurrent listeners enabled, will run on all available listeners")
}
// serve unix
if runUnix {
app.wg.Go(func() {
if err := app.serveUnix(); err != nil {
errChan <- err
}
})
}
// run listeners
lec, err := app.runListeners()
// serve to http
if runHTTP {
app.wg.Go(func() {
if err := app.serveHTTP(); err != nil {
errChan <- err
}
})
if err != nil {
return fmt.Errorf("failed to run listeners: %w", err)
}
// monitor cancellation and server errors
for {
select {
case <-app.ctx.Done():
app.ding.Wait()
app.log.App.Info().Msg("Oh, it's time for me to go, bye!")
return nil
case err := <-errChan:
case err := <-lec:
if err != nil {
return fmt.Errorf("server error: %w", err)
return fmt.Errorf("listener error: %w", err)
}
}
}
}
func (app *BootstrapApp) serveHTTP() error {
address := fmt.Sprintf("%s:%d", app.config.Server.Address, app.config.Server.Port)
app.log.App.Info().Msgf("Starting server on %s", address)
server := &http.Server{
Addr: address,
Handler: app.router.Handler(),
}
go func() {
<-app.ctx.Done()
app.log.App.Debug().Msg("Shutting down http listener")
server.Shutdown(app.ctx)
}()
err := server.ListenAndServe()
if err != nil && !errors.Is(err, http.ErrServerClosed) {
return fmt.Errorf("failed to start http listener: %w", err)
}
return nil
}
func (app *BootstrapApp) serveUnix() error {
if app.config.Server.SocketPath == "" {
return nil
}
_, err := os.Stat(app.config.Server.SocketPath)
if err == nil {
app.log.App.Info().Msgf("Removing existing socket file %s", app.config.Server.SocketPath)
err := os.Remove(app.config.Server.SocketPath)
if err != nil {
return fmt.Errorf("failed to remove existing socket file: %w", err)
}
}
app.log.App.Info().Msgf("Starting server on unix socket %s", app.config.Server.SocketPath)
listener, err := net.Listen("unix", app.config.Server.SocketPath)
if err != nil {
return fmt.Errorf("failed to create unix socket listener: %w", err)
}
server := &http.Server{
Handler: app.router.Handler(),
}
shutdown := func() {
server.Shutdown(app.ctx)
listener.Close()
os.Remove(app.config.Server.SocketPath)
}
go func() {
<-app.ctx.Done()
app.log.App.Debug().Msg("Shutting down unix socket listener")
shutdown()
}()
err = server.Serve(listener)
if err != nil && !errors.Is(err, http.ErrServerClosed) {
shutdown()
return fmt.Errorf("failed to start unix socket listener: %w", err)
}
return nil
}
func (app *BootstrapApp) heartbeatRoutine() {
func (app *BootstrapApp) heartbeatRoutine(ctx context.Context) {
ticker := time.NewTicker(time.Duration(12) * time.Hour)
defer ticker.Stop()
@@ -425,7 +363,7 @@ func (app *BootstrapApp) heartbeatRoutine() {
if res.StatusCode != 200 && res.StatusCode != 201 {
app.log.App.Debug().Str("status", res.Status).Msg("Heartbeat returned non-200/201 status")
}
case <-app.ctx.Done():
case <-ctx.Done():
app.log.App.Debug().Msg("Stopping heartbeat routine")
ticker.Stop()
return
@@ -433,7 +371,7 @@ func (app *BootstrapApp) heartbeatRoutine() {
}
}
func (app *BootstrapApp) dbCleanupRoutine() {
func (app *BootstrapApp) dbCleanupRoutine(ctx context.Context) {
ticker := time.NewTicker(time.Duration(30) * time.Minute)
defer ticker.Stop()
@@ -442,14 +380,14 @@ func (app *BootstrapApp) dbCleanupRoutine() {
case <-ticker.C:
app.log.App.Debug().Msg("Running database cleanup")
err := app.queries.DeleteExpiredSessions(app.ctx, time.Now().Unix())
err := app.queries.DeleteExpiredSessions(ctx, time.Now().Unix())
if err != nil {
app.log.App.Error().Err(err).Msg("Failed to delete expired sessions")
}
app.log.App.Debug().Msg("Database cleanup completed")
case <-app.ctx.Done():
case <-ctx.Done():
app.log.App.Debug().Msg("Stopping database cleanup routine")
ticker.Stop()
return
+77 -18
View File
@@ -6,30 +6,49 @@ import (
"os"
"path/filepath"
"github.com/tinyauthapp/tinyauth/internal/assets"
"github.com/golang-migrate/migrate/v4"
pgxmigrate "github.com/golang-migrate/migrate/v4/database/pgx/v5"
"github.com/golang-migrate/migrate/v4/database/sqlite3"
"github.com/golang-migrate/migrate/v4/source/iofs"
_ "github.com/jackc/pgx/v5/stdlib"
_ "modernc.org/sqlite"
"github.com/tinyauthapp/tinyauth/internal/assets"
"github.com/tinyauthapp/tinyauth/internal/repository"
"github.com/tinyauthapp/tinyauth/internal/repository/memory"
"github.com/tinyauthapp/tinyauth/internal/repository/postgres"
"github.com/tinyauthapp/tinyauth/internal/repository/sqlite"
)
func (app *BootstrapApp) SetupDatabase() error {
dir := filepath.Dir(app.config.Database.Path)
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)
case "postgres":
return app.setupPostgres(app.config.Database.Path)
default:
return nil, fmt.Errorf("unknown database driver %q: valid values are sqlite, postgres, memory", app.config.Database.Driver)
}
}
func (app *BootstrapApp) setupSQLite(databasePath string) (repository.Store, error) {
dir := filepath.Dir(databasePath)
if err := os.MkdirAll(dir, 0750); err != nil {
return fmt.Errorf("failed to create database directory %s: %w", dir, err)
return nil, fmt.Errorf("failed to create database directory %s: %w", dir, err)
}
db, err := sql.Open("sqlite", app.config.Database.Path)
db, err := sql.Open("sqlite", databasePath)
if err != nil {
return fmt.Errorf("failed to open database: %w", err)
return nil, fmt.Errorf("failed to open database: %w", err)
}
// Close the database if there is an error during migration
cleanup := true
defer func() {
if err != nil {
if cleanup {
db.Close()
}
}()
@@ -38,32 +57,72 @@ func (app *BootstrapApp) SetupDatabase() error {
// if the sqlite connection starts being a bottleneck
db.SetMaxOpenConns(1)
migrations, err := iofs.New(assets.Migrations, "migrations")
migrations, err := iofs.New(assets.Migrations, "migrations/sqlite")
if err != nil {
return fmt.Errorf("failed to create migrations: %w", err)
return nil, fmt.Errorf("failed to create migrations: %w", err)
}
target, err := sqlite3.WithInstance(db, &sqlite3.Config{})
if err != nil {
return fmt.Errorf("failed to create sqlite3 instance: %w", err)
return nil, fmt.Errorf("failed to create sqlite3 instance: %w", err)
}
migrator, err := migrate.NewWithInstance("iofs", migrations, "sqlite3", target)
if err != nil {
return fmt.Errorf("failed to create migrator: %w", err)
return nil, fmt.Errorf("failed to create migrator: %w", err)
}
if err := migrator.Up(); err != nil && err != migrate.ErrNoChange {
return fmt.Errorf("failed to migrate database: %w", err)
if err = migrator.Up(); err != nil && err != migrate.ErrNoChange {
return nil, fmt.Errorf("failed to migrate database: %w", err)
}
cleanup = false
app.db = db
return nil
return sqlite.NewStore(sqlite.New(db)), nil
}
func (app *BootstrapApp) GetDB() *sql.DB {
return app.db
func (app *BootstrapApp) setupPostgres(databaseURL string) (repository.Store, error) {
db, err := sql.Open("pgx", databaseURL)
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
cleanup := true
defer func() {
if cleanup {
db.Close()
}
}()
migrations, err := iofs.New(assets.Migrations, "migrations/postgres")
if err != nil {
return nil, fmt.Errorf("failed to create migrations: %w", err)
}
target, err := pgxmigrate.WithInstance(db, &pgxmigrate.Config{})
if err != nil {
return nil, fmt.Errorf("failed to create postgres instance: %w", err)
}
migrator, err := migrate.NewWithInstance("iofs", migrations, "pgx", target)
if err != nil {
return nil, fmt.Errorf("failed to create migrator: %w", err)
}
if err = migrator.Up(); err != nil && err != migrate.ErrNoChange {
return nil, fmt.Errorf("failed to migrate database: %w", err)
}
cleanup = false
app.db = db
return postgres.NewStore(postgres.New(db)), nil
}
+178 -3
View File
@@ -1,14 +1,30 @@
package bootstrap
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"os"
"time"
"github.com/steveiliop56/ding"
"github.com/tinyauthapp/tinyauth/internal/controller"
"github.com/tinyauthapp/tinyauth/internal/middleware"
"github.com/tinyauthapp/tinyauth/internal/model"
"github.com/gin-gonic/gin"
)
type Listener int
const (
ListenerHTTP Listener = iota
ListenerUnix
ListenerTailscale
)
func (app *BootstrapApp) setupRouter() error {
// we don't want gin debug mode
gin.SetMode(gin.ReleaseMode)
@@ -24,7 +40,7 @@ func (app *BootstrapApp) setupRouter() error {
}
}
contextMiddleware := middleware.NewContextMiddleware(app.log, app.runtime, app.services.authService, app.services.oauthBrokerService)
contextMiddleware := middleware.NewContextMiddleware(app.log, app.runtime, app.services.authService, app.services.oauthBrokerService, app.services.tailscaleService)
engine.Use(contextMiddleware.Middleware())
uiMiddleware, err := middleware.NewUIMiddleware()
@@ -43,8 +59,8 @@ 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)
controller.NewOIDCController(app.log, app.services.oidcService, app.runtime, apiRouter, &engine.RouterGroup)
controller.NewProxyController(app.log, app.runtime, apiRouter, app.services.accessControlService, app.services.authService, app.services.policyEngine)
controller.NewUserController(app.log, app.runtime, apiRouter, app.services.authService)
controller.NewResourcesController(app.config, &engine.RouterGroup)
controller.NewHealthController(apiRouter)
@@ -53,3 +69,162 @@ func (app *BootstrapApp) setupRouter() error {
app.router = engine
return nil
}
func (app *BootstrapApp) runListeners() (chan error, error) {
// lec -> listener error channel
lec := make(chan error, len(app.listeners))
for _, listenerType := range app.listeners {
listenerFunc, err := app.listenerFromType(listenerType)
if err != nil {
return nil, fmt.Errorf("failed to get listener function: %w", err)
}
app.ding.Go(func(ctx context.Context) {
lec <- listenerFunc(ctx)
}, ding.RingNormal)
}
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(ctx context.Context) error, error) {
switch listenerType {
case ListenerHTTP:
return app.serveHTTP, nil
case ListenerUnix:
return app.serveUnix, nil
case ListenerTailscale:
return app.serveTailscale, nil
default:
return nil, fmt.Errorf("invalid listener type: %d", listenerType)
}
}
func (app *BootstrapApp) serveHTTP(ctx context.Context) error {
address := fmt.Sprintf("%s:%d", app.config.Server.Address, app.config.Server.Port)
app.log.App.Info().Msgf("Starting server on %s", address)
listener, err := net.Listen("tcp", address)
if err != nil {
return fmt.Errorf("failed to create tcp listener: %w", err)
}
server := &http.Server{
Addr: address,
Handler: app.router.Handler(),
}
return app.serve(listener, server, ctx, "http")
}
func (app *BootstrapApp) serveUnix(ctx context.Context) error {
_, err := os.Stat(app.config.Server.SocketPath)
if err == nil {
app.log.App.Info().Msgf("Removing existing socket file %s", app.config.Server.SocketPath)
err := os.Remove(app.config.Server.SocketPath)
if err != nil {
return fmt.Errorf("failed to remove existing socket file: %w", err)
}
}
app.log.App.Info().Msgf("Starting server on unix socket %s", app.config.Server.SocketPath)
listener, err := net.Listen("unix", app.config.Server.SocketPath)
if err != nil {
return fmt.Errorf("failed to create unix socket listener: %w", err)
}
server := &http.Server{
Handler: app.router.Handler(),
}
return app.serve(listener, server, ctx, "unix socket")
}
func (app *BootstrapApp) serveTailscale(ctx context.Context) error {
app.log.App.Info().Msgf("Starting Tailscale server on %s", fmt.Sprintf("https://%s", app.services.tailscaleService.GetHostname()))
listener, err := app.services.tailscaleService.CreateListener()
if err != nil {
return fmt.Errorf("failed to create tailscale listener: %w", err)
}
server := &http.Server{
Handler: app.router.Handler(),
}
return app.serve(listener, server, ctx, "tailscale")
}
func (app *BootstrapApp) serve(listener net.Listener, server *http.Server, ctx context.Context, name string) error {
shutdown := func() {
// we use a new context for the shutdown since the main one is cancelled
sctx, cancel := context.WithTimeout(context.Background(), model.GracefulShutdownTimeout*time.Second)
defer cancel()
err := server.Shutdown(sctx)
if err != nil {
app.log.App.Error().Err(err).Msgf("Failed to shutdown %s listener gracefully", name)
}
listener.Close()
}
go func() {
<-ctx.Done()
app.log.App.Debug().Msgf("Shutting down %s listener", name)
shutdown()
}()
err := server.Serve(listener)
if err != nil && !errors.Is(err, http.ErrServerClosed) {
shutdown()
return fmt.Errorf("failed to start %s listener: %w", name, err)
}
return nil
}
+98 -30
View File
@@ -8,7 +8,7 @@ import (
)
func (app *BootstrapApp) setupServices() error {
ldapService, err := service.NewLdapService(app.log, app.config, app.ctx, &app.wg)
ldapService, err := service.NewLdapService(app.log, app.config, app.ding)
if err != nil {
app.log.App.Warn().Err(err).Msg("Failed to initialize LDAP connection, will continue without it")
@@ -16,45 +16,36 @@ func (app *BootstrapApp) setupServices() error {
app.services.ldapService = ldapService
useKubernetes := app.config.LabelProvider == "kubernetes" ||
(app.config.LabelProvider == "auto" && os.Getenv("KUBERNETES_SERVICE_HOST") != "")
labelProvider, err := app.getLabelProvider()
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
if err != nil {
return fmt.Errorf("failed to initialize label provider: %w", err)
}
accessControlsService := service.NewAccessControlsService(app.log, &labelProvider, app.config.Apps)
tailscaleService, err := service.NewTailscaleService(app.log, app.config, app.ctx, app.ding)
if err != nil {
app.log.App.Warn().Err(err).Msg("Failed to initialize Tailscale connection, will continue without it")
}
app.services.tailscaleService = tailscaleService
accessControlsService := service.NewAccessControlsService(app.log, app.config, &labelProvider)
app.services.accessControlService = accessControlsService
err = app.setupPolicyEngine()
if err != nil {
return fmt.Errorf("failed to initialize policy engine: %w", err)
}
oauthBrokerService := service.NewOAuthBrokerService(app.log, app.runtime.OAuthProviders, app.ctx)
app.services.oauthBrokerService = oauthBrokerService
authService := service.NewAuthService(app.log, app.config, app.runtime, app.ctx, &app.wg, app.services.ldapService, app.queries, app.services.oauthBrokerService)
authService := service.NewAuthService(app.log, app.config, app.runtime, app.ctx, app.ding, app.services.ldapService, app.queries, app.services.oauthBrokerService, app.services.tailscaleService, app.services.policyEngine)
app.services.authService = authService
oidcService, err := service.NewOIDCService(app.log, app.config, app.runtime, app.queries, app.ctx, &app.wg)
oidcService, err := service.NewOIDCService(app.log, app.config, app.runtime, app.queries, app.ding)
if err != nil {
return fmt.Errorf("failed to initialize oidc service: %w", err)
@@ -64,3 +55,80 @@ 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.ding)
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.ding)
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
}
+99 -57
View File
@@ -1,39 +1,74 @@
package controller
import (
"fmt"
"net/url"
"github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
"github.com/gin-gonic/gin"
)
// UCR -> User Context Response
type UCRAuth struct {
Authenticated bool `json:"authenticated"`
Username string `json:"username"`
Name string `json:"name"`
Email string `json:"email"`
ProviderID string `json:"providerId"`
}
type UCROAuth struct {
Active bool `json:"active"`
DisplayName string `json:"displayName"`
}
type UCRTOTP struct {
Pending bool `json:"pending"`
}
type UCRTailscale struct {
NodeName string `json:"nodeName,omitempty"`
}
type UserContextResponse struct {
Status int `json:"status"`
Message string `json:"message"`
IsLoggedIn bool `json:"isLoggedIn"`
Username string `json:"username"`
Name string `json:"name"`
Email string `json:"email"`
Provider string `json:"provider"`
OAuth bool `json:"oauth"`
TOTPPending bool `json:"totpPending"`
OAuthName string `json:"oauthName"`
Status int `json:"status"`
Message string `json:"message"`
Auth UCRAuth `json:"auth"`
OAuth UCROAuth `json:"oauth"`
TOTP UCRTOTP `json:"totp"`
Tailscale UCRTailscale `json:"tailscale"`
}
// ACR -> App Context Response
type ACRAuth struct {
Providers []model.Provider `json:"providers"`
}
type ACROAuth struct {
AutoRedirect string `json:"autoRedirect"`
}
type ACRUI struct {
Title string `json:"title"`
ForgotPasswordMessage string `json:"forgotPasswordMessage"`
BackgroundImage string `json:"backgroundImage"`
WarningsEnabled bool `json:"warningsEnabled"`
}
type ACRApp struct {
AppURL string `json:"appUrl"`
CookieDomain string `json:"cookieDomain"`
TrustedDomains []string `json:"trustedDomains"`
}
type AppContextResponse struct {
Status int `json:"status"`
Message string `json:"message"`
Providers []model.Provider `json:"providers"`
Title string `json:"title"`
AppURL string `json:"appUrl"`
CookieDomain string `json:"cookieDomain"`
ForgotPasswordMessage string `json:"forgotPasswordMessage"`
BackgroundImage string `json:"backgroundImage"`
OAuthAutoRedirect string `json:"oauthAutoRedirect"`
WarningsEnabled bool `json:"warningsEnabled"`
Status int `json:"status"`
Message string `json:"message"`
Auth ACRAuth `json:"auth"`
OAuth ACROAuth `json:"oauth"`
UI ACRUI `json:"ui"`
App ACRApp `json:"app"`
}
type ContextController struct {
@@ -71,51 +106,58 @@ func (controller *ContextController) userContextHandler(c *gin.Context) {
if err != nil {
controller.log.App.Error().Err(err).Msg("Failed to create user context from request")
c.JSON(200, UserContextResponse{
Status: 401,
Message: "Unauthorized",
IsLoggedIn: false,
Status: 401,
Message: "Unauthorized",
Auth: UCRAuth{Authenticated: false},
})
return
}
userContext := UserContextResponse{
Status: 200,
Message: "Success",
IsLoggedIn: context.Authenticated,
Username: context.GetUsername(),
Name: context.GetName(),
Email: context.GetEmail(),
Provider: context.GetProviderID(),
OAuth: context.IsOAuth(),
TOTPPending: context.TOTPPending(),
OAuthName: context.OAuthName(),
Status: 200,
Message: "Success",
Auth: UCRAuth{
Authenticated: context.Authenticated,
Username: context.GetUsername(),
Name: context.GetName(),
Email: context.GetEmail(),
ProviderID: context.GetProviderID(),
},
OAuth: UCROAuth{
Active: context.IsOAuth(),
DisplayName: context.OAuthName(),
},
TOTP: UCRTOTP{
Pending: context.TOTPPending(),
},
Tailscale: UCRTailscale{
NodeName: context.TailscaleNodeName(),
},
}
c.JSON(200, userContext)
}
func (controller *ContextController) appContextHandler(c *gin.Context) {
appUrl, err := url.Parse(controller.runtime.AppURL)
if err != nil {
controller.log.App.Error().Err(err).Msg("Failed to parse app URL")
c.JSON(500, gin.H{
"status": 500,
"message": "Internal Server Error",
})
return
}
c.JSON(200, AppContextResponse{
Status: 200,
Message: "Success",
Providers: controller.runtime.ConfiguredProviders,
Title: controller.config.UI.Title,
AppURL: fmt.Sprintf("%s://%s", appUrl.Scheme, appUrl.Host),
CookieDomain: controller.runtime.CookieDomain,
ForgotPasswordMessage: controller.config.UI.ForgotPasswordMessage,
BackgroundImage: controller.config.UI.BackgroundImage,
OAuthAutoRedirect: controller.config.OAuth.AutoRedirect,
WarningsEnabled: controller.config.UI.WarningsEnabled,
Status: 200,
Message: "Success",
Auth: ACRAuth{
Providers: controller.runtime.ConfiguredProviders,
},
OAuth: ACROAuth{
AutoRedirect: controller.config.OAuth.AutoRedirect,
},
UI: ACRUI{
Title: controller.config.UI.Title,
ForgotPasswordMessage: controller.config.UI.ForgotPasswordMessage,
BackgroundImage: controller.config.UI.BackgroundImage,
WarningsEnabled: controller.config.UI.WarningsEnabled,
},
App: ACRApp{
AppURL: controller.runtime.AppURL,
CookieDomain: controller.runtime.CookieDomain,
TrustedDomains: controller.runtime.TrustedDomains,
},
})
}
+28 -17
View File
@@ -34,16 +34,25 @@ func TestContextController(t *testing.T) {
path: "/api/context/app",
expected: func() string {
expectedAppContextResponse := controller.AppContextResponse{
Status: 200,
Message: "Success",
Providers: runtime.ConfiguredProviders,
Title: cfg.UI.Title,
AppURL: runtime.AppURL,
CookieDomain: runtime.CookieDomain,
ForgotPasswordMessage: cfg.UI.ForgotPasswordMessage,
BackgroundImage: cfg.UI.BackgroundImage,
OAuthAutoRedirect: cfg.OAuth.AutoRedirect,
WarningsEnabled: cfg.UI.WarningsEnabled,
Status: 200,
Message: "Success",
Auth: controller.ACRAuth{
Providers: runtime.ConfiguredProviders,
},
OAuth: controller.ACROAuth{
AutoRedirect: cfg.OAuth.AutoRedirect,
},
UI: controller.ACRUI{
Title: cfg.UI.Title,
ForgotPasswordMessage: cfg.UI.ForgotPasswordMessage,
BackgroundImage: cfg.UI.BackgroundImage,
WarningsEnabled: cfg.UI.WarningsEnabled,
},
App: controller.ACRApp{
AppURL: runtime.AppURL,
CookieDomain: runtime.CookieDomain,
TrustedDomains: runtime.TrustedDomains,
},
}
bytes, err := json.Marshal(expectedAppContextResponse)
require.NoError(t, err)
@@ -84,13 +93,15 @@ func TestContextController(t *testing.T) {
path: "/api/context/user",
expected: func() string {
expectedUserContextResponse := controller.UserContextResponse{
Status: 200,
Message: "Success",
IsLoggedIn: true,
Username: "johndoe",
Name: "John Doe",
Email: utils.CompileUserEmail("johndoe", runtime.CookieDomain),
Provider: "local",
Status: 200,
Message: "Success",
Auth: controller.UCRAuth{
Authenticated: true,
Username: "johndoe",
Name: "John Doe",
Email: utils.CompileUserEmail("johndoe", runtime.CookieDomain),
ProviderID: "local",
},
}
bytes, err := json.Marshal(expectedUserContextResponse)
require.NoError(t, err)
+16 -16
View File
@@ -183,9 +183,23 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
return
}
if !controller.auth.IsEmailWhitelisted(user.Email) {
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) {
controller.log.App.Warn().Str("email", user.Email).Msg("Email not whitelisted, denying access")
controller.log.AuditLoginFailure(user.Email, req.Provider, c.ClientIP(), "email not whitelisted")
controller.log.AuditLoginFailure(user.Email, svc.ID(), c.ClientIP(), "email not whitelisted")
queries, err := query.Values(UnauthorizedQuery{
Username: user.Email,
@@ -226,20 +240,6 @@ 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,
+234 -135
View File
@@ -1,6 +1,7 @@
package controller
import (
"encoding/json"
"errors"
"fmt"
"net/http"
@@ -12,10 +13,19 @@ import (
"github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/service"
"github.com/tinyauthapp/tinyauth/internal/utils"
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
)
type authorizeErrorParams struct {
err error
reason string
reasonPublic string
callback string
callbackError string
state string
json bool
}
type OIDCController struct {
log *logger.Logger
oidc *service.OIDCService
@@ -56,20 +66,34 @@ type ClientCredentials struct {
ClientSecret string
}
type AuthorizeScreenParams struct {
LoginFor string `url:"login_for"`
OIDCTicket string `url:"oidc_ticket"`
OIDCScope string `url:"oidc_scope"`
OIDCName string `url:"oidc_name"`
}
type AuthorizeCompleteRequest struct {
Ticket string `json:"ticket" binding:"required"`
}
func NewOIDCController(
log *logger.Logger,
oidcService *service.OIDCService,
runtimeConfig model.RuntimeConfig,
router *gin.RouterGroup) *OIDCController {
router *gin.RouterGroup,
mainRouter *gin.RouterGroup) *OIDCController {
controller := &OIDCController{
log: log,
oidc: oidcService,
runtime: runtimeConfig,
}
mainRouter.POST("/authorize", controller.authorize)
mainRouter.GET("/authorize", controller.authorize)
oidcGroup := router.Group("/oidc")
oidcGroup.GET("/clients/:id", controller.GetClientInfo)
oidcGroup.POST("/authorize", controller.Authorize)
oidcGroup.POST("/authorize-complete", controller.authorizeComplete)
oidcGroup.POST("/token", controller.Token)
oidcGroup.GET("/userinfo", controller.Userinfo)
oidcGroup.POST("/userinfo", controller.Userinfo)
@@ -77,133 +101,197 @@ func NewOIDCController(
return controller
}
func (controller *OIDCController) GetClientInfo(c *gin.Context) {
// This endpoint does **not** return a code, it handles param validation, ticket creation
// and then redirects to the frontend to handle the consent screen. It performs no destructive
// actions (like logging out an existing session)
func (controller *OIDCController) authorize(c *gin.Context) {
if controller.oidc == nil {
controller.log.App.Warn().Msg("Received OIDC client info request but OIDC server is not configured")
c.JSON(500, gin.H{
"status": 500,
"message": "OIDC not configured",
controller.authorizeError(c, authorizeErrorParams{
err: errors.New("err_oidc_not_configured"),
reason: "OIDC not configured",
reasonPublic: "This instance is not configured for OIDC",
})
return
}
var req ClientRequest
err := c.BindUri(&req)
if err != nil {
controller.log.App.Error().Err(err).Msg("Failed to bind URI")
c.JSON(400, gin.H{
"status": 400,
"message": "Bad Request",
})
return
}
client, ok := controller.oidc.GetClient(req.ClientID)
if !ok {
controller.log.App.Warn().Str("clientId", req.ClientID).Msg("Client not found")
c.JSON(404, gin.H{
"status": 404,
"message": "Client not found",
})
return
}
c.JSON(200, gin.H{
"status": 200,
"client": client.ClientID,
"name": client.Name,
})
}
func (controller *OIDCController) Authorize(c *gin.Context) {
if controller.oidc == nil {
controller.authorizeError(c, errors.New("err_oidc_not_configured"), "OIDC not configured", "This instance is not configured for OIDC", "", "", "")
return
}
userContext, err := new(model.UserContext).NewFromGin(c)
if err != nil {
controller.authorizeError(c, err, "Failed to get user context", "User is not logged in or the session is invalid", "", "", "")
return
}
if !userContext.Authenticated {
controller.authorizeError(c, errors.New("err user not logged in"), "User not logged in", "The user is not logged in", "", "", "")
return
}
var req service.AuthorizeRequest
err = c.BindJSON(&req)
err := c.Bind(&req)
if err != nil {
controller.authorizeError(c, err, "Failed to bind JSON", "The client provided an invalid authorization request", "", "", "")
controller.authorizeError(c, authorizeErrorParams{
err: err,
reason: "Failed to bind JSON",
reasonPublic: "The client provided an invalid authorization request",
})
return
}
client, ok := controller.oidc.GetClient(req.ClientID)
if !ok {
controller.authorizeError(c, fmt.Errorf("client not found: %s", req.ClientID), "Client not found", "The client ID is invalid", "", "", "")
controller.authorizeError(c, authorizeErrorParams{
err: fmt.Errorf("client not found: %s", req.ClientID),
reason: "Client not found",
reasonPublic: "The client ID is invalid",
})
return
}
// TODO: handle request= parameter with JWTs
err = controller.oidc.ValidateAuthorizeParams(req)
if err != nil {
controller.log.App.Warn().Err(err).Msg("Failed to validate authorize params")
if err.Error() != "invalid_request_uri" {
controller.authorizeError(c, err, "Failed validate authorize params", "Invalid request parameters", req.RedirectURI, err.Error(), req.State)
controller.authorizeError(c, authorizeErrorParams{
err: err,
reason: "Failed validate authorize params",
reasonPublic: "Invalid request parameters",
callback: req.RedirectURI,
callbackError: err.Error(),
state: req.State,
})
return
}
controller.authorizeError(c, err, "Redirect URI not trusted", "The provided redirect URI is not trusted", "", "", "")
controller.authorizeError(c, authorizeErrorParams{
err: err,
reason: "Redirect URI not trusted",
reasonPublic: "The provided redirect URI is not trusted",
})
return
}
// WARNING: Since Tinyauth is stateless, we cannot have a sub that never changes. We will just create a uuid out of the username and client name which remains stable, but if username or client name changes then sub changes too.
sub := utils.GenerateUUID(fmt.Sprintf("%s:%s", userContext.GetUsername(), client.ID))
code := utils.GenerateString(32)
ticket := controller.oidc.CreateAuthorizeRequestTicket(req)
queries, err := query.Values(AuthorizeScreenParams{
LoginFor: "oidc",
OIDCTicket: ticket,
OIDCScope: req.Scope,
OIDCName: client.Name,
})
if err != nil {
controller.authorizeError(c, authorizeErrorParams{
err: err,
reason: "Failed to compile authorize queries",
reasonPublic: "An internal error occured while processing your request",
})
return
}
redirectUrl := fmt.Sprintf("%s/oidc/authorize?%s", controller.oidc.GetIssuer(), queries.Encode())
c.Redirect(http.StatusFound, redirectUrl)
}
// The actual **internal** endpoint that actually creates the code and session.
// It is called by the frontend after the user has logged in and given consent.
func (controller *OIDCController) authorizeComplete(c *gin.Context) {
if controller.oidc == nil {
// For this endpoint we return JSON errors since it's called
// by the frontend and not an external client, so there's
// no redirect_uri to send the user to in case of error
controller.authorizeError(c, authorizeErrorParams{
err: errors.New("err_oidc_not_configured"),
reason: "OIDC not configured",
reasonPublic: "This instance is not configured for OIDC",
json: true,
})
return
}
userContext, err := new(model.UserContext).NewFromGin(c)
if err != nil {
controller.authorizeError(c, authorizeErrorParams{
err: err,
reason: "Failed to get user context",
reasonPublic: "User is not logged in or the session is invalid",
json: true,
})
return
}
if !userContext.Authenticated {
controller.authorizeError(c, authorizeErrorParams{
err: errors.New("err user not logged in"),
reason: "User not logged in",
reasonPublic: "The user is not logged in",
json: true,
})
return
}
var req AuthorizeCompleteRequest
err = c.BindJSON(&req)
if err != nil {
controller.authorizeError(c, authorizeErrorParams{
err: err,
reason: "Failed to bind JSON",
reasonPublic: "The client provided an invalid authorization request",
json: true,
})
return
}
authorizeReq, ok := controller.oidc.GetAuthorizeRequestByTicket(req.Ticket)
if !ok {
controller.authorizeError(c, authorizeErrorParams{
err: errors.New("authorize request not found for ticket"),
reason: "Invalid or expired ticket",
reasonPublic: "The authorization request has expired or is invalid",
json: true,
})
return
}
// We no longer need the ticket
controller.oidc.DeleteAuthorizeRequestTicket(req.Ticket)
// Create the sub to find and delete old sessions
sub := controller.oidc.CreateSub(*userContext, authorizeReq.ClientID)
// Before storing the code, delete old session
err = controller.oidc.DeleteOldSession(c, sub)
if err != nil {
controller.authorizeError(c, err, "Failed to delete old sessions", "Failed to delete old sessions", req.RedirectURI, "server_error", req.State)
controller.authorizeError(c, authorizeErrorParams{
err: err,
reason: "Failed to delete old sessions",
reasonPublic: "Failed to delete old sessions",
callback: authorizeReq.RedirectURI,
callbackError: "server_error",
state: authorizeReq.State,
})
return
}
err = controller.oidc.StoreCode(c, sub, code, req)
if err != nil {
controller.authorizeError(c, err, "Failed to store code", "Failed to store code", req.RedirectURI, "server_error", req.State)
return
}
// We also need a snapshot of the user that authorized this (skip if no openid scope)
if slices.Contains(strings.Fields(req.Scope), "openid") {
err = controller.oidc.StoreUserinfo(c, sub, *userContext, req)
if err != nil {
controller.log.App.Error().Err(err).Msg("Failed to store user info")
controller.authorizeError(c, err, "Failed to store user info", "Failed to store user info", req.RedirectURI, "server_error", req.State)
return
}
}
// Create the authorization code
code := controller.oidc.CreateCode(*authorizeReq, *userContext)
queries, err := query.Values(AuthorizeCallback{
Code: code,
State: req.State,
State: authorizeReq.State,
})
if err != nil {
controller.authorizeError(c, err, "Failed to build query", "Failed to build query", req.RedirectURI, "server_error", req.State)
controller.authorizeError(c, authorizeErrorParams{
err: err,
reason: "Failed to build query",
reasonPublic: "Failed to build query",
callback: authorizeReq.RedirectURI,
callbackError: "server_error",
state: authorizeReq.State,
})
return
}
c.JSON(200, gin.H{
"status": 200,
"redirect_uri": fmt.Sprintf("%s?%s", req.RedirectURI, queries.Encode()),
"redirect_uri": fmt.Sprintf("%s?%s", authorizeReq.RedirectURI, queries.Encode()),
})
}
@@ -285,39 +373,34 @@ func (controller *OIDCController) Token(c *gin.Context) {
switch req.GrantType {
case "authorization_code":
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")
}
if errors.Is(err, service.ErrCodeNotFound) {
controller.log.App.Warn().Msg("Code not found")
entry, ok := controller.oidc.GetCodeEntry(controller.oidc.Hash(req.Code), client.ClientID)
if !ok {
// ensure no code reuse
usedCodeSub, ok := controller.oidc.IsCodeUsed(controller.oidc.Hash(req.Code))
if ok {
controller.log.App.Warn().Msg("Code reuse detected")
err := controller.oidc.DeleteSessionBySub(c, usedCodeSub)
if err != nil {
controller.log.App.Error().Err(err).Msg("Failed to delete session for reused code")
}
c.JSON(400, gin.H{
"error": "invalid_grant",
})
return
}
if errors.Is(err, service.ErrCodeExpired) {
controller.log.App.Warn().Msg("Code expired")
c.JSON(400, gin.H{
"error": "invalid_grant",
})
return
}
if errors.Is(err, service.ErrInvalidClient) {
controller.log.App.Warn().Msg("Code does not belong to client")
c.JSON(400, gin.H{
"error": "invalid_client",
})
return
}
controller.log.App.Error().Err(err).Msg("Failed to get code entry")
controller.log.App.Warn().Msg("Code not found")
c.JSON(400, gin.H{
"error": "server_error",
"error": "invalid_grant",
})
return
}
// mark code as used to prevent reuse
controller.oidc.MarkCodeAsUsed(controller.oidc.Hash(req.Code), entry.Userinfo.Sub)
if entry.RedirectURI != req.RedirectURI {
controller.log.App.Warn().Msg("Redirect URI does not match")
c.JSON(400, gin.H{
@@ -326,7 +409,7 @@ func (controller *OIDCController) Token(c *gin.Context) {
return
}
ok := controller.oidc.ValidatePKCE(entry.CodeChallenge, req.CodeVerifier)
ok = controller.oidc.ValidatePKCE(entry.CodeChallenge, req.CodeVerifier)
if !ok {
controller.log.App.Warn().Msg("PKCE validation failed")
@@ -336,7 +419,7 @@ func (controller *OIDCController) Token(c *gin.Context) {
return
}
tokenRes, err := controller.oidc.GenerateAccessToken(c, client, entry)
tokenRes, err := controller.oidc.GenerateAccessToken(c, client, *entry)
if err != nil {
controller.log.App.Error().Err(err).Msg("Failed to generate access token")
@@ -346,7 +429,7 @@ func (controller *OIDCController) Token(c *gin.Context) {
return
}
tokenResponse = tokenRes
tokenResponse = *tokenRes
case "refresh_token":
tokenRes, err := controller.oidc.RefreshAccessToken(c, req.RefreshToken, creds.ClientID)
@@ -374,7 +457,7 @@ func (controller *OIDCController) Token(c *gin.Context) {
return
}
tokenResponse = tokenRes
tokenResponse = *tokenRes
}
c.Header("cache-control", "no-store")
@@ -438,7 +521,7 @@ func (controller *OIDCController) Userinfo(c *gin.Context) {
return
}
entry, err := controller.oidc.GetAccessToken(c, controller.oidc.Hash(token))
entry, err := controller.oidc.GetSessionByToken(c, controller.oidc.Hash(token))
if err != nil {
if errors.Is(err, service.ErrTokenNotFound) {
@@ -457,15 +540,17 @@ func (controller *OIDCController) Userinfo(c *gin.Context) {
}
// If we don't have the openid scope, return an error
if !slices.Contains(strings.Split(entry.Scope, ","), "openid") {
controller.log.App.Warn().Msg("OIDC userinfo accessed with token missing openid scope")
if !slices.Contains(strings.Split(entry.Scope, " "), "openid") {
controller.log.App.Warn().Msg("OIDC userinfo accessed with missing openid scope")
c.JSON(401, gin.H{
"error": "invalid_scope",
})
return
}
user, err := controller.oidc.GetUserinfo(c, entry.Sub)
var userinfo service.UserinfoResponse
err = json.Unmarshal([]byte(entry.UserinfoJson), &userinfo)
if err != nil {
controller.log.App.Error().Err(err).Msg("Failed to get user info")
@@ -475,46 +560,55 @@ func (controller *OIDCController) Userinfo(c *gin.Context) {
return
}
c.JSON(200, controller.oidc.CompileUserinfo(user, entry.Scope))
c.JSON(200, controller.oidc.CompileUserinfo(userinfo, entry.Scope))
}
func (controller *OIDCController) authorizeError(c *gin.Context, err error, reason string, reasonUser string, callback string, callbackError string, state string) {
controller.log.App.Warn().Err(err).Str("reason", reason).Msg("Authorization error")
func (controller *OIDCController) authorizeError(c *gin.Context, params authorizeErrorParams) {
controller.log.App.Error().Err(params.err).Str("reason", params.reason).Msg("Authorization error")
if callback != "" {
if params.callback != "" {
errorQueries := CallbackError{
Error: callbackError,
Error: params.callbackError,
}
if reasonUser != "" {
errorQueries.ErrorDescription = reasonUser
if params.reasonPublic != "" {
errorQueries.ErrorDescription = params.reasonPublic
}
if state != "" {
errorQueries.State = state
if params.state != "" {
errorQueries.State = params.state
}
queries, err := query.Values(errorQueries)
if err != nil {
controller.log.App.Error().Err(err).Msg("Failed to build callback error query")
c.AbortWithStatus(http.StatusInternalServerError)
return
}
c.JSON(200, gin.H{
"status": 200,
"redirect_uri": fmt.Sprintf("%s?%s", callback, queries.Encode()),
})
redirectUrl := fmt.Sprintf("%s?%s", params.callback, queries.Encode())
if params.json {
c.JSON(200, gin.H{
"status": 200,
"redirect_uri": redirectUrl,
})
return
}
c.Redirect(http.StatusFound, redirectUrl)
return
}
errorQueries := ErrorScreen{
Error: reasonUser,
Error: params.reasonPublic,
}
queries, err := query.Values(errorQueries)
if err != nil {
controller.log.App.Error().Err(err).Msg("Failed to build error query")
c.AbortWithStatus(http.StatusInternalServerError)
return
}
@@ -527,8 +621,13 @@ func (controller *OIDCController) authorizeError(c *gin.Context, err error, reas
redirectUrl = fmt.Sprintf("%s/error?%s", controller.runtime.AppURL, queries.Encode())
}
c.JSON(200, gin.H{
"status": 200,
"redirect_uri": redirectUrl,
})
if params.json {
c.JSON(200, gin.H{
"status": 200,
"redirect_uri": redirectUrl,
})
return
}
c.Redirect(http.StatusFound, redirectUrl)
}
+5 -15
View File
@@ -8,17 +8,16 @@ import (
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
"github.com/gin-gonic/gin"
"github.com/google/go-querystring/query"
"github.com/steveiliop56/ding"
"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"
@@ -839,16 +838,11 @@ func TestOIDCController(t *testing.T) {
},
}
app := bootstrap.NewBootstrapApp(cfg)
store := memory.New()
err := app.SetupDatabase()
require.NoError(t, err)
dg := ding.New(context.TODO())
queries := repository.New(app.GetDB())
wg := &sync.WaitGroup{}
oidcService, err := service.NewOIDCService(log, cfg, runtime, queries, context.TODO(), wg)
oidcService, err := service.NewOIDCService(log, cfg, runtime, store, dg)
require.NoError(t, err)
for _, test := range tests {
@@ -869,8 +863,4 @@ func TestOIDCController(t *testing.T) {
test.run(t, router, recorder)
})
}
t.Cleanup(func() {
app.GetDB().Close()
})
}
+30 -25
View File
@@ -3,6 +3,7 @@ package controller
import (
"errors"
"fmt"
"net"
"net/http"
"net/url"
"regexp"
@@ -51,10 +52,11 @@ type ProxyContext struct {
}
type ProxyController struct {
log *logger.Logger
runtime model.RuntimeConfig
acls *service.AccessControlsService
auth *service.AuthService
log *logger.Logger
runtime model.RuntimeConfig
acls *service.AccessControlsService
auth *service.AuthService
policyEngine *service.PolicyEngine
}
func NewProxyController(
@@ -63,12 +65,14 @@ 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,
log: log,
runtime: runtime,
acls: acls,
auth: auth,
policyEngine: policyEngine,
}
proxyGroup := router.Group("/auth")
@@ -101,7 +105,13 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
clientIP := c.ClientIP()
if controller.auth.IsBypassedIP(clientIP, acls) {
aclsCtx := &service.ACLContext{
ACLs: acls,
IP: net.ParseIP(clientIP),
Path: proxyCtx.Path,
}
if controller.policyEngine.Evaluate(service.RuleIPBypassed, aclsCtx) {
controller.setHeaders(c, acls)
c.JSON(200, gin.H{
"status": 200,
@@ -110,15 +120,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
return
}
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 {
if controller.policyEngine.Evaluate(service.RuleAuthEnabled, aclsCtx) {
controller.log.App.Debug().Msg("Authentication is disabled for this resource, allowing access without authentication")
controller.setHeaders(c, acls)
c.JSON(200, gin.H{
@@ -128,7 +130,7 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
return
}
if !controller.auth.CheckIP(clientIP, acls) {
if !controller.policyEngine.Evaluate(service.RuleIPAllowed, aclsCtx) {
queries, err := query.Values(UnauthorizedQuery{
Resource: strings.Split(proxyCtx.Host, ".")[0],
IP: clientIP,
@@ -158,16 +160,19 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
userContext, err := new(model.UserContext).NewFromGin(c)
if err != nil {
controller.log.App.Debug().Err(err).Msg("Failed to create user context from request, treating as unauthenticated")
// 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")
}
userContext = &model.UserContext{
Authenticated: false,
}
}
if userContext.Authenticated {
userAllowed := controller.auth.IsUserAllowed(c, *userContext, acls)
aclsCtx.UserContext = userContext
if !userAllowed {
if userContext.Authenticated {
if !controller.policyEngine.Evaluate(service.RuleUserAllowed, aclsCtx) {
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{
@@ -205,9 +210,9 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
var groupOK bool
if userContext.IsOAuth() {
groupOK = controller.auth.IsInOAuthGroup(c, *userContext, acls)
groupOK = controller.policyEngine.Evaluate(service.RuleOAuthGroup, aclsCtx)
} else {
groupOK = controller.auth.IsInLDAPGroup(c, *userContext, acls)
groupOK = controller.policyEngine.Evaluate(service.RuleLDAPGroup, aclsCtx)
}
if !groupOK {
+31 -44
View File
@@ -3,16 +3,15 @@ package controller_test
import (
"context"
"net/http/httptest"
"sync"
"testing"
"github.com/gin-gonic/gin"
"github.com/steveiliop56/ding"
"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"
@@ -24,33 +23,6 @@ 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`
@@ -379,19 +351,38 @@ func TestProxyController(t *testing.T) {
},
}
app := bootstrap.NewBootstrapApp(cfg)
store := memory.New()
err := app.SetupDatabase()
require.NoError(t, err)
queries := repository.New(app.GetDB())
wg := &sync.WaitGroup{}
ctx := context.TODO()
dg := ding.New(ctx)
broker := service.NewOAuthBrokerService(log, map[string]model.OAuthServiceConfig{}, ctx)
authService := service.NewAuthService(log, cfg, runtime, ctx, wg, nil, queries, broker)
aclsService := service.NewAccessControlsService(log, nil, acls)
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, dg, nil, store, broker, nil, policyEngine)
for _, test := range tests {
t.Run(test.description, func(t *testing.T) {
@@ -406,13 +397,9 @@ func TestProxyController(t *testing.T) {
recorder := httptest.NewRecorder()
controller.NewProxyController(log, runtime, group, aclsService, authService)
controller.NewProxyController(log, runtime, group, aclsService, authService, policyEngine)
test.run(t, router, recorder)
})
}
t.Cleanup(func() {
app.GetDB().Close()
})
}
+51
View File
@@ -47,6 +47,7 @@ func NewUserController(
userGroup.POST("/login", controller.loginHandler)
userGroup.POST("/logout", controller.logoutHandler)
userGroup.POST("/totp", controller.totpHandler)
userGroup.POST("/tailscale", controller.tailscaleHandler)
return controller
}
@@ -394,3 +395,53 @@ func (controller *UserController) totpHandler(c *gin.Context) {
"message": "Login successful",
})
}
func (controller *UserController) tailscaleHandler(c *gin.Context) {
context, err := new(model.UserContext).NewFromGin(c)
if err != nil {
controller.log.App.Error().Err(err).Msg("Failed to create user context from request")
c.JSON(401, gin.H{
"status": 401,
"message": "Unauthorized",
})
return
}
if context.Tailscale == nil {
controller.log.App.Warn().Msg("Tailscale login attempt without Tailscale context")
c.JSON(401, gin.H{
"status": 401,
"message": "Unauthorized",
})
return
}
sessionCookie := repository.Session{
Username: context.Tailscale.Username,
Name: context.Tailscale.Name,
Email: context.Tailscale.Email,
Provider: "tailscale",
}
cookie, err := controller.auth.CreateSession(c, sessionCookie)
if err != nil {
controller.log.App.Error().Err(err).Str("username", context.GetUsername()).Msg("Failed to create session cookie after successful Tailscale login")
c.JSON(500, gin.H{
"status": 500,
"message": "Internal Server Error",
})
return
}
http.SetCookie(c.Writer, cookie)
controller.log.App.Info().Str("username", context.GetUsername()).Msg("Tailscale login successful, login complete")
controller.log.AuditLoginSuccess(context.GetUsername(), "tailscale", c.ClientIP())
c.JSON(200, gin.H{
"status": 200,
"message": "Login successful",
})
}
+11 -17
View File
@@ -6,18 +6,18 @@ import (
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/pquerna/otp/totp"
"github.com/steveiliop56/ding"
"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,12 +73,7 @@ func TestUserController(t *testing.T) {
})
}
app := bootstrap.NewBootstrapApp(cfg)
err := app.SetupDatabase()
require.NoError(t, err)
queries := repository.New(app.GetDB())
store := memory.New()
type testCase struct {
description string
@@ -254,7 +249,7 @@ func TestUserController(t *testing.T) {
totpCtx,
},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
_, err := queries.CreateSession(context.TODO(), repository.CreateSessionParams{
_, err := store.CreateSession(context.TODO(), repository.CreateSessionParams{
UUID: "test-totp-login-uuid",
Username: "test",
Email: "test@example.com",
@@ -378,7 +373,7 @@ func TestUserController(t *testing.T) {
totpAttrCtx,
},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
_, err := queries.CreateSession(context.TODO(), repository.CreateSessionParams{
_, err := store.CreateSession(context.TODO(), repository.CreateSessionParams{
UUID: "test-totp-login-attributes-uuid",
Username: "test",
Email: "test@example.com",
@@ -417,14 +412,17 @@ func TestUserController(t *testing.T) {
}
ctx := context.TODO()
wg := &sync.WaitGroup{}
dg := ding.New(ctx)
policyEngine, err := service.NewPolicyEngine(cfg, log)
require.NoError(t, err)
broker := service.NewOAuthBrokerService(log, map[string]model.OAuthServiceConfig{}, ctx)
authService := service.NewAuthService(log, cfg, runtime, ctx, wg, nil, queries, broker)
authService := service.NewAuthService(log, cfg, runtime, ctx, dg, nil, store, broker, nil, policyEngine)
beforeEach := func() {
// Clear failed login attempts before each test
authService.ClearRateLimitsTestingOnly()
authService.ClearLoginAttempts()
}
for _, test := range tests {
@@ -446,8 +444,4 @@ func TestUserController(t *testing.T) {
test.run(t, router, recorder)
})
}
t.Cleanup(func() {
app.GetDB().Close()
})
}
@@ -5,15 +5,14 @@ import (
"encoding/json"
"fmt"
"net/http/httptest"
"sync"
"testing"
"github.com/gin-gonic/gin"
"github.com/steveiliop56/ding"
"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"
"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"
@@ -90,16 +89,11 @@ func TestWellKnownController(t *testing.T) {
}
ctx := context.TODO()
wg := &sync.WaitGroup{}
dg := ding.New(ctx)
app := bootstrap.NewBootstrapApp(cfg)
store := memory.New()
err := app.SetupDatabase()
require.NoError(t, err)
queries := repository.New(app.GetDB())
oidcService, err := service.NewOIDCService(log, cfg, runtime, queries, ctx, wg)
oidcService, err := service.NewOIDCService(log, cfg, runtime, store, dg)
require.NoError(t, err)
for _, test := range tests {
@@ -114,8 +108,4 @@ func TestWellKnownController(t *testing.T) {
test.run(t, router, recorder)
})
}
t.Cleanup(func() {
app.GetDB().Close()
})
}
+75 -11
View File
@@ -36,10 +36,11 @@ var (
)
type ContextMiddleware struct {
log *logger.Logger
runtime model.RuntimeConfig
auth *service.AuthService
broker *service.OAuthBrokerService
log *logger.Logger
runtime model.RuntimeConfig
auth *service.AuthService
broker *service.OAuthBrokerService
tailscale *service.TailscaleService
}
func NewContextMiddleware(
@@ -47,12 +48,14 @@ func NewContextMiddleware(
runtime model.RuntimeConfig,
auth *service.AuthService,
broker *service.OAuthBrokerService,
tailscale *service.TailscaleService,
) *ContextMiddleware {
return &ContextMiddleware{
log: log,
runtime: runtime,
auth: auth,
broker: broker,
log: log,
runtime: runtime,
auth: auth,
broker: broker,
tailscale: tailscale,
}
}
@@ -66,7 +69,7 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc {
uuid, err := c.Cookie(m.runtime.SessionCookieName)
if err == nil {
userContext, cookie, err := m.cookieAuth(c.Request.Context(), uuid)
userContext, cookie, err := m.cookieAuth(c.Request.Context(), uuid, c.RemoteIP())
if err == nil {
if cookie != nil {
@@ -102,11 +105,28 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc {
return
}
// Lastly check if we have a tailscale session to add
if m.tailscale != nil {
tailscaleContext, err := m.tailscaleWhois(c.Request.Context(), c.RemoteIP())
if err != nil {
m.log.App.Error().Err(err).Msgf("Error performing tailscale whois for IP %s: %v", c.RemoteIP(), err)
}
if tailscaleContext != nil {
c.Set("context", &model.UserContext{
Authenticated: false,
Provider: model.ProviderTailscale,
Tailscale: tailscaleContext,
})
}
}
c.Next()
}
}
func (m *ContextMiddleware) cookieAuth(ctx context.Context, uuid string) (*model.UserContext, *http.Cookie, error) {
func (m *ContextMiddleware) cookieAuth(ctx context.Context, uuid string, ip string) (*model.UserContext, *http.Cookie, error) {
session, err := m.auth.GetSession(ctx, uuid)
if err != nil {
@@ -141,6 +161,18 @@ func (m *ContextMiddleware) cookieAuth(ctx context.Context, uuid string) (*model
if userContext.Local.Attributes.Email == "" {
userContext.Local.Attributes.Email = utils.CompileUserEmail(user.Username, m.runtime.CookieDomain)
}
case model.ProviderTailscale:
tailscaleContext, err := m.tailscaleWhois(ctx, ip)
if err != nil {
return nil, nil, fmt.Errorf("error performing tailscale whois: %w", err)
}
if tailscaleContext == nil {
return nil, nil, fmt.Errorf("tailscale whois returned no result for IP: %s", ip)
}
userContext.Tailscale = tailscaleContext
case model.ProviderLDAP:
search, err := m.auth.SearchUser(userContext.LDAP.Username)
@@ -173,7 +205,7 @@ func (m *ContextMiddleware) cookieAuth(ctx context.Context, uuid string) (*model
return nil, nil, fmt.Errorf("oauth provider from session cookie not found: %s", userContext.OAuth.ID)
}
if !m.auth.IsEmailWhitelisted(userContext.OAuth.Email) {
if !m.auth.IsEmailWhitelisted(userContext.OAuth.ID, userContext.OAuth.Email) {
m.auth.DeleteSession(ctx, uuid)
return nil, nil, fmt.Errorf("email from session cookie not whitelisted: %s", userContext.OAuth.Email)
}
@@ -219,6 +251,10 @@ 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)
}
@@ -266,3 +302,31 @@ func (m *ContextMiddleware) isIgnorePath(path string) bool {
}
return false
}
func (m *ContextMiddleware) tailscaleWhois(ctx context.Context, ip string) (*model.TailscaleContext, error) {
if m.tailscale == nil {
return nil, nil
}
whois, err := m.tailscale.Whois(ctx, ip)
if err != nil {
m.log.App.Error().Err(err).Msgf("Error performing Tailscale whois for IP %s: %v", ip, err)
return nil, err
}
if whois == nil {
return nil, nil
}
uctx := model.TailscaleContext{
BaseContext: model.BaseContext{
Username: whois.NodeName,
Email: whois.LoginName,
Name: whois.DisplayName,
},
UserID: whois.UserID,
}
return &uctx, nil
}
+11 -17
View File
@@ -5,17 +5,17 @@ import (
"encoding/base64"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/steveiliop56/ding"
"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.Queries, params repository.CreateSessionParams) {
seedSession := func(t *testing.T, queries repository.Store, 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.Queries
queries repository.Store
}
type testCase struct {
@@ -250,22 +250,20 @@ func TestContextMiddleware(t *testing.T) {
}
ctx := context.TODO()
wg := &sync.WaitGroup{}
dg := ding.New(ctx)
app := bootstrap.NewBootstrapApp(cfg)
store := memory.New()
err := app.SetupDatabase()
policyEngine, err := service.NewPolicyEngine(cfg, log)
require.NoError(t, err)
queries := repository.New(app.GetDB())
broker := service.NewOAuthBrokerService(log, map[string]model.OAuthServiceConfig{}, ctx)
authService := service.NewAuthService(log, cfg, runtime, ctx, wg, nil, queries, broker)
authService := service.NewAuthService(log, cfg, runtime, ctx, dg, nil, store, broker, nil, policyEngine)
contextMiddleware := middleware.NewContextMiddleware(log, runtime, authService, broker)
contextMiddleware := middleware.NewContextMiddleware(log, runtime, authService, broker, nil)
for _, test := range tests {
authService.ClearRateLimitsTestingOnly()
authService.ClearLoginAttempts()
t.Run(test.description, func(t *testing.T) {
gin.SetMode(gin.TestMode)
@@ -286,11 +284,7 @@ func TestContextMiddleware(t *testing.T) {
return captured, recorder
}
test.run(t, runArgs{do: do, queries: queries})
test.run(t, runArgs{do: do, queries: store})
})
}
t.Cleanup(func() {
app.GetDB().Close()
})
}
+1 -1
View File
@@ -38,7 +38,7 @@ func (m *UIMiddleware) Middleware() gin.HandlerFunc {
path := strings.TrimPrefix(c.Request.URL.Path, "/")
switch strings.SplitN(path, "/", 2)[0] {
case "api", "resources", ".well-known":
case "api", "resources", ".well-known", "authorize":
c.Next()
return
case "robots.txt":
+33 -9
View File
@@ -4,7 +4,8 @@ package model
func NewDefaultConfiguration() *Config {
return &Config{
Database: DatabaseConfig{
Path: "./tinyauth.db",
Driver: "sqlite",
Path: "./tinyauth.db",
},
Analytics: AnalyticsConfig{
Enabled: true,
@@ -24,6 +25,9 @@ func NewDefaultConfiguration() *Config {
SessionMaxLifetime: 0, // disabled
LoginTimeout: 300, // 5 minutes
LoginMaxRetries: 3,
ACLs: ACLsConfig{
Policy: "allow",
},
},
UI: UIConfig{
Title: "Tinyauth",
@@ -58,8 +62,8 @@ func NewDefaultConfiguration() *Config {
PrivateKeyPath: "./tinyauth_oidc_key",
PublicKeyPath: "./tinyauth_oidc_key.pub",
},
Experimental: ExperimentalConfig{
ConfigFile: "",
Tailscale: TailscaleConfig{
Dir: "./tailscale_state",
},
LabelProvider: "auto",
}
@@ -78,12 +82,15 @@ 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, or kubernetes). auto detects the environment." yaml:"labelProvider"`
LabelProvider string `description:"Label provider to use for ACLs (auto, docker, kubernetes or none to disable). auto detects the environment." yaml:"labelProvider"`
Log LogConfig `description:"Logging configuration." yaml:"log"`
Tailscale TailscaleConfig `description:"Tailscale configuration." yaml:"tailscale"`
ConfigFile string `description:"Path to config file." yaml:"-"`
}
type DatabaseConfig struct {
Path string `description:"The path to the database, including file name." yaml:"path"`
Driver string `description:"The database driver to use. Valid values: sqlite, postgres, memory." yaml:"driver"`
Path string `description:"The path to the SQLite database file, or connection URL when driver is postgres." yaml:"path"`
}
type AnalyticsConfig struct {
@@ -114,6 +121,7 @@ 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 {
@@ -144,8 +152,9 @@ 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"`
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"`
}
type OAuthConfig struct {
@@ -197,14 +206,25 @@ type LogStreamConfig struct {
Level string `description:"Log level for this stream. Use global if empty." yaml:"level"`
}
type ExperimentalConfig struct {
ConfigFile string `description:"Path to config file." yaml:"-"`
// no experimental features
type ExperimentalConfig struct{}
type TailscaleConfig struct {
Enabled bool `description:"Enable Tailscale integration." yaml:"enabled"`
Dir string `description:"Tailscale state directory." yaml:"dir"`
Hostname string `description:"Tailscale hostname." yaml:"hostname"`
AuthKey string `description:"Tailscale auth key." yaml:"authKey"`
Ephemeral bool `description:"Use ephemeral Tailscale node." yaml:"ephemeral"`
}
// OAuth/OIDC config
type OAuthServiceConfig struct {
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"`
@@ -223,6 +243,10 @@ 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"`
}
// ACLs
type Apps struct {
+2
View File
@@ -21,3 +21,5 @@ const SessionCookieName = "tinyauth-session"
const CSRFCookieName = "tinyauth-csrf"
const RedirectCookieName = "tinyauth-redirect"
const OAuthSessionCookieName = "tinyauth-oauth"
const GracefulShutdownTimeout = 5 // seconds
+57 -58
View File
@@ -19,6 +19,7 @@ const (
ProviderBasicAuth
ProviderOAuth
ProviderLDAP
ProviderTailscale
)
type UserContext struct {
@@ -27,6 +28,7 @@ type UserContext struct {
Local *LocalContext
OAuth *OAuthContext
LDAP *LDAPContext
Tailscale *TailscaleContext
}
type BaseContext struct {
@@ -54,6 +56,11 @@ type LDAPContext struct {
Groups []string
}
type TailscaleContext struct {
BaseContext
UserID string
}
func (c *UserContext) IsAuthenticated() bool {
return c.Authenticated
}
@@ -74,6 +81,10 @@ func (c *UserContext) IsBasicAuth() bool {
return c.Provider == ProviderBasicAuth && c.Local != nil
}
func (c *UserContext) IsTailscale() bool {
return c.Provider == ProviderTailscale && c.Tailscale != nil
}
func (c *UserContext) NewFromGin(ginctx *gin.Context) (*UserContext, error) {
userContextValue, exists := ginctx.Get("context")
@@ -87,7 +98,7 @@ func (c *UserContext) NewFromGin(ginctx *gin.Context) (*UserContext, error) {
return nil, errors.New("invalid user context type")
}
if userContext.LDAP == nil && userContext.Local == nil && userContext.OAuth == nil {
if userContext.LDAP == nil && userContext.Local == nil && userContext.OAuth == nil && userContext.Tailscale == nil {
return nil, errors.New("incomplete user context")
}
@@ -121,6 +132,15 @@ func (c *UserContext) NewFromSession(session *repository.Session) (*UserContext,
Email: session.Email,
},
}
case "tailscale":
c.Provider = ProviderTailscale
c.Tailscale = &TailscaleContext{
BaseContext: BaseContext{
Username: session.Username,
Name: session.Name,
Email: session.Email,
},
}
// By default we assume an unknown name which is oauth
default:
c.Provider = ProviderOAuth
@@ -145,85 +165,55 @@ func (c *UserContext) NewFromSession(session *repository.Session) (*UserContext,
return c, nil
}
func (c *UserContext) GetUsername() string {
func (c *UserContext) getBaseContext() *BaseContext {
switch c.Provider {
case ProviderLocal:
case ProviderLocal, ProviderBasicAuth:
if c.Local == nil {
return ""
return nil
}
return c.Local.Username
return &c.Local.BaseContext
case ProviderLDAP:
if c.LDAP == nil {
return ""
return nil
}
return c.LDAP.Username
case ProviderBasicAuth:
if c.Local == nil {
return ""
}
return c.Local.Username
return &c.LDAP.BaseContext
case ProviderOAuth:
if c.OAuth == nil {
return ""
return nil
}
return c.OAuth.Username
return &c.OAuth.BaseContext
case ProviderTailscale:
if c.Tailscale == nil {
return nil
}
return &c.Tailscale.BaseContext
default:
return nil
}
}
func (c *UserContext) GetUsername() string {
base := c.getBaseContext()
if base == nil {
return ""
}
return base.Username
}
func (c *UserContext) GetEmail() string {
switch c.Provider {
case ProviderLocal:
if c.Local == nil {
return ""
}
return c.Local.Email
case ProviderLDAP:
if c.LDAP == nil {
return ""
}
return c.LDAP.Email
case ProviderBasicAuth:
if c.Local == nil {
return ""
}
return c.Local.Email
case ProviderOAuth:
if c.OAuth == nil {
return ""
}
return c.OAuth.Email
default:
base := c.getBaseContext()
if base == nil {
return ""
}
return base.Email
}
func (c *UserContext) GetName() string {
switch c.Provider {
case ProviderLocal:
if c.Local == nil {
return ""
}
return c.Local.Name
case ProviderLDAP:
if c.LDAP == nil {
return ""
}
return c.LDAP.Name
case ProviderBasicAuth:
if c.Local == nil {
return ""
}
return c.Local.Name
case ProviderOAuth:
if c.OAuth == nil {
return ""
}
return c.OAuth.Name
default:
base := c.getBaseContext()
if base == nil {
return ""
}
return base.Name
}
func (c *UserContext) GetProviderID() string {
@@ -234,6 +224,8 @@ func (c *UserContext) GetProviderID() string {
return "ldap"
case ProviderOAuth:
return c.OAuth.ID
case ProviderTailscale:
return "tailscale"
default:
return "unknown"
}
@@ -252,3 +244,10 @@ func (c *UserContext) OAuthName() string {
}
return ""
}
func (c *UserContext) TailscaleNodeName() string {
if c.Tailscale != nil {
return c.Tailscale.Username
}
return ""
}
+1
View File
@@ -13,6 +13,7 @@ type RuntimeConfig struct {
OAuthWhitelist []string
ConfiguredProviders []Provider
OIDCClients []OIDCClientConfig
TrustedDomains []string
}
type Provider struct {
+288
View File
@@ -0,0 +1,288 @@
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 session",
run: func(t *testing.T, s repository.Store) {
sess, err := s.CreateOIDCSession(ctx, repository.CreateOIDCSessionParams{
Sub: "sub-1",
AccessTokenHash: "at-1",
RefreshTokenHash: "rt-1",
Scope: "openid",
})
require.NoError(t, err)
assert.Equal(t, "sub-1", sess.Sub)
got, err := s.GetOIDCSessionBySub(ctx, "sub-1")
require.NoError(t, err)
assert.Equal(t, sess, got)
},
},
{
description: "Get OIDC session by sub not found",
run: func(t *testing.T, s repository.Store) {
_, err := s.GetOIDCSessionBySub(ctx, "missing")
assert.ErrorIs(t, err, repository.ErrNotFound)
},
},
{
description: "Get OIDC session by access token hash",
run: func(t *testing.T, s repository.Store) {
_, err := s.CreateOIDCSession(ctx, repository.CreateOIDCSessionParams{
Sub: "sub-1",
AccessTokenHash: "at-1",
RefreshTokenHash: "rt-1",
})
require.NoError(t, err)
got, err := s.GetOIDCSessionByAccessTokenHash(ctx, "at-1")
require.NoError(t, err)
assert.Equal(t, "sub-1", got.Sub)
},
},
{
description: "Get OIDC session by access token hash not found",
run: func(t *testing.T, s repository.Store) {
_, err := s.GetOIDCSessionByAccessTokenHash(ctx, "missing")
assert.ErrorIs(t, err, repository.ErrNotFound)
},
},
{
description: "Get OIDC session by refresh token hash",
run: func(t *testing.T, s repository.Store) {
_, err := s.CreateOIDCSession(ctx, repository.CreateOIDCSessionParams{
Sub: "sub-1",
AccessTokenHash: "at-1",
RefreshTokenHash: "rt-1",
})
require.NoError(t, err)
got, err := s.GetOIDCSessionByRefreshTokenHash(ctx, "rt-1")
require.NoError(t, err)
assert.Equal(t, "sub-1", got.Sub)
},
},
{
description: "Get OIDC session by refresh token hash not found",
run: func(t *testing.T, s repository.Store) {
_, err := s.GetOIDCSessionByRefreshTokenHash(ctx, "missing")
assert.ErrorIs(t, err, repository.ErrNotFound)
},
},
{
description: "Create OIDC session unique sub constraint",
run: func(t *testing.T, s repository.Store) {
_, err := s.CreateOIDCSession(ctx, repository.CreateOIDCSessionParams{Sub: "sub-1", AccessTokenHash: "at-1", RefreshTokenHash: "rt-1"})
require.NoError(t, err)
_, err = s.CreateOIDCSession(ctx, repository.CreateOIDCSessionParams{Sub: "sub-1", AccessTokenHash: "at-2", RefreshTokenHash: "rt-2"})
assert.ErrorContains(t, err, "UNIQUE constraint failed: oidc_sessions.sub")
},
},
{
description: "Create OIDC session unique access token hash constraint",
run: func(t *testing.T, s repository.Store) {
_, err := s.CreateOIDCSession(ctx, repository.CreateOIDCSessionParams{Sub: "sub-1", AccessTokenHash: "at-1", RefreshTokenHash: "rt-1"})
require.NoError(t, err)
_, err = s.CreateOIDCSession(ctx, repository.CreateOIDCSessionParams{Sub: "sub-2", AccessTokenHash: "at-1", RefreshTokenHash: "rt-2"})
assert.ErrorContains(t, err, "UNIQUE constraint failed: oidc_sessions.access_token_hash")
},
},
{
description: "Create OIDC session unique refresh token hash constraint",
run: func(t *testing.T, s repository.Store) {
_, err := s.CreateOIDCSession(ctx, repository.CreateOIDCSessionParams{Sub: "sub-1", AccessTokenHash: "at-1", RefreshTokenHash: "rt-1"})
require.NoError(t, err)
_, err = s.CreateOIDCSession(ctx, repository.CreateOIDCSessionParams{Sub: "sub-2", AccessTokenHash: "at-2", RefreshTokenHash: "rt-1"})
assert.ErrorContains(t, err, "UNIQUE constraint failed: oidc_sessions.refresh_token_hash")
},
},
{
description: "Update OIDC session",
run: func(t *testing.T, s repository.Store) {
_, err := s.CreateOIDCSession(ctx, repository.CreateOIDCSessionParams{
Sub: "sub-1",
AccessTokenHash: "at-1",
RefreshTokenHash: "rt-1",
})
require.NoError(t, err)
updated, err := s.UpdateOIDCSession(ctx, repository.UpdateOIDCSessionParams{
Sub: "sub-1",
AccessTokenHash: "at-2",
RefreshTokenHash: "rt-2",
Scope: "openid profile",
TokenExpiresAt: 200,
RefreshTokenExpiresAt: 400,
})
require.NoError(t, err)
assert.Equal(t, "at-2", updated.AccessTokenHash)
assert.Equal(t, "rt-2", updated.RefreshTokenHash)
assert.Equal(t, "openid profile", updated.Scope)
// updated token hashes are now queryable, old ones are gone
got, err := s.GetOIDCSessionByAccessTokenHash(ctx, "at-2")
require.NoError(t, err)
assert.Equal(t, "sub-1", got.Sub)
_, err = s.GetOIDCSessionByAccessTokenHash(ctx, "at-1")
assert.ErrorIs(t, err, repository.ErrNotFound)
},
},
{
description: "Update OIDC session not found",
run: func(t *testing.T, s repository.Store) {
_, err := s.UpdateOIDCSession(ctx, repository.UpdateOIDCSessionParams{Sub: "missing"})
assert.ErrorIs(t, err, repository.ErrNotFound)
},
},
{
description: "Delete OIDC session by sub",
run: func(t *testing.T, s repository.Store) {
_, err := s.CreateOIDCSession(ctx, repository.CreateOIDCSessionParams{Sub: "sub-1", AccessTokenHash: "at-1", RefreshTokenHash: "rt-1"})
require.NoError(t, err)
require.NoError(t, s.DeleteOIDCSessionBySub(ctx, "sub-1"))
_, err = s.GetOIDCSessionBySub(ctx, "sub-1")
assert.ErrorIs(t, err, repository.ErrNotFound)
},
},
{
description: "Delete expired OIDC sessions",
run: func(t *testing.T, s repository.Store) {
// both expiries past
_, err := s.CreateOIDCSession(ctx, repository.CreateOIDCSessionParams{
Sub: "sub-1", AccessTokenHash: "at-1", RefreshTokenHash: "rt-1",
TokenExpiresAt: 10, RefreshTokenExpiresAt: 10,
})
require.NoError(t, err)
// valid
_, err = s.CreateOIDCSession(ctx, repository.CreateOIDCSessionParams{
Sub: "sub-2", AccessTokenHash: "at-2", RefreshTokenHash: "rt-2",
TokenExpiresAt: 100, RefreshTokenExpiresAt: 100,
})
require.NoError(t, err)
require.NoError(t, s.DeleteExpiredOIDCSessions(ctx, repository.DeleteExpiredOIDCSessionsParams{
TokenExpiresAt: 50,
RefreshTokenExpiresAt: 50,
}))
_, err = s.GetOIDCSessionBySub(ctx, "sub-1")
assert.ErrorIs(t, err, repository.ErrNotFound)
_, err = s.GetOIDCSessionBySub(ctx, "sub-2")
assert.NoError(t, err)
},
},
}
for _, test := range tests {
t.Run(test.description, func(t *testing.T) {
s := memory.New()
test.run(t, s)
})
}
}
@@ -0,0 +1,96 @@
package memory
import (
"context"
"fmt"
"github.com/tinyauthapp/tinyauth/internal/repository"
)
func (s *Store) CreateOIDCSession(_ context.Context, arg repository.CreateOIDCSessionParams) (repository.OidcSession, error) {
s.mu.Lock()
defer s.mu.Unlock()
// Enforce UNIQUE constraints (sub is the primary key, access/refresh token hashes are unique).
for _, sess := range s.oidcSessions {
switch {
case sess.Sub == arg.Sub:
return repository.OidcSession{}, fmt.Errorf("UNIQUE constraint failed: oidc_sessions.sub")
case sess.AccessTokenHash == arg.AccessTokenHash:
return repository.OidcSession{}, fmt.Errorf("UNIQUE constraint failed: oidc_sessions.access_token_hash")
case sess.RefreshTokenHash == arg.RefreshTokenHash:
return repository.OidcSession{}, fmt.Errorf("UNIQUE constraint failed: oidc_sessions.refresh_token_hash")
}
}
sess := repository.OidcSession(arg)
s.oidcSessions[arg.Sub] = sess
return sess, nil
}
func (s *Store) GetOIDCSessionBySub(_ context.Context, sub string) (repository.OidcSession, error) {
s.mu.RLock()
defer s.mu.RUnlock()
sess, ok := s.oidcSessions[sub]
if !ok {
return repository.OidcSession{}, repository.ErrNotFound
}
return sess, nil
}
func (s *Store) GetOIDCSessionByAccessTokenHash(_ context.Context, accessTokenHash string) (repository.OidcSession, error) {
s.mu.RLock()
defer s.mu.RUnlock()
for _, sess := range s.oidcSessions {
if sess.AccessTokenHash == accessTokenHash {
return sess, nil
}
}
return repository.OidcSession{}, repository.ErrNotFound
}
func (s *Store) GetOIDCSessionByRefreshTokenHash(_ context.Context, refreshTokenHash string) (repository.OidcSession, error) {
s.mu.RLock()
defer s.mu.RUnlock()
for _, sess := range s.oidcSessions {
if sess.RefreshTokenHash == refreshTokenHash {
return sess, nil
}
}
return repository.OidcSession{}, repository.ErrNotFound
}
func (s *Store) UpdateOIDCSession(_ context.Context, arg repository.UpdateOIDCSessionParams) (repository.OidcSession, error) {
s.mu.Lock()
defer s.mu.Unlock()
sess, ok := s.oidcSessions[arg.Sub]
if !ok {
return repository.OidcSession{}, repository.ErrNotFound
}
sess.AccessTokenHash = arg.AccessTokenHash
sess.RefreshTokenHash = arg.RefreshTokenHash
sess.Scope = arg.Scope
sess.ClientID = arg.ClientID
sess.TokenExpiresAt = arg.TokenExpiresAt
sess.RefreshTokenExpiresAt = arg.RefreshTokenExpiresAt
sess.Nonce = arg.Nonce
sess.UserinfoJson = arg.UserinfoJson
s.oidcSessions[arg.Sub] = sess
return sess, nil
}
func (s *Store) DeleteOIDCSessionBySub(_ context.Context, sub string) error {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.oidcSessions, sub)
return nil
}
func (s *Store) DeleteExpiredOIDCSessions(_ context.Context, arg repository.DeleteExpiredOIDCSessionsParams) error {
s.mu.Lock()
defer s.mu.Unlock()
for k, sess := range s.oidcSessions {
if sess.TokenExpiresAt < arg.TokenExpiresAt && sess.RefreshTokenExpiresAt < arg.RefreshTokenExpiresAt {
delete(s.oidcSessions, k)
}
}
return nil
}
@@ -0,0 +1,63 @@
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
}
+23
View File
@@ -0,0 +1,23 @@
// 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
oidcSessions map[string]repository.OidcSession
}
// New returns a new empty in-memory Store.
func New() repository.Store {
return &Store{
sessions: make(map[string]repository.Session),
oidcSessions: make(map[string]repository.OidcSession),
}
}
+70 -48
View File
@@ -1,53 +1,7 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
package repository
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
}
// 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
@@ -62,3 +16,71 @@ type Session struct {
OAuthName string
OAuthSub string
}
type OidcSession struct {
Sub string
AccessTokenHash string
RefreshTokenHash string
Scope string
ClientID string
TokenExpiresAt int64
RefreshTokenExpiresAt int64
Nonce string
UserinfoJson string
}
type CreateSessionParams struct {
UUID string
Username string
Email string
Name string
Provider string
TotpPending bool
OAuthGroups string
Expiry int64
CreatedAt int64
OAuthName string
OAuthSub string
}
type UpdateSessionParams struct {
Username string
Email string
Name string
Provider string
TotpPending bool
OAuthGroups string
Expiry int64
OAuthName string
OAuthSub string
UUID string
}
type CreateOIDCSessionParams struct {
Sub string
AccessTokenHash string
RefreshTokenHash string
Scope string
ClientID string
TokenExpiresAt int64
RefreshTokenExpiresAt int64
Nonce string
UserinfoJson string
}
type UpdateOIDCSessionParams struct {
AccessTokenHash string
RefreshTokenHash string
Scope string
ClientID string
TokenExpiresAt int64
RefreshTokenExpiresAt int64
Nonce string
UserinfoJson string
Sub string
}
type DeleteExpiredOIDCSessionsParams struct {
TokenExpiresAt int64
RefreshTokenExpiresAt int64
}
-581
View File
@@ -1,581 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: oidc_queries.sql
package repository
import (
"context"
)
const createOidcCode = `-- name: CreateOidcCode :one
INSERT INTO "oidc_codes" (
"sub",
"code_hash",
"scope",
"redirect_uri",
"client_id",
"expires_at",
"nonce",
"code_challenge"
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?
)
RETURNING sub, code_hash, scope, redirect_uri, client_id, expires_at, nonce, code_challenge
`
type CreateOidcCodeParams struct {
Sub string
CodeHash string
Scope string
RedirectURI string
ClientID string
ExpiresAt int64
Nonce string
CodeChallenge string
}
func (q *Queries) CreateOidcCode(ctx context.Context, arg CreateOidcCodeParams) (OidcCode, error) {
row := q.db.QueryRowContext(ctx, createOidcCode,
arg.Sub,
arg.CodeHash,
arg.Scope,
arg.RedirectURI,
arg.ClientID,
arg.ExpiresAt,
arg.Nonce,
arg.CodeChallenge,
)
var i OidcCode
err := row.Scan(
&i.Sub,
&i.CodeHash,
&i.Scope,
&i.RedirectURI,
&i.ClientID,
&i.ExpiresAt,
&i.Nonce,
&i.CodeChallenge,
)
return i, err
}
const createOidcToken = `-- name: CreateOidcToken :one
INSERT INTO "oidc_tokens" (
"sub",
"access_token_hash",
"refresh_token_hash",
"scope",
"client_id",
"token_expires_at",
"refresh_token_expires_at",
"code_hash",
"nonce"
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?
)
RETURNING sub, access_token_hash, refresh_token_hash, code_hash, scope, client_id, token_expires_at, refresh_token_expires_at, nonce
`
type CreateOidcTokenParams struct {
Sub string
AccessTokenHash string
RefreshTokenHash string
Scope string
ClientID string
TokenExpiresAt int64
RefreshTokenExpiresAt int64
CodeHash string
Nonce string
}
func (q *Queries) CreateOidcToken(ctx context.Context, arg CreateOidcTokenParams) (OidcToken, error) {
row := q.db.QueryRowContext(ctx, createOidcToken,
arg.Sub,
arg.AccessTokenHash,
arg.RefreshTokenHash,
arg.Scope,
arg.ClientID,
arg.TokenExpiresAt,
arg.RefreshTokenExpiresAt,
arg.CodeHash,
arg.Nonce,
)
var i OidcToken
err := row.Scan(
&i.Sub,
&i.AccessTokenHash,
&i.RefreshTokenHash,
&i.CodeHash,
&i.Scope,
&i.ClientID,
&i.TokenExpiresAt,
&i.RefreshTokenExpiresAt,
&i.Nonce,
)
return i, err
}
const createOidcUserInfo = `-- name: CreateOidcUserInfo :one
INSERT INTO "oidc_userinfo" (
"sub",
"name",
"preferred_username",
"email",
"groups",
"updated_at",
"given_name",
"family_name",
"middle_name",
"nickname",
"profile",
"picture",
"website",
"gender",
"birthdate",
"zoneinfo",
"locale",
"phone_number",
"address"
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
)
RETURNING sub, name, preferred_username, email, "groups", updated_at, given_name, family_name, middle_name, nickname, profile, picture, website, gender, birthdate, zoneinfo, locale, phone_number, address
`
type CreateOidcUserInfoParams struct {
Sub string
Name string
PreferredUsername string
Email string
Groups string
UpdatedAt int64
GivenName string
FamilyName string
MiddleName string
Nickname string
Profile string
Picture string
Website string
Gender string
Birthdate string
Zoneinfo string
Locale string
PhoneNumber string
Address string
}
func (q *Queries) CreateOidcUserInfo(ctx context.Context, arg CreateOidcUserInfoParams) (OidcUserinfo, error) {
row := q.db.QueryRowContext(ctx, createOidcUserInfo,
arg.Sub,
arg.Name,
arg.PreferredUsername,
arg.Email,
arg.Groups,
arg.UpdatedAt,
arg.GivenName,
arg.FamilyName,
arg.MiddleName,
arg.Nickname,
arg.Profile,
arg.Picture,
arg.Website,
arg.Gender,
arg.Birthdate,
arg.Zoneinfo,
arg.Locale,
arg.PhoneNumber,
arg.Address,
)
var i OidcUserinfo
err := row.Scan(
&i.Sub,
&i.Name,
&i.PreferredUsername,
&i.Email,
&i.Groups,
&i.UpdatedAt,
&i.GivenName,
&i.FamilyName,
&i.MiddleName,
&i.Nickname,
&i.Profile,
&i.Picture,
&i.Website,
&i.Gender,
&i.Birthdate,
&i.Zoneinfo,
&i.Locale,
&i.PhoneNumber,
&i.Address,
)
return i, err
}
const deleteExpiredOidcCodes = `-- name: DeleteExpiredOidcCodes :many
DELETE FROM "oidc_codes"
WHERE "expires_at" < ?
RETURNING sub, code_hash, scope, redirect_uri, client_id, expires_at, nonce, code_challenge
`
func (q *Queries) DeleteExpiredOidcCodes(ctx context.Context, expiresAt int64) ([]OidcCode, error) {
rows, err := q.db.QueryContext(ctx, deleteExpiredOidcCodes, expiresAt)
if err != nil {
return nil, err
}
defer rows.Close()
var items []OidcCode
for rows.Next() {
var i OidcCode
if err := rows.Scan(
&i.Sub,
&i.CodeHash,
&i.Scope,
&i.RedirectURI,
&i.ClientID,
&i.ExpiresAt,
&i.Nonce,
&i.CodeChallenge,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const deleteExpiredOidcTokens = `-- name: DeleteExpiredOidcTokens :many
DELETE FROM "oidc_tokens"
WHERE "token_expires_at" < ? AND "refresh_token_expires_at" < ?
RETURNING sub, access_token_hash, refresh_token_hash, code_hash, scope, client_id, token_expires_at, refresh_token_expires_at, nonce
`
type DeleteExpiredOidcTokensParams struct {
TokenExpiresAt int64
RefreshTokenExpiresAt int64
}
func (q *Queries) DeleteExpiredOidcTokens(ctx context.Context, arg DeleteExpiredOidcTokensParams) ([]OidcToken, error) {
rows, err := q.db.QueryContext(ctx, deleteExpiredOidcTokens, arg.TokenExpiresAt, arg.RefreshTokenExpiresAt)
if err != nil {
return nil, err
}
defer rows.Close()
var items []OidcToken
for rows.Next() {
var i OidcToken
if err := rows.Scan(
&i.Sub,
&i.AccessTokenHash,
&i.RefreshTokenHash,
&i.CodeHash,
&i.Scope,
&i.ClientID,
&i.TokenExpiresAt,
&i.RefreshTokenExpiresAt,
&i.Nonce,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const deleteOidcCode = `-- name: DeleteOidcCode :exec
DELETE FROM "oidc_codes"
WHERE "code_hash" = ?
`
func (q *Queries) DeleteOidcCode(ctx context.Context, codeHash string) error {
_, err := q.db.ExecContext(ctx, deleteOidcCode, codeHash)
return err
}
const deleteOidcCodeBySub = `-- name: DeleteOidcCodeBySub :exec
DELETE FROM "oidc_codes"
WHERE "sub" = ?
`
func (q *Queries) DeleteOidcCodeBySub(ctx context.Context, sub string) error {
_, err := q.db.ExecContext(ctx, deleteOidcCodeBySub, sub)
return err
}
const deleteOidcToken = `-- name: DeleteOidcToken :exec
DELETE FROM "oidc_tokens"
WHERE "access_token_hash" = ?
`
func (q *Queries) DeleteOidcToken(ctx context.Context, accessTokenHash string) error {
_, err := q.db.ExecContext(ctx, deleteOidcToken, accessTokenHash)
return err
}
const deleteOidcTokenByCodeHash = `-- name: DeleteOidcTokenByCodeHash :exec
DELETE FROM "oidc_tokens"
WHERE "code_hash" = ?
`
func (q *Queries) DeleteOidcTokenByCodeHash(ctx context.Context, codeHash string) error {
_, err := q.db.ExecContext(ctx, deleteOidcTokenByCodeHash, codeHash)
return err
}
const deleteOidcTokenBySub = `-- name: DeleteOidcTokenBySub :exec
DELETE FROM "oidc_tokens"
WHERE "sub" = ?
`
func (q *Queries) DeleteOidcTokenBySub(ctx context.Context, sub string) error {
_, err := q.db.ExecContext(ctx, deleteOidcTokenBySub, sub)
return err
}
const deleteOidcUserInfo = `-- name: DeleteOidcUserInfo :exec
DELETE FROM "oidc_userinfo"
WHERE "sub" = ?
`
func (q *Queries) DeleteOidcUserInfo(ctx context.Context, sub string) error {
_, err := q.db.ExecContext(ctx, deleteOidcUserInfo, sub)
return err
}
const getOidcCode = `-- name: GetOidcCode :one
DELETE FROM "oidc_codes"
WHERE "code_hash" = ?
RETURNING sub, code_hash, scope, redirect_uri, client_id, expires_at, nonce, code_challenge
`
func (q *Queries) GetOidcCode(ctx context.Context, codeHash string) (OidcCode, error) {
row := q.db.QueryRowContext(ctx, getOidcCode, codeHash)
var i OidcCode
err := row.Scan(
&i.Sub,
&i.CodeHash,
&i.Scope,
&i.RedirectURI,
&i.ClientID,
&i.ExpiresAt,
&i.Nonce,
&i.CodeChallenge,
)
return i, err
}
const getOidcCodeBySub = `-- name: GetOidcCodeBySub :one
DELETE FROM "oidc_codes"
WHERE "sub" = ?
RETURNING sub, code_hash, scope, redirect_uri, client_id, expires_at, nonce, code_challenge
`
func (q *Queries) GetOidcCodeBySub(ctx context.Context, sub string) (OidcCode, error) {
row := q.db.QueryRowContext(ctx, getOidcCodeBySub, sub)
var i OidcCode
err := row.Scan(
&i.Sub,
&i.CodeHash,
&i.Scope,
&i.RedirectURI,
&i.ClientID,
&i.ExpiresAt,
&i.Nonce,
&i.CodeChallenge,
)
return i, err
}
const getOidcCodeBySubUnsafe = `-- name: GetOidcCodeBySubUnsafe :one
SELECT sub, code_hash, scope, redirect_uri, client_id, expires_at, nonce, code_challenge FROM "oidc_codes"
WHERE "sub" = ?
`
func (q *Queries) GetOidcCodeBySubUnsafe(ctx context.Context, sub string) (OidcCode, error) {
row := q.db.QueryRowContext(ctx, getOidcCodeBySubUnsafe, sub)
var i OidcCode
err := row.Scan(
&i.Sub,
&i.CodeHash,
&i.Scope,
&i.RedirectURI,
&i.ClientID,
&i.ExpiresAt,
&i.Nonce,
&i.CodeChallenge,
)
return i, err
}
const getOidcCodeUnsafe = `-- name: GetOidcCodeUnsafe :one
SELECT sub, code_hash, scope, redirect_uri, client_id, expires_at, nonce, code_challenge FROM "oidc_codes"
WHERE "code_hash" = ?
`
func (q *Queries) GetOidcCodeUnsafe(ctx context.Context, codeHash string) (OidcCode, error) {
row := q.db.QueryRowContext(ctx, getOidcCodeUnsafe, codeHash)
var i OidcCode
err := row.Scan(
&i.Sub,
&i.CodeHash,
&i.Scope,
&i.RedirectURI,
&i.ClientID,
&i.ExpiresAt,
&i.Nonce,
&i.CodeChallenge,
)
return i, err
}
const getOidcToken = `-- name: GetOidcToken :one
SELECT sub, access_token_hash, refresh_token_hash, code_hash, scope, client_id, token_expires_at, refresh_token_expires_at, nonce FROM "oidc_tokens"
WHERE "access_token_hash" = ?
`
func (q *Queries) GetOidcToken(ctx context.Context, accessTokenHash string) (OidcToken, error) {
row := q.db.QueryRowContext(ctx, getOidcToken, accessTokenHash)
var i OidcToken
err := row.Scan(
&i.Sub,
&i.AccessTokenHash,
&i.RefreshTokenHash,
&i.CodeHash,
&i.Scope,
&i.ClientID,
&i.TokenExpiresAt,
&i.RefreshTokenExpiresAt,
&i.Nonce,
)
return i, err
}
const getOidcTokenByRefreshToken = `-- name: GetOidcTokenByRefreshToken :one
SELECT sub, access_token_hash, refresh_token_hash, code_hash, scope, client_id, token_expires_at, refresh_token_expires_at, nonce FROM "oidc_tokens"
WHERE "refresh_token_hash" = ?
`
func (q *Queries) GetOidcTokenByRefreshToken(ctx context.Context, refreshTokenHash string) (OidcToken, error) {
row := q.db.QueryRowContext(ctx, getOidcTokenByRefreshToken, refreshTokenHash)
var i OidcToken
err := row.Scan(
&i.Sub,
&i.AccessTokenHash,
&i.RefreshTokenHash,
&i.CodeHash,
&i.Scope,
&i.ClientID,
&i.TokenExpiresAt,
&i.RefreshTokenExpiresAt,
&i.Nonce,
)
return i, err
}
const getOidcTokenBySub = `-- name: GetOidcTokenBySub :one
SELECT sub, access_token_hash, refresh_token_hash, code_hash, scope, client_id, token_expires_at, refresh_token_expires_at, nonce FROM "oidc_tokens"
WHERE "sub" = ?
`
func (q *Queries) GetOidcTokenBySub(ctx context.Context, sub string) (OidcToken, error) {
row := q.db.QueryRowContext(ctx, getOidcTokenBySub, sub)
var i OidcToken
err := row.Scan(
&i.Sub,
&i.AccessTokenHash,
&i.RefreshTokenHash,
&i.CodeHash,
&i.Scope,
&i.ClientID,
&i.TokenExpiresAt,
&i.RefreshTokenExpiresAt,
&i.Nonce,
)
return i, err
}
const getOidcUserInfo = `-- name: GetOidcUserInfo :one
SELECT sub, name, preferred_username, email, "groups", updated_at, given_name, family_name, middle_name, nickname, profile, picture, website, gender, birthdate, zoneinfo, locale, phone_number, address FROM "oidc_userinfo"
WHERE "sub" = ?
`
func (q *Queries) GetOidcUserInfo(ctx context.Context, sub string) (OidcUserinfo, error) {
row := q.db.QueryRowContext(ctx, getOidcUserInfo, sub)
var i OidcUserinfo
err := row.Scan(
&i.Sub,
&i.Name,
&i.PreferredUsername,
&i.Email,
&i.Groups,
&i.UpdatedAt,
&i.GivenName,
&i.FamilyName,
&i.MiddleName,
&i.Nickname,
&i.Profile,
&i.Picture,
&i.Website,
&i.Gender,
&i.Birthdate,
&i.Zoneinfo,
&i.Locale,
&i.PhoneNumber,
&i.Address,
)
return i, err
}
const updateOidcTokenByRefreshToken = `-- name: UpdateOidcTokenByRefreshToken :one
UPDATE "oidc_tokens" SET
"access_token_hash" = ?,
"refresh_token_hash" = ?,
"token_expires_at" = ?,
"refresh_token_expires_at" = ?
WHERE "refresh_token_hash" = ?
RETURNING sub, access_token_hash, refresh_token_hash, code_hash, scope, client_id, token_expires_at, refresh_token_expires_at, nonce
`
type UpdateOidcTokenByRefreshTokenParams struct {
AccessTokenHash string
RefreshTokenHash string
TokenExpiresAt int64
RefreshTokenExpiresAt int64
RefreshTokenHash_2 string
}
func (q *Queries) UpdateOidcTokenByRefreshToken(ctx context.Context, arg UpdateOidcTokenByRefreshTokenParams) (OidcToken, error) {
row := q.db.QueryRowContext(ctx, updateOidcTokenByRefreshToken,
arg.AccessTokenHash,
arg.RefreshTokenHash,
arg.TokenExpiresAt,
arg.RefreshTokenExpiresAt,
arg.RefreshTokenHash_2,
)
var i OidcToken
err := row.Scan(
&i.Sub,
&i.AccessTokenHash,
&i.RefreshTokenHash,
&i.CodeHash,
&i.Scope,
&i.ClientID,
&i.TokenExpiresAt,
&i.RefreshTokenExpiresAt,
&i.Nonce,
)
return i, err
}
@@ -1,8 +1,8 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// sqlc v1.31.1
package repository
package postgres
import (
"context"
+3
View File
@@ -0,0 +1,3 @@
package postgres
//go:generate go run github.com/tinyauthapp/tinyauth/gen/sqlc-wrapper -pkg github.com/tinyauthapp/tinyauth/internal/repository/postgres
+31
View File
@@ -0,0 +1,31 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
package postgres
type OidcSession struct {
Sub string
AccessTokenHash string
RefreshTokenHash string
Scope string
ClientID string
TokenExpiresAt int64
RefreshTokenExpiresAt int64
Nonce string
UserinfoJson 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
}
@@ -0,0 +1,210 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: oidc_queries.sql
package postgres
import (
"context"
)
const createOIDCSession = `-- name: CreateOIDCSession :one
INSERT INTO "oidc_sessions" (
"sub",
"access_token_hash",
"refresh_token_hash",
"scope",
"client_id",
"token_expires_at",
"refresh_token_expires_at",
"nonce",
"userinfo_json"
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9
)
RETURNING sub, access_token_hash, refresh_token_hash, scope, client_id, token_expires_at, refresh_token_expires_at, nonce, userinfo_json
`
type CreateOIDCSessionParams struct {
Sub string
AccessTokenHash string
RefreshTokenHash string
Scope string
ClientID string
TokenExpiresAt int64
RefreshTokenExpiresAt int64
Nonce string
UserinfoJson string
}
func (q *Queries) CreateOIDCSession(ctx context.Context, arg CreateOIDCSessionParams) (OidcSession, error) {
row := q.db.QueryRowContext(ctx, createOIDCSession,
arg.Sub,
arg.AccessTokenHash,
arg.RefreshTokenHash,
arg.Scope,
arg.ClientID,
arg.TokenExpiresAt,
arg.RefreshTokenExpiresAt,
arg.Nonce,
arg.UserinfoJson,
)
var i OidcSession
err := row.Scan(
&i.Sub,
&i.AccessTokenHash,
&i.RefreshTokenHash,
&i.Scope,
&i.ClientID,
&i.TokenExpiresAt,
&i.RefreshTokenExpiresAt,
&i.Nonce,
&i.UserinfoJson,
)
return i, err
}
const deleteExpiredOIDCSessions = `-- name: DeleteExpiredOIDCSessions :exec
DELETE FROM "oidc_sessions"
WHERE "token_expires_at" < $1 AND "refresh_token_expires_at" < $2
`
type DeleteExpiredOIDCSessionsParams struct {
TokenExpiresAt int64
RefreshTokenExpiresAt int64
}
func (q *Queries) DeleteExpiredOIDCSessions(ctx context.Context, arg DeleteExpiredOIDCSessionsParams) error {
_, err := q.db.ExecContext(ctx, deleteExpiredOIDCSessions, arg.TokenExpiresAt, arg.RefreshTokenExpiresAt)
return err
}
const deleteOIDCSessionBySub = `-- name: DeleteOIDCSessionBySub :exec
DELETE FROM "oidc_sessions"
WHERE "sub" = $1
`
func (q *Queries) DeleteOIDCSessionBySub(ctx context.Context, sub string) error {
_, err := q.db.ExecContext(ctx, deleteOIDCSessionBySub, sub)
return err
}
const getOIDCSessionByAccessTokenHash = `-- name: GetOIDCSessionByAccessTokenHash :one
SELECT sub, access_token_hash, refresh_token_hash, scope, client_id, token_expires_at, refresh_token_expires_at, nonce, userinfo_json FROM "oidc_sessions"
WHERE "access_token_hash" = $1
`
func (q *Queries) GetOIDCSessionByAccessTokenHash(ctx context.Context, accessTokenHash string) (OidcSession, error) {
row := q.db.QueryRowContext(ctx, getOIDCSessionByAccessTokenHash, accessTokenHash)
var i OidcSession
err := row.Scan(
&i.Sub,
&i.AccessTokenHash,
&i.RefreshTokenHash,
&i.Scope,
&i.ClientID,
&i.TokenExpiresAt,
&i.RefreshTokenExpiresAt,
&i.Nonce,
&i.UserinfoJson,
)
return i, err
}
const getOIDCSessionByRefreshTokenHash = `-- name: GetOIDCSessionByRefreshTokenHash :one
SELECT sub, access_token_hash, refresh_token_hash, scope, client_id, token_expires_at, refresh_token_expires_at, nonce, userinfo_json FROM "oidc_sessions"
WHERE "refresh_token_hash" = $1
`
func (q *Queries) GetOIDCSessionByRefreshTokenHash(ctx context.Context, refreshTokenHash string) (OidcSession, error) {
row := q.db.QueryRowContext(ctx, getOIDCSessionByRefreshTokenHash, refreshTokenHash)
var i OidcSession
err := row.Scan(
&i.Sub,
&i.AccessTokenHash,
&i.RefreshTokenHash,
&i.Scope,
&i.ClientID,
&i.TokenExpiresAt,
&i.RefreshTokenExpiresAt,
&i.Nonce,
&i.UserinfoJson,
)
return i, err
}
const getOIDCSessionBySub = `-- name: GetOIDCSessionBySub :one
SELECT sub, access_token_hash, refresh_token_hash, scope, client_id, token_expires_at, refresh_token_expires_at, nonce, userinfo_json FROM "oidc_sessions"
WHERE "sub" = $1
`
func (q *Queries) GetOIDCSessionBySub(ctx context.Context, sub string) (OidcSession, error) {
row := q.db.QueryRowContext(ctx, getOIDCSessionBySub, sub)
var i OidcSession
err := row.Scan(
&i.Sub,
&i.AccessTokenHash,
&i.RefreshTokenHash,
&i.Scope,
&i.ClientID,
&i.TokenExpiresAt,
&i.RefreshTokenExpiresAt,
&i.Nonce,
&i.UserinfoJson,
)
return i, err
}
const updateOIDCSession = `-- name: UpdateOIDCSession :one
UPDATE "oidc_sessions" SET
"access_token_hash" = $1,
"refresh_token_hash" = $2,
"scope" = $3,
"client_id" = $4,
"token_expires_at" = $5,
"refresh_token_expires_at" = $6,
"nonce" = $7,
"userinfo_json" = $8
WHERE "sub" = $9
RETURNING sub, access_token_hash, refresh_token_hash, scope, client_id, token_expires_at, refresh_token_expires_at, nonce, userinfo_json
`
type UpdateOIDCSessionParams struct {
AccessTokenHash string
RefreshTokenHash string
Scope string
ClientID string
TokenExpiresAt int64
RefreshTokenExpiresAt int64
Nonce string
UserinfoJson string
Sub string
}
func (q *Queries) UpdateOIDCSession(ctx context.Context, arg UpdateOIDCSessionParams) (OidcSession, error) {
row := q.db.QueryRowContext(ctx, updateOIDCSession,
arg.AccessTokenHash,
arg.RefreshTokenHash,
arg.Scope,
arg.ClientID,
arg.TokenExpiresAt,
arg.RefreshTokenExpiresAt,
arg.Nonce,
arg.UserinfoJson,
arg.Sub,
)
var i OidcSession
err := row.Scan(
&i.Sub,
&i.AccessTokenHash,
&i.RefreshTokenHash,
&i.Scope,
&i.ClientID,
&i.TokenExpiresAt,
&i.RefreshTokenExpiresAt,
&i.Nonce,
&i.UserinfoJson,
)
return i, err
}
@@ -0,0 +1,176 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: session_queries.sql
package postgres
import (
"context"
)
const createSession = `-- name: CreateSession :one
INSERT INTO "sessions" (
"uuid",
"username",
"email",
"name",
"provider",
"totp_pending",
"oauth_groups",
"expiry",
"created_at",
"oauth_name",
"oauth_sub"
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11
)
RETURNING uuid, username, email, name, provider, totp_pending, oauth_groups, expiry, created_at, oauth_name, oauth_sub
`
type CreateSessionParams struct {
UUID string
Username string
Email string
Name string
Provider string
TotpPending bool
OAuthGroups string
Expiry int64
CreatedAt int64
OAuthName string
OAuthSub string
}
func (q *Queries) CreateSession(ctx context.Context, arg CreateSessionParams) (Session, error) {
row := q.db.QueryRowContext(ctx, createSession,
arg.UUID,
arg.Username,
arg.Email,
arg.Name,
arg.Provider,
arg.TotpPending,
arg.OAuthGroups,
arg.Expiry,
arg.CreatedAt,
arg.OAuthName,
arg.OAuthSub,
)
var i Session
err := row.Scan(
&i.UUID,
&i.Username,
&i.Email,
&i.Name,
&i.Provider,
&i.TotpPending,
&i.OAuthGroups,
&i.Expiry,
&i.CreatedAt,
&i.OAuthName,
&i.OAuthSub,
)
return i, err
}
const deleteExpiredSessions = `-- name: DeleteExpiredSessions :exec
DELETE FROM "sessions"
WHERE "expiry" < $1
`
func (q *Queries) DeleteExpiredSessions(ctx context.Context, expiry int64) error {
_, err := q.db.ExecContext(ctx, deleteExpiredSessions, expiry)
return err
}
const deleteSession = `-- name: DeleteSession :exec
DELETE FROM "sessions"
WHERE "uuid" = $1
`
func (q *Queries) DeleteSession(ctx context.Context, uuid string) error {
_, err := q.db.ExecContext(ctx, deleteSession, uuid)
return err
}
const getSession = `-- name: GetSession :one
SELECT uuid, username, email, name, provider, totp_pending, oauth_groups, expiry, created_at, oauth_name, oauth_sub FROM "sessions"
WHERE "uuid" = $1
`
func (q *Queries) GetSession(ctx context.Context, uuid string) (Session, error) {
row := q.db.QueryRowContext(ctx, getSession, uuid)
var i Session
err := row.Scan(
&i.UUID,
&i.Username,
&i.Email,
&i.Name,
&i.Provider,
&i.TotpPending,
&i.OAuthGroups,
&i.Expiry,
&i.CreatedAt,
&i.OAuthName,
&i.OAuthSub,
)
return i, err
}
const updateSession = `-- name: UpdateSession :one
UPDATE "sessions" SET
"username" = $1,
"email" = $2,
"name" = $3,
"provider" = $4,
"totp_pending" = $5,
"oauth_groups" = $6,
"expiry" = $7,
"oauth_name" = $8,
"oauth_sub" = $9
WHERE "uuid" = $10
RETURNING uuid, username, email, name, provider, totp_pending, oauth_groups, expiry, created_at, oauth_name, oauth_sub
`
type UpdateSessionParams struct {
Username string
Email string
Name string
Provider string
TotpPending bool
OAuthGroups string
Expiry int64
OAuthName string
OAuthSub string
UUID string
}
func (q *Queries) UpdateSession(ctx context.Context, arg UpdateSessionParams) (Session, error) {
row := q.db.QueryRowContext(ctx, updateSession,
arg.Username,
arg.Email,
arg.Name,
arg.Provider,
arg.TotpPending,
arg.OAuthGroups,
arg.Expiry,
arg.OAuthName,
arg.OAuthSub,
arg.UUID,
)
var i Session
err := row.Scan(
&i.UUID,
&i.Username,
&i.Email,
&i.Name,
&i.Provider,
&i.TotpPending,
&i.OAuthGroups,
&i.Expiry,
&i.CreatedAt,
&i.OAuthName,
&i.OAuthSub,
)
return i, err
}
+113
View File
@@ -0,0 +1,113 @@
// Code generated by cmd/gen/sqlc-wrapper. DO NOT EDIT.
package postgres
import (
"context"
"database/sql"
"errors"
"github.com/tinyauthapp/tinyauth/internal/repository"
)
// Store wraps *Queries and implements repository.Store.
type Store struct {
q *Queries
}
// NewStore 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) CreateOIDCSession(ctx context.Context, arg repository.CreateOIDCSessionParams) (repository.OidcSession, error) {
r, err := s.q.CreateOIDCSession(ctx, CreateOIDCSessionParams(arg))
if err != nil {
return repository.OidcSession{}, mapErr(err)
}
return repository.OidcSession(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) DeleteExpiredOIDCSessions(ctx context.Context, arg repository.DeleteExpiredOIDCSessionsParams) error {
return mapErr(s.q.DeleteExpiredOIDCSessions(ctx, DeleteExpiredOIDCSessionsParams(arg)))
}
func (s *Store) DeleteExpiredSessions(ctx context.Context, expiry int64) error {
return mapErr(s.q.DeleteExpiredSessions(ctx, expiry))
}
func (s *Store) DeleteOIDCSessionBySub(ctx context.Context, sub string) error {
return mapErr(s.q.DeleteOIDCSessionBySub(ctx, sub))
}
func (s *Store) DeleteSession(ctx context.Context, uuid string) error {
return mapErr(s.q.DeleteSession(ctx, uuid))
}
func (s *Store) GetOIDCSessionByAccessTokenHash(ctx context.Context, accessTokenHash string) (repository.OidcSession, error) {
r, err := s.q.GetOIDCSessionByAccessTokenHash(ctx, accessTokenHash)
if err != nil {
return repository.OidcSession{}, mapErr(err)
}
return repository.OidcSession(r), nil
}
func (s *Store) GetOIDCSessionByRefreshTokenHash(ctx context.Context, refreshTokenHash string) (repository.OidcSession, error) {
r, err := s.q.GetOIDCSessionByRefreshTokenHash(ctx, refreshTokenHash)
if err != nil {
return repository.OidcSession{}, mapErr(err)
}
return repository.OidcSession(r), nil
}
func (s *Store) GetOIDCSessionBySub(ctx context.Context, sub string) (repository.OidcSession, error) {
r, err := s.q.GetOIDCSessionBySub(ctx, sub)
if err != nil {
return repository.OidcSession{}, mapErr(err)
}
return repository.OidcSession(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) UpdateOIDCSession(ctx context.Context, arg repository.UpdateOIDCSessionParams) (repository.OidcSession, error) {
r, err := s.q.UpdateOIDCSession(ctx, UpdateOIDCSessionParams(arg))
if err != nil {
return repository.OidcSession{}, mapErr(err)
}
return repository.OidcSession(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
}
+31
View File
@@ -0,0 +1,31 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
package sqlite
import (
"context"
"database/sql"
)
type DBTX interface {
ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
PrepareContext(context.Context, string) (*sql.Stmt, error)
QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
QueryRowContext(context.Context, string, ...interface{}) *sql.Row
}
func New(db DBTX) *Queries {
return &Queries{db: db}
}
type Queries struct {
db DBTX
}
func (q *Queries) WithTx(tx *sql.Tx) *Queries {
return &Queries{
db: tx,
}
}
+3
View File
@@ -0,0 +1,3 @@
package sqlite
//go:generate go run github.com/tinyauthapp/tinyauth/gen/sqlc-wrapper -pkg github.com/tinyauthapp/tinyauth/internal/repository/sqlite
+31
View File
@@ -0,0 +1,31 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
package sqlite
type OidcSession struct {
Sub string
AccessTokenHash string
RefreshTokenHash string
Scope string
ClientID string
TokenExpiresAt int64
RefreshTokenExpiresAt int64
Nonce string
UserinfoJson 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
}
@@ -0,0 +1,210 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: oidc_queries.sql
package sqlite
import (
"context"
)
const createOIDCSession = `-- name: CreateOIDCSession :one
INSERT INTO "oidc_sessions" (
"sub",
"access_token_hash",
"refresh_token_hash",
"scope",
"client_id",
"token_expires_at",
"refresh_token_expires_at",
"nonce",
"userinfo_json"
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?
)
RETURNING sub, access_token_hash, refresh_token_hash, scope, client_id, token_expires_at, refresh_token_expires_at, nonce, userinfo_json
`
type CreateOIDCSessionParams struct {
Sub string
AccessTokenHash string
RefreshTokenHash string
Scope string
ClientID string
TokenExpiresAt int64
RefreshTokenExpiresAt int64
Nonce string
UserinfoJson string
}
func (q *Queries) CreateOIDCSession(ctx context.Context, arg CreateOIDCSessionParams) (OidcSession, error) {
row := q.db.QueryRowContext(ctx, createOIDCSession,
arg.Sub,
arg.AccessTokenHash,
arg.RefreshTokenHash,
arg.Scope,
arg.ClientID,
arg.TokenExpiresAt,
arg.RefreshTokenExpiresAt,
arg.Nonce,
arg.UserinfoJson,
)
var i OidcSession
err := row.Scan(
&i.Sub,
&i.AccessTokenHash,
&i.RefreshTokenHash,
&i.Scope,
&i.ClientID,
&i.TokenExpiresAt,
&i.RefreshTokenExpiresAt,
&i.Nonce,
&i.UserinfoJson,
)
return i, err
}
const deleteExpiredOIDCSessions = `-- name: DeleteExpiredOIDCSessions :exec
DELETE FROM "oidc_sessions"
WHERE "token_expires_at" < ? AND "refresh_token_expires_at" < ?
`
type DeleteExpiredOIDCSessionsParams struct {
TokenExpiresAt int64
RefreshTokenExpiresAt int64
}
func (q *Queries) DeleteExpiredOIDCSessions(ctx context.Context, arg DeleteExpiredOIDCSessionsParams) error {
_, err := q.db.ExecContext(ctx, deleteExpiredOIDCSessions, arg.TokenExpiresAt, arg.RefreshTokenExpiresAt)
return err
}
const deleteOIDCSessionBySub = `-- name: DeleteOIDCSessionBySub :exec
DELETE FROM "oidc_sessions"
WHERE "sub" = ?
`
func (q *Queries) DeleteOIDCSessionBySub(ctx context.Context, sub string) error {
_, err := q.db.ExecContext(ctx, deleteOIDCSessionBySub, sub)
return err
}
const getOIDCSessionByAccessTokenHash = `-- name: GetOIDCSessionByAccessTokenHash :one
SELECT sub, access_token_hash, refresh_token_hash, scope, client_id, token_expires_at, refresh_token_expires_at, nonce, userinfo_json FROM "oidc_sessions"
WHERE "access_token_hash" = ?
`
func (q *Queries) GetOIDCSessionByAccessTokenHash(ctx context.Context, accessTokenHash string) (OidcSession, error) {
row := q.db.QueryRowContext(ctx, getOIDCSessionByAccessTokenHash, accessTokenHash)
var i OidcSession
err := row.Scan(
&i.Sub,
&i.AccessTokenHash,
&i.RefreshTokenHash,
&i.Scope,
&i.ClientID,
&i.TokenExpiresAt,
&i.RefreshTokenExpiresAt,
&i.Nonce,
&i.UserinfoJson,
)
return i, err
}
const getOIDCSessionByRefreshTokenHash = `-- name: GetOIDCSessionByRefreshTokenHash :one
SELECT sub, access_token_hash, refresh_token_hash, scope, client_id, token_expires_at, refresh_token_expires_at, nonce, userinfo_json FROM "oidc_sessions"
WHERE "refresh_token_hash" = ?
`
func (q *Queries) GetOIDCSessionByRefreshTokenHash(ctx context.Context, refreshTokenHash string) (OidcSession, error) {
row := q.db.QueryRowContext(ctx, getOIDCSessionByRefreshTokenHash, refreshTokenHash)
var i OidcSession
err := row.Scan(
&i.Sub,
&i.AccessTokenHash,
&i.RefreshTokenHash,
&i.Scope,
&i.ClientID,
&i.TokenExpiresAt,
&i.RefreshTokenExpiresAt,
&i.Nonce,
&i.UserinfoJson,
)
return i, err
}
const getOIDCSessionBySub = `-- name: GetOIDCSessionBySub :one
SELECT sub, access_token_hash, refresh_token_hash, scope, client_id, token_expires_at, refresh_token_expires_at, nonce, userinfo_json FROM "oidc_sessions"
WHERE "sub" = ?
`
func (q *Queries) GetOIDCSessionBySub(ctx context.Context, sub string) (OidcSession, error) {
row := q.db.QueryRowContext(ctx, getOIDCSessionBySub, sub)
var i OidcSession
err := row.Scan(
&i.Sub,
&i.AccessTokenHash,
&i.RefreshTokenHash,
&i.Scope,
&i.ClientID,
&i.TokenExpiresAt,
&i.RefreshTokenExpiresAt,
&i.Nonce,
&i.UserinfoJson,
)
return i, err
}
const updateOIDCSession = `-- name: UpdateOIDCSession :one
UPDATE "oidc_sessions" SET
"access_token_hash" = ?,
"refresh_token_hash" = ?,
"scope" = ?,
"client_id" = ?,
"token_expires_at" = ?,
"refresh_token_expires_at" = ?,
"nonce" = ?,
"userinfo_json" = ?
WHERE "sub" = ?
RETURNING sub, access_token_hash, refresh_token_hash, scope, client_id, token_expires_at, refresh_token_expires_at, nonce, userinfo_json
`
type UpdateOIDCSessionParams struct {
AccessTokenHash string
RefreshTokenHash string
Scope string
ClientID string
TokenExpiresAt int64
RefreshTokenExpiresAt int64
Nonce string
UserinfoJson string
Sub string
}
func (q *Queries) UpdateOIDCSession(ctx context.Context, arg UpdateOIDCSessionParams) (OidcSession, error) {
row := q.db.QueryRowContext(ctx, updateOIDCSession,
arg.AccessTokenHash,
arg.RefreshTokenHash,
arg.Scope,
arg.ClientID,
arg.TokenExpiresAt,
arg.RefreshTokenExpiresAt,
arg.Nonce,
arg.UserinfoJson,
arg.Sub,
)
var i OidcSession
err := row.Scan(
&i.Sub,
&i.AccessTokenHash,
&i.RefreshTokenHash,
&i.Scope,
&i.ClientID,
&i.TokenExpiresAt,
&i.RefreshTokenExpiresAt,
&i.Nonce,
&i.UserinfoJson,
)
return i, err
}
@@ -1,9 +1,9 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// sqlc v1.31.1
// source: session_queries.sql
package repository
package sqlite
import (
"context"
+113
View File
@@ -0,0 +1,113 @@
// 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) CreateOIDCSession(ctx context.Context, arg repository.CreateOIDCSessionParams) (repository.OidcSession, error) {
r, err := s.q.CreateOIDCSession(ctx, CreateOIDCSessionParams(arg))
if err != nil {
return repository.OidcSession{}, mapErr(err)
}
return repository.OidcSession(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) DeleteExpiredOIDCSessions(ctx context.Context, arg repository.DeleteExpiredOIDCSessionsParams) error {
return mapErr(s.q.DeleteExpiredOIDCSessions(ctx, DeleteExpiredOIDCSessionsParams(arg)))
}
func (s *Store) DeleteExpiredSessions(ctx context.Context, expiry int64) error {
return mapErr(s.q.DeleteExpiredSessions(ctx, expiry))
}
func (s *Store) DeleteOIDCSessionBySub(ctx context.Context, sub string) error {
return mapErr(s.q.DeleteOIDCSessionBySub(ctx, sub))
}
func (s *Store) DeleteSession(ctx context.Context, uuid string) error {
return mapErr(s.q.DeleteSession(ctx, uuid))
}
func (s *Store) GetOIDCSessionByAccessTokenHash(ctx context.Context, accessTokenHash string) (repository.OidcSession, error) {
r, err := s.q.GetOIDCSessionByAccessTokenHash(ctx, accessTokenHash)
if err != nil {
return repository.OidcSession{}, mapErr(err)
}
return repository.OidcSession(r), nil
}
func (s *Store) GetOIDCSessionByRefreshTokenHash(ctx context.Context, refreshTokenHash string) (repository.OidcSession, error) {
r, err := s.q.GetOIDCSessionByRefreshTokenHash(ctx, refreshTokenHash)
if err != nil {
return repository.OidcSession{}, mapErr(err)
}
return repository.OidcSession(r), nil
}
func (s *Store) GetOIDCSessionBySub(ctx context.Context, sub string) (repository.OidcSession, error) {
r, err := s.q.GetOIDCSessionBySub(ctx, sub)
if err != nil {
return repository.OidcSession{}, mapErr(err)
}
return repository.OidcSession(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) UpdateOIDCSession(ctx context.Context, arg repository.UpdateOIDCSessionParams) (repository.OidcSession, error) {
r, err := s.q.UpdateOIDCSession(ctx, UpdateOIDCSessionParams(arg))
if err != nil {
return repository.OidcSession{}, mapErr(err)
}
return repository.OidcSession(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
}
+30
View File
@@ -0,0 +1,30 @@
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 sessions
CreateOIDCSession(ctx context.Context, arg CreateOIDCSessionParams) (OidcSession, error)
DeleteExpiredOIDCSessions(ctx context.Context, arg DeleteExpiredOIDCSessionsParams) error
DeleteOIDCSessionBySub(ctx context.Context, sub string) error
GetOIDCSessionByAccessTokenHash(ctx context.Context, accessTokenHash string) (OidcSession, error)
GetOIDCSessionByRefreshTokenHash(ctx context.Context, refreshTokenHash string) (OidcSession, error)
GetOIDCSessionBySub(ctx context.Context, sub string) (OidcSession, error)
UpdateOIDCSession(ctx context.Context, arg UpdateOIDCSessionParams) (OidcSession, error)
}

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