mirror of
https://github.com/steveiliop56/tinyauth.git
synced 2026-06-03 18:10:16 +00:00
Compare commits
27 Commits
c855f9b8ac
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| dac844595d | |||
| 940ba6dff7 | |||
| faee58ca8e | |||
| e9b8ca3cf8 | |||
| f2c4e7932d | |||
| 4538922caf | |||
| 672db84200 | |||
| 359000f731 | |||
| 0a3e7bf265 | |||
| c3461131f5 | |||
| 3f584ca741 | |||
| 36d0ffc2b5 | |||
| 37b79735f0 | |||
| 09540fbe6e | |||
| 8e60a2e28e | |||
| 9619024c37 | |||
| 1c305bacca | |||
| e532cde2b6 | |||
| 2737a25227 | |||
| 7aa25210f5 | |||
| 55bef72639 | |||
| ae17bd3b66 | |||
| 8849d7e00f | |||
| c9e90547d4 | |||
| 3194f4b987 | |||
| 9b50670925 | |||
| 1166a15aa7 |
+70
-1
@@ -7,7 +7,9 @@ TINYAUTH_APPURL=
|
||||
|
||||
# database config
|
||||
|
||||
# The path to the database, including file name.
|
||||
# The database driver to use. Valid values: sqlite, memory.
|
||||
TINYAUTH_DATABASE_DRIVER="sqlite"
|
||||
# The path to the SQLite database, including file name. Only used when driver is sqlite.
|
||||
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
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
run: go mod download
|
||||
|
||||
- name: Setup sqlc
|
||||
uses: sqlc-dev/setup-sqlc@v4
|
||||
uses: sqlc-dev/setup-sqlc@v5
|
||||
with:
|
||||
sqlc-version: "1.31.1"
|
||||
|
||||
@@ -62,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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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
|
||||
|
||||
+3
-2
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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()}
|
||||
/>
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Игнориши",
|
||||
|
||||
@@ -77,7 +77,7 @@ 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();
|
||||
@@ -127,7 +127,7 @@ export const AuthorizePage = () => {
|
||||
);
|
||||
}
|
||||
|
||||
if (!isLoggedIn) {
|
||||
if (!auth.authenticated) {
|
||||
return <Navigate to={`/login?${oidcParams.compiled}`} replace />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -36,12 +36,17 @@ 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);
|
||||
|
||||
@@ -55,7 +60,7 @@ export const LoginPage = () => {
|
||||
const oidcParams = useOIDCParams(searchParams);
|
||||
|
||||
const [isOauthAutoRedirect, setIsOauthAutoRedirect] = useState(
|
||||
providers.find((provider) => provider.id === oauthAutoRedirect) !==
|
||||
providers.find((provider) => provider.id === oauth.autoRedirect) !==
|
||||
undefined && redirectUri !== undefined,
|
||||
);
|
||||
|
||||
@@ -148,21 +153,47 @@ export const LoginPage = () => {
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: tailscaleMutate, isPending: tailscaleIsPending } =
|
||||
useMutation({
|
||||
mutationFn: () => axios.post("/api/user/tailscale"),
|
||||
mutationKey: ["tailscale"],
|
||||
onSuccess: () => {
|
||||
toast.success(t("loginSuccessTitle"), {
|
||||
description: t("loginTailscaleSuccess"),
|
||||
});
|
||||
|
||||
redirectTimer.current = window.setTimeout(() => {
|
||||
if (oidcParams.isOidc) {
|
||||
window.location.replace(`/authorize?${oidcParams.compiled}`);
|
||||
return;
|
||||
}
|
||||
window.location.replace(
|
||||
`/continue${redirectUri ? `?redirect_uri=${encodeURIComponent(redirectUri)}` : ""}`,
|
||||
);
|
||||
}, 500);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("loginFailTitle"), {
|
||||
description: t("loginTailscaleFail"),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isLoggedIn &&
|
||||
!auth.authenticated &&
|
||||
isOauthAutoRedirect &&
|
||||
!hasAutoRedirectedRef.current &&
|
||||
redirectUri !== undefined
|
||||
) {
|
||||
hasAutoRedirectedRef.current = true;
|
||||
oauthMutate(oauthAutoRedirect);
|
||||
oauthMutate(oauth.autoRedirect);
|
||||
}
|
||||
}, [
|
||||
isLoggedIn,
|
||||
auth.authenticated,
|
||||
oauthMutate,
|
||||
hasAutoRedirectedRef,
|
||||
oauthAutoRedirect,
|
||||
oauth.autoRedirect,
|
||||
isOauthAutoRedirect,
|
||||
redirectUri,
|
||||
]);
|
||||
@@ -179,11 +210,11 @@ export const LoginPage = () => {
|
||||
};
|
||||
}, [redirectTimer, redirectButtonTimer]);
|
||||
|
||||
if (isLoggedIn && oidcParams.isOidc) {
|
||||
if (auth.authenticated && oidcParams.isOidc) {
|
||||
return <Navigate to={`/authorize?${oidcParams.compiled}`} replace />;
|
||||
}
|
||||
|
||||
if (isLoggedIn && redirectUri !== undefined) {
|
||||
if (auth.authenticated && redirectUri !== undefined) {
|
||||
return (
|
||||
<Navigate
|
||||
to={`/continue${redirectUri ? `?redirect_uri=${encodeURIComponent(redirectUri)}` : ""}`}
|
||||
@@ -192,7 +223,7 @@ export const LoginPage = () => {
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoggedIn) {
|
||||
if (auth.authenticated) {
|
||||
return <Navigate to="/logout" replace />;
|
||||
}
|
||||
|
||||
@@ -228,10 +259,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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import { toast } from "sonner";
|
||||
import { useOIDCParams } from "@/lib/hooks/oidc";
|
||||
|
||||
export const TotpPage = () => {
|
||||
const { totpPending } = useUserContext();
|
||||
const { totp } = useUserContext();
|
||||
const { t } = useTranslation();
|
||||
const { search } = useLocation();
|
||||
const formId = useId();
|
||||
@@ -64,7 +64,7 @@ export const TotpPage = () => {
|
||||
};
|
||||
}, [redirectTimer]);
|
||||
|
||||
if (!totpPending) {
|
||||
if (!totp.pending) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,18 +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
|
||||
golang.org/x/tools v0.43.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 (
|
||||
@@ -31,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
|
||||
@@ -55,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
|
||||
@@ -75,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
|
||||
@@ -84,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
|
||||
@@ -120,23 +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/mod v0.34.0 // 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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -11,5 +11,5 @@ var FrontendAssets embed.FS
|
||||
|
||||
// Migrations
|
||||
//
|
||||
//go:embed migrations/sqlite/*.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");
|
||||
@@ -7,18 +7,17 @@ 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"
|
||||
@@ -27,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
|
||||
@@ -35,20 +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.Store
|
||||
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 {
|
||||
@@ -63,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")
|
||||
@@ -80,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 {
|
||||
@@ -93,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)
|
||||
@@ -108,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 = ""
|
||||
@@ -170,15 +196,17 @@ func (app *BootstrapApp) Setup() error {
|
||||
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()
|
||||
if app.db != nil {
|
||||
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)
|
||||
|
||||
// store
|
||||
app.queries = store
|
||||
@@ -231,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()
|
||||
|
||||
@@ -240,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()
|
||||
|
||||
@@ -428,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
|
||||
@@ -436,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()
|
||||
|
||||
@@ -445,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
|
||||
|
||||
@@ -6,15 +6,18 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
pgxmigrate "github.com/golang-migrate/migrate/v4/database/pgx/v5"
|
||||
"github.com/golang-migrate/migrate/v4/database/sqlite3"
|
||||
"github.com/golang-migrate/migrate/v4/source/iofs"
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"github.com/tinyauthapp/tinyauth/internal/assets"
|
||||
"github.com/tinyauthapp/tinyauth/internal/repository"
|
||||
"github.com/tinyauthapp/tinyauth/internal/repository/memory"
|
||||
"github.com/tinyauthapp/tinyauth/internal/repository/postgres"
|
||||
"github.com/tinyauthapp/tinyauth/internal/repository/sqlite"
|
||||
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
"github.com/golang-migrate/migrate/v4/database/sqlite3"
|
||||
"github.com/golang-migrate/migrate/v4/source/iofs"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
func (app *BootstrapApp) SetupStore() (repository.Store, error) {
|
||||
@@ -23,8 +26,10 @@ func (app *BootstrapApp) SetupStore() (repository.Store, error) {
|
||||
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, memory", app.config.Database.Driver)
|
||||
return nil, fmt.Errorf("unknown database driver %q: valid values are sqlite, postgres, memory", app.config.Database.Driver)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,9 +46,9 @@ func (app *BootstrapApp) setupSQLite(databasePath string) (repository.Store, 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()
|
||||
}
|
||||
}()
|
||||
@@ -70,11 +75,54 @@ func (app *BootstrapApp) setupSQLite(databasePath string) (repository.Store, err
|
||||
return nil, fmt.Errorf("failed to create migrator: %w", err)
|
||||
}
|
||||
|
||||
if err := migrator.Up(); err != nil && err != migrate.ErrNoChange {
|
||||
if err = migrator.Up(); err != nil && err != migrate.ErrNoChange {
|
||||
return nil, fmt.Errorf("failed to migrate database: %w", err)
|
||||
}
|
||||
|
||||
cleanup = false
|
||||
app.db = db
|
||||
|
||||
return sqlite.NewStore(sqlite.New(db)), nil
|
||||
}
|
||||
|
||||
func (app *BootstrapApp) setupPostgres(databaseURL string) (repository.Store, error) {
|
||||
db, err := sql.Open("pgx", databaseURL)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open database: %w", err)
|
||||
}
|
||||
|
||||
cleanup := true
|
||||
defer func() {
|
||||
if cleanup {
|
||||
db.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
migrations, err := iofs.New(assets.Migrations, "migrations/postgres")
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create migrations: %w", err)
|
||||
}
|
||||
|
||||
target, err := pgxmigrate.WithInstance(db, &pgxmigrate.Config{})
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create postgres instance: %w", err)
|
||||
}
|
||||
|
||||
migrator, err := migrate.NewWithInstance("iofs", migrations, "pgx", target)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create migrator: %w", err)
|
||||
}
|
||||
|
||||
if err = migrator.Up(); err != nil && err != migrate.ErrNoChange {
|
||||
return nil, fmt.Errorf("failed to migrate database: %w", err)
|
||||
}
|
||||
|
||||
cleanup = false
|
||||
app.db = db
|
||||
|
||||
return postgres.NewStore(postgres.New(db)), nil
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
@@ -22,6 +22,14 @@ func (app *BootstrapApp) setupServices() error {
|
||||
return fmt.Errorf("failed to initialize label provider: %w", err)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -34,10 +42,10 @@ func (app *BootstrapApp) setupServices() error {
|
||||
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)
|
||||
@@ -61,7 +69,7 @@ func (app *BootstrapApp) getLabelProvider() (service.LabelProvider, error) {
|
||||
if useKubernetes {
|
||||
app.log.App.Debug().Msg("Using Kubernetes label provider")
|
||||
|
||||
kubernetesService, err := service.NewKubernetesService(app.log, app.ctx, &app.wg)
|
||||
kubernetesService, err := service.NewKubernetesService(app.log, app.ctx, app.ding)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize kubernetes service: %w", err)
|
||||
@@ -73,7 +81,7 @@ func (app *BootstrapApp) getLabelProvider() (service.LabelProvider, error) {
|
||||
|
||||
app.log.App.Debug().Msg("Using Docker label provider")
|
||||
|
||||
dockerService, err := service.NewDockerService(app.log, app.ctx, &app.wg)
|
||||
dockerService, err := service.NewDockerService(app.log, app.ctx, app.ding)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize docker service: %w", err)
|
||||
@@ -117,7 +125,8 @@ func (app *BootstrapApp) setupPolicyEngine() error {
|
||||
Config: app.config,
|
||||
})
|
||||
policyEngine.RegisterRule(service.RuleIPBypassed, &service.IPBypassedRule{
|
||||
Log: app.log,
|
||||
Log: app.log,
|
||||
Config: app.config,
|
||||
})
|
||||
|
||||
app.services.policyEngine = policyEngine
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -16,6 +16,15 @@ import (
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
)
|
||||
|
||||
type authorizeErrorParams struct {
|
||||
err error
|
||||
reason string
|
||||
reasonPublic string
|
||||
callback string
|
||||
callbackError string
|
||||
state string
|
||||
}
|
||||
|
||||
type OIDCController struct {
|
||||
log *logger.Logger
|
||||
oidc *service.OIDCService
|
||||
@@ -119,34 +128,55 @@ func (controller *OIDCController) GetClientInfo(c *gin.Context) {
|
||||
|
||||
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", "", "", "")
|
||||
controller.authorizeError(c, authorizeErrorParams{
|
||||
err: errors.New("err_oidc_not_configured"),
|
||||
reason: "OIDC not configured",
|
||||
reasonPublic: "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", "", "", "")
|
||||
controller.authorizeError(c, authorizeErrorParams{
|
||||
err: err,
|
||||
reason: "Failed to get user context",
|
||||
reasonPublic: "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", "", "", "")
|
||||
controller.authorizeError(c, authorizeErrorParams{
|
||||
err: errors.New("err user not logged in"),
|
||||
reason: "User not logged in",
|
||||
reasonPublic: "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
|
||||
}
|
||||
|
||||
@@ -155,10 +185,21 @@ func (controller *OIDCController) Authorize(c *gin.Context) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -169,14 +210,28 @@ func (controller *OIDCController) Authorize(c *gin.Context) {
|
||||
// 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: req.RedirectURI,
|
||||
callbackError: "server_error",
|
||||
state: req.State,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
err = controller.oidc.StoreCode(c, sub, code, req)
|
||||
|
||||
if err != nil {
|
||||
controller.authorizeError(c, err, "Failed to store code", "Failed to store code", req.RedirectURI, "server_error", req.State)
|
||||
controller.authorizeError(c, authorizeErrorParams{
|
||||
err: err,
|
||||
reason: "Failed to store code",
|
||||
reasonPublic: "Failed to store code",
|
||||
callback: req.RedirectURI,
|
||||
callbackError: "server_error",
|
||||
state: req.State,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -186,7 +241,14 @@ func (controller *OIDCController) Authorize(c *gin.Context) {
|
||||
|
||||
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)
|
||||
controller.authorizeError(c, authorizeErrorParams{
|
||||
err: err,
|
||||
reason: "Failed to store user info",
|
||||
reasonPublic: "Failed to store user info",
|
||||
callback: req.RedirectURI,
|
||||
callbackError: "server_error",
|
||||
state: req.State,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -197,7 +259,14 @@ func (controller *OIDCController) Authorize(c *gin.Context) {
|
||||
})
|
||||
|
||||
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: req.RedirectURI,
|
||||
callbackError: "server_error",
|
||||
state: req.State,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -478,20 +547,20 @@ func (controller *OIDCController) Userinfo(c *gin.Context) {
|
||||
c.JSON(200, controller.oidc.CompileUserinfo(user, 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)
|
||||
@@ -503,13 +572,13 @@ func (controller *OIDCController) authorizeError(c *gin.Context, err error, reas
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"status": 200,
|
||||
"redirect_uri": fmt.Sprintf("%s?%s", callback, queries.Encode()),
|
||||
"redirect_uri": fmt.Sprintf("%s?%s", params.callback, queries.Encode()),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
errorQueries := ErrorScreen{
|
||||
Error: reasonUser,
|
||||
Error: params.reasonPublic,
|
||||
}
|
||||
|
||||
queries, err := query.Values(errorQueries)
|
||||
|
||||
@@ -8,11 +8,11 @@ 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/controller"
|
||||
@@ -840,9 +840,9 @@ func TestOIDCController(t *testing.T) {
|
||||
|
||||
store := memory.New()
|
||||
|
||||
wg := &sync.WaitGroup{}
|
||||
dg := ding.New(context.TODO())
|
||||
|
||||
oidcService, err := service.NewOIDCService(log, cfg, runtime, store, context.TODO(), wg)
|
||||
oidcService, err := service.NewOIDCService(log, cfg, runtime, store, dg)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, test := range tests {
|
||||
|
||||
@@ -160,7 +160,10 @@ 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,
|
||||
}
|
||||
|
||||
@@ -3,10 +3,10 @@ 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/controller"
|
||||
@@ -353,11 +353,10 @@ func TestProxyController(t *testing.T) {
|
||||
|
||||
store := memory.New()
|
||||
|
||||
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, store, broker)
|
||||
aclsService := service.NewAccessControlsService(log, cfg, nil)
|
||||
|
||||
policyEngine, err := service.NewPolicyEngine(cfg, log)
|
||||
@@ -383,6 +382,8 @@ func TestProxyController(t *testing.T) {
|
||||
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) {
|
||||
router := gin.Default()
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,12 +6,12 @@ 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/controller"
|
||||
@@ -412,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, store, 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 {
|
||||
|
||||
@@ -5,10 +5,10 @@ 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/controller"
|
||||
@@ -89,11 +89,11 @@ func TestWellKnownController(t *testing.T) {
|
||||
}
|
||||
|
||||
ctx := context.TODO()
|
||||
wg := &sync.WaitGroup{}
|
||||
dg := ding.New(ctx)
|
||||
|
||||
store := memory.New()
|
||||
|
||||
oidcService, err := service.NewOIDCService(log, cfg, runtime, store, ctx, wg)
|
||||
oidcService, err := service.NewOIDCService(log, cfg, runtime, store, dg)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, test := range tests {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -5,11 +5,11 @@ 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/middleware"
|
||||
@@ -250,17 +250,20 @@ func TestContextMiddleware(t *testing.T) {
|
||||
}
|
||||
|
||||
ctx := context.TODO()
|
||||
wg := &sync.WaitGroup{}
|
||||
dg := ding.New(ctx)
|
||||
|
||||
store := memory.New()
|
||||
|
||||
broker := service.NewOAuthBrokerService(log, map[string]model.OAuthServiceConfig{}, ctx)
|
||||
authService := service.NewAuthService(log, cfg, runtime, ctx, wg, nil, store, broker)
|
||||
policyEngine, err := service.NewPolicyEngine(cfg, log)
|
||||
require.NoError(t, err)
|
||||
|
||||
contextMiddleware := middleware.NewContextMiddleware(log, runtime, authService, broker)
|
||||
broker := service.NewOAuthBrokerService(log, map[string]model.OAuthServiceConfig{}, ctx)
|
||||
authService := service.NewAuthService(log, cfg, runtime, ctx, dg, nil, store, broker, nil, policyEngine)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@@ -62,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",
|
||||
}
|
||||
@@ -84,11 +84,13 @@ type Config struct {
|
||||
Experimental ExperimentalConfig `description:"Experimental features, use with caution." yaml:"experimental"`
|
||||
LabelProvider string `description:"Label provider to use for ACLs (auto, docker, kubernetes or none to disable). auto detects the environment." yaml:"labelProvider"`
|
||||
Log LogConfig `description:"Logging configuration." yaml:"log"`
|
||||
Tailscale TailscaleConfig `description:"Tailscale configuration." yaml:"tailscale"`
|
||||
ConfigFile string `description:"Path to config file." yaml:"-"`
|
||||
}
|
||||
|
||||
type DatabaseConfig struct {
|
||||
Driver string `description:"The database driver to use. Valid values: sqlite, memory." yaml:"driver"`
|
||||
Path string `description:"The path to the SQLite database, including file name. Only used when driver is sqlite." yaml:"path"`
|
||||
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 {
|
||||
@@ -150,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 {
|
||||
@@ -203,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"`
|
||||
|
||||
@@ -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
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ type RuntimeConfig struct {
|
||||
OAuthWhitelist []string
|
||||
ConfiguredProviders []Provider
|
||||
OIDCClients []OIDCClientConfig
|
||||
TrustedDomains []string
|
||||
}
|
||||
|
||||
type Provider struct {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
type DBTX interface {
|
||||
ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
|
||||
PrepareContext(context.Context, string) (*sql.Stmt, error)
|
||||
QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
|
||||
QueryRowContext(context.Context, string, ...interface{}) *sql.Row
|
||||
}
|
||||
|
||||
func New(db DBTX) *Queries {
|
||||
return &Queries{db: db}
|
||||
}
|
||||
|
||||
type Queries struct {
|
||||
db DBTX
|
||||
}
|
||||
|
||||
func (q *Queries) WithTx(tx *sql.Tx) *Queries {
|
||||
return &Queries{
|
||||
db: tx,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,64 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
|
||||
package postgres
|
||||
|
||||
type OidcCode struct {
|
||||
Sub string
|
||||
CodeHash string
|
||||
Scope string
|
||||
RedirectURI string
|
||||
ClientID string
|
||||
ExpiresAt int64
|
||||
Nonce string
|
||||
CodeChallenge string
|
||||
}
|
||||
|
||||
type OidcToken struct {
|
||||
Sub string
|
||||
AccessTokenHash string
|
||||
RefreshTokenHash string
|
||||
CodeHash string
|
||||
Scope string
|
||||
ClientID string
|
||||
TokenExpiresAt int64
|
||||
RefreshTokenExpiresAt int64
|
||||
Nonce string
|
||||
}
|
||||
|
||||
type OidcUserinfo struct {
|
||||
Sub string
|
||||
Name string
|
||||
PreferredUsername string
|
||||
Email string
|
||||
Groups string
|
||||
UpdatedAt int64
|
||||
GivenName string
|
||||
FamilyName string
|
||||
MiddleName string
|
||||
Nickname string
|
||||
Profile string
|
||||
Picture string
|
||||
Website string
|
||||
Gender string
|
||||
Birthdate string
|
||||
Zoneinfo string
|
||||
Locale string
|
||||
PhoneNumber string
|
||||
Address string
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
UUID string
|
||||
Username string
|
||||
Email string
|
||||
Name string
|
||||
Provider string
|
||||
TotpPending bool
|
||||
OAuthGroups string
|
||||
Expiry int64
|
||||
CreatedAt int64
|
||||
OAuthName string
|
||||
OAuthSub string
|
||||
}
|
||||
@@ -0,0 +1,581 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: oidc_queries.sql
|
||||
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
const createOidcCode = `-- name: CreateOidcCode :one
|
||||
INSERT INTO "oidc_codes" (
|
||||
"sub",
|
||||
"code_hash",
|
||||
"scope",
|
||||
"redirect_uri",
|
||||
"client_id",
|
||||
"expires_at",
|
||||
"nonce",
|
||||
"code_challenge"
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8
|
||||
)
|
||||
RETURNING sub, code_hash, scope, redirect_uri, client_id, expires_at, nonce, code_challenge
|
||||
`
|
||||
|
||||
type CreateOidcCodeParams struct {
|
||||
Sub string
|
||||
CodeHash string
|
||||
Scope string
|
||||
RedirectURI string
|
||||
ClientID string
|
||||
ExpiresAt int64
|
||||
Nonce string
|
||||
CodeChallenge string
|
||||
}
|
||||
|
||||
func (q *Queries) CreateOidcCode(ctx context.Context, arg CreateOidcCodeParams) (OidcCode, error) {
|
||||
row := q.db.QueryRowContext(ctx, createOidcCode,
|
||||
arg.Sub,
|
||||
arg.CodeHash,
|
||||
arg.Scope,
|
||||
arg.RedirectURI,
|
||||
arg.ClientID,
|
||||
arg.ExpiresAt,
|
||||
arg.Nonce,
|
||||
arg.CodeChallenge,
|
||||
)
|
||||
var i OidcCode
|
||||
err := row.Scan(
|
||||
&i.Sub,
|
||||
&i.CodeHash,
|
||||
&i.Scope,
|
||||
&i.RedirectURI,
|
||||
&i.ClientID,
|
||||
&i.ExpiresAt,
|
||||
&i.Nonce,
|
||||
&i.CodeChallenge,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const createOidcToken = `-- name: CreateOidcToken :one
|
||||
INSERT INTO "oidc_tokens" (
|
||||
"sub",
|
||||
"access_token_hash",
|
||||
"refresh_token_hash",
|
||||
"scope",
|
||||
"client_id",
|
||||
"token_expires_at",
|
||||
"refresh_token_expires_at",
|
||||
"code_hash",
|
||||
"nonce"
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9
|
||||
)
|
||||
RETURNING sub, access_token_hash, refresh_token_hash, code_hash, scope, client_id, token_expires_at, refresh_token_expires_at, nonce
|
||||
`
|
||||
|
||||
type CreateOidcTokenParams struct {
|
||||
Sub string
|
||||
AccessTokenHash string
|
||||
RefreshTokenHash string
|
||||
Scope string
|
||||
ClientID string
|
||||
TokenExpiresAt int64
|
||||
RefreshTokenExpiresAt int64
|
||||
CodeHash string
|
||||
Nonce string
|
||||
}
|
||||
|
||||
func (q *Queries) CreateOidcToken(ctx context.Context, arg CreateOidcTokenParams) (OidcToken, error) {
|
||||
row := q.db.QueryRowContext(ctx, createOidcToken,
|
||||
arg.Sub,
|
||||
arg.AccessTokenHash,
|
||||
arg.RefreshTokenHash,
|
||||
arg.Scope,
|
||||
arg.ClientID,
|
||||
arg.TokenExpiresAt,
|
||||
arg.RefreshTokenExpiresAt,
|
||||
arg.CodeHash,
|
||||
arg.Nonce,
|
||||
)
|
||||
var i OidcToken
|
||||
err := row.Scan(
|
||||
&i.Sub,
|
||||
&i.AccessTokenHash,
|
||||
&i.RefreshTokenHash,
|
||||
&i.CodeHash,
|
||||
&i.Scope,
|
||||
&i.ClientID,
|
||||
&i.TokenExpiresAt,
|
||||
&i.RefreshTokenExpiresAt,
|
||||
&i.Nonce,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const createOidcUserInfo = `-- name: CreateOidcUserInfo :one
|
||||
INSERT INTO "oidc_userinfo" (
|
||||
"sub",
|
||||
"name",
|
||||
"preferred_username",
|
||||
"email",
|
||||
"groups",
|
||||
"updated_at",
|
||||
"given_name",
|
||||
"family_name",
|
||||
"middle_name",
|
||||
"nickname",
|
||||
"profile",
|
||||
"picture",
|
||||
"website",
|
||||
"gender",
|
||||
"birthdate",
|
||||
"zoneinfo",
|
||||
"locale",
|
||||
"phone_number",
|
||||
"address"
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19
|
||||
)
|
||||
RETURNING sub, name, preferred_username, email, groups, updated_at, given_name, family_name, middle_name, nickname, profile, picture, website, gender, birthdate, zoneinfo, locale, phone_number, address
|
||||
`
|
||||
|
||||
type CreateOidcUserInfoParams struct {
|
||||
Sub string
|
||||
Name string
|
||||
PreferredUsername string
|
||||
Email string
|
||||
Groups string
|
||||
UpdatedAt int64
|
||||
GivenName string
|
||||
FamilyName string
|
||||
MiddleName string
|
||||
Nickname string
|
||||
Profile string
|
||||
Picture string
|
||||
Website string
|
||||
Gender string
|
||||
Birthdate string
|
||||
Zoneinfo string
|
||||
Locale string
|
||||
PhoneNumber string
|
||||
Address string
|
||||
}
|
||||
|
||||
func (q *Queries) CreateOidcUserInfo(ctx context.Context, arg CreateOidcUserInfoParams) (OidcUserinfo, error) {
|
||||
row := q.db.QueryRowContext(ctx, createOidcUserInfo,
|
||||
arg.Sub,
|
||||
arg.Name,
|
||||
arg.PreferredUsername,
|
||||
arg.Email,
|
||||
arg.Groups,
|
||||
arg.UpdatedAt,
|
||||
arg.GivenName,
|
||||
arg.FamilyName,
|
||||
arg.MiddleName,
|
||||
arg.Nickname,
|
||||
arg.Profile,
|
||||
arg.Picture,
|
||||
arg.Website,
|
||||
arg.Gender,
|
||||
arg.Birthdate,
|
||||
arg.Zoneinfo,
|
||||
arg.Locale,
|
||||
arg.PhoneNumber,
|
||||
arg.Address,
|
||||
)
|
||||
var i OidcUserinfo
|
||||
err := row.Scan(
|
||||
&i.Sub,
|
||||
&i.Name,
|
||||
&i.PreferredUsername,
|
||||
&i.Email,
|
||||
&i.Groups,
|
||||
&i.UpdatedAt,
|
||||
&i.GivenName,
|
||||
&i.FamilyName,
|
||||
&i.MiddleName,
|
||||
&i.Nickname,
|
||||
&i.Profile,
|
||||
&i.Picture,
|
||||
&i.Website,
|
||||
&i.Gender,
|
||||
&i.Birthdate,
|
||||
&i.Zoneinfo,
|
||||
&i.Locale,
|
||||
&i.PhoneNumber,
|
||||
&i.Address,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const deleteExpiredOidcCodes = `-- name: DeleteExpiredOidcCodes :many
|
||||
DELETE FROM "oidc_codes"
|
||||
WHERE "expires_at" < $1
|
||||
RETURNING sub, code_hash, scope, redirect_uri, client_id, expires_at, nonce, code_challenge
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteExpiredOidcCodes(ctx context.Context, expiresAt int64) ([]OidcCode, error) {
|
||||
rows, err := q.db.QueryContext(ctx, deleteExpiredOidcCodes, expiresAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []OidcCode
|
||||
for rows.Next() {
|
||||
var i OidcCode
|
||||
if err := rows.Scan(
|
||||
&i.Sub,
|
||||
&i.CodeHash,
|
||||
&i.Scope,
|
||||
&i.RedirectURI,
|
||||
&i.ClientID,
|
||||
&i.ExpiresAt,
|
||||
&i.Nonce,
|
||||
&i.CodeChallenge,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const deleteExpiredOidcTokens = `-- name: DeleteExpiredOidcTokens :many
|
||||
DELETE FROM "oidc_tokens"
|
||||
WHERE "token_expires_at" < $1 AND "refresh_token_expires_at" < $2
|
||||
RETURNING sub, access_token_hash, refresh_token_hash, code_hash, scope, client_id, token_expires_at, refresh_token_expires_at, nonce
|
||||
`
|
||||
|
||||
type DeleteExpiredOidcTokensParams struct {
|
||||
TokenExpiresAt int64
|
||||
RefreshTokenExpiresAt int64
|
||||
}
|
||||
|
||||
func (q *Queries) DeleteExpiredOidcTokens(ctx context.Context, arg DeleteExpiredOidcTokensParams) ([]OidcToken, error) {
|
||||
rows, err := q.db.QueryContext(ctx, deleteExpiredOidcTokens, arg.TokenExpiresAt, arg.RefreshTokenExpiresAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []OidcToken
|
||||
for rows.Next() {
|
||||
var i OidcToken
|
||||
if err := rows.Scan(
|
||||
&i.Sub,
|
||||
&i.AccessTokenHash,
|
||||
&i.RefreshTokenHash,
|
||||
&i.CodeHash,
|
||||
&i.Scope,
|
||||
&i.ClientID,
|
||||
&i.TokenExpiresAt,
|
||||
&i.RefreshTokenExpiresAt,
|
||||
&i.Nonce,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const deleteOidcCode = `-- name: DeleteOidcCode :exec
|
||||
DELETE FROM "oidc_codes"
|
||||
WHERE "code_hash" = $1
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteOidcCode(ctx context.Context, codeHash string) error {
|
||||
_, err := q.db.ExecContext(ctx, deleteOidcCode, codeHash)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteOidcCodeBySub = `-- name: DeleteOidcCodeBySub :exec
|
||||
DELETE FROM "oidc_codes"
|
||||
WHERE "sub" = $1
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteOidcCodeBySub(ctx context.Context, sub string) error {
|
||||
_, err := q.db.ExecContext(ctx, deleteOidcCodeBySub, sub)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteOidcToken = `-- name: DeleteOidcToken :exec
|
||||
DELETE FROM "oidc_tokens"
|
||||
WHERE "access_token_hash" = $1
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteOidcToken(ctx context.Context, accessTokenHash string) error {
|
||||
_, err := q.db.ExecContext(ctx, deleteOidcToken, accessTokenHash)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteOidcTokenByCodeHash = `-- name: DeleteOidcTokenByCodeHash :exec
|
||||
DELETE FROM "oidc_tokens"
|
||||
WHERE "code_hash" = $1
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteOidcTokenByCodeHash(ctx context.Context, codeHash string) error {
|
||||
_, err := q.db.ExecContext(ctx, deleteOidcTokenByCodeHash, codeHash)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteOidcTokenBySub = `-- name: DeleteOidcTokenBySub :exec
|
||||
DELETE FROM "oidc_tokens"
|
||||
WHERE "sub" = $1
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteOidcTokenBySub(ctx context.Context, sub string) error {
|
||||
_, err := q.db.ExecContext(ctx, deleteOidcTokenBySub, sub)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteOidcUserInfo = `-- name: DeleteOidcUserInfo :exec
|
||||
DELETE FROM "oidc_userinfo"
|
||||
WHERE "sub" = $1
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteOidcUserInfo(ctx context.Context, sub string) error {
|
||||
_, err := q.db.ExecContext(ctx, deleteOidcUserInfo, sub)
|
||||
return err
|
||||
}
|
||||
|
||||
const getOidcCode = `-- name: GetOidcCode :one
|
||||
DELETE FROM "oidc_codes"
|
||||
WHERE "code_hash" = $1
|
||||
RETURNING sub, code_hash, scope, redirect_uri, client_id, expires_at, nonce, code_challenge
|
||||
`
|
||||
|
||||
func (q *Queries) GetOidcCode(ctx context.Context, codeHash string) (OidcCode, error) {
|
||||
row := q.db.QueryRowContext(ctx, getOidcCode, codeHash)
|
||||
var i OidcCode
|
||||
err := row.Scan(
|
||||
&i.Sub,
|
||||
&i.CodeHash,
|
||||
&i.Scope,
|
||||
&i.RedirectURI,
|
||||
&i.ClientID,
|
||||
&i.ExpiresAt,
|
||||
&i.Nonce,
|
||||
&i.CodeChallenge,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getOidcCodeBySub = `-- name: GetOidcCodeBySub :one
|
||||
DELETE FROM "oidc_codes"
|
||||
WHERE "sub" = $1
|
||||
RETURNING sub, code_hash, scope, redirect_uri, client_id, expires_at, nonce, code_challenge
|
||||
`
|
||||
|
||||
func (q *Queries) GetOidcCodeBySub(ctx context.Context, sub string) (OidcCode, error) {
|
||||
row := q.db.QueryRowContext(ctx, getOidcCodeBySub, sub)
|
||||
var i OidcCode
|
||||
err := row.Scan(
|
||||
&i.Sub,
|
||||
&i.CodeHash,
|
||||
&i.Scope,
|
||||
&i.RedirectURI,
|
||||
&i.ClientID,
|
||||
&i.ExpiresAt,
|
||||
&i.Nonce,
|
||||
&i.CodeChallenge,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getOidcCodeBySubUnsafe = `-- name: GetOidcCodeBySubUnsafe :one
|
||||
SELECT sub, code_hash, scope, redirect_uri, client_id, expires_at, nonce, code_challenge FROM "oidc_codes"
|
||||
WHERE "sub" = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetOidcCodeBySubUnsafe(ctx context.Context, sub string) (OidcCode, error) {
|
||||
row := q.db.QueryRowContext(ctx, getOidcCodeBySubUnsafe, sub)
|
||||
var i OidcCode
|
||||
err := row.Scan(
|
||||
&i.Sub,
|
||||
&i.CodeHash,
|
||||
&i.Scope,
|
||||
&i.RedirectURI,
|
||||
&i.ClientID,
|
||||
&i.ExpiresAt,
|
||||
&i.Nonce,
|
||||
&i.CodeChallenge,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getOidcCodeUnsafe = `-- name: GetOidcCodeUnsafe :one
|
||||
SELECT sub, code_hash, scope, redirect_uri, client_id, expires_at, nonce, code_challenge FROM "oidc_codes"
|
||||
WHERE "code_hash" = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetOidcCodeUnsafe(ctx context.Context, codeHash string) (OidcCode, error) {
|
||||
row := q.db.QueryRowContext(ctx, getOidcCodeUnsafe, codeHash)
|
||||
var i OidcCode
|
||||
err := row.Scan(
|
||||
&i.Sub,
|
||||
&i.CodeHash,
|
||||
&i.Scope,
|
||||
&i.RedirectURI,
|
||||
&i.ClientID,
|
||||
&i.ExpiresAt,
|
||||
&i.Nonce,
|
||||
&i.CodeChallenge,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getOidcToken = `-- name: GetOidcToken :one
|
||||
SELECT sub, access_token_hash, refresh_token_hash, code_hash, scope, client_id, token_expires_at, refresh_token_expires_at, nonce FROM "oidc_tokens"
|
||||
WHERE "access_token_hash" = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetOidcToken(ctx context.Context, accessTokenHash string) (OidcToken, error) {
|
||||
row := q.db.QueryRowContext(ctx, getOidcToken, accessTokenHash)
|
||||
var i OidcToken
|
||||
err := row.Scan(
|
||||
&i.Sub,
|
||||
&i.AccessTokenHash,
|
||||
&i.RefreshTokenHash,
|
||||
&i.CodeHash,
|
||||
&i.Scope,
|
||||
&i.ClientID,
|
||||
&i.TokenExpiresAt,
|
||||
&i.RefreshTokenExpiresAt,
|
||||
&i.Nonce,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getOidcTokenByRefreshToken = `-- name: GetOidcTokenByRefreshToken :one
|
||||
SELECT sub, access_token_hash, refresh_token_hash, code_hash, scope, client_id, token_expires_at, refresh_token_expires_at, nonce FROM "oidc_tokens"
|
||||
WHERE "refresh_token_hash" = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetOidcTokenByRefreshToken(ctx context.Context, refreshTokenHash string) (OidcToken, error) {
|
||||
row := q.db.QueryRowContext(ctx, getOidcTokenByRefreshToken, refreshTokenHash)
|
||||
var i OidcToken
|
||||
err := row.Scan(
|
||||
&i.Sub,
|
||||
&i.AccessTokenHash,
|
||||
&i.RefreshTokenHash,
|
||||
&i.CodeHash,
|
||||
&i.Scope,
|
||||
&i.ClientID,
|
||||
&i.TokenExpiresAt,
|
||||
&i.RefreshTokenExpiresAt,
|
||||
&i.Nonce,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getOidcTokenBySub = `-- name: GetOidcTokenBySub :one
|
||||
SELECT sub, access_token_hash, refresh_token_hash, code_hash, scope, client_id, token_expires_at, refresh_token_expires_at, nonce FROM "oidc_tokens"
|
||||
WHERE "sub" = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetOidcTokenBySub(ctx context.Context, sub string) (OidcToken, error) {
|
||||
row := q.db.QueryRowContext(ctx, getOidcTokenBySub, sub)
|
||||
var i OidcToken
|
||||
err := row.Scan(
|
||||
&i.Sub,
|
||||
&i.AccessTokenHash,
|
||||
&i.RefreshTokenHash,
|
||||
&i.CodeHash,
|
||||
&i.Scope,
|
||||
&i.ClientID,
|
||||
&i.TokenExpiresAt,
|
||||
&i.RefreshTokenExpiresAt,
|
||||
&i.Nonce,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getOidcUserInfo = `-- name: GetOidcUserInfo :one
|
||||
SELECT sub, name, preferred_username, email, groups, updated_at, given_name, family_name, middle_name, nickname, profile, picture, website, gender, birthdate, zoneinfo, locale, phone_number, address FROM "oidc_userinfo"
|
||||
WHERE "sub" = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetOidcUserInfo(ctx context.Context, sub string) (OidcUserinfo, error) {
|
||||
row := q.db.QueryRowContext(ctx, getOidcUserInfo, sub)
|
||||
var i OidcUserinfo
|
||||
err := row.Scan(
|
||||
&i.Sub,
|
||||
&i.Name,
|
||||
&i.PreferredUsername,
|
||||
&i.Email,
|
||||
&i.Groups,
|
||||
&i.UpdatedAt,
|
||||
&i.GivenName,
|
||||
&i.FamilyName,
|
||||
&i.MiddleName,
|
||||
&i.Nickname,
|
||||
&i.Profile,
|
||||
&i.Picture,
|
||||
&i.Website,
|
||||
&i.Gender,
|
||||
&i.Birthdate,
|
||||
&i.Zoneinfo,
|
||||
&i.Locale,
|
||||
&i.PhoneNumber,
|
||||
&i.Address,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const updateOidcTokenByRefreshToken = `-- name: UpdateOidcTokenByRefreshToken :one
|
||||
UPDATE "oidc_tokens" SET
|
||||
"access_token_hash" = $1,
|
||||
"refresh_token_hash" = $2,
|
||||
"token_expires_at" = $3,
|
||||
"refresh_token_expires_at" = $4
|
||||
WHERE "refresh_token_hash" = $5
|
||||
RETURNING sub, access_token_hash, refresh_token_hash, code_hash, scope, client_id, token_expires_at, refresh_token_expires_at, nonce
|
||||
`
|
||||
|
||||
type UpdateOidcTokenByRefreshTokenParams struct {
|
||||
AccessTokenHash string
|
||||
RefreshTokenHash string
|
||||
TokenExpiresAt int64
|
||||
RefreshTokenExpiresAt int64
|
||||
RefreshTokenHash_2 string
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateOidcTokenByRefreshToken(ctx context.Context, arg UpdateOidcTokenByRefreshTokenParams) (OidcToken, error) {
|
||||
row := q.db.QueryRowContext(ctx, updateOidcTokenByRefreshToken,
|
||||
arg.AccessTokenHash,
|
||||
arg.RefreshTokenHash,
|
||||
arg.TokenExpiresAt,
|
||||
arg.RefreshTokenExpiresAt,
|
||||
arg.RefreshTokenHash_2,
|
||||
)
|
||||
var i OidcToken
|
||||
err := row.Scan(
|
||||
&i.Sub,
|
||||
&i.AccessTokenHash,
|
||||
&i.RefreshTokenHash,
|
||||
&i.CodeHash,
|
||||
&i.Scope,
|
||||
&i.ClientID,
|
||||
&i.TokenExpiresAt,
|
||||
&i.RefreshTokenExpiresAt,
|
||||
&i.Nonce,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
// 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) CreateOidcCode(ctx context.Context, arg repository.CreateOidcCodeParams) (repository.OidcCode, error) {
|
||||
r, err := s.q.CreateOidcCode(ctx, CreateOidcCodeParams(arg))
|
||||
if err != nil {
|
||||
return repository.OidcCode{}, mapErr(err)
|
||||
}
|
||||
return repository.OidcCode(r), nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateOidcToken(ctx context.Context, arg repository.CreateOidcTokenParams) (repository.OidcToken, error) {
|
||||
r, err := s.q.CreateOidcToken(ctx, CreateOidcTokenParams(arg))
|
||||
if err != nil {
|
||||
return repository.OidcToken{}, mapErr(err)
|
||||
}
|
||||
return repository.OidcToken(r), nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateOidcUserInfo(ctx context.Context, arg repository.CreateOidcUserInfoParams) (repository.OidcUserinfo, error) {
|
||||
r, err := s.q.CreateOidcUserInfo(ctx, CreateOidcUserInfoParams(arg))
|
||||
if err != nil {
|
||||
return repository.OidcUserinfo{}, mapErr(err)
|
||||
}
|
||||
return repository.OidcUserinfo(r), nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateSession(ctx context.Context, arg repository.CreateSessionParams) (repository.Session, error) {
|
||||
r, err := s.q.CreateSession(ctx, CreateSessionParams(arg))
|
||||
if err != nil {
|
||||
return repository.Session{}, mapErr(err)
|
||||
}
|
||||
return repository.Session(r), nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteExpiredOidcCodes(ctx context.Context, expiresAt int64) ([]repository.OidcCode, error) {
|
||||
rows, err := s.q.DeleteExpiredOidcCodes(ctx, expiresAt)
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
out := make([]repository.OidcCode, len(rows))
|
||||
for i, row := range rows {
|
||||
out[i] = repository.OidcCode(row)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteExpiredOidcTokens(ctx context.Context, arg repository.DeleteExpiredOidcTokensParams) ([]repository.OidcToken, error) {
|
||||
rows, err := s.q.DeleteExpiredOidcTokens(ctx, DeleteExpiredOidcTokensParams(arg))
|
||||
if err != nil {
|
||||
return nil, mapErr(err)
|
||||
}
|
||||
out := make([]repository.OidcToken, len(rows))
|
||||
for i, row := range rows {
|
||||
out[i] = repository.OidcToken(row)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteExpiredSessions(ctx context.Context, expiry int64) error {
|
||||
return mapErr(s.q.DeleteExpiredSessions(ctx, expiry))
|
||||
}
|
||||
|
||||
func (s *Store) DeleteOidcCode(ctx context.Context, codeHash string) error {
|
||||
return mapErr(s.q.DeleteOidcCode(ctx, codeHash))
|
||||
}
|
||||
|
||||
func (s *Store) DeleteOidcCodeBySub(ctx context.Context, sub string) error {
|
||||
return mapErr(s.q.DeleteOidcCodeBySub(ctx, sub))
|
||||
}
|
||||
|
||||
func (s *Store) DeleteOidcToken(ctx context.Context, accessTokenHash string) error {
|
||||
return mapErr(s.q.DeleteOidcToken(ctx, accessTokenHash))
|
||||
}
|
||||
|
||||
func (s *Store) DeleteOidcTokenByCodeHash(ctx context.Context, codeHash string) error {
|
||||
return mapErr(s.q.DeleteOidcTokenByCodeHash(ctx, codeHash))
|
||||
}
|
||||
|
||||
func (s *Store) DeleteOidcTokenBySub(ctx context.Context, sub string) error {
|
||||
return mapErr(s.q.DeleteOidcTokenBySub(ctx, sub))
|
||||
}
|
||||
|
||||
func (s *Store) DeleteOidcUserInfo(ctx context.Context, sub string) error {
|
||||
return mapErr(s.q.DeleteOidcUserInfo(ctx, sub))
|
||||
}
|
||||
|
||||
func (s *Store) DeleteSession(ctx context.Context, uuid string) error {
|
||||
return mapErr(s.q.DeleteSession(ctx, uuid))
|
||||
}
|
||||
|
||||
func (s *Store) GetOidcCode(ctx context.Context, codeHash string) (repository.OidcCode, error) {
|
||||
r, err := s.q.GetOidcCode(ctx, codeHash)
|
||||
if err != nil {
|
||||
return repository.OidcCode{}, mapErr(err)
|
||||
}
|
||||
return repository.OidcCode(r), nil
|
||||
}
|
||||
|
||||
func (s *Store) GetOidcCodeBySub(ctx context.Context, sub string) (repository.OidcCode, error) {
|
||||
r, err := s.q.GetOidcCodeBySub(ctx, sub)
|
||||
if err != nil {
|
||||
return repository.OidcCode{}, mapErr(err)
|
||||
}
|
||||
return repository.OidcCode(r), nil
|
||||
}
|
||||
|
||||
func (s *Store) GetOidcCodeBySubUnsafe(ctx context.Context, sub string) (repository.OidcCode, error) {
|
||||
r, err := s.q.GetOidcCodeBySubUnsafe(ctx, sub)
|
||||
if err != nil {
|
||||
return repository.OidcCode{}, mapErr(err)
|
||||
}
|
||||
return repository.OidcCode(r), nil
|
||||
}
|
||||
|
||||
func (s *Store) GetOidcCodeUnsafe(ctx context.Context, codeHash string) (repository.OidcCode, error) {
|
||||
r, err := s.q.GetOidcCodeUnsafe(ctx, codeHash)
|
||||
if err != nil {
|
||||
return repository.OidcCode{}, mapErr(err)
|
||||
}
|
||||
return repository.OidcCode(r), nil
|
||||
}
|
||||
|
||||
func (s *Store) GetOidcToken(ctx context.Context, accessTokenHash string) (repository.OidcToken, error) {
|
||||
r, err := s.q.GetOidcToken(ctx, accessTokenHash)
|
||||
if err != nil {
|
||||
return repository.OidcToken{}, mapErr(err)
|
||||
}
|
||||
return repository.OidcToken(r), nil
|
||||
}
|
||||
|
||||
func (s *Store) GetOidcTokenByRefreshToken(ctx context.Context, refreshTokenHash string) (repository.OidcToken, error) {
|
||||
r, err := s.q.GetOidcTokenByRefreshToken(ctx, refreshTokenHash)
|
||||
if err != nil {
|
||||
return repository.OidcToken{}, mapErr(err)
|
||||
}
|
||||
return repository.OidcToken(r), nil
|
||||
}
|
||||
|
||||
func (s *Store) GetOidcTokenBySub(ctx context.Context, sub string) (repository.OidcToken, error) {
|
||||
r, err := s.q.GetOidcTokenBySub(ctx, sub)
|
||||
if err != nil {
|
||||
return repository.OidcToken{}, mapErr(err)
|
||||
}
|
||||
return repository.OidcToken(r), nil
|
||||
}
|
||||
|
||||
func (s *Store) GetOidcUserInfo(ctx context.Context, sub string) (repository.OidcUserinfo, error) {
|
||||
r, err := s.q.GetOidcUserInfo(ctx, sub)
|
||||
if err != nil {
|
||||
return repository.OidcUserinfo{}, mapErr(err)
|
||||
}
|
||||
return repository.OidcUserinfo(r), nil
|
||||
}
|
||||
|
||||
func (s *Store) GetSession(ctx context.Context, uuid string) (repository.Session, error) {
|
||||
r, err := s.q.GetSession(ctx, uuid)
|
||||
if err != nil {
|
||||
return repository.Session{}, mapErr(err)
|
||||
}
|
||||
return repository.Session(r), nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateOidcTokenByRefreshToken(ctx context.Context, arg repository.UpdateOidcTokenByRefreshTokenParams) (repository.OidcToken, error) {
|
||||
r, err := s.q.UpdateOidcTokenByRefreshToken(ctx, UpdateOidcTokenByRefreshTokenParams(arg))
|
||||
if err != nil {
|
||||
return repository.OidcToken{}, mapErr(err)
|
||||
}
|
||||
return repository.OidcToken(r), nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateSession(ctx context.Context, arg repository.UpdateSessionParams) (repository.Session, error) {
|
||||
r, err := s.q.UpdateSession(ctx, UpdateSessionParams(arg))
|
||||
if err != nil {
|
||||
return repository.Session{}, mapErr(err)
|
||||
}
|
||||
return repository.Session(r), nil
|
||||
}
|
||||
@@ -9,6 +9,12 @@ import (
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
)
|
||||
|
||||
// For LDAP and OAuth groups and IP allow/deny, we default to allow even with a deny policy.
|
||||
// This is because we can't force the user to use groups in LDAP and OAuth if they would like to use
|
||||
// a deny policy. As for IP checks, we can't reliably get the client IP (most of Tinyauth instances are
|
||||
// behind a Docker bridge network) so to make it easier for users to use a deny policy without
|
||||
// issues with IPs we allow by default.
|
||||
|
||||
type RuleName string
|
||||
|
||||
const (
|
||||
@@ -25,7 +31,11 @@ type UserAllowedRule struct {
|
||||
}
|
||||
|
||||
func (rule *UserAllowedRule) Evaluate(ctx *ACLContext) Effect {
|
||||
if ctx.ACLs == nil || ctx.UserContext == nil {
|
||||
if ctx.UserContext == nil {
|
||||
return EffectDeny
|
||||
}
|
||||
|
||||
if ctx.ACLs == nil {
|
||||
return EffectAbstain
|
||||
}
|
||||
|
||||
@@ -34,7 +44,7 @@ func (rule *UserAllowedRule) Evaluate(ctx *ACLContext) Effect {
|
||||
match, err := utils.CheckFilter(ctx.ACLs.OAuth.Whitelist, ctx.UserContext.OAuth.Email)
|
||||
if err != nil {
|
||||
rule.Log.App.Warn().Err(err).Str("item", ctx.UserContext.OAuth.Email).Msg("Invalid entry in OAuth whitelist")
|
||||
return EffectAbstain
|
||||
return EffectDeny
|
||||
}
|
||||
if match {
|
||||
rule.Log.App.Debug().Str("email", ctx.UserContext.OAuth.Email).Msg("User is in OAuth whitelist, allowing access")
|
||||
@@ -48,7 +58,7 @@ func (rule *UserAllowedRule) Evaluate(ctx *ACLContext) Effect {
|
||||
match, err := utils.CheckFilter(ctx.ACLs.Users.Block, ctx.UserContext.GetUsername())
|
||||
if err != nil {
|
||||
rule.Log.App.Warn().Err(err).Str("item", ctx.UserContext.GetUsername()).Msg("Invalid entry in users block list")
|
||||
return EffectAbstain
|
||||
return EffectDeny
|
||||
}
|
||||
if match {
|
||||
rule.Log.App.Debug().Str("username", ctx.UserContext.GetUsername()).Msg("User is in users block list, denying access")
|
||||
@@ -62,8 +72,11 @@ func (rule *UserAllowedRule) Evaluate(ctx *ACLContext) Effect {
|
||||
match, err := utils.CheckFilter(ctx.ACLs.Users.Allow, ctx.UserContext.GetUsername())
|
||||
|
||||
if err != nil {
|
||||
if err == utils.ErrFilterEmpty {
|
||||
return EffectAbstain
|
||||
}
|
||||
rule.Log.App.Warn().Err(err).Str("item", ctx.UserContext.GetUsername()).Msg("Invalid entry in users allow list")
|
||||
return EffectAbstain
|
||||
return EffectDeny
|
||||
}
|
||||
|
||||
if match {
|
||||
@@ -80,13 +93,22 @@ type OAuthGroupRule struct {
|
||||
}
|
||||
|
||||
func (rule *OAuthGroupRule) Evaluate(ctx *ACLContext) Effect {
|
||||
if ctx.ACLs == nil || ctx.UserContext == nil {
|
||||
return EffectAbstain
|
||||
if ctx.UserContext == nil {
|
||||
return EffectDeny
|
||||
}
|
||||
|
||||
if ctx.ACLs == nil {
|
||||
return EffectAllow
|
||||
}
|
||||
|
||||
if !ctx.UserContext.IsOAuth() {
|
||||
rule.Log.App.Debug().Msg("User is not an OAuth user, skipping OAuth group check")
|
||||
return EffectAbstain
|
||||
return EffectAllow
|
||||
}
|
||||
|
||||
if len(ctx.ACLs.OAuth.Groups) == 0 {
|
||||
rule.Log.App.Debug().Msg("No OAuth groups specified in ACLs, allowing access")
|
||||
return EffectAllow
|
||||
}
|
||||
|
||||
if _, ok := model.OverrideProviders[ctx.UserContext.OAuth.ID]; ok {
|
||||
@@ -97,7 +119,8 @@ func (rule *OAuthGroupRule) Evaluate(ctx *ACLContext) Effect {
|
||||
for _, group := range ctx.UserContext.OAuth.Groups {
|
||||
match, err := utils.CheckFilter(ctx.ACLs.OAuth.Groups, strings.TrimSpace(group))
|
||||
if err != nil {
|
||||
return EffectAbstain
|
||||
rule.Log.App.Warn().Err(err).Str("item", group).Msg("Invalid entry in OAuth groups ACL")
|
||||
return EffectDeny
|
||||
}
|
||||
if match {
|
||||
rule.Log.App.Trace().Str("group", group).Str("required", ctx.ACLs.OAuth.Groups).Msg("User group matched, allowing access")
|
||||
@@ -114,19 +137,29 @@ type LDAPGroupRule struct {
|
||||
}
|
||||
|
||||
func (rule *LDAPGroupRule) Evaluate(ctx *ACLContext) Effect {
|
||||
if ctx == nil || ctx.UserContext == nil {
|
||||
return EffectAbstain
|
||||
if ctx.UserContext == nil {
|
||||
return EffectDeny
|
||||
}
|
||||
|
||||
if ctx.ACLs == nil {
|
||||
return EffectAllow
|
||||
}
|
||||
|
||||
if !ctx.UserContext.IsLDAP() {
|
||||
rule.Log.App.Debug().Msg("User is not an LDAP user, skipping LDAP group check")
|
||||
return EffectAbstain
|
||||
return EffectAllow
|
||||
}
|
||||
|
||||
if len(ctx.ACLs.LDAP.Groups) == 0 {
|
||||
rule.Log.App.Debug().Msg("No LDAP groups specified in ACLs, allowing access")
|
||||
return EffectAllow
|
||||
}
|
||||
|
||||
for _, group := range ctx.UserContext.LDAP.Groups {
|
||||
match, err := utils.CheckFilter(ctx.ACLs.LDAP.Groups, strings.TrimSpace(group))
|
||||
if err != nil {
|
||||
return EffectAbstain
|
||||
rule.Log.App.Warn().Err(err).Str("item", group).Msg("Invalid entry in LDAP groups ACL")
|
||||
return EffectDeny
|
||||
}
|
||||
if match {
|
||||
rule.Log.App.Trace().Str("group", group).Str("required", ctx.ACLs.LDAP.Groups).Msg("User group matched, allowing access")
|
||||
@@ -182,13 +215,14 @@ type IPAllowedRule struct {
|
||||
}
|
||||
|
||||
func (rule *IPAllowedRule) Evaluate(ctx *ACLContext) Effect {
|
||||
if ctx.ACLs == nil {
|
||||
return EffectAbstain
|
||||
}
|
||||
// merge global and per-app block/allow lists
|
||||
blockedIps := append([]string{}, rule.Config.Auth.IP.Block...)
|
||||
allowedIPs := append([]string{}, rule.Config.Auth.IP.Allow...)
|
||||
|
||||
// Merge the global and app IP filter
|
||||
blockedIps := append(ctx.ACLs.IP.Block, rule.Config.Auth.IP.Block...)
|
||||
allowedIPs := append(ctx.ACLs.IP.Allow, rule.Config.Auth.IP.Allow...)
|
||||
if ctx.ACLs != nil {
|
||||
blockedIps = append(blockedIps, ctx.ACLs.IP.Block...)
|
||||
allowedIPs = append(allowedIPs, ctx.ACLs.IP.Allow...)
|
||||
}
|
||||
|
||||
for _, blocked := range blockedIps {
|
||||
match, err := utils.CheckIPFilter(blocked, ctx.IP.String())
|
||||
@@ -224,15 +258,18 @@ func (rule *IPAllowedRule) Evaluate(ctx *ACLContext) Effect {
|
||||
}
|
||||
|
||||
type IPBypassedRule struct {
|
||||
Log *logger.Logger
|
||||
Log *logger.Logger
|
||||
Config model.Config
|
||||
}
|
||||
|
||||
func (rule *IPBypassedRule) Evaluate(ctx *ACLContext) Effect {
|
||||
if ctx.ACLs == nil {
|
||||
return EffectDeny
|
||||
// merge global and per-app bypass lists
|
||||
bypassList := append([]string{}, rule.Config.Auth.IP.Bypass...)
|
||||
if ctx.ACLs != nil {
|
||||
bypassList = append(bypassList, ctx.ACLs.IP.Bypass...)
|
||||
}
|
||||
|
||||
for _, bypassed := range ctx.ACLs.IP.Bypass {
|
||||
for _, bypassed := range bypassList {
|
||||
match, err := utils.CheckIPFilter(bypassed, ctx.IP.String())
|
||||
if err != nil {
|
||||
rule.Log.App.Warn().Err(err).Str("item", bypassed).Msg("Invalid IP/CIDR in bypass list")
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
)
|
||||
@@ -20,6 +21,16 @@ func TestUserAllowedRule(t *testing.T) {
|
||||
ctx *ACLContext
|
||||
expected Effect
|
||||
}{
|
||||
{
|
||||
name: "denies when user context is nil",
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{
|
||||
OAuth: model.AppOAuth{Whitelist: "alice"},
|
||||
},
|
||||
UserContext: nil,
|
||||
},
|
||||
expected: EffectDeny,
|
||||
},
|
||||
{
|
||||
name: "abstains when ACLs are nil",
|
||||
ctx: &ACLContext{
|
||||
@@ -33,16 +44,6 @@ func TestUserAllowedRule(t *testing.T) {
|
||||
},
|
||||
expected: EffectAbstain,
|
||||
},
|
||||
{
|
||||
name: "abstains when user context is nil",
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{
|
||||
OAuth: model.AppOAuth{Whitelist: "alice"},
|
||||
},
|
||||
UserContext: nil,
|
||||
},
|
||||
expected: EffectAbstain,
|
||||
},
|
||||
{
|
||||
name: "allows OAuth user when email matches whitelist",
|
||||
ctx: &ACLContext{
|
||||
@@ -77,7 +78,7 @@ func TestUserAllowedRule(t *testing.T) {
|
||||
expected: EffectDeny,
|
||||
},
|
||||
{
|
||||
name: "abstains for OAuth user when whitelist filter is invalid",
|
||||
name: "denies for OAuth user when whitelist filter is invalid",
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{
|
||||
OAuth: model.AppOAuth{Whitelist: "/[/"},
|
||||
@@ -89,7 +90,7 @@ func TestUserAllowedRule(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: EffectAbstain,
|
||||
expected: EffectDeny,
|
||||
},
|
||||
{
|
||||
name: "denies local user when username matches block list",
|
||||
@@ -122,7 +123,7 @@ func TestUserAllowedRule(t *testing.T) {
|
||||
expected: EffectAllow,
|
||||
},
|
||||
{
|
||||
name: "abstains when block list filter is invalid",
|
||||
name: "denies when block list filter is invalid",
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{
|
||||
Users: model.AppUsers{Block: "/[/"},
|
||||
@@ -134,6 +135,21 @@ func TestUserAllowedRule(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: EffectDeny,
|
||||
},
|
||||
{
|
||||
name: "abstains when allow list is empty",
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{
|
||||
Users: model.AppUsers{Allow: ""},
|
||||
},
|
||||
UserContext: &model.UserContext{
|
||||
Provider: model.ProviderLocal,
|
||||
Local: &model.LocalContext{
|
||||
BaseContext: model.BaseContext{Username: "alice"},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: EffectAbstain,
|
||||
},
|
||||
{
|
||||
@@ -167,7 +183,7 @@ func TestUserAllowedRule(t *testing.T) {
|
||||
expected: EffectDeny,
|
||||
},
|
||||
{
|
||||
name: "abstains when allow list filter is invalid",
|
||||
name: "denies when allow list filter is invalid",
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{
|
||||
Users: model.AppUsers{Allow: "/[/"},
|
||||
@@ -179,7 +195,7 @@ func TestUserAllowedRule(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: EffectAbstain,
|
||||
expected: EffectDeny,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -202,7 +218,17 @@ func TestOAuthGroupRule(t *testing.T) {
|
||||
expected Effect
|
||||
}{
|
||||
{
|
||||
name: "abstains when ACLs are nil",
|
||||
name: "denies when user context is nil",
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{
|
||||
OAuth: model.AppOAuth{Whitelist: "alice"},
|
||||
},
|
||||
UserContext: nil,
|
||||
},
|
||||
expected: EffectDeny,
|
||||
},
|
||||
{
|
||||
name: "allows when ACLs are nil",
|
||||
ctx: &ACLContext{
|
||||
ACLs: nil,
|
||||
UserContext: &model.UserContext{
|
||||
@@ -212,20 +238,10 @@ func TestOAuthGroupRule(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: EffectAbstain,
|
||||
expected: EffectAllow,
|
||||
},
|
||||
{
|
||||
name: "abstains when user context is nil",
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{
|
||||
OAuth: model.AppOAuth{Whitelist: "alice"},
|
||||
},
|
||||
UserContext: nil,
|
||||
},
|
||||
expected: EffectAbstain,
|
||||
},
|
||||
{
|
||||
name: "abstains when user is not OAuth",
|
||||
name: "allows when user is not OAuth",
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{
|
||||
OAuth: model.AppOAuth{Groups: "admins"},
|
||||
@@ -237,7 +253,22 @@ func TestOAuthGroupRule(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: EffectAbstain,
|
||||
expected: EffectAllow,
|
||||
},
|
||||
{
|
||||
name: "allows when group filter is empty",
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{
|
||||
OAuth: model.AppOAuth{Groups: ""},
|
||||
},
|
||||
UserContext: &model.UserContext{
|
||||
Provider: model.ProviderOAuth,
|
||||
OAuth: &model.OAuthContext{
|
||||
BaseContext: model.BaseContext{Username: "alice"},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: EffectAllow,
|
||||
},
|
||||
{
|
||||
name: "allows when provider is an override provider regardless of groups",
|
||||
@@ -304,7 +335,7 @@ func TestOAuthGroupRule(t *testing.T) {
|
||||
expected: EffectDeny,
|
||||
},
|
||||
{
|
||||
name: "abstains when groups filter is invalid",
|
||||
name: "denies when groups filter is invalid",
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{
|
||||
OAuth: model.AppOAuth{Groups: "/[/"},
|
||||
@@ -317,7 +348,7 @@ func TestOAuthGroupRule(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: EffectAbstain,
|
||||
expected: EffectDeny,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -340,22 +371,30 @@ func TestLDAPGroupRule(t *testing.T) {
|
||||
expected Effect
|
||||
}{
|
||||
{
|
||||
name: "abstains when context is nil",
|
||||
ctx: nil,
|
||||
expected: EffectAbstain,
|
||||
},
|
||||
{
|
||||
name: "abstains when user context is nil",
|
||||
name: "denies when user context is nil",
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{
|
||||
OAuth: model.AppOAuth{Whitelist: "alice"},
|
||||
},
|
||||
UserContext: nil,
|
||||
},
|
||||
expected: EffectAbstain,
|
||||
expected: EffectDeny,
|
||||
},
|
||||
{
|
||||
name: "abstains when user is not LDAP",
|
||||
name: "allows when acls are nil",
|
||||
ctx: &ACLContext{
|
||||
ACLs: nil,
|
||||
UserContext: &model.UserContext{
|
||||
Provider: model.ProviderLocal,
|
||||
Local: &model.LocalContext{
|
||||
BaseContext: model.BaseContext{Username: "alice"},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: EffectAllow,
|
||||
},
|
||||
{
|
||||
name: "allows when user is not LDAP",
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{
|
||||
LDAP: model.AppLDAP{Groups: "admins"},
|
||||
@@ -367,7 +406,22 @@ func TestLDAPGroupRule(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: EffectAbstain,
|
||||
expected: EffectAllow,
|
||||
},
|
||||
{
|
||||
name: "allows when group filter is empty",
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{
|
||||
LDAP: model.AppLDAP{Groups: ""},
|
||||
},
|
||||
UserContext: &model.UserContext{
|
||||
Provider: model.ProviderLDAP,
|
||||
LDAP: &model.LDAPContext{
|
||||
BaseContext: model.BaseContext{Username: "alice"},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: EffectAllow,
|
||||
},
|
||||
{
|
||||
name: "allows LDAP user when a group matches",
|
||||
@@ -415,7 +469,7 @@ func TestLDAPGroupRule(t *testing.T) {
|
||||
expected: EffectDeny,
|
||||
},
|
||||
{
|
||||
name: "abstains when groups filter is invalid",
|
||||
name: "denies when groups filter is invalid",
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{
|
||||
LDAP: model.AppLDAP{Groups: "/[/"},
|
||||
@@ -427,7 +481,7 @@ func TestLDAPGroupRule(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: EffectAbstain,
|
||||
expected: EffectDeny,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -558,12 +612,12 @@ func TestIPAllowedRule(t *testing.T) {
|
||||
expected Effect
|
||||
}{
|
||||
{
|
||||
name: "abstains when ACLs are nil",
|
||||
name: "allows when ACLs are nil and no global lists configured",
|
||||
ctx: &ACLContext{
|
||||
ACLs: nil,
|
||||
IP: net.ParseIP("10.0.0.1"),
|
||||
},
|
||||
expected: EffectAbstain,
|
||||
expected: EffectAllow,
|
||||
},
|
||||
{
|
||||
name: "denies when IP matches app block list",
|
||||
@@ -669,23 +723,70 @@ func TestIPBypassedRule(t *testing.T) {
|
||||
log := logger.NewLogger().WithTestConfig()
|
||||
log.Init()
|
||||
|
||||
rule := &IPBypassedRule{Log: log}
|
||||
defaultIPBR := &IPBypassedRule{Log: log}
|
||||
globBypassIPBR := &IPBypassedRule{
|
||||
Log: log,
|
||||
Config: model.Config{Auth: model.AuthConfig{IP: model.IPConfig{Bypass: []string{"10.0.0.0/24"}}}},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
rule *IPBypassedRule
|
||||
ctx *ACLContext
|
||||
expected Effect
|
||||
}{
|
||||
{
|
||||
name: "deny when ACLs are nil",
|
||||
name: "deny when ACLs are nil and no global bypass",
|
||||
rule: defaultIPBR,
|
||||
ctx: &ACLContext{
|
||||
ACLs: nil,
|
||||
IP: net.ParseIP("10.0.0.1"),
|
||||
},
|
||||
expected: EffectDeny,
|
||||
},
|
||||
{
|
||||
name: "allows when ACLs are nil but IP matches global bypass",
|
||||
rule: globBypassIPBR,
|
||||
ctx: &ACLContext{
|
||||
ACLs: nil,
|
||||
IP: net.ParseIP("10.0.0.5"),
|
||||
},
|
||||
expected: EffectAllow,
|
||||
},
|
||||
{
|
||||
name: "denies when ACLs are nil and IP does not match global bypass",
|
||||
rule: globBypassIPBR,
|
||||
ctx: &ACLContext{
|
||||
ACLs: nil,
|
||||
IP: net.ParseIP("192.168.1.1"),
|
||||
},
|
||||
expected: EffectDeny,
|
||||
},
|
||||
{
|
||||
name: "allows when IP matches per-app bypass but not global bypass",
|
||||
rule: defaultIPBR,
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{
|
||||
IP: model.AppIP{Bypass: []string{"10.0.0.0/24"}},
|
||||
},
|
||||
IP: net.ParseIP("10.0.0.5"),
|
||||
},
|
||||
expected: EffectAllow,
|
||||
},
|
||||
{
|
||||
name: "allows when IP matches global bypass but not per-app bypass",
|
||||
rule: globBypassIPBR,
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{
|
||||
IP: model.AppIP{Bypass: []string{"172.16.0.0/24"}},
|
||||
},
|
||||
IP: net.ParseIP("10.0.0.5"),
|
||||
},
|
||||
expected: EffectAllow,
|
||||
},
|
||||
{
|
||||
name: "allows when IP matches bypass list",
|
||||
rule: defaultIPBR,
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{
|
||||
IP: model.AppIP{Bypass: []string{"10.0.0.0/24"}},
|
||||
@@ -696,6 +797,7 @@ func TestIPBypassedRule(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "denies when IP does not match bypass list",
|
||||
rule: defaultIPBR,
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{
|
||||
IP: model.AppIP{Bypass: []string{"10.0.0.0/24"}},
|
||||
@@ -706,6 +808,7 @@ func TestIPBypassedRule(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "denies when bypass list is empty",
|
||||
rule: defaultIPBR,
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{},
|
||||
IP: net.ParseIP("10.0.0.1"),
|
||||
@@ -714,6 +817,7 @@ func TestIPBypassedRule(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "skips invalid bypass entries and allows on later match",
|
||||
rule: defaultIPBR,
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{
|
||||
IP: model.AppIP{Bypass: []string{"not-an-ip", "10.0.0.1"}},
|
||||
@@ -726,7 +830,7 @@ func TestIPBypassedRule(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.expected, rule.Evaluate(tt.ctx))
|
||||
assert.Equal(t, tt.expected, tt.rule.Evaluate(tt.ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+186
-200
@@ -9,13 +9,12 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/steveiliop56/ding"
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/repository"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
|
||||
"slices"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"golang.org/x/oauth2"
|
||||
@@ -53,41 +52,37 @@ type OAuthPendingSession struct {
|
||||
CallbackParams OAuthURLParams
|
||||
}
|
||||
|
||||
type LdapGroupsCache struct {
|
||||
Groups []string
|
||||
Expires time.Time
|
||||
}
|
||||
|
||||
type LoginAttempt struct {
|
||||
FailedAttempts int
|
||||
LastAttempt time.Time
|
||||
LockedUntil time.Time
|
||||
}
|
||||
|
||||
type Lockdown struct {
|
||||
Active bool
|
||||
ActiveUntil time.Time
|
||||
}
|
||||
|
||||
type AuthService struct {
|
||||
log *logger.Logger
|
||||
config model.Config
|
||||
runtime model.RuntimeConfig
|
||||
context context.Context
|
||||
ctx context.Context
|
||||
|
||||
ldap *LdapService
|
||||
queries repository.Store
|
||||
oauthBroker *OAuthBrokerService
|
||||
ldap *LdapService
|
||||
queries repository.Store
|
||||
oauthBroker *OAuthBrokerService
|
||||
tailscale *TailscaleService
|
||||
policyEngine *PolicyEngine
|
||||
|
||||
loginAttempts map[string]*LoginAttempt
|
||||
ldapGroupsCache map[string]*LdapGroupsCache
|
||||
oauthPendingSessions map[string]*OAuthPendingSession
|
||||
oauthMutex sync.RWMutex
|
||||
loginMutex sync.RWMutex
|
||||
ldapGroupsMutex sync.RWMutex
|
||||
lockdown *Lockdown
|
||||
lockdownCtx context.Context
|
||||
lockdownCancelFunc context.CancelFunc
|
||||
lockdown struct {
|
||||
active bool
|
||||
until time.Time
|
||||
ctx context.Context
|
||||
cancelFunc context.CancelFunc
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
caches struct {
|
||||
login *CacheStore[LoginAttempt]
|
||||
oauth *CacheStore[OAuthPendingSession]
|
||||
ldap *CacheStore[[]string]
|
||||
}
|
||||
}
|
||||
|
||||
func NewAuthService(
|
||||
@@ -95,25 +90,49 @@ func NewAuthService(
|
||||
config model.Config,
|
||||
runtime model.RuntimeConfig,
|
||||
ctx context.Context,
|
||||
wg *sync.WaitGroup,
|
||||
dg *ding.Ding,
|
||||
ldap *LdapService,
|
||||
queries repository.Store,
|
||||
oauthBroker *OAuthBrokerService,
|
||||
tailscale *TailscaleService,
|
||||
policy *PolicyEngine,
|
||||
) *AuthService {
|
||||
service := &AuthService{
|
||||
log: log,
|
||||
runtime: runtime,
|
||||
context: ctx,
|
||||
config: config,
|
||||
loginAttempts: make(map[string]*LoginAttempt),
|
||||
ldapGroupsCache: make(map[string]*LdapGroupsCache),
|
||||
oauthPendingSessions: make(map[string]*OAuthPendingSession),
|
||||
ldap: ldap,
|
||||
queries: queries,
|
||||
oauthBroker: oauthBroker,
|
||||
log: log,
|
||||
runtime: runtime,
|
||||
ctx: ctx,
|
||||
config: config,
|
||||
ldap: ldap,
|
||||
queries: queries,
|
||||
oauthBroker: oauthBroker,
|
||||
tailscale: tailscale,
|
||||
policyEngine: policy,
|
||||
}
|
||||
|
||||
wg.Go(service.CleanupOAuthSessionsRoutine)
|
||||
// caches setup
|
||||
oauthCache := NewCacheStore[OAuthPendingSession](256)
|
||||
loginCache := NewCacheStore[LoginAttempt](1024)
|
||||
ldapCache := NewCacheStore[[]string](1024)
|
||||
|
||||
service.caches.oauth = oauthCache
|
||||
service.caches.login = loginCache
|
||||
service.caches.ldap = ldapCache
|
||||
|
||||
dg.Go(func(ctx context.Context) {
|
||||
ticker := time.NewTicker(1 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
service.caches.oauth.Sweep()
|
||||
service.caches.login.Sweep()
|
||||
service.caches.ldap.Sweep()
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}, ding.RingMinor)
|
||||
|
||||
return service
|
||||
}
|
||||
@@ -188,14 +207,12 @@ func (auth *AuthService) GetLDAPUser(userDN string) (*model.LDAPUser, error) {
|
||||
return nil, errors.New("ldap service not configured")
|
||||
}
|
||||
|
||||
auth.ldapGroupsMutex.RLock()
|
||||
entry, exists := auth.ldapGroupsCache[userDN]
|
||||
auth.ldapGroupsMutex.RUnlock()
|
||||
entry, exists := auth.caches.ldap.Get(userDN)
|
||||
|
||||
if exists && time.Now().Before(entry.Expires) {
|
||||
if exists {
|
||||
return &model.LDAPUser{
|
||||
DN: userDN,
|
||||
Groups: entry.Groups,
|
||||
Groups: entry,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -205,12 +222,7 @@ func (auth *AuthService) GetLDAPUser(userDN string) (*model.LDAPUser, error) {
|
||||
return nil, fmt.Errorf("failed to get ldap groups: %w", err)
|
||||
}
|
||||
|
||||
auth.ldapGroupsMutex.Lock()
|
||||
auth.ldapGroupsCache[userDN] = &LdapGroupsCache{
|
||||
Groups: groups,
|
||||
Expires: time.Now().Add(time.Duration(auth.config.LDAP.GroupCacheTTL) * time.Second),
|
||||
}
|
||||
auth.ldapGroupsMutex.Unlock()
|
||||
auth.caches.ldap.Set(userDN, groups, time.Duration(auth.config.LDAP.GroupCacheTTL)*time.Second)
|
||||
|
||||
return &model.LDAPUser{
|
||||
DN: userDN,
|
||||
@@ -219,11 +231,7 @@ func (auth *AuthService) GetLDAPUser(userDN string) (*model.LDAPUser, error) {
|
||||
}
|
||||
|
||||
func (auth *AuthService) IsAccountLocked(identifier string) (bool, int) {
|
||||
auth.loginMutex.RLock()
|
||||
defer auth.loginMutex.RUnlock()
|
||||
|
||||
if auth.lockdown != nil && auth.lockdown.Active {
|
||||
remaining := int(time.Until(auth.lockdown.ActiveUntil).Seconds())
|
||||
if locked, remaining := auth.IsInLockdown(); locked {
|
||||
return true, remaining
|
||||
}
|
||||
|
||||
@@ -231,7 +239,7 @@ func (auth *AuthService) IsAccountLocked(identifier string) (bool, int) {
|
||||
return false, 0
|
||||
}
|
||||
|
||||
attempt, exists := auth.loginAttempts[identifier]
|
||||
attempt, exists := auth.caches.login.Get(identifier)
|
||||
if !exists {
|
||||
return false, 0
|
||||
}
|
||||
@@ -249,49 +257,79 @@ func (auth *AuthService) RecordLoginAttempt(identifier string, success bool) {
|
||||
return
|
||||
}
|
||||
|
||||
auth.loginMutex.Lock()
|
||||
defer auth.loginMutex.Unlock()
|
||||
|
||||
if len(auth.loginAttempts) >= MaxLoginAttemptRecords {
|
||||
if auth.lockdown != nil && auth.lockdown.Active {
|
||||
if auth.caches.login.Size() >= MaxLoginAttemptRecords {
|
||||
if locked, _ := auth.IsInLockdown(); locked {
|
||||
return
|
||||
}
|
||||
go auth.lockdownMode()
|
||||
return
|
||||
}
|
||||
|
||||
attempt, exists := auth.loginAttempts[identifier]
|
||||
if !exists {
|
||||
attempt = &LoginAttempt{}
|
||||
auth.loginAttempts[identifier] = attempt
|
||||
}
|
||||
auth.caches.login.WithLock(func(actions CacheStoreActions[LoginAttempt]) {
|
||||
entry, ok := actions.Get(identifier)
|
||||
|
||||
attempt.LastAttempt = time.Now()
|
||||
if !ok {
|
||||
attempt := LoginAttempt{
|
||||
LastAttempt: time.Now(),
|
||||
}
|
||||
if !success {
|
||||
attempt.FailedAttempts = 1
|
||||
if attempt.FailedAttempts >= auth.config.Auth.LoginMaxRetries {
|
||||
attempt.LockedUntil = time.Now().Add(time.Duration(auth.config.Auth.LoginTimeout) * time.Second)
|
||||
auth.log.App.Warn().Str("identifier", identifier).Int("failedAttempts", attempt.FailedAttempts).Msg("Account locked due to too many failed login attempts")
|
||||
}
|
||||
}
|
||||
// match current tinyauth behavior which doesn't expire rate limits
|
||||
actions.Set(identifier, attempt, 0)
|
||||
return
|
||||
}
|
||||
|
||||
if success {
|
||||
attempt.FailedAttempts = 0
|
||||
attempt.LockedUntil = time.Time{} // Reset lock time
|
||||
return
|
||||
}
|
||||
entry.LastAttempt = time.Now()
|
||||
|
||||
attempt.FailedAttempts++
|
||||
if success {
|
||||
entry.FailedAttempts = 0
|
||||
entry.LockedUntil = time.Time{}
|
||||
} else {
|
||||
entry.FailedAttempts++
|
||||
|
||||
if attempt.FailedAttempts >= auth.config.Auth.LoginMaxRetries {
|
||||
attempt.LockedUntil = time.Now().Add(time.Duration(auth.config.Auth.LoginTimeout) * time.Second)
|
||||
auth.log.App.Warn().Str("identifier", identifier).Int("failedAttempts", attempt.FailedAttempts).Msg("Account locked due to too many failed login attempts")
|
||||
}
|
||||
if entry.FailedAttempts >= auth.config.Auth.LoginMaxRetries {
|
||||
entry.LockedUntil = time.Now().Add(time.Duration(auth.config.Auth.LoginTimeout) * time.Second)
|
||||
auth.log.App.Warn().Str("identifier", identifier).Int("failedAttempts", entry.FailedAttempts).Msg("Account locked due to too many failed login attempts")
|
||||
}
|
||||
}
|
||||
|
||||
actions.Set(identifier, entry, 0)
|
||||
})
|
||||
}
|
||||
|
||||
func (auth *AuthService) IsEmailWhitelisted(email string) bool {
|
||||
match, err := utils.CheckFilter(strings.Join(auth.runtime.OAuthWhitelist, ","), email)
|
||||
if err != nil {
|
||||
auth.log.App.Warn().Err(err).Str("email", email).Msg("Invalid email filter pattern")
|
||||
return false
|
||||
}
|
||||
return match
|
||||
// We could also directly access the policyEngine.effectToAccess but
|
||||
// I believe it's better to use the exported functions instead
|
||||
func (auth *AuthService) IsEmailWhitelisted(provider string, email string) bool {
|
||||
return auth.policyEngine.EvaluateFunc(func() Effect {
|
||||
whitelist := auth.runtime.OAuthWhitelist
|
||||
if providerConfig, ok := auth.runtime.OAuthProviders[provider]; ok && len(providerConfig.Whitelist) > 0 {
|
||||
whitelist = providerConfig.Whitelist
|
||||
}
|
||||
match, err := utils.CheckFilter(strings.Join(whitelist, ","), email)
|
||||
if err != nil {
|
||||
if err == utils.ErrFilterEmpty {
|
||||
return EffectAbstain
|
||||
}
|
||||
auth.log.App.Error().Err(err).Str("email", email).Msg("Failed to evaluate email whitelist filter, defaulting to deny")
|
||||
return EffectDeny
|
||||
}
|
||||
if match {
|
||||
return EffectAllow
|
||||
}
|
||||
return EffectDeny
|
||||
})
|
||||
}
|
||||
|
||||
func (auth *AuthService) CreateSession(ctx context.Context, data repository.Session) (*http.Cookie, error) {
|
||||
if data.Provider == "tailscale" && auth.tailscale == nil {
|
||||
return nil, fmt.Errorf("tailscale service not configured, cannot create session for tailscale user")
|
||||
}
|
||||
|
||||
uuid, err := uuid.NewRandom()
|
||||
|
||||
if err != nil {
|
||||
@@ -328,6 +366,28 @@ func (auth *AuthService) CreateSession(ctx context.Context, data repository.Sess
|
||||
return nil, fmt.Errorf("failed to create session entry: %w", err)
|
||||
}
|
||||
|
||||
if data.Provider == "tailscale" {
|
||||
auth.log.App.Trace().Str("url", fmt.Sprintf("https://%s", auth.tailscale.GetHostname())).Msg("Extracting root domain from Tailscale hostname")
|
||||
|
||||
tsCookieDomain, err := utils.GetCookieDomain(fmt.Sprintf("https://%s", auth.tailscale.GetHostname()))
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get cookie domain for tailscale user: %w", err)
|
||||
}
|
||||
|
||||
return &http.Cookie{
|
||||
Name: auth.runtime.SessionCookieName,
|
||||
Value: session.UUID,
|
||||
Path: "/",
|
||||
Domain: fmt.Sprintf(".%s", tsCookieDomain),
|
||||
Expires: expiresAt,
|
||||
MaxAge: int(time.Until(expiresAt).Seconds()),
|
||||
Secure: auth.config.Auth.SecureCookie,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &http.Cookie{
|
||||
Name: auth.runtime.SessionCookieName,
|
||||
Value: session.UUID,
|
||||
@@ -457,8 +517,6 @@ func (auth *AuthService) LDAPAuthConfigured() bool {
|
||||
}
|
||||
|
||||
func (auth *AuthService) NewOAuthSession(serviceName string, params OAuthURLParams) (string, OAuthPendingSession, error) {
|
||||
auth.ensureOAuthSessionLimit()
|
||||
|
||||
service, ok := auth.oauthBroker.GetService(serviceName)
|
||||
|
||||
if !ok {
|
||||
@@ -482,9 +540,7 @@ func (auth *AuthService) NewOAuthSession(serviceName string, params OAuthURLPara
|
||||
CallbackParams: params,
|
||||
}
|
||||
|
||||
auth.oauthMutex.Lock()
|
||||
auth.oauthPendingSessions[sessionId.String()] = &session
|
||||
auth.oauthMutex.Unlock()
|
||||
auth.caches.oauth.Set(sessionId.String(), session, time.Minute*10)
|
||||
|
||||
return sessionId.String(), session, nil
|
||||
}
|
||||
@@ -500,10 +556,10 @@ func (auth *AuthService) GetOAuthURL(sessionId string) (string, error) {
|
||||
}
|
||||
|
||||
func (auth *AuthService) GetOAuthToken(sessionId string, code string) (*oauth2.Token, error) {
|
||||
session, err := auth.GetOAuthPendingSession(sessionId)
|
||||
session, ok := auth.caches.oauth.Get(sessionId)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("oauth session not found: %s", sessionId)
|
||||
}
|
||||
|
||||
token, err := (*session.Service).GetToken(code, session.Verifier)
|
||||
@@ -512,9 +568,14 @@ func (auth *AuthService) GetOAuthToken(sessionId string, code string) (*oauth2.T
|
||||
return nil, fmt.Errorf("failed to exchange code for token: %w", err)
|
||||
}
|
||||
|
||||
auth.oauthMutex.Lock()
|
||||
session.Token = token
|
||||
auth.oauthMutex.Unlock()
|
||||
|
||||
// ttl 0 means keep current expiration
|
||||
ok = auth.caches.oauth.Update(sessionId, session, 0)
|
||||
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("failed to update oauth session with token: %s", sessionId)
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
@@ -550,123 +611,39 @@ func (auth *AuthService) GetOAuthService(sessionId string) (OAuthServiceImpl, er
|
||||
}
|
||||
|
||||
func (auth *AuthService) EndOAuthSession(sessionId string) {
|
||||
auth.oauthMutex.Lock()
|
||||
delete(auth.oauthPendingSessions, sessionId)
|
||||
auth.oauthMutex.Unlock()
|
||||
}
|
||||
|
||||
func (auth *AuthService) CleanupOAuthSessionsRoutine() {
|
||||
auth.log.App.Debug().Msg("Starting OAuth session cleanup routine")
|
||||
|
||||
ticker := time.NewTicker(30 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
auth.log.App.Debug().Msg("Running OAuth session cleanup")
|
||||
|
||||
auth.oauthMutex.Lock()
|
||||
|
||||
now := time.Now()
|
||||
|
||||
for sessionId, session := range auth.oauthPendingSessions {
|
||||
if now.After(session.ExpiresAt) {
|
||||
delete(auth.oauthPendingSessions, sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
auth.oauthMutex.Unlock()
|
||||
auth.log.App.Debug().Msg("OAuth session cleanup completed")
|
||||
case <-auth.context.Done():
|
||||
auth.log.App.Debug().Msg("Stopping OAuth session cleanup routine")
|
||||
return
|
||||
}
|
||||
}
|
||||
auth.caches.oauth.Delete(sessionId)
|
||||
}
|
||||
|
||||
func (auth *AuthService) GetOAuthPendingSession(sessionId string) (*OAuthPendingSession, error) {
|
||||
auth.ensureOAuthSessionLimit()
|
||||
|
||||
auth.oauthMutex.RLock()
|
||||
session, exists := auth.oauthPendingSessions[sessionId]
|
||||
auth.oauthMutex.RUnlock()
|
||||
session, exists := auth.caches.oauth.Get(sessionId)
|
||||
|
||||
if !exists {
|
||||
return &OAuthPendingSession{}, fmt.Errorf("oauth session not found: %s", sessionId)
|
||||
}
|
||||
|
||||
if time.Now().After(session.ExpiresAt) {
|
||||
auth.oauthMutex.Lock()
|
||||
delete(auth.oauthPendingSessions, sessionId)
|
||||
auth.oauthMutex.Unlock()
|
||||
return &OAuthPendingSession{}, fmt.Errorf("oauth session expired: %s", sessionId)
|
||||
}
|
||||
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func (auth *AuthService) ensureOAuthSessionLimit() {
|
||||
auth.oauthMutex.Lock()
|
||||
defer auth.oauthMutex.Unlock()
|
||||
|
||||
if len(auth.oauthPendingSessions) <= MaxOAuthPendingSessions {
|
||||
return
|
||||
}
|
||||
|
||||
type entry struct {
|
||||
id string
|
||||
expiresAt int64
|
||||
}
|
||||
|
||||
entries := make([]entry, 0, len(auth.oauthPendingSessions))
|
||||
for id, session := range auth.oauthPendingSessions {
|
||||
entries = append(entries, entry{id, session.ExpiresAt.Unix()})
|
||||
}
|
||||
|
||||
slices.SortFunc(entries, func(a, b entry) int {
|
||||
if a.expiresAt < b.expiresAt {
|
||||
return -1
|
||||
}
|
||||
if a.expiresAt > b.expiresAt {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
|
||||
for _, e := range entries[:OAuthCleanupCount] {
|
||||
delete(auth.oauthPendingSessions, e.id)
|
||||
}
|
||||
return &session, nil
|
||||
}
|
||||
|
||||
func (auth *AuthService) lockdownMode() {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
auth.lockdown.mu.Lock()
|
||||
|
||||
auth.loginMutex.Lock()
|
||||
|
||||
if auth.lockdown != nil && auth.lockdown.Active {
|
||||
auth.loginMutex.Unlock()
|
||||
cancel()
|
||||
if auth.lockdown.active {
|
||||
auth.lockdown.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
auth.lockdownCtx = ctx
|
||||
auth.lockdownCancelFunc = cancel
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
auth.log.App.Warn().Msg("Too many failed login attempts, entering lockdown mode")
|
||||
|
||||
auth.lockdown = &Lockdown{
|
||||
Active: true,
|
||||
ActiveUntil: time.Now().Add(time.Duration(auth.config.Auth.LoginTimeout) * time.Second),
|
||||
}
|
||||
auth.lockdown.active = true
|
||||
auth.lockdown.ctx = ctx
|
||||
auth.lockdown.cancelFunc = cancel
|
||||
auth.lockdown.until = time.Now().Add(time.Duration(auth.config.Auth.LoginTimeout) * time.Second)
|
||||
|
||||
// At this point all login attemps will also expire so,
|
||||
// we might as well clear them to free up memory
|
||||
auth.loginAttempts = make(map[string]*LoginAttempt)
|
||||
timer := time.NewTimer(time.Until(auth.lockdown.until))
|
||||
|
||||
timer := time.NewTimer(time.Until(auth.lockdown.ActiveUntil))
|
||||
|
||||
auth.loginMutex.Unlock()
|
||||
auth.lockdown.mu.Unlock()
|
||||
|
||||
defer cancel()
|
||||
defer timer.Stop()
|
||||
@@ -676,24 +653,33 @@ func (auth *AuthService) lockdownMode() {
|
||||
// Timer expired, end lockdown
|
||||
case <-ctx.Done():
|
||||
// Context cancelled, end lockdown
|
||||
case <-auth.context.Done():
|
||||
case <-auth.ctx.Done():
|
||||
// Service is shutting down, end lockdown
|
||||
}
|
||||
|
||||
auth.loginMutex.Lock()
|
||||
auth.lockdown.mu.Lock()
|
||||
|
||||
auth.log.App.Info().Msg("Exiting lockdown mode")
|
||||
|
||||
auth.lockdown = nil
|
||||
auth.loginMutex.Unlock()
|
||||
auth.lockdown.active = false
|
||||
auth.lockdown.until = time.Time{}
|
||||
auth.lockdown.ctx = nil
|
||||
auth.lockdown.cancelFunc = nil
|
||||
|
||||
auth.lockdown.mu.Unlock()
|
||||
}
|
||||
|
||||
// Function only used for testing - do not use in prod!
|
||||
func (auth *AuthService) ClearRateLimitsTestingOnly() {
|
||||
auth.loginMutex.Lock()
|
||||
auth.loginAttempts = make(map[string]*LoginAttempt)
|
||||
if auth.lockdown != nil {
|
||||
auth.lockdownCancelFunc()
|
||||
func (auth *AuthService) IsInLockdown() (bool, int) {
|
||||
auth.lockdown.mu.RLock()
|
||||
defer auth.lockdown.mu.RUnlock()
|
||||
if auth.lockdown.active {
|
||||
remaining := int(time.Until(auth.lockdown.until).Seconds())
|
||||
return true, remaining
|
||||
}
|
||||
auth.loginMutex.Unlock()
|
||||
return false, 0
|
||||
}
|
||||
|
||||
// mostly a testing function, not useful for anything else
|
||||
func (auth *AuthService) ClearLoginAttempts() {
|
||||
auth.caches.login.Clear()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
)
|
||||
|
||||
func TestIsEmailWhitelistedUsesProviderSpecificList(t *testing.T) {
|
||||
log := logger.NewLogger().WithTestConfig()
|
||||
log.Init()
|
||||
|
||||
auth := &AuthService{
|
||||
log: log,
|
||||
runtime: model.RuntimeConfig{
|
||||
OAuthWhitelist: []string{"global@example.com"},
|
||||
OAuthProviders: map[string]model.OAuthServiceConfig{
|
||||
"github": {
|
||||
Whitelist: []string{"github@example.com"},
|
||||
},
|
||||
"pocketid": {
|
||||
Whitelist: []string{"pocket@example.com"},
|
||||
},
|
||||
"gitlab": {
|
||||
Whitelist: []string{},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
assert.True(t, auth.IsEmailWhitelisted("github", "github@example.com"))
|
||||
assert.False(t, auth.IsEmailWhitelisted("github", "pocket@example.com"))
|
||||
assert.True(t, auth.IsEmailWhitelisted("pocketid", "pocket@example.com"))
|
||||
assert.True(t, auth.IsEmailWhitelisted("google", "global@example.com"))
|
||||
assert.True(t, auth.IsEmailWhitelisted("gitlab", "global@example.com"))
|
||||
assert.False(t, auth.IsEmailWhitelisted("gitlab", "unknown@example.com"))
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type CacheStoreActions[T any] struct {
|
||||
Set func(key string, value T, ttl time.Duration)
|
||||
Get func(key string) (T, bool)
|
||||
Delete func(key string)
|
||||
Update func(key string, value T, ttl time.Duration) bool
|
||||
}
|
||||
|
||||
type cacheEntry[T any] struct {
|
||||
value T
|
||||
expiresAt *time.Time
|
||||
}
|
||||
|
||||
type CacheStore[T any] struct {
|
||||
cache map[string]cacheEntry[T]
|
||||
order []string
|
||||
mu sync.RWMutex
|
||||
maxSize int
|
||||
}
|
||||
|
||||
func NewCacheStore[T any](maxSize int) *CacheStore[T] {
|
||||
return &CacheStore[T]{
|
||||
cache: make(map[string]cacheEntry[T]),
|
||||
order: make([]string, 0),
|
||||
maxSize: maxSize,
|
||||
}
|
||||
}
|
||||
|
||||
// With lock allows performing multiple operations on the cache store atomically.
|
||||
// The provided mutate function receives a set of actions (Set, Get, Delete) that
|
||||
// can be used to manipulate the cache store within the locked context.
|
||||
func (cs *CacheStore[T]) WithLock(mutate func(actions CacheStoreActions[T])) {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
actions := CacheStoreActions[T]{
|
||||
Set: cs.setCallback,
|
||||
Get: cs.getCallback,
|
||||
Delete: cs.deleteCallback,
|
||||
Update: cs.updateCallback,
|
||||
}
|
||||
mutate(actions)
|
||||
}
|
||||
|
||||
func (cs *CacheStore[T]) updateCallback(key string, value T, ttl time.Duration) bool {
|
||||
if currentEntry, exists := cs.cache[key]; exists {
|
||||
if currentEntry.expiresAt != nil && time.Now().After(*currentEntry.expiresAt) {
|
||||
return false
|
||||
}
|
||||
|
||||
entry := cacheEntry[T]{
|
||||
value: value,
|
||||
expiresAt: currentEntry.expiresAt,
|
||||
}
|
||||
|
||||
if ttl > 0 {
|
||||
expiration := time.Now().Add(ttl)
|
||||
entry.expiresAt = &expiration
|
||||
}
|
||||
|
||||
cs.cache[key] = entry
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (cs *CacheStore[T]) Update(key string, value T, ttl time.Duration) bool {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
return cs.updateCallback(key, value, ttl)
|
||||
}
|
||||
|
||||
func (cs *CacheStore[T]) setCallback(key string, value T, ttl time.Duration) {
|
||||
if cs.maxSize > 0 {
|
||||
if _, exists := cs.cache[key]; !exists && len(cs.cache) >= cs.maxSize {
|
||||
cs.evictOne()
|
||||
}
|
||||
}
|
||||
|
||||
var expiresAt *time.Time
|
||||
|
||||
if ttl > 0 {
|
||||
expiration := time.Now().Add(ttl)
|
||||
expiresAt = &expiration
|
||||
}
|
||||
|
||||
cs.cache[key] = cacheEntry[T]{
|
||||
value: value,
|
||||
expiresAt: expiresAt,
|
||||
}
|
||||
|
||||
if !slices.Contains(cs.order, key) {
|
||||
cs.order = append(cs.order, key)
|
||||
}
|
||||
}
|
||||
|
||||
func (cs *CacheStore[T]) Set(key string, value T, ttl time.Duration) {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
cs.setCallback(key, value, ttl)
|
||||
}
|
||||
|
||||
func (cs *CacheStore[T]) getCallback(key string) (T, bool) {
|
||||
entry, exists := cs.cache[key]
|
||||
|
||||
if !exists {
|
||||
var zero T
|
||||
return zero, false
|
||||
}
|
||||
|
||||
if entry.expiresAt != nil && time.Now().After(*entry.expiresAt) {
|
||||
var zero T
|
||||
return zero, false
|
||||
}
|
||||
|
||||
return entry.value, true
|
||||
}
|
||||
|
||||
func (cs *CacheStore[T]) Get(key string) (T, bool) {
|
||||
cs.mu.RLock()
|
||||
defer cs.mu.RUnlock()
|
||||
return cs.getCallback(key)
|
||||
}
|
||||
|
||||
func (cs *CacheStore[T]) deleteCallback(key string) {
|
||||
delete(cs.cache, key)
|
||||
keyIdx := slices.Index(cs.order, key)
|
||||
if keyIdx != -1 {
|
||||
cs.order = append(cs.order[:keyIdx], cs.order[keyIdx+1:]...)
|
||||
}
|
||||
}
|
||||
|
||||
func (cs *CacheStore[T]) Delete(key string) {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
cs.deleteCallback(key)
|
||||
}
|
||||
|
||||
func (cs *CacheStore[T]) Sweep() {
|
||||
cs.mu.Lock()
|
||||
for key, entry := range cs.cache {
|
||||
if entry.expiresAt != nil && time.Now().After(*entry.expiresAt) {
|
||||
cs.deleteCallback(key)
|
||||
}
|
||||
}
|
||||
cs.mu.Unlock()
|
||||
}
|
||||
|
||||
func (cs *CacheStore[T]) evictOne() bool {
|
||||
now := time.Now()
|
||||
var oldestKey string
|
||||
var oldestExp *time.Time
|
||||
|
||||
for k, e := range cs.cache {
|
||||
if e.expiresAt != nil && now.After(*e.expiresAt) {
|
||||
cs.deleteCallback(k)
|
||||
return true
|
||||
}
|
||||
if e.expiresAt != nil && (oldestExp == nil || e.expiresAt.Before(*oldestExp)) {
|
||||
oldestKey, oldestExp = k, e.expiresAt
|
||||
}
|
||||
}
|
||||
|
||||
// If we found an oldest key, evict it else we delete the first key in the order list
|
||||
if oldestKey != "" {
|
||||
cs.deleteCallback(oldestKey)
|
||||
return true
|
||||
} else {
|
||||
if len(cs.order) > 0 {
|
||||
cs.deleteCallback(cs.order[0])
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (cs *CacheStore[T]) Size() int {
|
||||
cs.mu.RLock()
|
||||
defer cs.mu.RUnlock()
|
||||
return len(cs.cache)
|
||||
}
|
||||
|
||||
func (cs *CacheStore[T]) Clear() {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
cs.cache = make(map[string]cacheEntry[T])
|
||||
cs.order = make([]string, 0)
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCacheStoreGet(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(cs *CacheStore[string])
|
||||
wantValue string
|
||||
wantOk bool
|
||||
}{
|
||||
{
|
||||
name: "returns a stored value",
|
||||
setup: func(cs *CacheStore[string]) { cs.Set("key", "value", 0) },
|
||||
wantValue: "value",
|
||||
wantOk: true,
|
||||
},
|
||||
{
|
||||
name: "reports a missing key",
|
||||
setup: func(cs *CacheStore[string]) {},
|
||||
wantOk: false,
|
||||
},
|
||||
{
|
||||
name: "returns the latest value after an overwrite",
|
||||
setup: func(cs *CacheStore[string]) {
|
||||
cs.Set("key", "first", 0)
|
||||
cs.Set("key", "second", 0)
|
||||
},
|
||||
wantValue: "second",
|
||||
wantOk: true,
|
||||
},
|
||||
{
|
||||
name: "returns a non-expired entry",
|
||||
setup: func(cs *CacheStore[string]) { cs.Set("key", "value", time.Minute) },
|
||||
wantValue: "value",
|
||||
wantOk: true,
|
||||
},
|
||||
{
|
||||
name: "treats an expired entry as missing",
|
||||
setup: func(cs *CacheStore[string]) {
|
||||
cs.Set("key", "value", 10*time.Millisecond)
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
},
|
||||
wantOk: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cs := NewCacheStore[string](0)
|
||||
tt.setup(cs)
|
||||
|
||||
value, ok := cs.Get("key")
|
||||
assert.Equal(t, tt.wantOk, ok)
|
||||
if tt.wantOk {
|
||||
assert.Equal(t, tt.wantValue, value)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheStoreUpdate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(cs *CacheStore[string])
|
||||
ttl time.Duration
|
||||
wantOk bool
|
||||
afterWait time.Duration
|
||||
wantPresent bool
|
||||
wantValue string
|
||||
}{
|
||||
{
|
||||
name: "updates an existing entry",
|
||||
setup: func(cs *CacheStore[string]) { cs.Set("key", "old", 0) },
|
||||
ttl: 0,
|
||||
wantOk: true,
|
||||
wantPresent: true,
|
||||
wantValue: "new",
|
||||
},
|
||||
{
|
||||
name: "does not create a missing entry",
|
||||
setup: func(cs *CacheStore[string]) {},
|
||||
ttl: 0,
|
||||
wantOk: false,
|
||||
wantPresent: false,
|
||||
},
|
||||
{
|
||||
name: "preserves the existing expiry when ttl is zero",
|
||||
setup: func(cs *CacheStore[string]) { cs.Set("key", "old", 30*time.Millisecond) },
|
||||
ttl: 0,
|
||||
wantOk: true,
|
||||
afterWait: 40 * time.Millisecond,
|
||||
wantPresent: false,
|
||||
},
|
||||
{
|
||||
name: "refreshes the expiry when ttl is provided",
|
||||
setup: func(cs *CacheStore[string]) { cs.Set("key", "old", 10*time.Millisecond) },
|
||||
ttl: time.Minute,
|
||||
wantOk: true,
|
||||
afterWait: 20 * time.Millisecond,
|
||||
wantPresent: true,
|
||||
wantValue: "new",
|
||||
},
|
||||
{
|
||||
name: "does not update an expired entry",
|
||||
setup: func(cs *CacheStore[string]) {
|
||||
cs.Set("key", "old", 10*time.Millisecond)
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
},
|
||||
ttl: time.Minute,
|
||||
wantOk: false,
|
||||
wantPresent: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cs := NewCacheStore[string](0)
|
||||
tt.setup(cs)
|
||||
|
||||
ok := cs.Update("key", "new", tt.ttl)
|
||||
assert.Equal(t, tt.wantOk, ok)
|
||||
|
||||
time.Sleep(tt.afterWait)
|
||||
|
||||
value, present := cs.Get("key")
|
||||
assert.Equal(t, tt.wantPresent, present)
|
||||
if tt.wantPresent {
|
||||
assert.Equal(t, tt.wantValue, value)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheStoreDelete(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(cs *CacheStore[string])
|
||||
key string
|
||||
wantSize int
|
||||
}{
|
||||
{
|
||||
name: "removes an existing key",
|
||||
setup: func(cs *CacheStore[string]) {
|
||||
cs.Set("a", "1", 0)
|
||||
cs.Set("b", "2", 0)
|
||||
},
|
||||
key: "a",
|
||||
wantSize: 1,
|
||||
},
|
||||
{
|
||||
name: "is a no-op for a missing key",
|
||||
setup: func(cs *CacheStore[string]) { cs.Set("a", "1", 0) },
|
||||
key: "missing",
|
||||
wantSize: 1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cs := NewCacheStore[string](0)
|
||||
tt.setup(cs)
|
||||
|
||||
cs.Delete(tt.key)
|
||||
|
||||
_, ok := cs.Get(tt.key)
|
||||
assert.False(t, ok)
|
||||
assert.Equal(t, tt.wantSize, cs.Size())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheStoreSweep(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(cs *CacheStore[string])
|
||||
present []string
|
||||
absent []string
|
||||
wantSize int
|
||||
}{
|
||||
{
|
||||
name: "removes expired entries and keeps the rest",
|
||||
setup: func(cs *CacheStore[string]) {
|
||||
cs.Set("permanent", "value", 0)
|
||||
cs.Set("expired", "value", 10*time.Millisecond)
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
},
|
||||
present: []string{"permanent"},
|
||||
absent: []string{"expired"},
|
||||
wantSize: 1,
|
||||
},
|
||||
{
|
||||
name: "keeps all live entries",
|
||||
setup: func(cs *CacheStore[string]) {
|
||||
cs.Set("a", "value", 0)
|
||||
cs.Set("b", "value", time.Minute)
|
||||
},
|
||||
present: []string{"a", "b"},
|
||||
wantSize: 2,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cs := NewCacheStore[string](0)
|
||||
tt.setup(cs)
|
||||
|
||||
cs.Sweep()
|
||||
|
||||
for _, key := range tt.present {
|
||||
_, ok := cs.Get(key)
|
||||
assert.True(t, ok)
|
||||
}
|
||||
for _, key := range tt.absent {
|
||||
_, ok := cs.Get(key)
|
||||
assert.False(t, ok)
|
||||
}
|
||||
assert.Equal(t, tt.wantSize, cs.Size())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheStoreEviction(t *testing.T) {
|
||||
// Every case uses a cache with maxSize 2; the final Set in setup is the
|
||||
// insertion that overflows the cache and triggers an eviction.
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(cs *CacheStore[string])
|
||||
present []string
|
||||
absent []string
|
||||
wantSize int
|
||||
}{
|
||||
{
|
||||
name: "evicts an already expired entry first",
|
||||
setup: func(cs *CacheStore[string]) {
|
||||
cs.Set("expired", "value", 10*time.Millisecond)
|
||||
cs.Set("fresh", "value", time.Minute)
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
cs.Set("new", "value", time.Minute)
|
||||
},
|
||||
present: []string{"fresh", "new"},
|
||||
absent: []string{"expired"},
|
||||
wantSize: 2,
|
||||
},
|
||||
{
|
||||
name: "evicts the entry expiring soonest",
|
||||
setup: func(cs *CacheStore[string]) {
|
||||
cs.Set("soon", "value", 50*time.Millisecond)
|
||||
cs.Set("later", "value", time.Hour)
|
||||
cs.Set("new", "value", time.Hour)
|
||||
},
|
||||
present: []string{"later", "new"},
|
||||
absent: []string{"soon"},
|
||||
wantSize: 2,
|
||||
},
|
||||
{
|
||||
name: "evicts the oldest inserted entry when none have a ttl",
|
||||
setup: func(cs *CacheStore[string]) {
|
||||
cs.Set("first", "value", 0)
|
||||
cs.Set("second", "value", 0)
|
||||
cs.Set("third", "value", 0)
|
||||
},
|
||||
present: []string{"second", "third"},
|
||||
absent: []string{"first"},
|
||||
wantSize: 2,
|
||||
},
|
||||
{
|
||||
name: "overwriting an existing key does not trigger eviction",
|
||||
setup: func(cs *CacheStore[string]) {
|
||||
cs.Set("a", "1", 0)
|
||||
cs.Set("b", "2", 0)
|
||||
cs.Set("a", "updated", 0)
|
||||
},
|
||||
present: []string{"a", "b"},
|
||||
wantSize: 2,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cs := NewCacheStore[string](2)
|
||||
tt.setup(cs)
|
||||
|
||||
for _, key := range tt.present {
|
||||
_, ok := cs.Get(key)
|
||||
assert.True(t, ok)
|
||||
}
|
||||
for _, key := range tt.absent {
|
||||
_, ok := cs.Get(key)
|
||||
assert.False(t, ok)
|
||||
}
|
||||
assert.Equal(t, tt.wantSize, cs.Size())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheStoreSizeAndClear(t *testing.T) {
|
||||
cs := NewCacheStore[string](0)
|
||||
assert.Equal(t, 0, cs.Size())
|
||||
|
||||
cs.Set("a", "1", 0)
|
||||
cs.Set("b", "2", 0)
|
||||
assert.Equal(t, 2, cs.Size())
|
||||
|
||||
cs.Clear()
|
||||
assert.Equal(t, 0, cs.Size())
|
||||
|
||||
_, ok := cs.Get("a")
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestCacheStoreWithLock(t *testing.T) {
|
||||
cs := NewCacheStore[int](0)
|
||||
cs.Set("counter", 1, 0)
|
||||
|
||||
// All four actions run atomically under a single lock.
|
||||
cs.WithLock(func(actions CacheStoreActions[int]) {
|
||||
current, ok := actions.Get("counter")
|
||||
assert.True(t, ok)
|
||||
|
||||
actions.Set("counter", current+1, 0)
|
||||
actions.Set("other", 100, 0)
|
||||
actions.Delete("counter")
|
||||
|
||||
updated := actions.Update("other", 200, 0)
|
||||
assert.True(t, updated)
|
||||
})
|
||||
|
||||
_, ok := cs.Get("counter")
|
||||
assert.False(t, ok)
|
||||
|
||||
value, ok := cs.Get("other")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, 200, value)
|
||||
}
|
||||
|
||||
// TestCacheStoreConcurrency exercises every locking path concurrently so the
|
||||
// race detector (go test -race) can flag unsynchronised access.
|
||||
func TestCacheStoreConcurrency(t *testing.T) {
|
||||
cs := NewCacheStore[int](64)
|
||||
|
||||
const goroutines = 16
|
||||
const iterations = 200
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(goroutines)
|
||||
|
||||
for g := range goroutines {
|
||||
go func(g int) {
|
||||
defer wg.Done()
|
||||
for i := range iterations {
|
||||
key := strconv.Itoa((g*iterations + i) % 32)
|
||||
switch i % 6 {
|
||||
case 0:
|
||||
cs.Set(key, i, time.Minute)
|
||||
case 1:
|
||||
cs.Get(key)
|
||||
case 2:
|
||||
cs.Update(key, i, time.Minute)
|
||||
case 3:
|
||||
cs.Delete(key)
|
||||
case 4:
|
||||
cs.Size()
|
||||
case 5:
|
||||
cs.WithLock(func(actions CacheStoreActions[int]) {
|
||||
if v, ok := actions.Get(key); ok {
|
||||
actions.Set(key, v+1, time.Minute)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}(g)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
@@ -3,8 +3,8 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/steveiliop56/ding"
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/decoders"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
@@ -24,7 +24,7 @@ type DockerService struct {
|
||||
func NewDockerService(
|
||||
log *logger.Logger,
|
||||
ctx context.Context,
|
||||
wg *sync.WaitGroup,
|
||||
dg *ding.Ding,
|
||||
) (*DockerService, error) {
|
||||
|
||||
client, err := client.NewClientWithOpts(client.FromEnv)
|
||||
@@ -50,7 +50,7 @@ func NewDockerService(
|
||||
service.isConnected = true
|
||||
service.log.App.Debug().Msg("Docker connected successfully")
|
||||
|
||||
wg.Go(service.watchAndClose)
|
||||
dg.Go(service.watchAndClose, ding.RingMajor)
|
||||
|
||||
return service, nil
|
||||
}
|
||||
@@ -108,8 +108,8 @@ func (docker *DockerService) GetLabels(appDomain string) (*model.App, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (docker *DockerService) watchAndClose() {
|
||||
<-docker.context.Done()
|
||||
func (docker *DockerService) watchAndClose(ctx context.Context) {
|
||||
<-ctx.Done()
|
||||
docker.log.App.Debug().Msg("Closing Docker client")
|
||||
if docker.client != nil {
|
||||
err := docker.client.Close()
|
||||
|
||||
@@ -3,10 +3,12 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/steveiliop56/ding"
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/decoders"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
@@ -37,7 +39,6 @@ type ingressApp struct {
|
||||
|
||||
type KubernetesService struct {
|
||||
log *logger.Logger
|
||||
ctx context.Context
|
||||
|
||||
client dynamic.Interface
|
||||
started bool
|
||||
@@ -50,7 +51,7 @@ type KubernetesService struct {
|
||||
func NewKubernetesService(
|
||||
log *logger.Logger,
|
||||
ctx context.Context,
|
||||
wg *sync.WaitGroup,
|
||||
dg *ding.Ding,
|
||||
) (*KubernetesService, error) {
|
||||
cfg, err := rest.InClusterConfig()
|
||||
if err != nil {
|
||||
@@ -81,16 +82,15 @@ func NewKubernetesService(
|
||||
|
||||
service := &KubernetesService{
|
||||
log: log,
|
||||
ctx: ctx,
|
||||
client: client,
|
||||
ingressApps: make(map[ingressKey][]ingressApp),
|
||||
domainIndex: make(map[string]ingressAppKey),
|
||||
appNameIndex: make(map[string]ingressAppKey),
|
||||
}
|
||||
|
||||
wg.Go(func() {
|
||||
service.watchGVR(gvr)
|
||||
})
|
||||
dg.Go(func(ctx context.Context) {
|
||||
service.watchGVR(gvr, ctx)
|
||||
}, ding.RingMajor)
|
||||
|
||||
service.started = true
|
||||
log.App.Debug().Msg("Kubernetes label provider started successfully")
|
||||
@@ -167,6 +167,68 @@ func (k *KubernetesService) getByAppName(appName string) *model.App {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (k *KubernetesService) extractPaths(rule map[string]any) ([]string, error) {
|
||||
http, found, err := unstructured.NestedMap(rule, "http")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading http from rule: %w", err)
|
||||
}
|
||||
if !found {
|
||||
return nil, nil
|
||||
}
|
||||
paths, found, err := unstructured.NestedSlice(http, "paths")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading http.paths: %w", err)
|
||||
}
|
||||
if !found {
|
||||
return nil, nil
|
||||
}
|
||||
var result []string
|
||||
for _, p := range paths {
|
||||
path, ok := p.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if p, ok := path["path"].(string); ok && p != "" {
|
||||
result = append(result, p)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (k *KubernetesService) extractHosts(item *unstructured.Unstructured) ([]string, error) {
|
||||
rules, found, err := unstructured.NestedSlice(item.Object, "spec", "rules")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading spec.rules: %w", err)
|
||||
}
|
||||
if !found {
|
||||
return nil, nil
|
||||
}
|
||||
var hosts []string
|
||||
for _, r := range rules {
|
||||
rule, ok := r.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if host, ok := rule["host"].(string); ok && host != "" {
|
||||
hosts = append(hosts, host)
|
||||
}
|
||||
paths, err := k.extractPaths(rule)
|
||||
if err != nil {
|
||||
// This is purely to warn users, it doesn't affect our ability to extract hosts so we won't fail the whole operation
|
||||
k.log.App.Warn().Err(err).Str("namespace", item.GetNamespace()).Str("name", item.GetName()).Msg("Failed to extract paths from ingress rule")
|
||||
continue
|
||||
}
|
||||
if len(paths) == 0 {
|
||||
continue
|
||||
}
|
||||
if !slices.Contains(paths, "/") {
|
||||
k.log.App.Warn().Str("namespace", item.GetNamespace()).Str("name", item.GetName()).Strs("paths", paths).Msg("Ingress rule does not contain a catch-all path, another ingress may be able to bypass auth checks if it routes the same host with a different path. Consider adding a catch-all path to this rule to ensure auth checks are applied to all paths for this host.")
|
||||
}
|
||||
}
|
||||
k.log.App.Trace().Strs("hosts", hosts).Msg("Extracted hosts from ingress rules")
|
||||
return hosts, nil
|
||||
}
|
||||
|
||||
func (k *KubernetesService) updateFromItem(item *unstructured.Unstructured) {
|
||||
namespace := item.GetNamespace()
|
||||
name := item.GetName()
|
||||
@@ -175,6 +237,11 @@ func (k *KubernetesService) updateFromItem(item *unstructured.Unstructured) {
|
||||
k.removeIngress(namespace, name)
|
||||
return
|
||||
}
|
||||
hosts, err := k.extractHosts(item)
|
||||
if err != nil {
|
||||
k.removeIngress(namespace, name)
|
||||
return
|
||||
}
|
||||
labels, err := decoders.DecodeLabels[model.Apps](annotations, "apps")
|
||||
if err != nil {
|
||||
k.log.App.Warn().Err(err).Str("namespace", namespace).Str("name", name).Msg("Failed to decode ingress labels, skipping")
|
||||
@@ -186,6 +253,10 @@ func (k *KubernetesService) updateFromItem(item *unstructured.Unstructured) {
|
||||
if appLabels.Config.Domain == "" {
|
||||
continue
|
||||
}
|
||||
if len(hosts) > 0 && !slices.Contains(hosts, appLabels.Config.Domain) {
|
||||
k.log.App.Warn().Str("namespace", namespace).Str("name", name).Str("appName", appName).Str("domain", appLabels.Config.Domain).Msg("App domain does not match any hosts defined in ingress rules, skipping")
|
||||
continue
|
||||
}
|
||||
apps = append(apps, ingressApp{
|
||||
domain: appLabels.Config.Domain,
|
||||
appName: appName,
|
||||
@@ -199,8 +270,8 @@ func (k *KubernetesService) updateFromItem(item *unstructured.Unstructured) {
|
||||
}
|
||||
}
|
||||
|
||||
func (k *KubernetesService) resyncGVR(gvr schema.GroupVersionResource) error {
|
||||
ctx, cancel := context.WithTimeout(k.ctx, 30*time.Second)
|
||||
func (k *KubernetesService) resyncGVR(gvr schema.GroupVersionResource, ctx context.Context) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
list, err := k.client.Resource(gvr).List(ctx, metav1.ListOptions{})
|
||||
@@ -217,10 +288,10 @@ func (k *KubernetesService) resyncGVR(gvr schema.GroupVersionResource) error {
|
||||
|
||||
// runWatcher drains events from an active watcher until it closes or the context is done.
|
||||
// Returns true if the caller should restart the watcher, false if it should exit.
|
||||
func (k *KubernetesService) runWatcher(gvr schema.GroupVersionResource, w watch.Interface, resyncTicker *time.Ticker) bool {
|
||||
func (k *KubernetesService) runWatcher(gvr schema.GroupVersionResource, w watch.Interface, resyncTicker *time.Ticker, ctx context.Context) bool {
|
||||
for {
|
||||
select {
|
||||
case <-k.ctx.Done():
|
||||
case <-ctx.Done():
|
||||
w.Stop()
|
||||
return false
|
||||
case event, ok := <-w.ResultChan():
|
||||
@@ -242,33 +313,33 @@ func (k *KubernetesService) runWatcher(gvr schema.GroupVersionResource, w watch.
|
||||
k.removeIngress(item.GetNamespace(), item.GetName())
|
||||
}
|
||||
case <-resyncTicker.C:
|
||||
if err := k.resyncGVR(gvr); err != nil {
|
||||
if err := k.resyncGVR(gvr, ctx); err != nil {
|
||||
k.log.App.Warn().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Periodic resync failed during watcher run")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (k *KubernetesService) watchGVR(gvr schema.GroupVersionResource) {
|
||||
func (k *KubernetesService) watchGVR(gvr schema.GroupVersionResource, ctx context.Context) {
|
||||
resyncTicker := time.NewTicker(5 * time.Minute)
|
||||
defer resyncTicker.Stop()
|
||||
|
||||
if err := k.resyncGVR(gvr); err != nil {
|
||||
if err := k.resyncGVR(gvr, ctx); err != nil {
|
||||
k.log.App.Warn().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Initial resync failed, will retry")
|
||||
time.Sleep(30 * time.Second)
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-k.ctx.Done():
|
||||
case <-ctx.Done():
|
||||
k.log.App.Debug().Str("api", gvr.GroupVersion().String()).Msg("Shutting down kubernetes watcher")
|
||||
return
|
||||
case <-resyncTicker.C:
|
||||
if err := k.resyncGVR(gvr); err != nil {
|
||||
if err := k.resyncGVR(gvr, ctx); err != nil {
|
||||
k.log.App.Warn().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Periodic resync failed, will retry")
|
||||
}
|
||||
default:
|
||||
ctx, cancel := context.WithCancel(k.ctx)
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
watcher, err := k.client.Resource(gvr).Watch(ctx, metav1.ListOptions{})
|
||||
if err != nil {
|
||||
k.log.App.Warn().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Failed to start watcher, will retry")
|
||||
@@ -277,7 +348,7 @@ func (k *KubernetesService) watchGVR(gvr schema.GroupVersionResource) {
|
||||
continue
|
||||
}
|
||||
k.log.App.Debug().Str("api", gvr.GroupVersion().String()).Msg("Watcher started successfully")
|
||||
if !k.runWatcher(gvr, watcher, resyncTicker) {
|
||||
if !k.runWatcher(gvr, watcher, resyncTicker, ctx) {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -9,14 +9,14 @@ import (
|
||||
|
||||
"github.com/cenkalti/backoff/v5"
|
||||
ldapgo "github.com/go-ldap/ldap/v3"
|
||||
"github.com/steveiliop56/ding"
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
)
|
||||
|
||||
type LdapService struct {
|
||||
log *logger.Logger
|
||||
config model.Config
|
||||
context context.Context
|
||||
log *logger.Logger
|
||||
config model.Config
|
||||
|
||||
conn *ldapgo.Conn
|
||||
mutex sync.RWMutex
|
||||
@@ -26,17 +26,15 @@ type LdapService struct {
|
||||
func NewLdapService(
|
||||
log *logger.Logger,
|
||||
config model.Config,
|
||||
ctx context.Context,
|
||||
wg *sync.WaitGroup,
|
||||
dg *ding.Ding,
|
||||
) (*LdapService, error) {
|
||||
if config.LDAP.Address == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
ldap := &LdapService{
|
||||
log: log,
|
||||
config: config,
|
||||
context: ctx,
|
||||
log: log,
|
||||
config: config,
|
||||
}
|
||||
|
||||
// Check whether authentication with client certificate is possible
|
||||
@@ -69,7 +67,7 @@ func NewLdapService(
|
||||
return nil, fmt.Errorf("failed to connect to ldap server: %w", err)
|
||||
}
|
||||
|
||||
wg.Go(func() {
|
||||
dg.Go(func(ctx context.Context) {
|
||||
ldap.log.App.Debug().Msg("Starting LDAP connection heartbeat routine")
|
||||
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
@@ -87,12 +85,12 @@ func NewLdapService(
|
||||
}
|
||||
ldap.log.App.Info().Msg("Successfully reconnected to LDAP server")
|
||||
}
|
||||
case <-ldap.context.Done():
|
||||
case <-ctx.Done():
|
||||
ldap.log.App.Debug().Msg("LDAP service context cancelled, stopping heartbeat")
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
}, ding.RingMajor)
|
||||
|
||||
return ldap, nil
|
||||
}
|
||||
|
||||
@@ -15,13 +15,13 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"slices"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-jose/go-jose/v4"
|
||||
"github.com/steveiliop56/ding"
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/repository"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils"
|
||||
@@ -116,7 +116,6 @@ type OIDCService struct {
|
||||
config model.Config
|
||||
runtime model.RuntimeConfig
|
||||
queries repository.Store
|
||||
context context.Context
|
||||
|
||||
clients map[string]model.OIDCClientConfig
|
||||
privateKey *rsa.PrivateKey
|
||||
@@ -129,8 +128,7 @@ func NewOIDCService(
|
||||
config model.Config,
|
||||
runtime model.RuntimeConfig,
|
||||
queries repository.Store,
|
||||
ctx context.Context,
|
||||
wg *sync.WaitGroup) (*OIDCService, error) {
|
||||
dg *ding.Ding) (*OIDCService, error) {
|
||||
// If not configured, skip init
|
||||
if len(runtime.OIDCClients) == 0 {
|
||||
return nil, nil
|
||||
@@ -276,7 +274,6 @@ func NewOIDCService(
|
||||
config: config,
|
||||
runtime: runtime,
|
||||
queries: queries,
|
||||
context: ctx,
|
||||
|
||||
clients: clients,
|
||||
privateKey: privateKey,
|
||||
@@ -285,7 +282,7 @@ func NewOIDCService(
|
||||
}
|
||||
|
||||
// Start cleanup routine
|
||||
wg.Go(service.cleanupRoutine)
|
||||
dg.Go(service.cleanupRoutine, ding.RingMinor)
|
||||
|
||||
return service, nil
|
||||
}
|
||||
@@ -759,7 +756,7 @@ func (service *OIDCService) DeleteOldSession(ctx context.Context, sub string) er
|
||||
}
|
||||
|
||||
// Cleanup routine - Resource heavy due to the linked tables
|
||||
func (service *OIDCService) cleanupRoutine() {
|
||||
func (service *OIDCService) cleanupRoutine(ctx context.Context) {
|
||||
service.log.App.Debug().Msg("Starting OIDC cleanup routine")
|
||||
ticker := time.NewTicker(time.Duration(30) * time.Minute)
|
||||
defer ticker.Stop()
|
||||
@@ -772,7 +769,7 @@ func (service *OIDCService) cleanupRoutine() {
|
||||
currentTime := time.Now().Unix()
|
||||
|
||||
// For the OIDC tokens, if they are expired we delete the userinfo and codes
|
||||
expiredTokens, err := service.queries.DeleteExpiredOidcTokens(service.context, repository.DeleteExpiredOidcTokensParams{
|
||||
expiredTokens, err := service.queries.DeleteExpiredOidcTokens(ctx, repository.DeleteExpiredOidcTokensParams{
|
||||
TokenExpiresAt: currentTime,
|
||||
RefreshTokenExpiresAt: currentTime,
|
||||
})
|
||||
@@ -782,21 +779,21 @@ func (service *OIDCService) cleanupRoutine() {
|
||||
}
|
||||
|
||||
for _, expiredToken := range expiredTokens {
|
||||
err := service.DeleteOldSession(service.context, expiredToken.Sub)
|
||||
err := service.DeleteOldSession(ctx, expiredToken.Sub)
|
||||
if err != nil {
|
||||
service.log.App.Warn().Err(err).Msg("Failed to delete session for expired token")
|
||||
}
|
||||
}
|
||||
|
||||
// For expired codes, we need to get the sub, check if tokens are expired and if they are remove everything
|
||||
expiredCodes, err := service.queries.DeleteExpiredOidcCodes(service.context, currentTime)
|
||||
expiredCodes, err := service.queries.DeleteExpiredOidcCodes(ctx, currentTime)
|
||||
|
||||
if err != nil {
|
||||
service.log.App.Warn().Err(err).Msg("Failed to delete expired codes")
|
||||
service.log.App.Warn().Err(err).Msg("Failed to delete expired codes")
|
||||
}
|
||||
|
||||
for _, expiredCode := range expiredCodes {
|
||||
token, err := service.queries.GetOidcTokenBySub(service.context, expiredCode.Sub)
|
||||
token, err := service.queries.GetOidcTokenBySub(ctx, expiredCode.Sub)
|
||||
|
||||
if err != nil {
|
||||
if !errors.Is(err, repository.ErrNotFound) {
|
||||
@@ -806,7 +803,7 @@ func (service *OIDCService) cleanupRoutine() {
|
||||
}
|
||||
|
||||
if token.TokenExpiresAt < currentTime && token.RefreshTokenExpiresAt < currentTime {
|
||||
err := service.DeleteOldSession(service.context, expiredCode.Sub)
|
||||
err := service.DeleteOldSession(ctx, expiredCode.Sub)
|
||||
if err != nil {
|
||||
service.log.App.Warn().Err(err).Msg("Failed to delete session for expired code")
|
||||
}
|
||||
@@ -814,7 +811,7 @@ func (service *OIDCService) cleanupRoutine() {
|
||||
}
|
||||
|
||||
service.log.App.Debug().Msg("Finished OIDC cleanup routine")
|
||||
case <-service.context.Done():
|
||||
case <-ctx.Done():
|
||||
service.log.App.Debug().Msg("Stopping OIDC cleanup routine")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@ package service_test
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/steveiliop56/ding"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
@@ -70,9 +70,9 @@ func TestCompileUserinfo(t *testing.T) {
|
||||
log.Init()
|
||||
|
||||
ctx := context.TODO()
|
||||
wg := &sync.WaitGroup{}
|
||||
dg := ding.New(ctx)
|
||||
|
||||
svc, err := service.NewOIDCService(log, cfg, runtime, nil, ctx, wg)
|
||||
svc, err := service.NewOIDCService(log, cfg, runtime, nil, dg)
|
||||
require.NoError(t, err)
|
||||
|
||||
type testCase struct {
|
||||
|
||||
@@ -108,3 +108,7 @@ func (engine *PolicyEngine) Policy() Policy {
|
||||
func (engine *PolicyEngine) Rules() map[RuleName]Rule {
|
||||
return engine.rules
|
||||
}
|
||||
|
||||
func (engine *PolicyEngine) EvaluateFunc(f func() Effect) bool {
|
||||
return engine.effectToAccess(f())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/steveiliop56/ding"
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
"tailscale.com/client/local"
|
||||
"tailscale.com/tsnet"
|
||||
)
|
||||
|
||||
type TailscaleWhoisResponse struct {
|
||||
UserID string
|
||||
LoginName string
|
||||
DisplayName string
|
||||
NodeName string
|
||||
}
|
||||
|
||||
type TailscaleService struct {
|
||||
log *logger.Logger
|
||||
config model.Config
|
||||
ctx context.Context
|
||||
|
||||
srv *tsnet.Server
|
||||
lc *local.Client
|
||||
ln *net.Listener
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewTailscaleService(log *logger.Logger, config model.Config, ctx context.Context, dg *ding.Ding) (*TailscaleService, error) {
|
||||
if !config.Tailscale.Enabled {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
srv := new(tsnet.Server)
|
||||
|
||||
// node options
|
||||
srv.Dir = config.Tailscale.Dir
|
||||
srv.Hostname = config.Tailscale.Hostname
|
||||
srv.AuthKey = config.Tailscale.AuthKey
|
||||
srv.Ephemeral = config.Tailscale.Ephemeral
|
||||
|
||||
// redirect logs to zerolog
|
||||
srv.Logf = log.App.Printf
|
||||
srv.UserLogf = log.App.Printf
|
||||
|
||||
err := srv.Start()
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to start tailscale server: %w", err)
|
||||
}
|
||||
|
||||
lc, err := srv.LocalClient()
|
||||
|
||||
if err != nil {
|
||||
_ = srv.Close()
|
||||
return nil, fmt.Errorf("failed to get tailscale local client: %w", err)
|
||||
}
|
||||
|
||||
service := &TailscaleService{
|
||||
log: log,
|
||||
config: config,
|
||||
ctx: ctx,
|
||||
srv: srv,
|
||||
lc: lc,
|
||||
}
|
||||
|
||||
connectCtx, cancel := context.WithTimeout(ctx, 2*time.Minute) // large enough timeout to allow for user to manually authenticate with link if needed
|
||||
defer cancel()
|
||||
|
||||
err = service.waitForConn(connectCtx)
|
||||
|
||||
if err != nil {
|
||||
_ = srv.Close()
|
||||
return nil, fmt.Errorf("failed to connect to tailscale network: %w", err)
|
||||
}
|
||||
|
||||
dg.Go(service.watchAndClose, ding.RingMajor)
|
||||
|
||||
return service, nil
|
||||
}
|
||||
|
||||
func (ts *TailscaleService) watchAndClose(ctx context.Context) {
|
||||
<-ctx.Done()
|
||||
ts.log.App.Debug().Msg("Shutting down Tailscale service")
|
||||
ts.mu.Lock()
|
||||
srv := ts.srv
|
||||
ln := ts.ln
|
||||
ts.ln = nil
|
||||
ts.srv = nil
|
||||
ts.mu.Unlock()
|
||||
if ln != nil {
|
||||
(*ln).Close()
|
||||
}
|
||||
if srv != nil {
|
||||
srv.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (ts *TailscaleService) Whois(ctx context.Context, addr string) (*TailscaleWhoisResponse, error) {
|
||||
who, err := ts.lc.WhoIs(ctx, addr)
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, local.ErrPeerNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get client whois: %w", err)
|
||||
}
|
||||
|
||||
if who.Node.IsTagged() {
|
||||
ts.log.App.Debug().Msgf("Skipping whois for tagged node %s", who.Node.Name)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
uid := strings.TrimPrefix(who.UserProfile.ID.String(), "userid:")
|
||||
|
||||
res := TailscaleWhoisResponse{
|
||||
UserID: uid,
|
||||
LoginName: who.UserProfile.LoginName,
|
||||
DisplayName: who.UserProfile.DisplayName,
|
||||
NodeName: strings.TrimSuffix(who.Node.Name, "."),
|
||||
}
|
||||
|
||||
ts.log.App.Debug().Interface("res", res).Msg("tailscale")
|
||||
|
||||
return &res, nil
|
||||
}
|
||||
|
||||
func (ts *TailscaleService) CreateListener() (net.Listener, error) {
|
||||
ts.mu.Lock()
|
||||
defer ts.mu.Unlock()
|
||||
|
||||
if ts.ln != nil {
|
||||
return *ts.ln, nil
|
||||
}
|
||||
ln, err := ts.srv.ListenTLS("tcp", ":443")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ts.ln = &ln
|
||||
return ln, nil
|
||||
}
|
||||
|
||||
func (ts *TailscaleService) GetHostname() string {
|
||||
status, err := ts.lc.Status(ts.ctx)
|
||||
|
||||
if err != nil {
|
||||
ts.log.App.Error().Err(err).Msg("Failed to get Tailscale status")
|
||||
return ""
|
||||
}
|
||||
|
||||
return strings.TrimSuffix(status.Self.DNSName, ".")
|
||||
}
|
||||
|
||||
func (ts *TailscaleService) waitForConn(ctx context.Context) error {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("timed out waiting for tailscale connection")
|
||||
default:
|
||||
ip4, _ := ts.srv.TailscaleIPs()
|
||||
if !ip4.IsValid() {
|
||||
time.Sleep(1 * time.Second)
|
||||
continue
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package loaders
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/tinyauthapp/paerser/cli"
|
||||
"github.com/tinyauthapp/paerser/file"
|
||||
"github.com/tinyauthapp/paerser/flag"
|
||||
@@ -19,8 +18,8 @@ func (f *FileLoader) Load(args []string, cmd *cli.Command) (bool, error) {
|
||||
}
|
||||
|
||||
// I guess we are using traefik as the root name (we can't change it)
|
||||
configFileFlag := "traefik.experimental.configfile"
|
||||
envVar := "TINYAUTH_EXPERIMENTAL_CONFIGFILE"
|
||||
configFileFlag := "traefik.configfile"
|
||||
envVar := "TINYAUTH_CONFIGFILE"
|
||||
|
||||
if _, ok := flags[configFileFlag]; !ok {
|
||||
if value := os.Getenv(envVar); value != "" {
|
||||
@@ -30,8 +29,6 @@ func (f *FileLoader) Load(args []string, cmd *cli.Command) (bool, error) {
|
||||
}
|
||||
}
|
||||
|
||||
log.Warn().Msg("Using experimental file config loader, this feature is experimental and may change or be removed in future releases")
|
||||
|
||||
err = file.Decode(flags[configFileFlag], cmd.Configuration)
|
||||
|
||||
if err != nil {
|
||||
|
||||
@@ -3,6 +3,7 @@ package utils
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"regexp"
|
||||
@@ -11,6 +12,10 @@ import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrFilterEmpty = errors.New("filter is empty")
|
||||
)
|
||||
|
||||
func GetSecret(conf string, file string) string {
|
||||
if conf == "" && file == "" {
|
||||
return ""
|
||||
@@ -78,7 +83,7 @@ func CheckIPFilter(filter string, ip string) (bool, error) {
|
||||
|
||||
func CheckFilter(filter string, input string) (bool, error) {
|
||||
if len(strings.TrimSpace(filter)) == 0 {
|
||||
return false, fmt.Errorf("filter is empty")
|
||||
return false, ErrFilterEmpty
|
||||
}
|
||||
|
||||
if strings.HasPrefix(filter, "/") && strings.HasSuffix(filter, "/") {
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
-- name: CreateOidcCode :one
|
||||
INSERT INTO "oidc_codes" (
|
||||
"sub",
|
||||
"code_hash",
|
||||
"scope",
|
||||
"redirect_uri",
|
||||
"client_id",
|
||||
"expires_at",
|
||||
"nonce",
|
||||
"code_challenge"
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8
|
||||
)
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetOidcCodeUnsafe :one
|
||||
SELECT * FROM "oidc_codes"
|
||||
WHERE "code_hash" = $1;
|
||||
|
||||
-- name: GetOidcCode :one
|
||||
DELETE FROM "oidc_codes"
|
||||
WHERE "code_hash" = $1
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetOidcCodeBySubUnsafe :one
|
||||
SELECT * FROM "oidc_codes"
|
||||
WHERE "sub" = $1;
|
||||
|
||||
-- name: GetOidcCodeBySub :one
|
||||
DELETE FROM "oidc_codes"
|
||||
WHERE "sub" = $1
|
||||
RETURNING *;
|
||||
|
||||
-- name: DeleteOidcCode :exec
|
||||
DELETE FROM "oidc_codes"
|
||||
WHERE "code_hash" = $1;
|
||||
|
||||
-- name: DeleteOidcCodeBySub :exec
|
||||
DELETE FROM "oidc_codes"
|
||||
WHERE "sub" = $1;
|
||||
|
||||
-- name: CreateOidcToken :one
|
||||
INSERT INTO "oidc_tokens" (
|
||||
"sub",
|
||||
"access_token_hash",
|
||||
"refresh_token_hash",
|
||||
"scope",
|
||||
"client_id",
|
||||
"token_expires_at",
|
||||
"refresh_token_expires_at",
|
||||
"code_hash",
|
||||
"nonce"
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9
|
||||
)
|
||||
RETURNING *;
|
||||
|
||||
-- name: UpdateOidcTokenByRefreshToken :one
|
||||
UPDATE "oidc_tokens" SET
|
||||
"access_token_hash" = $1,
|
||||
"refresh_token_hash" = $2,
|
||||
"token_expires_at" = $3,
|
||||
"refresh_token_expires_at" = $4
|
||||
WHERE "refresh_token_hash" = $5
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetOidcToken :one
|
||||
SELECT * FROM "oidc_tokens"
|
||||
WHERE "access_token_hash" = $1;
|
||||
|
||||
-- name: GetOidcTokenByRefreshToken :one
|
||||
SELECT * FROM "oidc_tokens"
|
||||
WHERE "refresh_token_hash" = $1;
|
||||
|
||||
-- name: GetOidcTokenBySub :one
|
||||
SELECT * FROM "oidc_tokens"
|
||||
WHERE "sub" = $1;
|
||||
|
||||
-- name: DeleteOidcTokenByCodeHash :exec
|
||||
DELETE FROM "oidc_tokens"
|
||||
WHERE "code_hash" = $1;
|
||||
|
||||
-- name: DeleteOidcToken :exec
|
||||
DELETE FROM "oidc_tokens"
|
||||
WHERE "access_token_hash" = $1;
|
||||
|
||||
-- name: DeleteOidcTokenBySub :exec
|
||||
DELETE FROM "oidc_tokens"
|
||||
WHERE "sub" = $1;
|
||||
|
||||
-- name: CreateOidcUserInfo :one
|
||||
INSERT INTO "oidc_userinfo" (
|
||||
"sub",
|
||||
"name",
|
||||
"preferred_username",
|
||||
"email",
|
||||
"groups",
|
||||
"updated_at",
|
||||
"given_name",
|
||||
"family_name",
|
||||
"middle_name",
|
||||
"nickname",
|
||||
"profile",
|
||||
"picture",
|
||||
"website",
|
||||
"gender",
|
||||
"birthdate",
|
||||
"zoneinfo",
|
||||
"locale",
|
||||
"phone_number",
|
||||
"address"
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19
|
||||
)
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetOidcUserInfo :one
|
||||
SELECT * FROM "oidc_userinfo"
|
||||
WHERE "sub" = $1;
|
||||
|
||||
-- name: DeleteOidcUserInfo :exec
|
||||
DELETE FROM "oidc_userinfo"
|
||||
WHERE "sub" = $1;
|
||||
|
||||
-- name: DeleteExpiredOidcCodes :many
|
||||
DELETE FROM "oidc_codes"
|
||||
WHERE "expires_at" < $1
|
||||
RETURNING *;
|
||||
|
||||
-- name: DeleteExpiredOidcTokens :many
|
||||
DELETE FROM "oidc_tokens"
|
||||
WHERE "token_expires_at" < $1 AND "refresh_token_expires_at" < $2
|
||||
RETURNING *;
|
||||
@@ -0,0 +1,44 @@
|
||||
CREATE TABLE IF NOT EXISTS "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 IF NOT EXISTS "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 IF NOT EXISTS "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,43 @@
|
||||
-- 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 *;
|
||||
|
||||
-- name: GetSession :one
|
||||
SELECT * FROM "sessions"
|
||||
WHERE "uuid" = $1;
|
||||
|
||||
-- name: DeleteSession :exec
|
||||
DELETE FROM "sessions"
|
||||
WHERE "uuid" = $1;
|
||||
|
||||
-- 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 *;
|
||||
|
||||
-- name: DeleteExpiredSessions :exec
|
||||
DELETE FROM "sessions"
|
||||
WHERE "expiry" < $1;
|
||||
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE IF NOT EXISTS "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 ''
|
||||
);
|
||||
@@ -28,3 +28,16 @@ sql:
|
||||
go_type: "string"
|
||||
- column: "oidc_codes.code_challenge"
|
||||
go_type: "string"
|
||||
- engine: "postgresql"
|
||||
queries: "sql/postgres/*_queries.sql"
|
||||
schema: "sql/postgres/*_schemas.sql"
|
||||
gen:
|
||||
go:
|
||||
package: "postgres"
|
||||
out: "internal/repository/postgres"
|
||||
rename:
|
||||
uuid: "UUID"
|
||||
oauth_groups: "OAuthGroups"
|
||||
oauth_name: "OAuthName"
|
||||
oauth_sub: "OAuthSub"
|
||||
redirect_uri: "RedirectURI"
|
||||
|
||||
Reference in New Issue
Block a user