mirror of
https://github.com/steveiliop56/tinyauth.git
synced 2026-07-04 17:20:19 +00:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ab9c0a0c5 | |||
| 4aa05aeb79 | |||
| 440a3a3ef5 | |||
| a3c4d6ac83 | |||
| c8b31c54a0 | |||
| 04b93fa107 | |||
| a6c716c4e2 | |||
| ffafb5bff5 | |||
| bb867ea5f4 | |||
| fdd516edf1 | |||
| 1b14b90ede | |||
| 6ba55b3d9c | |||
| 09ec40cb76 | |||
| 08af4557fd | |||
| 45a88ea041 | |||
| 89ffdf7e22 | |||
| c692dfe422 | |||
| ac819cc868 | |||
| 69f4206f65 | |||
| 2572376686 | |||
| ea1baaa9ac |
+21
-15
@@ -32,8 +32,6 @@ 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
|
||||
|
||||
@@ -99,6 +97,8 @@ TINYAUTH_AUTH_SESSIONMAXLIFETIME=0
|
||||
TINYAUTH_AUTH_LOGINTIMEOUT=300
|
||||
# Maximum login retries.
|
||||
TINYAUTH_AUTH_LOGINMAXRETRIES=3
|
||||
# Enable lockdown mode after maximum login retries. Lockdown mode limit is calculated automatically.
|
||||
TINYAUTH_AUTH_LOCKDOWNENABLED=true
|
||||
# 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.
|
||||
@@ -206,6 +206,8 @@ TINYAUTH_LDAP_ADDRESS=
|
||||
TINYAUTH_LDAP_BINDDN=
|
||||
# Bind password for LDAP authentication.
|
||||
TINYAUTH_LDAP_BINDPASSWORD=
|
||||
# Path to the Bind password.
|
||||
TINYAUTH_LDAP_BINDPASSWORDFILE=
|
||||
# Base DN for LDAP searches.
|
||||
TINYAUTH_LDAP_BASEDN=
|
||||
# Allow insecure LDAP connections.
|
||||
@@ -218,6 +220,23 @@ TINYAUTH_LDAP_AUTHCERT=
|
||||
TINYAUTH_LDAP_AUTHKEY=
|
||||
# Cache duration for LDAP group membership in seconds.
|
||||
TINYAUTH_LDAP_GROUPCACHETTL=900
|
||||
|
||||
# experimental config
|
||||
|
||||
# Enable Tailscale integration.
|
||||
TINYAUTH_EXPERIMENTAL_TAILSCALE_ENABLED=false
|
||||
# Tailscale state directory.
|
||||
TINYAUTH_EXPERIMENTAL_TAILSCALE_DIR="./tailscale_state"
|
||||
# Tailscale hostname.
|
||||
TINYAUTH_EXPERIMENTAL_TAILSCALE_HOSTNAME=
|
||||
# Tailscale auth key.
|
||||
TINYAUTH_EXPERIMENTAL_TAILSCALE_AUTHKEY=
|
||||
# Use ephemeral Tailscale node.
|
||||
TINYAUTH_EXPERIMENTAL_TAILSCALE_EPHEMERAL=false
|
||||
# Enable Tailscale Funnel.
|
||||
TINYAUTH_EXPERIMENTAL_TAILSCALE_FUNNEL=false
|
||||
# Listen on the Tailscale address instead of standard address.
|
||||
TINYAUTH_EXPERIMENTAL_TAILSCALE_LISTEN=false
|
||||
# Label provider to use for ACLs (auto, docker, kubernetes or none to disable). auto detects the environment.
|
||||
TINYAUTH_LABELPROVIDER="auto"
|
||||
|
||||
@@ -239,16 +258,3 @@ TINYAUTH_LOG_STREAMS_APP_LEVEL=
|
||||
TINYAUTH_LOG_STREAMS_AUDIT_ENABLED=false
|
||||
# Log level for this stream. Use global if empty.
|
||||
TINYAUTH_LOG_STREAMS_AUDIT_LEVEL=
|
||||
|
||||
# tailscale config
|
||||
|
||||
# Enable Tailscale integration.
|
||||
TINYAUTH_TAILSCALE_ENABLED=false
|
||||
# Tailscale state directory.
|
||||
TINYAUTH_TAILSCALE_DIR="./tailscale_state"
|
||||
# Tailscale hostname.
|
||||
TINYAUTH_TAILSCALE_HOSTNAME=
|
||||
# Tailscale auth key.
|
||||
TINYAUTH_TAILSCALE_AUTHKEY=
|
||||
# Use ephemeral Tailscale node.
|
||||
TINYAUTH_TAILSCALE_EPHEMERAL=false
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
package_json_file: ./frontend/package.json
|
||||
|
||||
- name: Setup go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
|
||||
with:
|
||||
go-version: "^1.26.4"
|
||||
|
||||
@@ -36,9 +36,9 @@ jobs:
|
||||
- name: Check codegen is up to date
|
||||
run: |
|
||||
sqlc generate
|
||||
go generate ./internal/repository/...
|
||||
git diff --exit-code -- internal/repository/
|
||||
git status --porcelain -- internal/repository/ | grep -q . && echo "untracked files in internal/repository/" && exit 1 || true
|
||||
go generate ./...
|
||||
git diff --exit-code
|
||||
git status --porcelain | grep -q . && echo "untracked files code gen files" && exit 1 || true
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: ./frontend
|
||||
@@ -62,6 +62,6 @@ jobs:
|
||||
run: go test -coverprofile=coverage.txt -v ./...
|
||||
|
||||
- name: Upload coverage reports to Codecov
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Delete old release
|
||||
run: gh release delete --cleanup-tag --yes nightly || echo release not found
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
REPO: ${{ github.event.repository.name }}
|
||||
|
||||
- name: Create release
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3
|
||||
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3
|
||||
with:
|
||||
prerelease: true
|
||||
tag_name: nightly
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
BUILD_TIMESTAMP: ${{ steps.metadata.outputs.BUILD_TIMESTAMP }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: nightly
|
||||
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
- generate-metadata
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: nightly
|
||||
|
||||
@@ -65,7 +65,7 @@ jobs:
|
||||
package_json_file: ./frontend/package.json
|
||||
|
||||
- name: Install go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
|
||||
with:
|
||||
go-version: "^1.26.4"
|
||||
|
||||
@@ -100,7 +100,7 @@ jobs:
|
||||
- generate-metadata
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: nightly
|
||||
|
||||
@@ -110,7 +110,7 @@ jobs:
|
||||
package_json_file: ./frontend/package.json
|
||||
|
||||
- name: Install go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
|
||||
with:
|
||||
go-version: "^1.26.4"
|
||||
|
||||
@@ -145,7 +145,7 @@ jobs:
|
||||
- generate-metadata
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: nightly
|
||||
|
||||
@@ -203,7 +203,7 @@ jobs:
|
||||
- image-build
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: nightly
|
||||
|
||||
@@ -261,7 +261,7 @@ jobs:
|
||||
- generate-metadata
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: nightly
|
||||
|
||||
@@ -319,7 +319,7 @@ jobs:
|
||||
- image-build-arm
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: nightly
|
||||
|
||||
@@ -461,7 +461,7 @@ jobs:
|
||||
merge-multiple: true
|
||||
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3
|
||||
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3
|
||||
with:
|
||||
files: binaries/*
|
||||
tag_name: nightly
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
BUILD_TIMESTAMP: ${{ steps.metadata.outputs.BUILD_TIMESTAMP }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Generate metadata
|
||||
id: metadata
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
- generate-metadata
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
package_json_file: ./frontend/package.json
|
||||
|
||||
- name: Install go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
|
||||
with:
|
||||
go-version: "^1.26.4"
|
||||
|
||||
@@ -75,7 +75,7 @@ jobs:
|
||||
- generate-metadata
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
@@ -83,7 +83,7 @@ jobs:
|
||||
package_json_file: ./frontend/package.json
|
||||
|
||||
- name: Install go
|
||||
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
|
||||
with:
|
||||
go-version: "^1.26.4"
|
||||
|
||||
@@ -117,7 +117,7 @@ jobs:
|
||||
- generate-metadata
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
@@ -173,7 +173,7 @@ jobs:
|
||||
- image-build
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
@@ -229,7 +229,7 @@ jobs:
|
||||
- generate-metadata
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
@@ -285,7 +285,7 @@ jobs:
|
||||
- image-build-arm
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
@@ -432,6 +432,6 @@ jobs:
|
||||
merge-multiple: true
|
||||
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3
|
||||
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3
|
||||
with:
|
||||
files: binaries/*
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Generate Sponsors
|
||||
uses: JamesIves/github-sponsors-readme-action@2fd9142e765f755780202122261dc85e78459405 # v1
|
||||
|
||||
@@ -51,3 +51,6 @@ config.certify.yml
|
||||
|
||||
# deepsec
|
||||
/.deepsec
|
||||
|
||||
# jetbrains
|
||||
/.idea/
|
||||
|
||||
+8
-6
@@ -1,5 +1,5 @@
|
||||
# Site builder
|
||||
FROM node:26.3-alpine3.23 AS frontend-builder
|
||||
FROM node:26.4-alpine3.23 AS frontend-builder
|
||||
|
||||
WORKDIR /frontend
|
||||
|
||||
@@ -52,15 +52,17 @@ WORKDIR /tinyauth
|
||||
|
||||
COPY --from=builder /tinyauth/tinyauth ./
|
||||
|
||||
RUN mkdir -p /data
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
# Make the data directory with a non-root user
|
||||
RUN addgroup tinyauth && adduser -DH tinyauth -G tinyauth
|
||||
RUN mkdir -p /data/resources /data/oidc /data/tailscale
|
||||
RUN chown -R tinyauth:tinyauth /data
|
||||
|
||||
VOLUME ["/data"]
|
||||
|
||||
ENV TINYAUTH_DATABASE_PATH=/data/tinyauth.db
|
||||
|
||||
ENV TINYAUTH_RESOURCES_PATH=/data/resources
|
||||
# Tell tinyauth that it's running in a container and where to find the data directory
|
||||
ENV RUNTIME_ENV=docker
|
||||
|
||||
ENV PATH=$PATH:/tinyauth
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Site builder
|
||||
FROM node:26.3-alpine3.23 AS frontend-builder
|
||||
FROM node:26.4-alpine3.23 AS frontend-builder
|
||||
|
||||
WORKDIR /frontend
|
||||
|
||||
@@ -40,13 +40,16 @@ COPY ./cmd ./cmd
|
||||
COPY ./internal ./internal
|
||||
COPY --from=frontend-builder /frontend/dist ./internal/assets/dist
|
||||
|
||||
RUN mkdir -p data
|
||||
|
||||
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
|
||||
|
||||
# Make the data directory with a non-root user
|
||||
RUN addgroup tinyauth && adduser -DH tinyauth -G tinyauth
|
||||
RUN mkdir -p /data/resources /data/oidc /data/tailscale
|
||||
RUN chown -R tinyauth:tinyauth /data
|
||||
|
||||
# Runner
|
||||
FROM gcr.io/distroless/static-debian12:latest AS runner
|
||||
|
||||
@@ -55,15 +58,14 @@ WORKDIR /tinyauth
|
||||
COPY --from=builder /tinyauth/tinyauth ./
|
||||
|
||||
# Since it's distroless, we need to copy the data directory from the builder stage
|
||||
COPY --from=builder /tinyauth/data /data
|
||||
COPY --from=builder /data /data
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
VOLUME ["/data"]
|
||||
|
||||
ENV TINYAUTH_DATABASE_PATH=/data/tinyauth.db
|
||||
|
||||
ENV TINYAUTH_RESOURCES_PATH=/data/resources
|
||||
# Tell tinyauth that it's running in a container and where to find the data directory
|
||||
ENV RUNTIME_ENV=docker
|
||||
|
||||
ENV PATH=$PATH:/tinyauth
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ PROD_COMPOSE := $(shell test -f "docker-compose.test.prod.yml" && echo "docker-c
|
||||
|
||||
.DEFAULT_GOAL := binary
|
||||
|
||||
.PHONY: deps clean-data clean-webui webui binary binary-linux-amd64 binary-linux-arm64 test vet test-race dev dev-infisical prod prod-infisical sql generate docker docker-distroless
|
||||
|
||||
# Deps
|
||||
deps:
|
||||
cd frontend && pnpm ci
|
||||
@@ -58,12 +60,10 @@ binary-linux-arm64:
|
||||
$(MAKE) binary
|
||||
|
||||
# Go test
|
||||
.PHONY: test
|
||||
test:
|
||||
go test -v ./...
|
||||
|
||||
# Go vet
|
||||
.PHONY: vet
|
||||
vet:
|
||||
go vet ./...
|
||||
|
||||
@@ -88,11 +88,17 @@ prod-infisical:
|
||||
infisical run --env=dev -- docker compose -f $(PROD_COMPOSE) up --force-recreate --pull=always --remove-orphans
|
||||
|
||||
# SQL
|
||||
.PHONY: sql
|
||||
sql:
|
||||
sqlc generate
|
||||
|
||||
# Go gen
|
||||
generate:
|
||||
go run ./gen
|
||||
go generate ./internal/repository/...
|
||||
go generate ./...
|
||||
|
||||
# Docker image
|
||||
docker:
|
||||
docker buildx build -t tinyauthapp/tinyauth:dev --build-arg=VERSION=$(TAG_NAME) --build-arg=COMMIT_HASH=$(COMMIT_HASH) --build-arg=BUILD_TIMESTAMP=$(BUILD_TIMESTAMP) -f Dockerfile .
|
||||
|
||||
# Docker image distroless
|
||||
docker-distroless:
|
||||
docker buildx build -t tinyauthapp/tinyauth:dev-distroless --build-arg=VERSION=$(TAG_NAME) --build-arg=COMMIT_HASH=$(COMMIT_HASH) --build-arg=BUILD_TIMESTAMP=$(BUILD_TIMESTAMP) -f Dockerfile.distroless .
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<div align="center">
|
||||
<img alt="Tinyauth" title="Tinyauth" width="96" src="assets/logo-rounded.png">
|
||||
<h1>Tinyauth</h1>
|
||||
<p>The tiniest authentication and authorization server you have ever seen.</p>
|
||||
<p>The tiniest OpenID Certified™ authorization and authentication server you have ever seen.</p>
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
@@ -28,6 +28,10 @@ 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.
|
||||
|
||||
As of 2026-06-25, Tinyauth v5.1.0 is OpenID Certified™ for Basic OP. You can find the certification details [here](https://openid.net/certification-old/certified-openid-providers-profiles/), test suite available [here](https://www.certification.openid.net/plan-detail.html?public=true&plan=H0qhpsOcQkxUE).
|
||||
|
||||
<img alt="OpenID Certified" width="200" src="https://openid.net/wordpress-content/uploads/2016/05/oid-l-certification-mark-l-cmyk-150dpi-90mm.jpg" />
|
||||
|
||||
## 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).
|
||||
@@ -58,11 +62,20 @@ If you like, you can help translate Tinyauth into more languages by visiting the
|
||||
|
||||
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.
|
||||
|
||||
|
||||
## Hosting Partners
|
||||
|
||||
If you use one of our partners, you can help support us while getting a great hosting deal.
|
||||
|
||||
<div>
|
||||
<a title="InstaPods" target="_blank" href="https://app.instapods.com/dashboard/pods/create?app=tinyauth&ref=tinyauth"><img src="https://instapods.com/deploy-button.svg"></a>
|
||||
</div>
|
||||
|
||||
## Sponsors
|
||||
|
||||
A big thank you to the following people for providing me with more coffee:
|
||||
|
||||
<!-- sponsors --><a href="https://github.com/erwinkramer"><img src="https://github.com/erwinkramer.png" width="64px" alt="User avatar: erwinkramer" /></a> <a href="https://github.com/nicotsx"><img src="https://github.com/nicotsx.png" width="64px" alt="User avatar: nicotsx" /></a> <a href="https://github.com/SimpleHomelab"><img src="https://github.com/SimpleHomelab.png" width="64px" alt="User avatar: SimpleHomelab" /></a> <a href="https://github.com/jmadden91"><img src="https://github.com/jmadden91.png" width="64px" alt="User avatar: jmadden91" /></a> <a href="https://github.com/tribor"><img src="https://github.com/tribor.png" width="64px" alt="User avatar: tribor" /></a> <a href="https://github.com/eliasbenb"><img src="https://github.com/eliasbenb.png" width="64px" alt="User avatar: eliasbenb" /></a> <a href="https://github.com/afunworm"><img src="https://github.com/afunworm.png" width="64px" alt="User avatar: afunworm" /></a> <a href="https://github.com/chip-well"><img src="https://github.com/chip-well.png" width="64px" alt="User avatar: chip-well" /></a> <a href="https://github.com/Lancelot-Enguerrand"><img src="https://github.com/Lancelot-Enguerrand.png" width="64px" alt="User avatar: Lancelot-Enguerrand" /></a> <a href="https://github.com/allgoewer"><img src="https://github.com/allgoewer.png" width="64px" alt="User avatar: allgoewer" /></a> <a href="https://github.com/NEANC"><img src="https://github.com/NEANC.png" width="64px" alt="User avatar: NEANC" /></a> <a href="https://github.com/ax-mad"><img src="https://github.com/ax-mad.png" width="64px" alt="User avatar: ax-mad" /></a> <a href="https://github.com/stegratech"><img src="https://github.com/stegratech.png" width="64px" alt="User avatar: stegratech" /></a> <a href="https://github.com/apearson"><img src="https://github.com/apearson.png" width="64px" alt="User avatar: apearson" /></a> <!-- sponsors -->
|
||||
<!-- sponsors --><a href="https://github.com/erwinkramer"><img src="https://github.com/erwinkramer.png" width="64px" alt="User avatar: erwinkramer" /></a> <a href="https://github.com/nicotsx"><img src="https://github.com/nicotsx.png" width="64px" alt="User avatar: nicotsx" /></a> <a href="https://github.com/SimpleHomelab"><img src="https://github.com/SimpleHomelab.png" width="64px" alt="User avatar: SimpleHomelab" /></a> <a href="https://github.com/jmadden91"><img src="https://github.com/jmadden91.png" width="64px" alt="User avatar: jmadden91" /></a> <a href="https://github.com/tribor"><img src="https://github.com/tribor.png" width="64px" alt="User avatar: tribor" /></a> <a href="https://github.com/eliasbenb"><img src="https://github.com/eliasbenb.png" width="64px" alt="User avatar: eliasbenb" /></a> <a href="https://github.com/afunworm"><img src="https://github.com/afunworm.png" width="64px" alt="User avatar: afunworm" /></a> <a href="https://github.com/chip-well"><img src="https://github.com/chip-well.png" width="64px" alt="User avatar: chip-well" /></a> <a href="https://github.com/Lancelot-Enguerrand"><img src="https://github.com/Lancelot-Enguerrand.png" width="64px" alt="User avatar: Lancelot-Enguerrand" /></a> <a href="https://github.com/allgoewer"><img src="https://github.com/allgoewer.png" width="64px" alt="User avatar: allgoewer" /></a> <a href="https://github.com/NEANC"><img src="https://github.com/NEANC.png" width="64px" alt="User avatar: NEANC" /></a> <a href="https://github.com/axjab"><img src="https://github.com/axjab.png" width="64px" alt="User avatar: axjab" /></a> <a href="https://github.com/stegratech"><img src="https://github.com/stegratech.png" width="64px" alt="User avatar: stegratech" /></a> <a href="https://github.com/apearson"><img src="https://github.com/apearson.png" width="64px" alt="User avatar: apearson" /></a> <a href="https://github.com/Micky5991"><img src="https://github.com/Micky5991.png" width="64px" alt="User avatar: Micky5991" /></a> <!-- sponsors -->
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/tinyauthapp/paerser/cli"
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
)
|
||||
|
||||
func configCmd(tconfig *model.Config, loaders []cli.ResourceLoader) *cli.Command {
|
||||
return &cli.Command{
|
||||
Name: "config",
|
||||
Description: "Dump the current configuration in YAML format, useful for debugging",
|
||||
Configuration: tconfig,
|
||||
Resources: loaders,
|
||||
Run: func(_ []string) error {
|
||||
buf := strings.Builder{}
|
||||
|
||||
fmt.Fprint(&buf, "Your current configuration in YAML is:\n\n")
|
||||
|
||||
err := renderYamlToBuf(&buf, tconfig)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to render yaml config: %w", err)
|
||||
}
|
||||
|
||||
fmt.Print(buf.String())
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,9 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils"
|
||||
"github.com/tinyauthapp/paerser/cli"
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils"
|
||||
)
|
||||
|
||||
func createOidcClientCmd() *cli.Command {
|
||||
@@ -31,40 +32,84 @@ func createOidcClientCmd() *cli.Command {
|
||||
return errors.New("client name can only contain alphanumeric characters and hyphens")
|
||||
}
|
||||
|
||||
uuid := uuid.New()
|
||||
clientId := uuid.String()
|
||||
u := uuid.New()
|
||||
clientId := u.String()
|
||||
clientSecret := "ta-" + utils.GenerateString(61)
|
||||
|
||||
uclientName := strings.ToUpper(clientName)
|
||||
lclientName := strings.ToLower(clientName)
|
||||
|
||||
builder := strings.Builder{}
|
||||
buf := strings.Builder{}
|
||||
|
||||
// header
|
||||
fmt.Fprintf(&builder, "Created credentials for client %s\n\n", clientName)
|
||||
fmt.Fprintf(&buf, "Created '%s' OIDC client.\n\n", clientName)
|
||||
|
||||
// credentials
|
||||
fmt.Fprintf(&builder, "Client Name: %s\n", clientName)
|
||||
fmt.Fprintf(&builder, "Client ID: %s\n", clientId)
|
||||
fmt.Fprintf(&builder, "Client Secret: %s\n\n", clientSecret)
|
||||
fmt.Fprintf(&buf, "Credentials:\n\n")
|
||||
fmt.Fprintf(&buf, "Client Name: %s\n", clientName)
|
||||
fmt.Fprintf(&buf, "Client ID: %s\n", clientId)
|
||||
fmt.Fprintf(&buf, "Client Secret: %s\n\n", clientSecret)
|
||||
|
||||
// env variables
|
||||
fmt.Fprint(&builder, "Environment variables:\n\n")
|
||||
fmt.Fprintf(&builder, "TINYAUTH_OIDC_CLIENTS_%s_CLIENTID=%s\n", uclientName, clientId)
|
||||
fmt.Fprintf(&builder, "TINYAUTH_OIDC_CLIENTS_%s_CLIENTSECRET=%s\n", uclientName, clientSecret)
|
||||
fmt.Fprintf(&builder, "TINYAUTH_OIDC_CLIENTS_%s_NAME=%s\n\n", uclientName, utils.Capitalize(lclientName))
|
||||
// end variables
|
||||
fmt.Fprintf(&buf, "Environment variables:\n\n")
|
||||
renderToBuf(&buf, []kv{
|
||||
{
|
||||
k: fmt.Sprintf("TINYAUTH_OIDC_CLIENTS_%s_CLIENTID", uclientName),
|
||||
v: clientId,
|
||||
},
|
||||
{
|
||||
k: fmt.Sprintf("TINYAUTH_OIDC_CLIENTS_%s_CLIENTSECRET", uclientName),
|
||||
v: clientSecret,
|
||||
},
|
||||
{
|
||||
k: fmt.Sprintf("TINYAUTH_OIDC_CLIENTS_%s_NAME", uclientName),
|
||||
v: utils.Capitalize(lclientName),
|
||||
},
|
||||
}, "=")
|
||||
fmt.Fprintf(&buf, "\n")
|
||||
|
||||
// cli flags
|
||||
fmt.Fprint(&builder, "CLI flags:\n\n")
|
||||
fmt.Fprintf(&builder, "--oidc.clients.%s.clientid=%s\n", lclientName, clientId)
|
||||
fmt.Fprintf(&builder, "--oidc.clients.%s.clientsecret=%s\n", lclientName, clientSecret)
|
||||
fmt.Fprintf(&builder, "--oidc.clients.%s.name=%s\n\n", lclientName, utils.Capitalize(lclientName))
|
||||
fmt.Fprintf(&buf, "CLI flags:\n\n")
|
||||
renderToBuf(&buf, []kv{
|
||||
{
|
||||
k: fmt.Sprintf("--oidc.clients.%s.clientid", lclientName),
|
||||
v: clientId,
|
||||
},
|
||||
{
|
||||
k: fmt.Sprintf("--oidc.clients.%s.clientsecret", lclientName),
|
||||
v: clientSecret,
|
||||
},
|
||||
{
|
||||
k: fmt.Sprintf("--oidc.clients.%s.name", lclientName),
|
||||
v: utils.Capitalize(lclientName),
|
||||
},
|
||||
}, "=")
|
||||
fmt.Fprintf(&buf, "\n")
|
||||
|
||||
// yaml config
|
||||
fmt.Fprintf(&buf, "YAML config:\n\n")
|
||||
|
||||
err = renderYamlToBuf(&buf, &model.OIDCConfig{
|
||||
Clients: map[string]model.OIDCClientConfig{
|
||||
lclientName: {
|
||||
ClientID: clientId,
|
||||
ClientSecret: clientSecret,
|
||||
Name: utils.Capitalize(lclientName),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to render yaml config: %w", err)
|
||||
}
|
||||
|
||||
buf.WriteString("\n")
|
||||
|
||||
// footer
|
||||
fmt.Fprintln(&builder, "You can use either option to configure your OIDC client. Make sure to save these credentials as there is no way to regenerate them.")
|
||||
fmt.Fprintln(&buf, "You can use any of the above options to configure your OIDC client. Make sure to save these credentials as there is no way to regenerate them.")
|
||||
|
||||
// print
|
||||
out := builder.String()
|
||||
out := buf.String()
|
||||
fmt.Print(out)
|
||||
return nil
|
||||
},
|
||||
|
||||
+100
-54
@@ -3,11 +3,12 @@ package main
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"charm.land/huh/v2"
|
||||
"github.com/tinyauthapp/paerser/cli"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
@@ -34,62 +35,107 @@ func createUserCmd() *cli.Command {
|
||||
&cli.FlagLoader{},
|
||||
}
|
||||
|
||||
return &cli.Command{
|
||||
cmd := &cli.Command{
|
||||
Name: "create",
|
||||
Description: "Create a user",
|
||||
Configuration: tCfg,
|
||||
Resources: loaders,
|
||||
Run: func(_ []string) error {
|
||||
log := logger.NewLogger().WithSimpleConfig()
|
||||
log.Init()
|
||||
|
||||
if tCfg.Interactive {
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewInput().Title("Username").Value(&tCfg.Username).Validate((func(s string) error {
|
||||
if s == "" {
|
||||
return errors.New("username cannot be empty")
|
||||
}
|
||||
return nil
|
||||
})),
|
||||
huh.NewInput().Title("Password").Value(&tCfg.Password).Validate((func(s string) error {
|
||||
if s == "" {
|
||||
return errors.New("password cannot be empty")
|
||||
}
|
||||
return nil
|
||||
})),
|
||||
huh.NewSelect[bool]().Title("Format the output for Docker?").Options(huh.NewOption("Yes", true), huh.NewOption("No", false)).Value(&tCfg.Docker),
|
||||
),
|
||||
)
|
||||
|
||||
theme := new(themeBase)
|
||||
err := form.WithTheme(theme).Run()
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to run interactive prompt: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if tCfg.Username == "" || tCfg.Password == "" {
|
||||
return errors.New("username and password cannot be empty")
|
||||
}
|
||||
|
||||
log.App.Info().Str("username", tCfg.Username).Msg("Creating user")
|
||||
|
||||
passwd, err := bcrypt.GenerateFromPassword([]byte(tCfg.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to hash password: %w", err)
|
||||
}
|
||||
|
||||
// If docker format is enabled, escape the dollar sign
|
||||
passwdStr := string(passwd)
|
||||
if tCfg.Docker {
|
||||
passwdStr = strings.ReplaceAll(passwdStr, "$", "$$")
|
||||
}
|
||||
|
||||
log.App.Info().Str("user", fmt.Sprintf("%s:%s", tCfg.Username, passwdStr)).Msg("User created")
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Run = func(_ []string) error {
|
||||
if tCfg.Interactive {
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewInput().Title("Username").Value(&tCfg.Username).Validate(func(s string) error {
|
||||
if s == "" {
|
||||
return errors.New("username cannot be empty")
|
||||
}
|
||||
if strings.Contains(s, ":") {
|
||||
return errors.New("username cannot contain ':'")
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
huh.NewInput().Title("Password").Value(&tCfg.Password).Validate(func(s string) error {
|
||||
if s == "" {
|
||||
return errors.New("password cannot be empty")
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
huh.NewSelect[bool]().Title("Format the output for Docker?").Options(huh.NewOption("Yes", true), huh.NewOption("No", false)).Value(&tCfg.Docker),
|
||||
),
|
||||
)
|
||||
|
||||
theme := new(themeBase)
|
||||
|
||||
err := form.WithTheme(theme).Run()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to run interactive prompt: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if tCfg.Username == "" || tCfg.Password == "" {
|
||||
cmd.PrintHelp(os.Stdout)
|
||||
return errors.New("username and password cannot be empty")
|
||||
}
|
||||
|
||||
if strings.Contains(tCfg.Username, ":") {
|
||||
return errors.New("username cannot contain ':'")
|
||||
}
|
||||
|
||||
passwd, err := bcrypt.GenerateFromPassword([]byte(tCfg.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to hash password: %w", err)
|
||||
}
|
||||
|
||||
// Only the docker compose output needs $ escaped, the raw hash is correct everywhere else
|
||||
passwdStr := string(passwd)
|
||||
outputStr := passwdStr
|
||||
|
||||
if tCfg.Docker {
|
||||
outputStr = strings.ReplaceAll(passwdStr, "$", "$$")
|
||||
}
|
||||
|
||||
user := fmt.Sprintf("%s:%s", tCfg.Username, passwdStr)
|
||||
escapedUser := fmt.Sprintf("%s:%s", tCfg.Username, outputStr)
|
||||
|
||||
buf := strings.Builder{}
|
||||
|
||||
// header
|
||||
fmt.Fprintf(&buf, "Created user '%s'.\n\n", tCfg.Username)
|
||||
|
||||
// environment variable
|
||||
fmt.Fprint(&buf, "Environment variable:\n\n")
|
||||
renderToBuf(&buf, []kv{
|
||||
{"TINYAUTH_AUTH_USERS", escapedUser},
|
||||
}, "=")
|
||||
|
||||
// cli flags
|
||||
fmt.Fprint(&buf, "\nCLI flags:\n\n")
|
||||
renderToBuf(&buf, []kv{
|
||||
{"--auth.users", user},
|
||||
}, "=")
|
||||
|
||||
// yaml config
|
||||
fmt.Fprint(&buf, "\nYAML config:\n\n")
|
||||
|
||||
err = renderYamlToBuf(&buf, &model.Config{
|
||||
Auth: model.AuthConfig{
|
||||
Users: []string{user},
|
||||
},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to render yaml config: %w", err)
|
||||
}
|
||||
|
||||
buf.WriteString("\n")
|
||||
|
||||
// footer
|
||||
fmt.Fprint(&buf, "Use your config option of choice to add the user to Tinyauth and then restart.")
|
||||
|
||||
fmt.Println(buf.String())
|
||||
return nil
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
|
||||
"charm.land/huh/v2"
|
||||
"github.com/mdp/qrterminal/v3"
|
||||
@@ -34,85 +33,98 @@ func generateTotpCmd() *cli.Command {
|
||||
&cli.FlagLoader{},
|
||||
}
|
||||
|
||||
return &cli.Command{
|
||||
cmd := &cli.Command{
|
||||
Name: "generate",
|
||||
Description: "Generate a TOTP secret",
|
||||
Configuration: tCfg,
|
||||
Resources: loaders,
|
||||
Run: func(_ []string) error {
|
||||
log := logger.NewLogger().WithSimpleConfig()
|
||||
log.Init()
|
||||
|
||||
if tCfg.Interactive {
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewInput().Title("Current user (username:hash)").Value(&tCfg.User).Validate((func(s string) error {
|
||||
if s == "" {
|
||||
return errors.New("user cannot be empty")
|
||||
}
|
||||
return nil
|
||||
})),
|
||||
),
|
||||
)
|
||||
|
||||
theme := new(themeBase)
|
||||
err := form.WithTheme(theme).Run()
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to run interactive prompt: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
user, err := utils.ParseUser(tCfg.User)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse user: %w", err)
|
||||
}
|
||||
|
||||
docker := false
|
||||
if strings.Contains(tCfg.User, "$$") {
|
||||
docker = true
|
||||
}
|
||||
|
||||
if user.TOTPSecret != "" {
|
||||
return fmt.Errorf("user already has a TOTP secret")
|
||||
}
|
||||
|
||||
key, err := totp.Generate(totp.GenerateOpts{
|
||||
Issuer: "Tinyauth",
|
||||
AccountName: user.Username,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate TOTP secret: %w", err)
|
||||
}
|
||||
|
||||
secret := key.Secret()
|
||||
|
||||
log.App.Info().Str("secret", secret).Msg("Generated TOTP secret")
|
||||
|
||||
log.App.Info().Msg("Generated QR code")
|
||||
|
||||
config := qrterminal.Config{
|
||||
Level: qrterminal.L,
|
||||
Writer: os.Stdout,
|
||||
BlackChar: qrterminal.BLACK,
|
||||
WhiteChar: qrterminal.WHITE,
|
||||
QuietZone: 2,
|
||||
}
|
||||
|
||||
qrterminal.GenerateWithConfig(key.URL(), config)
|
||||
|
||||
user.TOTPSecret = secret
|
||||
|
||||
// If using docker escape re-escape it
|
||||
if docker {
|
||||
user.Password = strings.ReplaceAll(user.Password, "$", "$$")
|
||||
}
|
||||
|
||||
log.App.Info().Str("user", fmt.Sprintf("%s:%s:%s", user.Username, user.Password, user.TOTPSecret)).Msg("Add the totp secret to your authenticator app then use the verify command to ensure everything is working correctly.")
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Run = func(_ []string) error {
|
||||
colors := getColors()
|
||||
|
||||
if tCfg.Interactive {
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewInput().Title("Current user (username:hash)").Value(&tCfg.User).Validate((func(s string) error {
|
||||
if s == "" {
|
||||
return errors.New("user cannot be empty")
|
||||
}
|
||||
return nil
|
||||
})),
|
||||
),
|
||||
)
|
||||
|
||||
theme := new(themeBase)
|
||||
err := form.WithTheme(theme).Run()
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to run interactive prompt: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if tCfg.User == "" {
|
||||
cmd.PrintHelp(os.Stdout)
|
||||
return fmt.Errorf("user is required")
|
||||
}
|
||||
|
||||
user, err := utils.ParseUser(tCfg.User)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse user: %w", err)
|
||||
}
|
||||
|
||||
docker := false
|
||||
if strings.Contains(tCfg.User, "$$") {
|
||||
docker = true
|
||||
}
|
||||
|
||||
if user.TOTPSecret != "" {
|
||||
return fmt.Errorf("user already has a TOTP secret")
|
||||
}
|
||||
|
||||
key, err := totp.Generate(totp.GenerateOpts{
|
||||
Issuer: "Tinyauth",
|
||||
AccountName: user.Username,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate TOTP secret: %w", err)
|
||||
}
|
||||
|
||||
secret := key.Secret()
|
||||
|
||||
fmt.Printf("Scan the following QR code with your authenticator app (e.g., Google Authenticator, 2fauth, Microsoft Authenticator):\n\n")
|
||||
|
||||
config := qrterminal.Config{
|
||||
Level: qrterminal.L,
|
||||
Writer: os.Stdout,
|
||||
BlackChar: qrterminal.BLACK,
|
||||
WhiteChar: qrterminal.WHITE,
|
||||
QuietZone: 2,
|
||||
}
|
||||
|
||||
qrterminal.GenerateWithConfig(key.URL(), config)
|
||||
|
||||
user.TOTPSecret = secret
|
||||
|
||||
// If using docker escape re-escape it
|
||||
if docker {
|
||||
user.Password = strings.ReplaceAll(user.Password, "$", "$$")
|
||||
}
|
||||
|
||||
userStr := fmt.Sprintf("%s:%s:%s", user.Username, user.Password, user.TOTPSecret)
|
||||
|
||||
fmt.Print("\nOr add the following TOTP secret to your authenticator app: ")
|
||||
fmt.Print(colors.green.Render(secret))
|
||||
fmt.Print("\n\n")
|
||||
|
||||
fmt.Printf("Finally, add your user '%s' back to your configuration: ", user.Username)
|
||||
fmt.Print(colors.green.Render(userStr))
|
||||
fmt.Print("\n")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
+147
-16
@@ -2,18 +2,23 @@ package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"charm.land/huh/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/tinyauthapp/tinyauth/internal/bootstrap"
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/loaders"
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/tinyauthapp/paerser/cli"
|
||||
)
|
||||
|
||||
func main() {
|
||||
tConfig := model.NewDefaultConfiguration()
|
||||
env := model.DetectRuntimeEnv()
|
||||
tConfig := model.NewDefaultConfiguration(env)
|
||||
|
||||
loaders := []cli.ResourceLoader{
|
||||
&loaders.FileLoader{},
|
||||
@@ -27,83 +32,114 @@ func main() {
|
||||
Configuration: tConfig,
|
||||
Resources: loaders,
|
||||
Run: func(_ []string) error {
|
||||
if !reflect.DeepEqual(model.NewDefaultConfiguration(env).Experimental, tConfig.Experimental) {
|
||||
colors := getColors()
|
||||
fmt.Println(colors.yellow.Render("⚠") + " Experimental features are enabled, use with caution. Experimental features may change with each release.")
|
||||
}
|
||||
return runCmd(*tConfig)
|
||||
},
|
||||
}
|
||||
|
||||
cmdUser := &cli.Command{
|
||||
Name: "user",
|
||||
Description: "Manage Tinyauth users",
|
||||
Description: "Manage users",
|
||||
}
|
||||
|
||||
cmdTotp := &cli.Command{
|
||||
Name: "totp",
|
||||
Description: "Manage Tinyauth TOTP users",
|
||||
Description: "Manage TOTP users",
|
||||
}
|
||||
|
||||
cmdOidc := &cli.Command{
|
||||
Name: "oidc",
|
||||
Description: "Manage Tinyauth OIDC clients",
|
||||
Description: "Manage OIDC clients",
|
||||
}
|
||||
|
||||
err := cmdTinyauth.AddCommand(versionCmd())
|
||||
helpCmd := &cli.Command{
|
||||
Name: "help",
|
||||
Description: "Show the help message",
|
||||
Run: func(_ []string) error {
|
||||
return cmdTinyauth.PrintHelp(os.Stdout)
|
||||
},
|
||||
}
|
||||
|
||||
err := cmdTinyauth.AddCommand(helpCmd)
|
||||
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("Failed to add version command")
|
||||
fatalf(err, "Failed to add help command")
|
||||
}
|
||||
|
||||
err = cmdTinyauth.AddCommand(versionCmd())
|
||||
|
||||
if err != nil {
|
||||
fatalf(err, "Failed to add version command")
|
||||
}
|
||||
|
||||
err = cmdTinyauth.AddCommand(configCmd(tConfig, loaders))
|
||||
|
||||
if err != nil {
|
||||
fatalf(err, "Failed to add config command")
|
||||
}
|
||||
|
||||
err = cmdUser.AddCommand(verifyUserCmd())
|
||||
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("Failed to add verify command")
|
||||
fatalf(err, "Failed to add user verify command")
|
||||
}
|
||||
|
||||
err = cmdTinyauth.AddCommand(healthcheckCmd())
|
||||
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("Failed to add healthcheck command")
|
||||
fatalf(err, "Failed to add healthcheck command")
|
||||
}
|
||||
|
||||
err = cmdTotp.AddCommand(generateTotpCmd())
|
||||
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("Failed to add generate command")
|
||||
fatalf(err, "Failed to add totp generate command")
|
||||
}
|
||||
|
||||
err = cmdUser.AddCommand(createUserCmd())
|
||||
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("Failed to add create command")
|
||||
fatalf(err, "Failed to add create user command")
|
||||
}
|
||||
|
||||
err = cmdOidc.AddCommand(createOidcClientCmd())
|
||||
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("Failed to add create command")
|
||||
fatalf(err, "Failed to add create oidc client command")
|
||||
}
|
||||
|
||||
err = cmdTinyauth.AddCommand(cmdUser)
|
||||
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("Failed to add user command")
|
||||
fatalf(err, "Failed to add user command")
|
||||
}
|
||||
|
||||
err = cmdTinyauth.AddCommand(cmdTotp)
|
||||
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("Failed to add totp command")
|
||||
fatalf(err, "Failed to add totp command")
|
||||
}
|
||||
|
||||
err = cmdTinyauth.AddCommand(cmdOidc)
|
||||
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("Failed to add oidc command")
|
||||
fatalf(err, "Failed to add oidc command")
|
||||
}
|
||||
|
||||
err = cli.Execute(cmdTinyauth)
|
||||
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("Failed to execute command")
|
||||
if strings.Contains(err.Error(), "command not found") {
|
||||
fmt.Println("Command not found. Use 'tinyauth help' to see available commands.")
|
||||
return
|
||||
}
|
||||
if strings.Contains(err.Error(), "is not runnable") {
|
||||
return
|
||||
}
|
||||
fatalf(err, "Failed to execute command")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,3 +160,98 @@ type themeBase struct{}
|
||||
func (t *themeBase) Theme(isDark bool) *huh.Styles {
|
||||
return huh.ThemeBase(isDark)
|
||||
}
|
||||
|
||||
type colors struct {
|
||||
blue lipgloss.Style
|
||||
gray lipgloss.Style
|
||||
lightGray lipgloss.Style
|
||||
green lipgloss.Style
|
||||
yellow lipgloss.Style
|
||||
}
|
||||
|
||||
func getColors() colors {
|
||||
noColor := os.Getenv("NO_COLOR")
|
||||
forceColor := os.Getenv("FORCE_COLOR")
|
||||
|
||||
colorOut := colors{
|
||||
green: lipgloss.NewStyle().Foreground(lipgloss.ANSIColor(34)),
|
||||
gray: lipgloss.NewStyle().Foreground(lipgloss.ANSIColor(245)),
|
||||
yellow: lipgloss.NewStyle().Foreground(lipgloss.ANSIColor(214)),
|
||||
blue: lipgloss.NewStyle().Foreground(lipgloss.ANSIColor(75)),
|
||||
lightGray: lipgloss.NewStyle().Foreground(lipgloss.ANSIColor(250)),
|
||||
}
|
||||
|
||||
noColorOut := colors{
|
||||
green: lipgloss.NewStyle(),
|
||||
gray: lipgloss.NewStyle(),
|
||||
yellow: lipgloss.NewStyle(),
|
||||
blue: lipgloss.NewStyle(),
|
||||
lightGray: lipgloss.NewStyle(),
|
||||
}
|
||||
|
||||
useColors := true
|
||||
|
||||
if noColor == "true" || noColor == "1" {
|
||||
useColors = false
|
||||
}
|
||||
|
||||
if forceColor == "true" || forceColor == "1" {
|
||||
useColors = true
|
||||
}
|
||||
|
||||
if !useColors {
|
||||
return noColorOut
|
||||
}
|
||||
|
||||
return colorOut
|
||||
}
|
||||
|
||||
func fatalf(err error, msg string) {
|
||||
fmt.Printf("%s: %v\n", msg, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
type kv struct {
|
||||
k string
|
||||
v string
|
||||
}
|
||||
|
||||
func renderToBuf(buf *strings.Builder, kv []kv, sep string) {
|
||||
colors := getColors()
|
||||
for _, i := range kv {
|
||||
buf.WriteString(colors.blue.Render(i.k))
|
||||
buf.WriteString(colors.gray.Render(sep))
|
||||
buf.WriteString(colors.lightGray.Render(i.v))
|
||||
buf.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
func renderYamlToBuf(buf *strings.Builder, i any) error {
|
||||
colors := getColors()
|
||||
|
||||
yout, err := yaml.Marshal(i)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal yaml: %w", err)
|
||||
}
|
||||
|
||||
for l := range strings.SplitSeq(string(yout), "\n") {
|
||||
if l == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(strings.TrimLeft(l, " "), "- ") {
|
||||
buf.WriteString(colors.lightGray.Render(l))
|
||||
buf.WriteString("\n")
|
||||
continue
|
||||
}
|
||||
lp := strings.SplitN(l, ":", 2)
|
||||
buf.WriteString(colors.blue.Render(lp[0]))
|
||||
buf.WriteString(colors.gray.Render(":"))
|
||||
if len(lp) == 2 {
|
||||
buf.WriteString(colors.lightGray.Render(lp[1]))
|
||||
}
|
||||
buf.WriteString("\n")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
+79
-73
@@ -3,9 +3,9 @@ package main
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
|
||||
"charm.land/huh/v2"
|
||||
"github.com/pquerna/otp/totp"
|
||||
@@ -38,81 +38,87 @@ func verifyUserCmd() *cli.Command {
|
||||
&cli.FlagLoader{},
|
||||
}
|
||||
|
||||
return &cli.Command{
|
||||
cmd := &cli.Command{
|
||||
Name: "verify",
|
||||
Description: "Verify a user is set up correctly",
|
||||
Configuration: tCfg,
|
||||
Resources: loaders,
|
||||
Run: func(_ []string) error {
|
||||
log := logger.NewLogger().WithSimpleConfig()
|
||||
log.Init()
|
||||
|
||||
if tCfg.Interactive {
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewInput().Title("User (username:hash:totp)").Value(&tCfg.User).Validate((func(s string) error {
|
||||
if s == "" {
|
||||
return errors.New("user cannot be empty")
|
||||
}
|
||||
return nil
|
||||
})),
|
||||
huh.NewInput().Title("Username").Value(&tCfg.Username).Validate((func(s string) error {
|
||||
if s == "" {
|
||||
return errors.New("username cannot be empty")
|
||||
}
|
||||
return nil
|
||||
})),
|
||||
huh.NewInput().Title("Password").Value(&tCfg.Password).Validate((func(s string) error {
|
||||
if s == "" {
|
||||
return errors.New("password cannot be empty")
|
||||
}
|
||||
return nil
|
||||
})),
|
||||
huh.NewInput().Title("TOTP Code (optional)").Value(&tCfg.Totp),
|
||||
),
|
||||
)
|
||||
|
||||
theme := new(themeBase)
|
||||
err := form.WithTheme(theme).Run()
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to run interactive prompt: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
user, err := utils.ParseUser(tCfg.User)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse user: %w", err)
|
||||
}
|
||||
|
||||
if user.Username != tCfg.Username {
|
||||
return fmt.Errorf("username is incorrect")
|
||||
}
|
||||
|
||||
err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(tCfg.Password))
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("password is incorrect: %w", err)
|
||||
}
|
||||
|
||||
if user.TOTPSecret == "" {
|
||||
if tCfg.Totp != "" {
|
||||
log.App.Warn().Msg("User does not have TOTP secret")
|
||||
}
|
||||
log.App.Info().Msg("User verified")
|
||||
return nil
|
||||
}
|
||||
|
||||
ok := totp.Validate(tCfg.Totp, user.TOTPSecret)
|
||||
|
||||
if !ok {
|
||||
return fmt.Errorf("TOTP code incorrect")
|
||||
}
|
||||
|
||||
log.App.Info().Msg("User verified")
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Run = func(_ []string) error {
|
||||
colors := getColors()
|
||||
|
||||
if tCfg.Interactive {
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewInput().Title("User (username:hash:totp)").Value(&tCfg.User).Validate((func(s string) error {
|
||||
if s == "" {
|
||||
return errors.New("user cannot be empty")
|
||||
}
|
||||
return nil
|
||||
})),
|
||||
huh.NewInput().Title("Username").Value(&tCfg.Username).Validate((func(s string) error {
|
||||
if s == "" {
|
||||
return errors.New("username cannot be empty")
|
||||
}
|
||||
return nil
|
||||
})),
|
||||
huh.NewInput().Title("Password").Value(&tCfg.Password).Validate((func(s string) error {
|
||||
if s == "" {
|
||||
return errors.New("password cannot be empty")
|
||||
}
|
||||
return nil
|
||||
})),
|
||||
huh.NewInput().Title("TOTP Code (optional)").Value(&tCfg.Totp),
|
||||
),
|
||||
)
|
||||
|
||||
theme := new(themeBase)
|
||||
err := form.WithTheme(theme).Run()
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to run interactive prompt: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if tCfg.User == "" || tCfg.Username == "" || tCfg.Password == "" {
|
||||
cmd.PrintHelp(os.Stdout)
|
||||
return fmt.Errorf("user, username, and password are required")
|
||||
}
|
||||
|
||||
user, err := utils.ParseUser(tCfg.User)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse user: %w", err)
|
||||
}
|
||||
|
||||
if user.Username != tCfg.Username {
|
||||
return fmt.Errorf("username is incorrect")
|
||||
}
|
||||
|
||||
err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(tCfg.Password))
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("password is incorrect: %w", err)
|
||||
}
|
||||
|
||||
if user.TOTPSecret == "" {
|
||||
if tCfg.Totp != "" {
|
||||
fmt.Println(colors.yellow.Render("⚠") + " TOTP code provided but user does not have TOTP enabled")
|
||||
}
|
||||
fmt.Println(colors.green.Render("✓") + " User verified")
|
||||
return nil
|
||||
}
|
||||
|
||||
ok := totp.Validate(tCfg.Totp, user.TOTPSecret)
|
||||
if !ok {
|
||||
return fmt.Errorf("TOTP code incorrect")
|
||||
}
|
||||
|
||||
fmt.Println(colors.green.Render("✓") + " User verified")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -14,9 +14,10 @@ func versionCmd() *cli.Command {
|
||||
Configuration: nil,
|
||||
Resources: nil,
|
||||
Run: func(_ []string) error {
|
||||
fmt.Printf("Version: %s\n", model.Version)
|
||||
fmt.Printf("Commit Hash: %s\n", model.CommitHash)
|
||||
fmt.Printf("Build Timestamp: %s\n", model.BuildTimestamp)
|
||||
colors := getColors()
|
||||
fmt.Printf("Version: %s\n", colors.blue.Render(model.Version))
|
||||
fmt.Printf("Commit Hash: %s\n", colors.blue.Render(model.CommitHash))
|
||||
fmt.Printf("Build Timestamp: %s\n", colors.blue.Render(model.BuildTimestamp))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -51,7 +51,6 @@
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.5.0",
|
||||
"prettier": "3.8.2",
|
||||
"rollup-plugin-visualizer": "^7.0.1",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "~6.0.2",
|
||||
|
||||
Generated
-10
@@ -120,9 +120,6 @@ importers:
|
||||
globals:
|
||||
specifier: ^17.5.0
|
||||
version: 17.6.0
|
||||
prettier:
|
||||
specifier: 3.8.2
|
||||
version: 3.8.2
|
||||
rollup-plugin-visualizer:
|
||||
specifier: ^7.0.1
|
||||
version: 7.0.1(rolldown@1.0.1)
|
||||
@@ -2148,11 +2145,6 @@ packages:
|
||||
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
prettier@3.8.2:
|
||||
resolution: {integrity: sha512-8c3mgTe0ASwWAJK+78dpviD+A8EqhndQPUBpNUIPt6+xWlIigCwfN01lWr9MAede4uqXGTEKeQWTvzb3vjia0Q==}
|
||||
engines: {node: '>=14'}
|
||||
hasBin: true
|
||||
|
||||
property-information@7.1.0:
|
||||
resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==}
|
||||
|
||||
@@ -4658,8 +4650,6 @@ snapshots:
|
||||
|
||||
prelude-ls@1.2.1: {}
|
||||
|
||||
prettier@3.8.2: {}
|
||||
|
||||
property-information@7.1.0: {}
|
||||
|
||||
proxy-from-env@2.1.0: {}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
dangerouslyAllowAllBuilds: false
|
||||
blockExoticSubdeps: true
|
||||
minimumReleaseAge: 1440 # 1 day
|
||||
trustPolicy: no-downgrade
|
||||
minimumReleaseAge: 1440 # 1 day
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { SVGProps } from "react";
|
||||
|
||||
export function LocalAuthIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="1em"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M8 7a4 4 0 1 0 8 0a4 4 0 0 0-8 0M6 21v-2a4 4 0 0 1 4-4h5m3.5 3.5L15 22l-1.5-1.5m5.054-2.086a2 2 0 1 1 2.828-2.828a2 2 0 0 1-2.828 2.828M16 19l1 1"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { Outlet } from "react-router";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { DomainWarning } from "../domain-warning/domain-warning";
|
||||
import { QuickActions } from "../quick-actions/quick-actions";
|
||||
import { isTrustedDomain } from "@/lib/hooks/redirect-uri";
|
||||
|
||||
const BaseLayout = ({ children }: { children: React.ReactNode }) => {
|
||||
const { ui } = useAppContext();
|
||||
@@ -40,11 +41,18 @@ export const Layout = () => {
|
||||
setIgnoreDomainWarning(true);
|
||||
}, [setIgnoreDomainWarning]);
|
||||
|
||||
if (
|
||||
!ignoreDomainWarning &&
|
||||
ui.warningsEnabled &&
|
||||
!app.trustedDomains.includes(currentUrl)
|
||||
) {
|
||||
const isTrusted = (() => {
|
||||
try {
|
||||
const appUrlObj = new URL(app.appUrl);
|
||||
const currentUrlObj = new URL(currentUrl);
|
||||
|
||||
return isTrustedDomain(currentUrlObj, appUrlObj, "", false);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
if (!ignoreDomainWarning && ui.warningsEnabled && !isTrusted) {
|
||||
return (
|
||||
<BaseLayout>
|
||||
<DomainWarning
|
||||
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
Palette,
|
||||
Settings,
|
||||
Sun,
|
||||
UserRoundKey,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "react-router";
|
||||
@@ -37,20 +39,26 @@ import { useMutation } from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
import { toast } from "sonner";
|
||||
import { useEffect } from "react";
|
||||
import { GoogleIcon } from "../icons/google";
|
||||
import { GithubIcon } from "../icons/github";
|
||||
import { TailscaleIcon } from "../icons/tailscale";
|
||||
import { MicrosoftIcon } from "../icons/microsoft";
|
||||
import { PocketIDIcon } from "../icons/pocket-id";
|
||||
import { OAuthIcon } from "../icons/oauth";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||
|
||||
function Avatar({ initial }: { initial: string }) {
|
||||
return (
|
||||
<span className="group relative grid size-10 place-items-center rounded-full">
|
||||
<span className="absolute inset-0 overflow-hidden rounded-full bg-linear-to-b from-neutral-50 to-neutral-100 dark:from-neutral-700 dark:to-neutral-950 shadow-lg"></span>
|
||||
<span className="relative text-sm font-semibold text-primary">
|
||||
{initial}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const iconStyles = "size-4";
|
||||
|
||||
const iconMap: Record<string, React.ReactNode> = {
|
||||
google: <GoogleIcon className={iconStyles} />,
|
||||
github: <GithubIcon className={iconStyles} />,
|
||||
tailscale: <TailscaleIcon className={iconStyles} />,
|
||||
microsoft: <MicrosoftIcon className={iconStyles} />,
|
||||
pocketid: <PocketIDIcon className={iconStyles} />,
|
||||
};
|
||||
|
||||
export const QuickActions = () => {
|
||||
const { auth } = useUserContext();
|
||||
const { auth, oauth, tailscale } = useUserContext();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const { search } = useLocation();
|
||||
@@ -64,6 +72,49 @@ export const QuickActions = () => {
|
||||
const screenParams = useScreenParams(searchParams);
|
||||
const compiledParams = recompileScreenParams(screenParams);
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const providerDetails = (():
|
||||
| { name: string; icon: React.ReactNode }
|
||||
| undefined => {
|
||||
if (!auth.authenticated) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (auth.providerId === "local" || auth.providerId === "ldap") {
|
||||
return {
|
||||
name: t(
|
||||
auth.providerId === "ldap"
|
||||
? "quickActionsProviderLDAP"
|
||||
: "quickActionsProviderLocal",
|
||||
),
|
||||
icon: (
|
||||
<UserRoundKey
|
||||
strokeWidth={1.5}
|
||||
size={16}
|
||||
className="text-muted-foreground ml-0.5"
|
||||
/>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (oauth.active) {
|
||||
return {
|
||||
name: t("quickActionsProviderOAuth", { provider: oauth.displayName }),
|
||||
icon: iconMap[auth.providerId] || <OAuthIcon className={iconStyles} />,
|
||||
};
|
||||
}
|
||||
|
||||
if (auth.providerId === "tailscale") {
|
||||
return {
|
||||
name: `Tailscale (${tailscale.nodeName})`,
|
||||
icon: <TailscaleIcon className={iconStyles} />,
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
})();
|
||||
|
||||
const logoutMutation = useMutation({
|
||||
mutationFn: () => axios.post("/api/user/logout"),
|
||||
mutationKey: ["logout"],
|
||||
@@ -107,17 +158,29 @@ export const QuickActions = () => {
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenu onOpenChange={(open) => setIsOpen(open)} open={isOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
aria-label={t("quickActionsTitle")}
|
||||
className="rounded-full transition-transform duration-200 will-change-transform hover:scale-105 hover:cursor-pointer focus:ring-0 focus:outline-3 focus:outline-ring/50"
|
||||
>
|
||||
{auth.authenticated ? (
|
||||
<Avatar initial={initial!} />
|
||||
<div className="size-10 flex justify-center items-center p-2 rounded-full bg-card border border-border">
|
||||
{isOpen ? (
|
||||
<X className="size-4 text-primary rotate-0 transition-transform duration-200 starting:rotate-45" />
|
||||
) : (
|
||||
<span className="text-sm text-primary rotate-0 transition-transform duration-200 starting:-rotate-45">
|
||||
{initial}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="bg-card text-primary border-border size-10 flex items-center justify-center rounded-full border shadow-lg">
|
||||
<Settings className="size-4" />
|
||||
<Settings
|
||||
className={`size-4 transition-transform duration-200 ${
|
||||
isOpen ? "rotate-45" : "rotate-0"
|
||||
}`}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
@@ -126,19 +189,22 @@ export const QuickActions = () => {
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
sideOffset={8}
|
||||
className="rounded-xl p-1"
|
||||
className="rounded-xl p-1 w-3xs"
|
||||
>
|
||||
{auth.authenticated && (
|
||||
<>
|
||||
<DropdownMenuLabel className="flex items-center gap-3 p-2">
|
||||
<div className="bg-foreground text-background flex size-9 shrink-0 items-center justify-center rounded-full text-sm font-medium">
|
||||
{initial}
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<Tooltip>
|
||||
<TooltipTrigger className="size-9 rounded-full p-2 bg-muted border-border border flex items-center justify-center">
|
||||
{providerDetails!.icon}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{providerDetails!.name}</TooltipContent>
|
||||
</Tooltip>
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="truncate text-sm font-medium">
|
||||
{auth.name}
|
||||
</span>
|
||||
<span className="text-muted-foreground truncate text-xs font-normal">
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{auth.email}
|
||||
</span>
|
||||
</div>
|
||||
@@ -197,7 +263,7 @@ export const QuickActions = () => {
|
||||
onSelect={() => logoutMutation.mutate()}
|
||||
className="text-destructive"
|
||||
>
|
||||
<DoorOpenIcon className="size-4" />
|
||||
<DoorOpenIcon className="size-4 text-destructive" />
|
||||
{t("quickActionsLogout")}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
|
||||
@@ -9,12 +9,27 @@ type IuseRedirectUri = {
|
||||
export const useRedirectUri = (
|
||||
redirect_uri: string | undefined,
|
||||
cookieDomain: string,
|
||||
appUrl: string,
|
||||
subdomainsEnabled: boolean,
|
||||
): IuseRedirectUri => {
|
||||
let isValid = false;
|
||||
let isTrusted = false;
|
||||
let isAllowedProto = false;
|
||||
let isHttpsDowngrade = false;
|
||||
|
||||
let appUrlObj: URL;
|
||||
|
||||
try {
|
||||
appUrlObj = new URL(appUrl);
|
||||
} catch {
|
||||
return {
|
||||
valid: isValid,
|
||||
trusted: isTrusted,
|
||||
allowedProto: isAllowedProto,
|
||||
httpsDowngrade: isHttpsDowngrade,
|
||||
};
|
||||
}
|
||||
|
||||
if (!redirect_uri) {
|
||||
return {
|
||||
valid: isValid,
|
||||
@@ -39,10 +54,7 @@ export const useRedirectUri = (
|
||||
|
||||
isValid = true;
|
||||
|
||||
if (
|
||||
url.hostname == cookieDomain ||
|
||||
url.hostname.endsWith(`.${cookieDomain}`)
|
||||
) {
|
||||
if (isTrustedDomain(url, appUrlObj, cookieDomain, subdomainsEnabled)) {
|
||||
isTrusted = true;
|
||||
}
|
||||
|
||||
@@ -62,3 +74,45 @@ export const useRedirectUri = (
|
||||
httpsDowngrade: isHttpsDowngrade,
|
||||
};
|
||||
};
|
||||
|
||||
// ported from internal/controller/oauth_controller.go
|
||||
const getEffectivePort = (url: URL): string => {
|
||||
if (url.port) {
|
||||
return url.port;
|
||||
}
|
||||
|
||||
if (url.protocol == "https:") {
|
||||
return "443";
|
||||
}
|
||||
|
||||
return "80";
|
||||
};
|
||||
|
||||
export const isTrustedDomain = (
|
||||
url: URL,
|
||||
appUrl: URL,
|
||||
cookieDomain: string,
|
||||
subdomainsEnabled: boolean,
|
||||
): boolean => {
|
||||
if (url.protocol != appUrl.protocol) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (getEffectivePort(url) != getEffectivePort(appUrl)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (url.hostname == appUrl.hostname) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!subdomainsEnabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (url.hostname.endsWith("." + cookieDomain.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -99,5 +99,8 @@
|
||||
"quickActionsThemeDark": "Dark",
|
||||
"quickActionsThemeSystem": "System",
|
||||
"quickActionsLogout": "Logout",
|
||||
"quickActionsTitle": "Quick Actions"
|
||||
"quickActionsTitle": "Quick Actions",
|
||||
"quickActionsProviderLocal": "Local",
|
||||
"quickActionsProviderLDAP": "LDAP",
|
||||
"quickActionsProviderOAuth": "{{provider}} OAuth"
|
||||
}
|
||||
|
||||
@@ -99,5 +99,8 @@
|
||||
"quickActionsThemeDark": "Dark",
|
||||
"quickActionsThemeSystem": "System",
|
||||
"quickActionsLogout": "Logout",
|
||||
"quickActionsTitle": "Quick Actions"
|
||||
"quickActionsTitle": "Quick Actions",
|
||||
"quickActionsProviderLocal": "Local",
|
||||
"quickActionsProviderLDAP": "LDAP",
|
||||
"quickActionsProviderOAuth": "{{provider}} OAuth"
|
||||
}
|
||||
|
||||
@@ -37,6 +37,8 @@ export const ContinuePage = () => {
|
||||
const { url, valid, trusted, allowedProto, httpsDowngrade } = useRedirectUri(
|
||||
redirectUri,
|
||||
app.cookieDomain,
|
||||
app.appUrl,
|
||||
app.subdomainsEnabled,
|
||||
);
|
||||
|
||||
const urlHref = url?.href;
|
||||
@@ -108,7 +110,11 @@ export const ContinuePage = () => {
|
||||
components={{
|
||||
code: <code />,
|
||||
}}
|
||||
values={{ cookieDomain: app.cookieDomain }}
|
||||
values={{
|
||||
cookieDomain: app.subdomainsEnabled
|
||||
? `.${app.cookieDomain}`
|
||||
: app.cookieDomain,
|
||||
}}
|
||||
shouldUnescape={true}
|
||||
/>
|
||||
</CardDescription>
|
||||
|
||||
@@ -137,7 +137,7 @@ function LogoutLayout({ children, logoutMutation }: LogoutLayoutProps) {
|
||||
</CardHeader>
|
||||
<CardFooter>
|
||||
<Button
|
||||
className="w-full"
|
||||
className="w-full text-destructive"
|
||||
variant="outline"
|
||||
loading={logoutMutation.isPending}
|
||||
onClick={() => logoutMutation.mutate()}
|
||||
|
||||
@@ -24,7 +24,7 @@ const uiSchema = z.object({
|
||||
const appSchema = z.object({
|
||||
appUrl: z.string(),
|
||||
cookieDomain: z.string(),
|
||||
trustedDomains: z.array(z.string()),
|
||||
subdomainsEnabled: z.boolean(),
|
||||
});
|
||||
|
||||
export const appContextSchema = z.object({
|
||||
|
||||
@@ -20,7 +20,7 @@ type EnvEntry struct {
|
||||
}
|
||||
|
||||
func generateExampleEnv() {
|
||||
cfg := model.NewDefaultConfiguration()
|
||||
cfg := model.NewDefaultConfiguration(model.RuntimeEnvUnknown)
|
||||
entries := make([]EnvEntry, 0)
|
||||
|
||||
root := reflect.TypeOf(cfg).Elem()
|
||||
@@ -21,7 +21,7 @@ type MarkdownEntry struct {
|
||||
}
|
||||
|
||||
func generateMarkdown() {
|
||||
cfg := model.NewDefaultConfiguration()
|
||||
cfg := model.NewDefaultConfiguration(model.RuntimeEnvUnknown)
|
||||
entries := make([]MarkdownEntry, 0)
|
||||
|
||||
root := reflect.TypeOf(cfg).Elem()
|
||||
@@ -1,4 +1,4 @@
|
||||
// gen/sqlc-wrapper generates store.go wrapper files for each sqlc driver package under
|
||||
// gen/sqlc_wrapper generates store.go wrapper files for each sqlc driver package under
|
||||
// internal/repository/<driver>/. Run via:
|
||||
//
|
||||
// go generate ./internal/repository/...
|
||||
@@ -32,7 +32,7 @@ import (
|
||||
var storeSrc string
|
||||
|
||||
func main() {
|
||||
fmt.Println("sqlc-wrapper: generating store.go files for sqlc driver packages...")
|
||||
fmt.Println("sqlc_wrapper: generating store.go files for sqlc driver packages...")
|
||||
if err := run(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package tinyauth
|
||||
|
||||
//go:generate go run github.com/tinyauthapp/tinyauth/gen/docs
|
||||
@@ -24,10 +24,10 @@ require (
|
||||
go.uber.org/dig v1.19.0
|
||||
golang.org/x/crypto v0.53.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
golang.org/x/tools v0.46.0
|
||||
golang.org/x/tools v0.47.0
|
||||
k8s.io/apimachinery v0.36.2
|
||||
k8s.io/client-go v0.36.2
|
||||
modernc.org/sqlite v1.52.0
|
||||
modernc.org/sqlite v1.53.0
|
||||
tailscale.com v1.100.0
|
||||
)
|
||||
|
||||
@@ -175,7 +175,7 @@ require (
|
||||
k8s.io/klog/v2 v2.140.0 // indirect
|
||||
k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect
|
||||
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect
|
||||
modernc.org/libc v1.72.3 // indirect
|
||||
modernc.org/libc v1.73.4 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
rsc.io/qr v0.2.0 // indirect
|
||||
|
||||
@@ -526,8 +526,8 @@ golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
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.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk=
|
||||
golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
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=
|
||||
@@ -571,20 +571,20 @@ k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a h1:xCeOEAOoGYl2jnJoHkC3hk
|
||||
k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0=
|
||||
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU=
|
||||
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
|
||||
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
|
||||
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
|
||||
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
|
||||
modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c=
|
||||
modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws=
|
||||
modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE=
|
||||
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=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
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/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc=
|
||||
modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
|
||||
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
|
||||
modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA=
|
||||
modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8=
|
||||
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=
|
||||
@@ -593,8 +593,8 @@ 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.52.0 h1:p4dhYh2tXZCiyaqHwRVJDjIGKWyXayiQpThxgDzJaxo=
|
||||
modernc.org/sqlite v1.52.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
|
||||
modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
|
||||
modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s=
|
||||
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=
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"syscall"
|
||||
@@ -46,18 +47,17 @@ type Services struct {
|
||||
}
|
||||
|
||||
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
|
||||
ding *ding.Ding
|
||||
listeners []Listener
|
||||
dig *dig.Container
|
||||
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
|
||||
dig *dig.Container
|
||||
}
|
||||
|
||||
func NewBootstrapApp(config model.Config) *BootstrapApp {
|
||||
@@ -98,8 +98,7 @@ func (app *BootstrapApp) Setup() error {
|
||||
return fmt.Errorf("failed to parse app url: %w", err)
|
||||
}
|
||||
|
||||
app.runtime.AppURL = appUrl.Scheme + "://" + appUrl.Host
|
||||
app.runtime.TrustedDomains = append(app.runtime.TrustedDomains, app.runtime.AppURL)
|
||||
app.runtime.AppURL = strings.ToLower(appUrl.Scheme + "://" + appUrl.Host)
|
||||
|
||||
// validate session config
|
||||
if app.config.Auth.SessionMaxLifetime != 0 && app.config.Auth.SessionMaxLifetime < app.config.Auth.SessionExpiry {
|
||||
@@ -133,6 +132,10 @@ func (app *BootstrapApp) Setup() error {
|
||||
app.runtime.OAuthProviders = app.config.OAuth.Providers
|
||||
|
||||
for id, provider := range app.runtime.OAuthProviders {
|
||||
if slices.Contains(model.ReservedProviderNames, id) {
|
||||
return fmt.Errorf("provider id %s is reserved and cannot be used", id)
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -144,15 +147,6 @@ func (app *BootstrapApp) Setup() error {
|
||||
provider.ClientSecret = secret
|
||||
provider.ClientSecretFile = ""
|
||||
|
||||
if provider.RedirectURL == "" {
|
||||
provider.RedirectURL = app.runtime.AppURL + "/api/oauth/callback/" + id
|
||||
}
|
||||
|
||||
app.runtime.OAuthProviders[id] = provider
|
||||
}
|
||||
|
||||
// set presets for built-in providers
|
||||
for id, provider := range app.runtime.OAuthProviders {
|
||||
if provider.Name == "" {
|
||||
if name, ok := model.OverrideProviders[id]; ok {
|
||||
provider.Name = name
|
||||
@@ -160,18 +154,16 @@ func (app *BootstrapApp) Setup() error {
|
||||
provider.Name = utils.Capitalize(id)
|
||||
}
|
||||
}
|
||||
|
||||
app.runtime.OAuthProviders[id] = provider
|
||||
}
|
||||
|
||||
// cookie domain
|
||||
cookieDomainResolver := utils.GetCookieDomain
|
||||
|
||||
if !app.config.Auth.SubdomainsEnabled {
|
||||
app.log.App.Warn().Msg("Subdomains are disabled, using standalone cookie domain resolver which will not work with subdomains")
|
||||
cookieDomainResolver = utils.GetStandaloneCookieDomain
|
||||
app.log.App.Warn().Msg("Subdomains are disabled, cookies will be set for the current domain only")
|
||||
}
|
||||
|
||||
cookieDomain, err := cookieDomainResolver(app.runtime.AppURL)
|
||||
cookieDomain, err := utils.GetCookieDomain(app.runtime.AppURL, app.config.Auth.SubdomainsEnabled)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get cookie domain: %w", err)
|
||||
@@ -286,9 +278,43 @@ 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())
|
||||
// if tailscale is enabled and listening, replace the app url with the tailscale hostname
|
||||
if app.services.tailscaleService != nil && app.config.Experimental.Tailscale.Listen {
|
||||
tailscaleUrl := "https://" + app.services.tailscaleService.GetHostname()
|
||||
|
||||
// if the tailscale url is different from the app url, replace it
|
||||
if tailscaleUrl != app.runtime.AppURL {
|
||||
app.log.App.Info().Msg("Listening on tailscale, replacing app url with tailscale hostname")
|
||||
|
||||
app.runtime.AppURL = tailscaleUrl
|
||||
|
||||
// also update cookie domain
|
||||
cookieDomain, err := utils.GetCookieDomain(tailscaleUrl, app.config.Auth.SubdomainsEnabled)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get cookie domain: %w", err)
|
||||
}
|
||||
|
||||
app.runtime.CookieDomain = cookieDomain
|
||||
}
|
||||
}
|
||||
|
||||
// force an update of the redirect urls for all oauth providers, if they are empty
|
||||
services := app.services.oauthBrokerService.GetConfiguredServices()
|
||||
|
||||
for _, service := range services {
|
||||
oauthService, ok := app.services.oauthBrokerService.GetService(service)
|
||||
|
||||
if !ok {
|
||||
return fmt.Errorf("failed to get oauth service for provider %s", service)
|
||||
}
|
||||
|
||||
providerConfig := oauthService.GetConfig()
|
||||
|
||||
if providerConfig.RedirectURL == "" {
|
||||
providerConfig.RedirectURL = app.runtime.AppURL + "/api/oauth/callback/" + service
|
||||
oauthService.UpdateConfig(providerConfig)
|
||||
}
|
||||
}
|
||||
|
||||
// setup router
|
||||
@@ -308,20 +334,20 @@ func (app *BootstrapApp) Setup() error {
|
||||
app.ding.Go(app.heartbeatRoutine, ding.RingMinor)
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
|
||||
// run listeners
|
||||
lec, err := app.runListeners()
|
||||
// get listener
|
||||
listenerFunc, err := app.getListenerFunc()
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to run listeners: %w", err)
|
||||
return fmt.Errorf("failed to get listener function: %w", err)
|
||||
}
|
||||
|
||||
// run listener
|
||||
lec := make(chan error, 1)
|
||||
|
||||
app.ding.Go(func(ctx context.Context) {
|
||||
lec <- listenerFunc(ctx)
|
||||
}, ding.RingNormal)
|
||||
|
||||
// monitor cancellation and server errors
|
||||
for {
|
||||
select {
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/steveiliop56/ding"
|
||||
"github.com/tinyauthapp/tinyauth/internal/controller"
|
||||
"github.com/tinyauthapp/tinyauth/internal/middleware"
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
@@ -18,14 +17,6 @@ import (
|
||||
"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)
|
||||
@@ -134,79 +125,29 @@ func (app *BootstrapApp) setupRouter() error {
|
||||
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)
|
||||
// Top down
|
||||
// 1. Tailscale (if tailscale.listen)
|
||||
// 2. Unix socket (if server.socketPath)
|
||||
// 3. HTTP - default
|
||||
func (app *BootstrapApp) getListenerFunc() (func(ctx context.Context) error, error) {
|
||||
if app.config.Experimental.Tailscale.Listen {
|
||||
if app.services.tailscaleService == nil {
|
||||
return nil, fmt.Errorf("experimental.tailscale.listen is enabled but tailscale service is not initialized")
|
||||
}
|
||||
|
||||
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
|
||||
return app.serveTailscale, nil
|
||||
}
|
||||
|
||||
if app.config.Server.SocketPath != "" {
|
||||
l = append(l, ListenerUnix)
|
||||
}
|
||||
|
||||
if app.services.tailscaleService != nil {
|
||||
l = append(l, ListenerTailscale)
|
||||
}
|
||||
|
||||
l = append(l, ListenerHTTP)
|
||||
|
||||
return l
|
||||
}
|
||||
|
||||
func (app *BootstrapApp) listenerFromType(listenerType Listener) (func(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)
|
||||
}
|
||||
|
||||
return app.serveHTTP, nil
|
||||
}
|
||||
|
||||
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)
|
||||
app.log.App.Info().Msgf("Starting server on http://%s", address)
|
||||
|
||||
listener, err := net.Listen("tcp", address)
|
||||
|
||||
@@ -286,7 +227,6 @@ func (app *BootstrapApp) serve(listener net.Listener, server *http.Server, ctx c
|
||||
err := server.Serve(listener)
|
||||
|
||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
shutdown()
|
||||
return fmt.Errorf("failed to start %s listener: %w", name, err)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
"go.uber.org/dig"
|
||||
@@ -58,9 +60,9 @@ type ACRUI struct {
|
||||
}
|
||||
|
||||
type ACRApp struct {
|
||||
AppURL string `json:"appUrl"`
|
||||
CookieDomain string `json:"cookieDomain"`
|
||||
TrustedDomains []string `json:"trustedDomains"`
|
||||
AppURL string `json:"appUrl"`
|
||||
CookieDomain string `json:"cookieDomain"`
|
||||
SubdomainsEnabled bool `json:"subdomainsEnabled"`
|
||||
}
|
||||
|
||||
type AppContextResponse struct {
|
||||
@@ -109,7 +111,9 @@ func (controller *ContextController) userContextHandler(c *gin.Context) {
|
||||
context, err := new(model.UserContext).NewFromGin(c)
|
||||
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to create user context from request")
|
||||
if !errors.Is(err, model.ErrUserContextNotFound) {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to create user context from request")
|
||||
}
|
||||
c.JSON(200, UserContextResponse{
|
||||
Status: 401,
|
||||
Message: "Unauthorized",
|
||||
@@ -160,9 +164,9 @@ func (controller *ContextController) appContextHandler(c *gin.Context) {
|
||||
WarningsEnabled: controller.config.UI.WarningsEnabled,
|
||||
},
|
||||
App: ACRApp{
|
||||
AppURL: controller.runtime.AppURL,
|
||||
CookieDomain: controller.runtime.CookieDomain,
|
||||
TrustedDomains: controller.runtime.TrustedDomains,
|
||||
AppURL: controller.runtime.AppURL,
|
||||
CookieDomain: controller.runtime.CookieDomain,
|
||||
SubdomainsEnabled: controller.config.Auth.SubdomainsEnabled,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -48,9 +48,9 @@ func TestContextController(t *testing.T) {
|
||||
WarningsEnabled: cfg.UI.WarningsEnabled,
|
||||
},
|
||||
App: ACRApp{
|
||||
AppURL: runtime.AppURL,
|
||||
CookieDomain: runtime.CookieDomain,
|
||||
TrustedDomains: runtime.TrustedDomains,
|
||||
AppURL: runtime.AppURL,
|
||||
CookieDomain: runtime.CookieDomain,
|
||||
SubdomainsEnabled: cfg.Auth.SubdomainsEnabled,
|
||||
},
|
||||
}
|
||||
bytes, err := json.Marshal(expectedAppContextResponse)
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"github.com/tinyauthapp/tinyauth/internal/service"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
"github.com/weppos/publicsuffix-go/publicsuffix"
|
||||
"go.uber.org/dig"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -305,8 +304,8 @@ func (controller *OAuthController) isOidcRequest(params service.OAuthCallbackPar
|
||||
}
|
||||
|
||||
func (controller *OAuthController) getCookieDomain() string {
|
||||
if controller.config.Auth.SubdomainsEnabled {
|
||||
return "." + controller.runtime.CookieDomain
|
||||
if !controller.config.Auth.SubdomainsEnabled {
|
||||
return ""
|
||||
}
|
||||
return controller.runtime.CookieDomain
|
||||
}
|
||||
@@ -314,51 +313,53 @@ func (controller *OAuthController) getCookieDomain() string {
|
||||
func (controller *OAuthController) isRedirectSafe(redirectURI string) bool {
|
||||
u, err := url.Parse(redirectURI)
|
||||
|
||||
if err != nil || u.Host == "" || u.Scheme == "" {
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to parse redirect URI")
|
||||
return false
|
||||
}
|
||||
|
||||
for _, allowed := range controller.runtime.TrustedDomains {
|
||||
tu, err := url.Parse(allowed)
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Str("allowed", allowed).Msg("Failed to parse trusted domain")
|
||||
continue
|
||||
if u.Scheme == "" || u.Host == "" {
|
||||
controller.log.App.Warn().Msg("Redirect URI has invalid scheme or host")
|
||||
return false
|
||||
}
|
||||
|
||||
au, err := url.Parse(controller.runtime.AppURL)
|
||||
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to parse app URL")
|
||||
return false
|
||||
}
|
||||
|
||||
if u.Scheme != au.Scheme {
|
||||
controller.log.App.Warn().Msg("Redirect URI scheme does not match app URL scheme")
|
||||
return false
|
||||
}
|
||||
|
||||
getEffectivePort := func(u *url.URL) string {
|
||||
if u.Port() != "" {
|
||||
return u.Port()
|
||||
}
|
||||
|
||||
if tu.Scheme != u.Scheme {
|
||||
continue
|
||||
if u.Scheme == "https" {
|
||||
return "443"
|
||||
}
|
||||
return "80"
|
||||
}
|
||||
|
||||
// exact match
|
||||
if strings.EqualFold(u.Host, tu.Host) {
|
||||
return true
|
||||
}
|
||||
if getEffectivePort(u) != getEffectivePort(au) {
|
||||
controller.log.App.Warn().Msg("Redirect URI port does not match app URL port")
|
||||
return false
|
||||
}
|
||||
|
||||
// if subdomains are disabled, end here
|
||||
if !controller.config.Auth.SubdomainsEnabled {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(u.Hostname(), au.Hostname()) {
|
||||
return true
|
||||
}
|
||||
|
||||
// get the root domain (e.g. tinyauth.example.com -> example.com or
|
||||
// tinyauth.sub.example.com -> sub.example.com)
|
||||
_, root, ok := strings.Cut(tu.Host, ".")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if !controller.config.Auth.SubdomainsEnabled {
|
||||
return false
|
||||
}
|
||||
|
||||
root = strings.ToLower(root)
|
||||
|
||||
// check if the root domain is in the psl
|
||||
_, err = publicsuffix.DomainFromListWithOptions(publicsuffix.DefaultList, root, nil)
|
||||
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// subdomain match
|
||||
if strings.HasSuffix(strings.ToLower(u.Host), "."+root) {
|
||||
return true
|
||||
}
|
||||
if strings.HasSuffix(strings.ToLower(u.Hostname()), "."+strings.ToLower(controller.runtime.CookieDomain)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
)
|
||||
|
||||
func TestOAuthController(t *testing.T) {
|
||||
func TestOAuthControllerIsRedirectSafe(t *testing.T) {
|
||||
log := logger.NewLogger().WithTestConfig()
|
||||
log.Init()
|
||||
|
||||
@@ -17,145 +17,171 @@ func TestOAuthController(t *testing.T) {
|
||||
|
||||
type testCase struct {
|
||||
description string
|
||||
run func(ctrl *OAuthController)
|
||||
trustedDomains []string
|
||||
appURL string
|
||||
cookieDomain string
|
||||
subdomainsEnabled bool
|
||||
redirectURI string
|
||||
expected bool
|
||||
}
|
||||
|
||||
tests := []testCase{
|
||||
{
|
||||
description: "Test exact match of redirect URI",
|
||||
trustedDomains: []string{"https://tinyauth.example.com"},
|
||||
description: "Exact host match returns true",
|
||||
appURL: "https://tinyauth.example.com",
|
||||
cookieDomain: "example.com",
|
||||
subdomainsEnabled: true,
|
||||
run: func(ctrl *OAuthController) {
|
||||
redirectUri := "https://tinyauth.example.com"
|
||||
assert.True(t, ctrl.isRedirectSafe(redirectUri))
|
||||
},
|
||||
redirectURI: "https://tinyauth.example.com",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
description: "Test subdomain match of redirect URI",
|
||||
trustedDomains: []string{"https://tinyauth.example.com"},
|
||||
description: "Exact host match is case insensitive",
|
||||
appURL: "https://tinyauth.example.com",
|
||||
cookieDomain: "example.com",
|
||||
subdomainsEnabled: true,
|
||||
run: func(ctrl *OAuthController) {
|
||||
redirectUri := "https://sub.example.com"
|
||||
assert.True(t, ctrl.isRedirectSafe(redirectUri))
|
||||
},
|
||||
redirectURI: "https://TinyAuth.Example.com",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
description: "Test different trusted domain",
|
||||
trustedDomains: []string{"https://tinyauth.example.com", "https://tinyauth.foo.com"},
|
||||
subdomainsEnabled: true,
|
||||
run: func(ctrl *OAuthController) {
|
||||
redirectUri := "https://app.foo.com"
|
||||
assert.True(t, ctrl.isRedirectSafe(redirectUri))
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Test invalid redirect URI",
|
||||
run: func(ctrl *OAuthController) {
|
||||
redirectUri := "https:/malicious"
|
||||
assert.False(t, ctrl.isRedirectSafe(redirectUri))
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Test empty redirect URI",
|
||||
run: func(ctrl *OAuthController) {
|
||||
redirectUri := ""
|
||||
assert.False(t, ctrl.isRedirectSafe(redirectUri))
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Test redirect URI with different scheme",
|
||||
trustedDomains: []string{"https://tinyauth.example.com"},
|
||||
subdomainsEnabled: true,
|
||||
run: func(ctrl *OAuthController) {
|
||||
redirectUri := "http://tinyauth.example.com"
|
||||
assert.False(t, ctrl.isRedirectSafe(redirectUri))
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Test redirect URI with different port",
|
||||
trustedDomains: []string{"https://tinyauth.example.com"},
|
||||
subdomainsEnabled: true,
|
||||
run: func(ctrl *OAuthController) {
|
||||
redirectUri := "https://tinyauth.example.com:8080"
|
||||
assert.False(t, ctrl.isRedirectSafe(redirectUri))
|
||||
},
|
||||
},
|
||||
{
|
||||
// weird case, subdomains enabled and domain without subdomain can't happen
|
||||
description: "Test with trusted domain that's in PSL when split",
|
||||
trustedDomains: []string{"https://example.com"}, // will become .com which we
|
||||
// obviously don't want to allow
|
||||
subdomainsEnabled: true,
|
||||
run: func(ctrl *OAuthController) {
|
||||
redirectUri := "https://sub.example.com"
|
||||
assert.False(t, ctrl.isRedirectSafe(redirectUri))
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Test subdomain redirect URI when subdomains are disabled",
|
||||
trustedDomains: []string{"https://tinyauth.example.com"},
|
||||
description: "Exact host match with subdomains disabled returns true",
|
||||
appURL: "https://tinyauth.example.com",
|
||||
cookieDomain: "example.com",
|
||||
subdomainsEnabled: false,
|
||||
run: func(ctrl *OAuthController) {
|
||||
redirectUri := "https://sub.tinyauth.example.com"
|
||||
assert.False(t, ctrl.isRedirectSafe(redirectUri))
|
||||
},
|
||||
redirectURI: "https://tinyauth.example.com",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
description: "Test domain like the .co.uk",
|
||||
trustedDomains: []string{"https://example.co.uk"},
|
||||
description: "Subdomain of cookie domain returns true when subdomains enabled",
|
||||
appURL: "https://tinyauth.example.com",
|
||||
cookieDomain: "example.com",
|
||||
subdomainsEnabled: true,
|
||||
run: func(ctrl *OAuthController) {
|
||||
redirectUri := "https://sub.example.co.uk"
|
||||
assert.False(t, ctrl.isRedirectSafe(redirectUri))
|
||||
},
|
||||
redirectURI: "https://sub.example.com",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
description: "Test domain like the .co.uk with subdomains disabled",
|
||||
trustedDomains: []string{"https://example.co.uk"},
|
||||
description: "Subdomain of cookie domain is case insensitive",
|
||||
appURL: "https://tinyauth.example.com",
|
||||
cookieDomain: "Example.COM",
|
||||
subdomainsEnabled: true,
|
||||
redirectURI: "https://SUB.example.com",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
description: "Subdomain not matching cookie domain returns false",
|
||||
appURL: "https://tinyauth.example.com",
|
||||
cookieDomain: "example.com",
|
||||
subdomainsEnabled: true,
|
||||
redirectURI: "https://sub.evil.com",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
description: "Subdomain returns false when subdomains disabled",
|
||||
appURL: "https://tinyauth.example.com",
|
||||
cookieDomain: "example.com",
|
||||
subdomainsEnabled: false,
|
||||
run: func(ctrl *OAuthController) {
|
||||
redirectUri := "https://example.co.uk"
|
||||
assert.True(t, ctrl.isRedirectSafe(redirectUri))
|
||||
},
|
||||
redirectURI: "https://sub.example.com",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
description: "Test caps domain",
|
||||
trustedDomains: []string{"https://TINYAUTH.ExAmpLe.com"},
|
||||
description: "Cookie domain itself is not a subdomain match",
|
||||
appURL: "https://tinyauth.example.com",
|
||||
cookieDomain: "example.com",
|
||||
subdomainsEnabled: true,
|
||||
run: func(ctrl *OAuthController) {
|
||||
redirectUri := "https://sUb.ExAmPle.com"
|
||||
assert.True(t, ctrl.isRedirectSafe(redirectUri))
|
||||
},
|
||||
redirectURI: "https://example.com",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
description: "Test edge case with @",
|
||||
trustedDomains: []string{"https://tinyauth.example.com"},
|
||||
description: "Different scheme returns false",
|
||||
appURL: "https://tinyauth.example.com",
|
||||
cookieDomain: "example.com",
|
||||
subdomainsEnabled: true,
|
||||
run: func(ctrl *OAuthController) {
|
||||
redirectUri := "https://malicious.example.com@evil.com"
|
||||
assert.False(t, ctrl.isRedirectSafe(redirectUri))
|
||||
},
|
||||
redirectURI: "http://tinyauth.example.com",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
description: "Different port returns false",
|
||||
appURL: "https://tinyauth.example.com",
|
||||
cookieDomain: "example.com",
|
||||
subdomainsEnabled: true,
|
||||
redirectURI: "https://tinyauth.example.com:8080",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
description: "Empty redirect URI returns false",
|
||||
appURL: "https://tinyauth.example.com",
|
||||
cookieDomain: "example.com",
|
||||
subdomainsEnabled: true,
|
||||
redirectURI: "",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
description: "Redirect URI without host returns false",
|
||||
appURL: "https://tinyauth.example.com",
|
||||
cookieDomain: "example.com",
|
||||
subdomainsEnabled: true,
|
||||
redirectURI: "https:/malicious",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
description: "Redirect URI without scheme returns false",
|
||||
appURL: "https://tinyauth.example.com",
|
||||
cookieDomain: "example.com",
|
||||
subdomainsEnabled: true,
|
||||
redirectURI: "tinyauth.example.com",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
description: "Relative redirect URI returns false",
|
||||
appURL: "https://tinyauth.example.com",
|
||||
cookieDomain: "example.com",
|
||||
subdomainsEnabled: true,
|
||||
redirectURI: "/some/path",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
description: "Userinfo trick with malicious host returns false",
|
||||
appURL: "https://tinyauth.example.com",
|
||||
cookieDomain: "example.com",
|
||||
subdomainsEnabled: true,
|
||||
redirectURI: "https://malicious.example.com@evil.com",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
description: "Unparseable redirect URI returns false",
|
||||
appURL: "https://tinyauth.example.com",
|
||||
cookieDomain: "example.com",
|
||||
subdomainsEnabled: true,
|
||||
redirectURI: "https://exa\x7fmple.com",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
description: "Unparseable app URL returns false",
|
||||
appURL: "https://tinyauth.\x7fexample.com",
|
||||
cookieDomain: "example.com",
|
||||
subdomainsEnabled: true,
|
||||
redirectURI: "https://tinyauth.example.com",
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
// TODO: add auth service
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.description, func(t *testing.T) {
|
||||
router := gin.Default()
|
||||
group := router.Group("/api")
|
||||
gin.SetMode(gin.TestMode)
|
||||
// overwrite the trusted domains and subdomain setting for each test case
|
||||
runtime.TrustedDomains = tc.trustedDomains
|
||||
|
||||
// Overwrite the app URL, cookie domain and subdomain setting for each test case
|
||||
runtime.AppURL = tc.appURL
|
||||
runtime.CookieDomain = tc.cookieDomain
|
||||
cfg.Auth.SubdomainsEnabled = tc.subdomainsEnabled
|
||||
|
||||
ctrl := NewOAuthController(OAuthControllerInput{
|
||||
Log: log,
|
||||
Config: &cfg,
|
||||
RuntimeConfig: &runtime,
|
||||
RouterGroup: group,
|
||||
})
|
||||
tc.run(ctrl)
|
||||
|
||||
assert.Equal(t, tc.expected, ctrl.isRedirectSafe(tc.redirectURI))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,6 +295,14 @@ func (controller *UserController) totpHandler(c *gin.Context) {
|
||||
context, err := new(model.UserContext).NewFromGin(c)
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrUserContextNotFound) {
|
||||
controller.log.App.Warn().Msg("TOTP verification attempt without user context")
|
||||
c.JSON(401, gin.H{
|
||||
"status": 401,
|
||||
"message": "Unauthorized",
|
||||
})
|
||||
return
|
||||
}
|
||||
controller.log.App.Error().Err(err).Msg("Failed to create user context from request for TOTP verification")
|
||||
c.JSON(500, gin.H{
|
||||
"status": 500,
|
||||
@@ -405,6 +413,14 @@ func (controller *UserController) tailscaleHandler(c *gin.Context) {
|
||||
context, err := new(model.UserContext).NewFromGin(c)
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrUserContextNotFound) {
|
||||
controller.log.App.Warn().Msg("Tailscale login attempt without user context")
|
||||
c.JSON(401, gin.H{
|
||||
"status": 401,
|
||||
"message": "Unauthorized",
|
||||
})
|
||||
return
|
||||
}
|
||||
controller.log.App.Error().Err(err).Msg("Failed to create user context from request")
|
||||
c.JSON(401, gin.H{
|
||||
"status": 401,
|
||||
|
||||
@@ -74,7 +74,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, c.RemoteIP())
|
||||
userContext, cookie, err := m.cookieAuth(c.Request.Context(), uuid, c.ClientIP())
|
||||
|
||||
if err == nil {
|
||||
if cookie != nil {
|
||||
@@ -112,10 +112,10 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc {
|
||||
|
||||
// Lastly check if we have a tailscale session to add
|
||||
if m.tailscale != nil {
|
||||
tailscaleContext, err := m.tailscaleWhois(c.Request.Context(), c.RemoteIP())
|
||||
tailscaleContext, err := m.tailscaleWhois(c.Request.Context(), c.ClientIP())
|
||||
|
||||
if err != nil {
|
||||
m.log.App.Error().Err(err).Msgf("Error performing tailscale whois for IP %s: %v", c.RemoteIP(), err)
|
||||
m.log.App.Error().Err(err).Msgf("Error performing tailscale whois for IP %s: %v", c.ClientIP(), err)
|
||||
}
|
||||
|
||||
if tailscaleContext != nil {
|
||||
|
||||
+178
-146
@@ -1,8 +1,27 @@
|
||||
package model
|
||||
|
||||
import "os"
|
||||
|
||||
type RuntimeEnv int
|
||||
|
||||
const (
|
||||
RuntimeEnvUnknown RuntimeEnv = iota
|
||||
RuntimeEnvDocker
|
||||
)
|
||||
|
||||
func DetectRuntimeEnv() RuntimeEnv {
|
||||
env := os.Getenv("RUNTIME_ENV")
|
||||
switch env {
|
||||
case "docker":
|
||||
return RuntimeEnvDocker
|
||||
default:
|
||||
return RuntimeEnvUnknown
|
||||
}
|
||||
}
|
||||
|
||||
// Default configuration
|
||||
func NewDefaultConfiguration() *Config {
|
||||
return &Config{
|
||||
func NewDefaultConfiguration(runtimeEnv RuntimeEnv) *Config {
|
||||
cfg := &Config{
|
||||
Database: DatabaseConfig{
|
||||
Driver: "sqlite",
|
||||
Path: "./tinyauth.db",
|
||||
@@ -15,9 +34,8 @@ func NewDefaultConfiguration() *Config {
|
||||
Path: "./resources",
|
||||
},
|
||||
Server: ServerConfig{
|
||||
Port: 3000,
|
||||
Address: "0.0.0.0",
|
||||
ConcurrentListenersEnabled: false,
|
||||
Port: 3000,
|
||||
Address: "0.0.0.0",
|
||||
},
|
||||
Auth: AuthConfig{
|
||||
SubdomainsEnabled: true,
|
||||
@@ -63,245 +81,259 @@ func NewDefaultConfiguration() *Config {
|
||||
PrivateKeyPath: "./tinyauth_oidc_key",
|
||||
PublicKeyPath: "./tinyauth_oidc_key.pub",
|
||||
},
|
||||
Tailscale: TailscaleConfig{
|
||||
Dir: "./tailscale_state",
|
||||
Experimental: ExperimentalConfig{
|
||||
Tailscale: TailscaleConfig{
|
||||
Dir: "./tailscale_state",
|
||||
},
|
||||
},
|
||||
LabelProvider: "auto",
|
||||
}
|
||||
|
||||
// apply path overrides for docker runtime
|
||||
if runtimeEnv == RuntimeEnvDocker {
|
||||
cfg.Database.Path = "/data/tinyauth.db"
|
||||
cfg.Resources.Path = "/data/resources"
|
||||
cfg.OIDC.PrivateKeyPath = "/data/oidc/key.pem"
|
||||
cfg.OIDC.PublicKeyPath = "/data/oidc/key.pub"
|
||||
cfg.Experimental.Tailscale.Dir = "/data/tailscale"
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
AppURL string `description:"The base URL where the app is hosted." yaml:"appUrl"`
|
||||
Database DatabaseConfig `description:"Database configuration." yaml:"database"`
|
||||
Analytics AnalyticsConfig `description:"Analytics configuration." yaml:"analytics"`
|
||||
Resources ResourcesConfig `description:"Resources configuration." yaml:"resources"`
|
||||
Server ServerConfig `description:"Server configuration." yaml:"server"`
|
||||
Auth AuthConfig `description:"Authentication configuration." yaml:"auth"`
|
||||
Apps map[string]App `description:"Application ACLs configuration." yaml:"apps"`
|
||||
OAuth OAuthConfig `description:"OAuth configuration." yaml:"oauth"`
|
||||
OIDC OIDCConfig `description:"OIDC configuration." yaml:"oidc"`
|
||||
UI UIConfig `description:"UI customization." yaml:"ui"`
|
||||
LDAP LDAPConfig `description:"LDAP configuration." yaml:"ldap"`
|
||||
Experimental ExperimentalConfig `description:"Experimental features, use with caution." yaml:"experimental"`
|
||||
LabelProvider string `description:"Label provider to use for ACLs (auto, docker, 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"`
|
||||
AppURL string `description:"The base URL where the app is hosted." yaml:"appUrl,omitempty"`
|
||||
Database DatabaseConfig `description:"Database configuration." yaml:"database,omitempty"`
|
||||
Analytics AnalyticsConfig `description:"Analytics configuration." yaml:"analytics,omitempty"`
|
||||
Resources ResourcesConfig `description:"Resources configuration." yaml:"resources,omitempty"`
|
||||
Server ServerConfig `description:"Server configuration." yaml:"server,omitempty"`
|
||||
Auth AuthConfig `description:"Authentication configuration." yaml:"auth,omitempty"`
|
||||
Apps map[string]App `description:"Application ACLs configuration." yaml:"apps,omitempty"`
|
||||
OAuth OAuthConfig `description:"OAuth configuration." yaml:"oauth,omitempty"`
|
||||
OIDC OIDCConfig `description:"OIDC configuration." yaml:"oidc,omitempty"`
|
||||
UI UIConfig `description:"UI customization." yaml:"ui,omitempty"`
|
||||
LDAP LDAPConfig `description:"LDAP configuration." yaml:"ldap,omitempty"`
|
||||
Experimental ExperimentalConfig `description:"Experimental features, use with caution." yaml:"experimental,omitempty"`
|
||||
LabelProvider string `description:"Label provider to use for ACLs (auto, docker, kubernetes or none to disable). auto detects the environment." yaml:"labelProvider,omitempty"`
|
||||
Log LogConfig `description:"Logging configuration." yaml:"log,omitempty"`
|
||||
ConfigFile string `description:"Path to config file." yaml:"-"`
|
||||
}
|
||||
|
||||
type DatabaseConfig struct {
|
||||
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"`
|
||||
Driver string `description:"The database driver to use. Valid values: sqlite, postgres, memory." yaml:"driver,omitempty"`
|
||||
Path string `description:"The path to the SQLite database file, or connection URL when driver is postgres." yaml:"path,omitempty"`
|
||||
}
|
||||
|
||||
type AnalyticsConfig struct {
|
||||
Enabled bool `description:"Enable periodic version information collection." yaml:"enabled"`
|
||||
Enabled bool `description:"Enable periodic version information collection." yaml:"enabled,omitempty"`
|
||||
}
|
||||
|
||||
type ResourcesConfig struct {
|
||||
Enabled bool `description:"Enable the resources server." yaml:"enabled"`
|
||||
Path string `description:"The directory where resources are stored." yaml:"path"`
|
||||
Enabled bool `description:"Enable the resources server." yaml:"enabled,omitempty"`
|
||||
Path string `description:"The directory where resources are stored." yaml:"path,omitempty"`
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Port int `description:"The port on which the server listens." yaml:"port"`
|
||||
Address string `description:"The address on which the server listens." yaml:"address"`
|
||||
SocketPath string `description:"The path to the Unix socket." yaml:"socketPath"`
|
||||
ConcurrentListenersEnabled bool `description:"Enable listening on both TCP and Unix socket at the same time." yaml:"concurrentListenersEnabled"`
|
||||
Port int `description:"The port on which the server listens." yaml:"port,omitempty"`
|
||||
Address string `description:"The address on which the server listens." yaml:"address,omitempty"`
|
||||
SocketPath string `description:"The path to the Unix socket." yaml:"socketPath,omitempty"`
|
||||
}
|
||||
|
||||
type AuthConfig struct {
|
||||
IP IPConfig `description:"IP whitelisting config options." yaml:"ip"`
|
||||
Users []string `description:"Comma-separated list of users (username:hashed_password)." yaml:"users"`
|
||||
SubdomainsEnabled bool `description:"Enable subdomains support." yaml:"subdomainsEnabled"`
|
||||
UserAttributes map[string]UserAttributes `description:"Map of per-user OIDC attributes (username -> attributes)." yaml:"userAttributes"`
|
||||
UsersFile string `description:"Path to the users file." yaml:"usersFile"`
|
||||
SecureCookie bool `description:"Enable secure cookies." yaml:"secureCookie"`
|
||||
SessionExpiry int `description:"Session expiry time in seconds." yaml:"sessionExpiry"`
|
||||
SessionMaxLifetime int `description:"Maximum session lifetime in seconds." yaml:"sessionMaxLifetime"`
|
||||
LoginTimeout int `description:"Login timeout in seconds." yaml:"loginTimeout"`
|
||||
LoginMaxRetries int `description:"Maximum login retries." yaml:"loginMaxRetries"`
|
||||
LockdownEnabled bool `description:"Enable lockdown mode after maximum login retries. Lockdown mode limit is calculated automatically." yaml:"lockdownEnabled"`
|
||||
TrustedProxies []string `description:"Comma-separated list of trusted proxy addresses." yaml:"trustedProxies"`
|
||||
ACLs ACLsConfig `description:"ACLs configuration." yaml:"acls"`
|
||||
IP IPConfig `description:"IP whitelisting config options." yaml:"ip,omitempty"`
|
||||
Users []string `description:"Comma-separated list of users (username:hashed_password)." yaml:"users,omitempty"`
|
||||
SubdomainsEnabled bool `description:"Enable subdomains support." yaml:"subdomainsEnabled,omitempty"`
|
||||
UserAttributes map[string]UserAttributes `description:"Map of per-user OIDC attributes (username -> attributes)." yaml:"userAttributes,omitempty"`
|
||||
UsersFile string `description:"Path to the users file." yaml:"usersFile,omitempty"`
|
||||
SecureCookie bool `description:"Enable secure cookies." yaml:"secureCookie,omitempty"`
|
||||
SessionExpiry int `description:"Session expiry time in seconds." yaml:"sessionExpiry,omitempty"`
|
||||
SessionMaxLifetime int `description:"Maximum session lifetime in seconds." yaml:"sessionMaxLifetime,omitempty"`
|
||||
LoginTimeout int `description:"Login timeout in seconds." yaml:"loginTimeout,omitempty"`
|
||||
LoginMaxRetries int `description:"Maximum login retries." yaml:"loginMaxRetries,omitempty"`
|
||||
LockdownEnabled bool `description:"Enable lockdown mode after maximum login retries. Lockdown mode limit is calculated automatically." yaml:"lockdownEnabled,omitempty"`
|
||||
TrustedProxies []string `description:"Comma-separated list of trusted proxy addresses." yaml:"trustedProxies,omitempty"`
|
||||
ACLs ACLsConfig `description:"ACLs configuration." yaml:"acls,omitempty"`
|
||||
}
|
||||
|
||||
type UserAttributes struct {
|
||||
Name string `description:"Full name of the user." yaml:"name"`
|
||||
GivenName string `description:"Given (first) name of the user." yaml:"givenName"`
|
||||
FamilyName string `description:"Family (last) name of the user." yaml:"familyName"`
|
||||
MiddleName string `description:"Middle name of the user." yaml:"middleName"`
|
||||
Nickname string `description:"Nickname of the user." yaml:"nickname"`
|
||||
Profile string `description:"URL of the user's profile page." yaml:"profile"`
|
||||
Picture string `description:"URL of the user's profile picture." yaml:"picture"`
|
||||
Website string `description:"URL of the user's website." yaml:"website"`
|
||||
Email string `description:"Email address of the user." yaml:"email"`
|
||||
Gender string `description:"Gender of the user." yaml:"gender"`
|
||||
Birthdate string `description:"Birthdate of the user (YYYY-MM-DD)." yaml:"birthdate"`
|
||||
Zoneinfo string `description:"Time zone of the user (e.g. Europe/Athens)." yaml:"zoneinfo"`
|
||||
Locale string `description:"Locale of the user (e.g. en-US)." yaml:"locale"`
|
||||
PhoneNumber string `description:"Phone number of the user." yaml:"phoneNumber"`
|
||||
Address AddressClaim `description:"Address of the user." yaml:"address"`
|
||||
Name string `description:"Full name of the user." yaml:"name,omitempty"`
|
||||
GivenName string `description:"Given (first) name of the user." yaml:"givenName,omitempty"`
|
||||
FamilyName string `description:"Family (last) name of the user." yaml:"familyName,omitempty"`
|
||||
MiddleName string `description:"Middle name of the user." yaml:"middleName,omitempty"`
|
||||
Nickname string `description:"Nickname of the user." yaml:"nickname,omitempty"`
|
||||
Profile string `description:"URL of the user's profile page." yaml:"profile,omitempty"`
|
||||
Picture string `description:"URL of the user's profile picture." yaml:"picture,omitempty"`
|
||||
Website string `description:"URL of the user's website." yaml:"website,omitempty"`
|
||||
Email string `description:"Email address of the user." yaml:"email,omitempty"`
|
||||
Gender string `description:"Gender of the user." yaml:"gender,omitempty"`
|
||||
Birthdate string `description:"Birthdate of the user (YYYY-MM-DD)." yaml:"birthdate,omitempty"`
|
||||
Zoneinfo string `description:"Time zone of the user (e.g. Europe/Athens)." yaml:"zoneinfo,omitempty"`
|
||||
Locale string `description:"Locale of the user (e.g. en-US)." yaml:"locale,omitempty"`
|
||||
PhoneNumber string `description:"Phone number of the user." yaml:"phoneNumber,omitempty"`
|
||||
Address AddressClaim `description:"Address of the user." yaml:"address,omitempty"`
|
||||
}
|
||||
|
||||
type AddressClaim struct {
|
||||
Formatted string `description:"Full mailing address, formatted for display." yaml:"formatted" json:"formatted,omitempty"`
|
||||
StreetAddress string `description:"Street address." yaml:"streetAddress" json:"street_address,omitempty"`
|
||||
Locality string `description:"City or locality." yaml:"locality" json:"locality,omitempty"`
|
||||
Region string `description:"State, province, or region." yaml:"region" json:"region,omitempty"`
|
||||
PostalCode string `description:"Zip or postal code." yaml:"postalCode" json:"postal_code,omitempty"`
|
||||
Country string `description:"Country." yaml:"country" json:"country,omitempty"`
|
||||
Formatted string `description:"Full mailing address, formatted for display." yaml:"formatted,omitempty" json:"formatted,omitempty"`
|
||||
StreetAddress string `description:"Street address." yaml:"streetAddress,omitempty" json:"street_address,omitempty"`
|
||||
Locality string `description:"City or locality." yaml:"locality,omitempty" json:"locality,omitempty"`
|
||||
Region string `description:"State, province, or region." yaml:"region,omitempty" json:"region,omitempty"`
|
||||
PostalCode string `description:"Zip or postal code." yaml:"postalCode,omitempty" json:"postal_code,omitempty"`
|
||||
Country string `description:"Country." yaml:"country,omitempty" json:"country,omitempty"`
|
||||
}
|
||||
|
||||
type IPConfig struct {
|
||||
Allow []string `description:"List of allowed IPs or CIDR ranges." yaml:"allow"`
|
||||
Block []string `description:"List of blocked IPs or CIDR ranges." yaml:"block"`
|
||||
Bypass []string `description:"List of IPs or CIDR ranges that bypass authentication entirely." yaml:"bypass"`
|
||||
Allow []string `description:"List of allowed IPs or CIDR ranges." yaml:"allow,omitempty"`
|
||||
Block []string `description:"List of blocked IPs or CIDR ranges." yaml:"block,omitempty"`
|
||||
Bypass []string `description:"List of IPs or CIDR ranges that bypass authentication entirely." yaml:"bypass,omitempty"`
|
||||
}
|
||||
|
||||
type OAuthConfig struct {
|
||||
Whitelist []string `description:"Comma-separated list of allowed OAuth domains." yaml:"whitelist"`
|
||||
WhitelistFile string `description:"Path to the OAuth whitelist file." yaml:"whitelistFile"`
|
||||
AutoRedirect string `description:"The OAuth provider to use for automatic redirection." yaml:"autoRedirect"`
|
||||
Providers map[string]OAuthServiceConfig `description:"OAuth providers configuration." yaml:"providers"`
|
||||
Whitelist []string `description:"Comma-separated list of allowed OAuth domains." yaml:"whitelist,omitempty"`
|
||||
WhitelistFile string `description:"Path to the OAuth whitelist file." yaml:"whitelistFile,omitempty"`
|
||||
AutoRedirect string `description:"The OAuth provider to use for automatic redirection." yaml:"autoRedirect,omitempty"`
|
||||
Providers map[string]OAuthServiceConfig `description:"OAuth providers configuration." yaml:"providers,omitempty"`
|
||||
}
|
||||
|
||||
type OIDCConfig struct {
|
||||
PrivateKeyPath string `description:"Path to the private key file, including file name." yaml:"privateKeyPath"`
|
||||
PublicKeyPath string `description:"Path to the public key file, including file name." yaml:"publicKeyPath"`
|
||||
Clients map[string]OIDCClientConfig `description:"OIDC clients configuration." yaml:"clients"`
|
||||
PrivateKeyPath string `description:"Path to the private key file, including file name." yaml:"privateKeyPath,omitempty"`
|
||||
PublicKeyPath string `description:"Path to the public key file, including file name." yaml:"publicKeyPath,omitempty"`
|
||||
Clients map[string]OIDCClientConfig `description:"OIDC clients configuration." yaml:"clients,omitempty"`
|
||||
}
|
||||
|
||||
type UIConfig struct {
|
||||
Title string `description:"The title of the UI." yaml:"title"`
|
||||
ForgotPasswordMessage string `description:"Message displayed on the forgot password page." yaml:"forgotPasswordMessage"`
|
||||
BackgroundImage string `description:"Path to the background image." yaml:"backgroundImage"`
|
||||
WarningsEnabled bool `description:"Enable UI warnings." yaml:"warningsEnabled"`
|
||||
Title string `description:"The title of the UI." yaml:"title,omitempty"`
|
||||
ForgotPasswordMessage string `description:"Message displayed on the forgot password page." yaml:"forgotPasswordMessage,omitempty"`
|
||||
BackgroundImage string `description:"Path to the background image." yaml:"backgroundImage,omitempty"`
|
||||
WarningsEnabled bool `description:"Enable UI warnings." yaml:"warningsEnabled,omitempty"`
|
||||
}
|
||||
|
||||
type LDAPConfig struct {
|
||||
Address string `description:"LDAP server address." yaml:"address"`
|
||||
BindDN string `description:"Bind DN for LDAP authentication." yaml:"bindDn"`
|
||||
BindPassword string `description:"Bind password for LDAP authentication." yaml:"bindPassword"`
|
||||
BindPasswordFile string `description:"Path to the Bind password." yaml:"bindPasswordFile"`
|
||||
BaseDN string `description:"Base DN for LDAP searches." yaml:"baseDn"`
|
||||
Insecure bool `description:"Allow insecure LDAP connections." yaml:"insecure"`
|
||||
SearchFilter string `description:"LDAP search filter." yaml:"searchFilter"`
|
||||
AuthCert string `description:"Certificate for mTLS authentication." yaml:"authCert"`
|
||||
AuthKey string `description:"Certificate key for mTLS authentication." yaml:"authKey"`
|
||||
GroupCacheTTL int `description:"Cache duration for LDAP group membership in seconds." yaml:"groupCacheTTL"`
|
||||
Address string `description:"LDAP server address." yaml:"address,omitempty"`
|
||||
BindDN string `description:"Bind DN for LDAP authentication." yaml:"bindDn,omitempty"`
|
||||
BindPassword string `description:"Bind password for LDAP authentication." yaml:"bindPassword,omitempty"`
|
||||
BindPasswordFile string `description:"Path to the Bind password." yaml:"bindPasswordFile,omitempty"`
|
||||
BaseDN string `description:"Base DN for LDAP searches." yaml:"baseDn,omitempty"`
|
||||
Insecure bool `description:"Allow insecure LDAP connections." yaml:"insecure,omitempty"`
|
||||
SearchFilter string `description:"LDAP search filter." yaml:"searchFilter,omitempty"`
|
||||
AuthCert string `description:"Certificate for mTLS authentication." yaml:"authCert,omitempty"`
|
||||
AuthKey string `description:"Certificate key for mTLS authentication." yaml:"authKey,omitempty"`
|
||||
GroupCacheTTL int `description:"Cache duration for LDAP group membership in seconds." yaml:"groupCacheTTL,omitempty"`
|
||||
}
|
||||
|
||||
type LogConfig struct {
|
||||
Level string `description:"Log level (trace, debug, info, warn, error)." yaml:"level"`
|
||||
Json bool `description:"Enable JSON formatted logs." yaml:"json"`
|
||||
Streams LogStreams `description:"Configuration for specific log streams." yaml:"streams"`
|
||||
Level string `description:"Log level (trace, debug, info, warn, error)." yaml:"level,omitempty"`
|
||||
Json bool `description:"Enable JSON formatted logs." yaml:"json,omitempty"`
|
||||
Streams LogStreams `description:"Configuration for specific log streams." yaml:"streams,omitempty"`
|
||||
}
|
||||
|
||||
type LogStreams struct {
|
||||
HTTP LogStreamConfig `description:"HTTP request logging." yaml:"http"`
|
||||
App LogStreamConfig `description:"Application logging." yaml:"app"`
|
||||
Audit LogStreamConfig `description:"Audit logging." yaml:"audit"`
|
||||
HTTP LogStreamConfig `description:"HTTP request logging." yaml:"http,omitempty"`
|
||||
App LogStreamConfig `description:"Application logging." yaml:"app,omitempty"`
|
||||
Audit LogStreamConfig `description:"Audit logging." yaml:"audit,omitempty"`
|
||||
}
|
||||
|
||||
type LogStreamConfig struct {
|
||||
Enabled bool `description:"Enable this log stream." yaml:"enabled"`
|
||||
Level string `description:"Log level for this stream. Use global if empty." yaml:"level"`
|
||||
Enabled bool `description:"Enable this log stream." yaml:"enabled,omitempty"`
|
||||
Level string `description:"Log level for this stream. Use global if empty." yaml:"level,omitempty"`
|
||||
}
|
||||
|
||||
// no experimental features
|
||||
type ExperimentalConfig struct{}
|
||||
type ExperimentalConfig struct {
|
||||
Tailscale TailscaleConfig `description:"Tailscale configuration." yaml:"tailscale"`
|
||||
}
|
||||
|
||||
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"`
|
||||
Enabled bool `description:"Enable Tailscale integration." yaml:"enabled,omitempty"`
|
||||
Dir string `description:"Tailscale state directory." yaml:"dir,omitempty"`
|
||||
Hostname string `description:"Tailscale hostname." yaml:"hostname,omitempty"`
|
||||
AuthKey string `description:"Tailscale auth key." yaml:"authKey,omitempty"`
|
||||
Ephemeral bool `description:"Use ephemeral Tailscale node." yaml:"ephemeral,omitempty"`
|
||||
Funnel bool `description:"Enable Tailscale Funnel." yaml:"funnel,omitempty"`
|
||||
Listen bool `description:"Listen on the Tailscale address instead of standard address." yaml:"listen,omitempty"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
TokenURL string `description:"OAuth token URL." yaml:"tokenUrl"`
|
||||
UserinfoURL string `description:"OAuth userinfo URL." yaml:"userinfoUrl"`
|
||||
Insecure bool `description:"Allow insecure OAuth connections." yaml:"insecure"`
|
||||
Name string `description:"Provider name in UI." yaml:"name"`
|
||||
ClientID string `description:"OAuth client ID." yaml:"clientId,omitempty"`
|
||||
ClientSecret string `description:"OAuth client secret." yaml:"clientSecret,omitempty"`
|
||||
ClientSecretFile string `description:"Path to the file containing the OAuth client secret." yaml:"clientSecretFile,omitempty"`
|
||||
Whitelist []string `description:"Comma-separated list of allowed OAuth domains for this provider." yaml:"whitelist,omitempty"`
|
||||
WhitelistFile string `description:"Path to the OAuth whitelist file for this provider." yaml:"whitelistFile,omitempty"`
|
||||
Scopes []string `description:"OAuth scopes." yaml:"scopes,omitempty"`
|
||||
RedirectURL string `description:"OAuth redirect URL." yaml:"redirectUrl,omitempty"`
|
||||
AuthURL string `description:"OAuth authorization URL." yaml:"authUrl,omitempty"`
|
||||
TokenURL string `description:"OAuth token URL." yaml:"tokenUrl,omitempty"`
|
||||
UserinfoURL string `description:"OAuth userinfo URL." yaml:"userinfoUrl,omitempty"`
|
||||
Insecure bool `description:"Allow insecure OAuth connections." yaml:"insecure,omitempty"`
|
||||
Name string `description:"Provider name in UI." yaml:"name,omitempty"`
|
||||
}
|
||||
|
||||
type OIDCClientConfig struct {
|
||||
ID string `description:"OIDC client ID." yaml:"-"`
|
||||
ClientID string `description:"OIDC client ID." yaml:"clientId"`
|
||||
ClientSecret string `description:"OIDC client secret." yaml:"clientSecret"`
|
||||
ClientSecretFile string `description:"Path to the file containing the OIDC client secret." yaml:"clientSecretFile"`
|
||||
TrustedRedirectURIs []string `description:"List of trusted redirect URIs." yaml:"trustedRedirectUris"`
|
||||
Name string `description:"Client name in UI." yaml:"name"`
|
||||
ClientID string `description:"OIDC client ID." yaml:"clientId,omitempty"`
|
||||
ClientSecret string `description:"OIDC client secret." yaml:"clientSecret,omitempty"`
|
||||
ClientSecretFile string `description:"Path to the file containing the OIDC client secret." yaml:"clientSecretFile,omitempty"`
|
||||
TrustedRedirectURIs []string `description:"List of trusted redirect URIs." yaml:"trustedRedirectUris,omitempty"`
|
||||
Name string `description:"Client name in UI." yaml:"name,omitempty"`
|
||||
}
|
||||
|
||||
type ACLsConfig struct {
|
||||
Policy string `description:"ACL policy for allow-by-default or deny-by-default, available options are allow and deny, default is allow." yaml:"policy"`
|
||||
Policy string `description:"ACL policy for allow-by-default or deny-by-default, available options are allow and deny, default is allow." yaml:"policy,omitempty"`
|
||||
}
|
||||
|
||||
// ACLs
|
||||
|
||||
type Apps struct {
|
||||
Apps map[string]App `description:"App ACLs configuration." yaml:"apps"`
|
||||
Apps map[string]App `description:"App ACLs configuration." yaml:"apps,omitempty"`
|
||||
}
|
||||
|
||||
type App struct {
|
||||
Config AppConfig `description:"App configuration." yaml:"config"`
|
||||
Users AppUsers `description:"User access configuration." yaml:"users"`
|
||||
OAuth AppOAuth `description:"OAuth access configuration." yaml:"oauth"`
|
||||
IP AppIP `description:"IP access configuration." yaml:"ip"`
|
||||
Response AppResponse `description:"Response customization." yaml:"response"`
|
||||
Path AppPath `description:"Path access configuration." yaml:"path"`
|
||||
LDAP AppLDAP `description:"LDAP access configuration." yaml:"ldap"`
|
||||
Config AppConfig `description:"App configuration." yaml:"config,omitempty"`
|
||||
Users AppUsers `description:"User access configuration." yaml:"users,omitempty"`
|
||||
OAuth AppOAuth `description:"OAuth access configuration." yaml:"oauth,omitempty"`
|
||||
IP AppIP `description:"IP access configuration." yaml:"ip,omitempty"`
|
||||
Response AppResponse `description:"Response customization." yaml:"response,omitempty"`
|
||||
Path AppPath `description:"Path access configuration." yaml:"path,omitempty"`
|
||||
LDAP AppLDAP `description:"LDAP access configuration." yaml:"ldap,omitempty"`
|
||||
}
|
||||
|
||||
type AppConfig struct {
|
||||
Domain string `description:"The domain of the app." yaml:"domain"`
|
||||
Domain string `description:"The domain of the app." yaml:"domain,omitempty"`
|
||||
}
|
||||
|
||||
type AppUsers struct {
|
||||
Allow string `description:"Comma-separated list of allowed users." yaml:"allow"`
|
||||
Block string `description:"Comma-separated list of blocked users." yaml:"block"`
|
||||
Allow string `description:"Comma-separated list of allowed users." yaml:"allow,omitempty"`
|
||||
Block string `description:"Comma-separated list of blocked users." yaml:"block,omitempty"`
|
||||
}
|
||||
|
||||
type AppOAuth struct {
|
||||
Whitelist string `description:"Comma-separated list of allowed OAuth groups." yaml:"whitelist"`
|
||||
Groups string `description:"Comma-separated list of required OAuth groups." yaml:"groups"`
|
||||
Whitelist string `description:"Comma-separated list of allowed OAuth groups." yaml:"whitelist,omitempty"`
|
||||
Groups string `description:"Comma-separated list of required OAuth groups." yaml:"groups,omitempty"`
|
||||
}
|
||||
|
||||
type AppLDAP struct {
|
||||
Groups string `description:"Comma-separated list of required LDAP groups." yaml:"groups"`
|
||||
Groups string `description:"Comma-separated list of required LDAP groups." yaml:"groups,omitempty"`
|
||||
}
|
||||
|
||||
type AppIP struct {
|
||||
Allow []string `description:"List of allowed IPs or CIDR ranges." yaml:"allow"`
|
||||
Block []string `description:"List of blocked IPs or CIDR ranges." yaml:"block"`
|
||||
Bypass []string `description:"List of IPs or CIDR ranges that bypass authentication." yaml:"bypass"`
|
||||
Allow []string `description:"List of allowed IPs or CIDR ranges." yaml:"allow,omitempty"`
|
||||
Block []string `description:"List of blocked IPs or CIDR ranges." yaml:"block,omitempty"`
|
||||
Bypass []string `description:"List of IPs or CIDR ranges that bypass authentication." yaml:"bypass,omitempty"`
|
||||
}
|
||||
|
||||
type AppResponse struct {
|
||||
Headers []string `description:"Custom headers to add to the response." yaml:"headers"`
|
||||
BasicAuth AppBasicAuth `description:"Basic authentication for the app." yaml:"basicAuth"`
|
||||
Headers []string `description:"Custom headers to add to the response." yaml:"headers,omitempty"`
|
||||
BasicAuth AppBasicAuth `description:"Basic authentication for the app." yaml:"basicAuth,omitempty"`
|
||||
}
|
||||
|
||||
type AppBasicAuth struct {
|
||||
Username string `description:"Basic auth username." yaml:"username"`
|
||||
Password string `description:"Basic auth password." yaml:"password"`
|
||||
PasswordFile string `description:"Path to the file containing the basic auth password." yaml:"passwordFile"`
|
||||
Username string `description:"Basic auth username." yaml:"username,omitempty"`
|
||||
Password string `description:"Basic auth password." yaml:"password,omitempty"`
|
||||
PasswordFile string `description:"Path to the file containing the basic auth password." yaml:"passwordFile,omitempty"`
|
||||
}
|
||||
|
||||
type AppPath struct {
|
||||
Allow string `description:"Comma-separated list of allowed paths." yaml:"allow"`
|
||||
Block string `description:"Comma-separated list of blocked paths." yaml:"block"`
|
||||
Allow string `description:"Comma-separated list of allowed paths." yaml:"allow,omitempty"`
|
||||
Block string `description:"Comma-separated list of blocked paths." yaml:"block,omitempty"`
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ var OverrideProviders = map[string]string{
|
||||
"github": "GitHub",
|
||||
}
|
||||
|
||||
var ReservedProviderNames = []string{"local", "ldap", "tailscale"}
|
||||
|
||||
const SessionCookieName = "tinyauth-session"
|
||||
const CSRFCookieName = "tinyauth-csrf"
|
||||
const RedirectCookieName = "tinyauth-redirect"
|
||||
|
||||
@@ -12,7 +12,6 @@ type RuntimeConfig struct {
|
||||
OAuthProviders map[string]OAuthServiceConfig
|
||||
OAuthWhitelist []string
|
||||
ConfiguredProviders []Provider
|
||||
TrustedDomains []string
|
||||
}
|
||||
|
||||
type Provider struct {
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
package postgres
|
||||
|
||||
//go:generate go run github.com/tinyauthapp/tinyauth/gen/sqlc-wrapper -pkg github.com/tinyauthapp/tinyauth/internal/repository/postgres
|
||||
//go:generate go run github.com/tinyauthapp/tinyauth/gen/sqlc_wrapper -pkg github.com/tinyauthapp/tinyauth/internal/repository/postgres
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
package sqlite
|
||||
|
||||
//go:generate go run github.com/tinyauthapp/tinyauth/gen/sqlc-wrapper -pkg github.com/tinyauthapp/tinyauth/internal/repository/sqlite
|
||||
//go:generate go run github.com/tinyauthapp/tinyauth/gen/sqlc_wrapper -pkg github.com/tinyauthapp/tinyauth/internal/repository/sqlite
|
||||
|
||||
@@ -46,7 +46,7 @@ type OAuthPendingSession struct {
|
||||
State string
|
||||
Verifier string
|
||||
Token *oauth2.Token
|
||||
Service *OAuthServiceImpl
|
||||
Service IOAuthService
|
||||
ExpiresAt time.Time
|
||||
CallbackParams OAuthCallbackParams
|
||||
}
|
||||
@@ -380,33 +380,11 @@ 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,
|
||||
Path: "/",
|
||||
Domain: fmt.Sprintf(".%s", auth.runtime.CookieDomain),
|
||||
Domain: auth.getCookieDomain(),
|
||||
Expires: expiresAt,
|
||||
MaxAge: int(time.Until(expiresAt).Seconds()),
|
||||
Secure: auth.config.Auth.SecureCookie,
|
||||
@@ -459,7 +437,7 @@ func (auth *AuthService) RefreshSession(ctx context.Context, uuid string) (*http
|
||||
Name: auth.runtime.SessionCookieName,
|
||||
Value: session.UUID,
|
||||
Path: "/",
|
||||
Domain: fmt.Sprintf(".%s", auth.runtime.CookieDomain),
|
||||
Domain: auth.getCookieDomain(),
|
||||
Expires: time.Now().Add(time.Duration(newExpiry-currentTime) * time.Second),
|
||||
MaxAge: int(newExpiry - currentTime),
|
||||
Secure: auth.config.Auth.SecureCookie,
|
||||
@@ -480,7 +458,7 @@ func (auth *AuthService) DeleteSession(ctx context.Context, uuid string) (*http.
|
||||
Name: auth.runtime.SessionCookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
Domain: fmt.Sprintf(".%s", auth.runtime.CookieDomain),
|
||||
Domain: auth.getCookieDomain(),
|
||||
Expires: time.Now(),
|
||||
MaxAge: -1,
|
||||
Secure: auth.config.Auth.SecureCookie,
|
||||
@@ -549,7 +527,7 @@ func (auth *AuthService) NewOAuthSession(serviceName string, params OAuthCallbac
|
||||
session := OAuthPendingSession{
|
||||
State: state,
|
||||
Verifier: verifier,
|
||||
Service: &service,
|
||||
Service: service,
|
||||
ExpiresAt: time.Now().Add(1 * time.Hour),
|
||||
CallbackParams: params,
|
||||
}
|
||||
@@ -566,7 +544,7 @@ func (auth *AuthService) GetOAuthURL(sessionId string) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return (*session.Service).GetAuthURL(session.State, session.Verifier), nil
|
||||
return session.Service.GetAuthURL(session.State, session.Verifier), nil
|
||||
}
|
||||
|
||||
func (auth *AuthService) GetOAuthToken(sessionId string, code string) (*oauth2.Token, error) {
|
||||
@@ -576,7 +554,7 @@ func (auth *AuthService) GetOAuthToken(sessionId string, code string) (*oauth2.T
|
||||
return nil, fmt.Errorf("oauth session not found: %s", sessionId)
|
||||
}
|
||||
|
||||
token, err := (*session.Service).GetToken(code, session.Verifier)
|
||||
token, err := session.Service.GetToken(code, session.Verifier)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to exchange code for token: %w", err)
|
||||
@@ -605,7 +583,7 @@ func (auth *AuthService) GetOAuthUserinfo(sessionId string) (*model.Claims, erro
|
||||
return nil, fmt.Errorf("oauth token not found for session: %s", sessionId)
|
||||
}
|
||||
|
||||
userinfo, err := (*session.Service).GetUserinfo(session.Token)
|
||||
userinfo, err := session.Service.GetUserinfo(session.Token)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get userinfo: %w", err)
|
||||
@@ -614,14 +592,14 @@ func (auth *AuthService) GetOAuthUserinfo(sessionId string) (*model.Claims, erro
|
||||
return userinfo, nil
|
||||
}
|
||||
|
||||
func (auth *AuthService) GetOAuthService(sessionId string) (OAuthServiceImpl, error) {
|
||||
func (auth *AuthService) GetOAuthService(sessionId string) (IOAuthService, error) {
|
||||
session, err := auth.GetOAuthPendingSession(sessionId)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return *session.Service, nil
|
||||
return session.Service, nil
|
||||
}
|
||||
|
||||
func (auth *AuthService) EndOAuthSession(sessionId string) {
|
||||
@@ -726,3 +704,10 @@ func (auth *AuthService) calculateLockdownLimit() int {
|
||||
|
||||
return limit
|
||||
}
|
||||
|
||||
func (auth *AuthService) getCookieDomain() string {
|
||||
if !auth.config.Auth.SubdomainsEnabled {
|
||||
return ""
|
||||
}
|
||||
return auth.runtime.CookieDomain
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
|
||||
type LdapService struct {
|
||||
log *logger.Logger
|
||||
ctx context.Context
|
||||
config *model.Config
|
||||
|
||||
conn *ldapgo.Conn
|
||||
@@ -32,6 +33,7 @@ type LdapServiceInput struct {
|
||||
Log *logger.Logger
|
||||
Config *model.Config
|
||||
Ding *ding.Ding
|
||||
Ctx context.Context
|
||||
}
|
||||
|
||||
func NewLdapService(i LdapServiceInput) (*LdapService, error) {
|
||||
@@ -42,6 +44,7 @@ func NewLdapService(i LdapServiceInput) (*LdapService, error) {
|
||||
ldap := &LdapService{
|
||||
log: i.Log,
|
||||
config: i.Config,
|
||||
ctx: i.Ctx,
|
||||
}
|
||||
|
||||
ldap.bindPw = utils.GetSecret(i.Config.LDAP.BindPassword, i.Config.LDAP.BindPasswordFile)
|
||||
@@ -73,6 +76,8 @@ func NewLdapService(i LdapServiceInput) (*LdapService, error) {
|
||||
_, err := ldap.connect()
|
||||
|
||||
if err != nil {
|
||||
// 3s + 4.5s (3x1.5) = ~6.75-8.25s total wait time before giving up
|
||||
err = ldap.reconnect(3 * time.Second)
|
||||
return nil, fmt.Errorf("failed to connect to ldap server: %w", err)
|
||||
}
|
||||
|
||||
@@ -88,7 +93,7 @@ func NewLdapService(i LdapServiceInput) (*LdapService, error) {
|
||||
err := ldap.heartbeat()
|
||||
if err != nil {
|
||||
ldap.log.App.Warn().Err(err).Msg("LDAP connection heartbeat failed, attempting to reconnect")
|
||||
if reconnectErr := ldap.reconnect(); reconnectErr != nil {
|
||||
if reconnectErr := ldap.reconnect(1 * time.Second); reconnectErr != nil {
|
||||
ldap.log.App.Error().Err(reconnectErr).Msg("Failed to reconnect to LDAP server")
|
||||
continue
|
||||
}
|
||||
@@ -276,17 +281,19 @@ func (ldap *LdapService) heartbeat() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ldap *LdapService) reconnect() error {
|
||||
func (ldap *LdapService) reconnect(interval time.Duration) error {
|
||||
ldap.log.App.Info().Msg("Attempting to reconnect to LDAP server")
|
||||
|
||||
exp := backoff.NewExponentialBackOff()
|
||||
exp.InitialInterval = 500 * time.Millisecond
|
||||
exp.InitialInterval = interval
|
||||
exp.RandomizationFactor = 0.1
|
||||
exp.Multiplier = 1.5
|
||||
exp.Reset()
|
||||
|
||||
operation := func() (*ldapgo.Conn, error) {
|
||||
ldap.conn.Close()
|
||||
if ldap.conn != nil {
|
||||
ldap.conn.Close()
|
||||
}
|
||||
conn, err := ldap.connect()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -294,7 +301,7 @@ func (ldap *LdapService) reconnect() error {
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
_, err := backoff.Retry(context.TODO(), operation, backoff.WithBackOff(exp), backoff.WithMaxTries(3))
|
||||
_, err := backoff.Retry(ldap.ctx, operation, backoff.WithBackOff(exp), backoff.WithMaxTries(3))
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -12,19 +12,21 @@ import (
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
type OAuthServiceImpl interface {
|
||||
type IOAuthService interface {
|
||||
Name() string
|
||||
ID() string
|
||||
NewRandom() string
|
||||
GetAuthURL(state string, verifier string) string
|
||||
GetToken(code string, verifier string) (*oauth2.Token, error)
|
||||
GetAuthURL(state, verifier string) string
|
||||
GetToken(code, verifier string) (*oauth2.Token, error)
|
||||
GetUserinfo(token *oauth2.Token) (*model.Claims, error)
|
||||
GetConfig() model.OAuthServiceConfig
|
||||
UpdateConfig(config model.OAuthServiceConfig)
|
||||
}
|
||||
|
||||
type OAuthBrokerService struct {
|
||||
log *logger.Logger
|
||||
|
||||
services map[string]OAuthServiceImpl
|
||||
services map[string]IOAuthService
|
||||
configs map[string]model.OAuthServiceConfig
|
||||
}
|
||||
|
||||
@@ -44,7 +46,7 @@ type OAuthBrokerServiceInput struct {
|
||||
func NewOAuthBrokerService(i OAuthBrokerServiceInput) *OAuthBrokerService {
|
||||
service := &OAuthBrokerService{
|
||||
log: i.Log,
|
||||
services: make(map[string]OAuthServiceImpl),
|
||||
services: make(map[string]IOAuthService),
|
||||
configs: i.Runtime.OAuthProviders,
|
||||
}
|
||||
|
||||
@@ -70,7 +72,7 @@ func (broker *OAuthBrokerService) GetConfiguredServices() []string {
|
||||
return services
|
||||
}
|
||||
|
||||
func (broker *OAuthBrokerService) GetService(name string) (OAuthServiceImpl, bool) {
|
||||
func (broker *OAuthBrokerService) GetService(name string) (IOAuthService, bool) {
|
||||
service, exists := broker.services[name]
|
||||
return service, exists
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ func (s *OAuthService) NewRandom() string {
|
||||
return random
|
||||
}
|
||||
|
||||
func (s *OAuthService) GetAuthURL(state string, verifier string) string {
|
||||
func (s *OAuthService) GetAuthURL(state, verifier string) string {
|
||||
return s.config.AuthCodeURL(state, oauth2.AccessTypeOnline, oauth2.S256ChallengeOption(verifier))
|
||||
}
|
||||
|
||||
@@ -82,3 +82,17 @@ func (s *OAuthService) GetUserinfo(token *oauth2.Token) (*model.Claims, error) {
|
||||
client := oauth2.NewClient(s.ctx, oauth2.StaticTokenSource(token))
|
||||
return s.userinfoExtractor(client, s.serviceCfg.UserinfoURL)
|
||||
}
|
||||
|
||||
func (s *OAuthService) GetConfig() model.OAuthServiceConfig {
|
||||
return s.serviceCfg
|
||||
}
|
||||
|
||||
func (s *OAuthService) UpdateConfig(config model.OAuthServiceConfig) {
|
||||
s.serviceCfg = config
|
||||
s.config.ClientID = config.ClientID
|
||||
s.config.ClientSecret = config.ClientSecret
|
||||
s.config.Scopes = config.Scopes
|
||||
s.config.Endpoint.AuthURL = config.AuthURL
|
||||
s.config.Endpoint.TokenURL = config.TokenURL
|
||||
s.config.RedirectURL = config.RedirectURL
|
||||
}
|
||||
|
||||
@@ -45,17 +45,17 @@ type TailscaleServiceInput struct {
|
||||
}
|
||||
|
||||
func NewTailscaleService(i TailscaleServiceInput) (*TailscaleService, error) {
|
||||
if !i.Config.Tailscale.Enabled {
|
||||
if !i.Config.Experimental.Tailscale.Enabled {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
srv := new(tsnet.Server)
|
||||
|
||||
// node options
|
||||
srv.Dir = i.Config.Tailscale.Dir
|
||||
srv.Hostname = i.Config.Tailscale.Hostname
|
||||
srv.AuthKey = i.Config.Tailscale.AuthKey
|
||||
srv.Ephemeral = i.Config.Tailscale.Ephemeral
|
||||
srv.Dir = i.Config.Experimental.Tailscale.Dir
|
||||
srv.Hostname = i.Config.Experimental.Tailscale.Hostname
|
||||
srv.AuthKey = i.Config.Experimental.Tailscale.AuthKey
|
||||
srv.Ephemeral = i.Config.Experimental.Tailscale.Ephemeral
|
||||
|
||||
// redirect logs to zerolog
|
||||
srv.Logf = i.Log.App.Printf
|
||||
@@ -94,6 +94,10 @@ func NewTailscaleService(i TailscaleServiceInput) (*TailscaleService, error) {
|
||||
|
||||
i.Ding.Go(service.watchAndClose, ding.RingMajor)
|
||||
|
||||
if i.Config.Experimental.Tailscale.Funnel && !i.Config.Experimental.Tailscale.Listen {
|
||||
service.log.App.Warn().Msg("Tailscale Funnel is enabled but listen is disabled. Funnel will not work without listen enabled.")
|
||||
}
|
||||
|
||||
return service, nil
|
||||
}
|
||||
|
||||
@@ -148,6 +152,16 @@ func (ts *TailscaleService) CreateListener() (net.Listener, error) {
|
||||
if ts.ln != nil {
|
||||
return *ts.ln, nil
|
||||
}
|
||||
|
||||
if ts.config.Experimental.Tailscale.Funnel {
|
||||
ln, err := ts.srv.ListenFunnel("tcp", ":443")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ts.ln = &ln
|
||||
return ln, nil
|
||||
}
|
||||
|
||||
ln, err := ts.srv.ListenTLS("tcp", ":443")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -43,6 +43,7 @@ func CreateTestConfigs(t *testing.T) (model.Config, model.RuntimeConfig) {
|
||||
ACLs: model.ACLsConfig{
|
||||
Policy: "allow",
|
||||
},
|
||||
SubdomainsEnabled: true,
|
||||
},
|
||||
Database: model.DatabaseConfig{
|
||||
Path: filepath.Join(tempDir, "test.db"),
|
||||
@@ -165,10 +166,6 @@ func CreateTestConfigs(t *testing.T) (model.Config, model.RuntimeConfig) {
|
||||
CookieDomain: "example.com",
|
||||
AppURL: "https://tinyauth.example.com",
|
||||
SessionCookieName: "tinyauth-session",
|
||||
TrustedDomains: []string{
|
||||
"https://tinyauth.example.com",
|
||||
"https://tinyauth.foo.com",
|
||||
},
|
||||
}
|
||||
|
||||
return config, runtime
|
||||
|
||||
+23
-35
@@ -1,7 +1,7 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
@@ -9,27 +9,36 @@ import (
|
||||
"github.com/weppos/publicsuffix-go/publicsuffix"
|
||||
)
|
||||
|
||||
// Get cookie domain parses a hostname and returns the upper domain (e.g. sub1.sub2.domain.com -> sub2.domain.com)
|
||||
func GetCookieDomain(u string) (string, error) {
|
||||
parsed, err := url.Parse(u)
|
||||
// GetCookieDomain parses the app url and returns the domain value to use for cookies.
|
||||
// When auth for subdomains is enabled, it strips the leftmost label
|
||||
// (e.g. sub1.sub2.domain.com -> sub2.domain.com), otherwise it returns the full hostname.
|
||||
func GetCookieDomain(appUrl string, subdomainsEnabled bool) (string, error) {
|
||||
u, err := url.Parse(appUrl)
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", fmt.Errorf("invalid app url: %w", err)
|
||||
}
|
||||
|
||||
host := parsed.Hostname()
|
||||
hostname := strings.ToLower(u.Hostname())
|
||||
|
||||
if netIP := net.ParseIP(host); netIP != nil {
|
||||
return "", errors.New("ip addresses not allowed")
|
||||
if netIP := net.ParseIP(hostname); netIP != nil {
|
||||
return "", fmt.Errorf("ip addresses not allowed")
|
||||
}
|
||||
|
||||
parts := strings.Split(host, ".")
|
||||
parts := strings.Split(hostname, ".")
|
||||
|
||||
if len(parts) == 2 {
|
||||
return host, nil
|
||||
if len(parts) < 2 {
|
||||
return "", fmt.Errorf("invalid app url, must be in format subdomain.domain.tld or domain.tld")
|
||||
}
|
||||
|
||||
if len(parts) < 3 {
|
||||
return "", errors.New("invalid app url, must be at least second level domain")
|
||||
if !subdomainsEnabled || len(parts) == 2 {
|
||||
_, err = publicsuffix.DomainFromListWithOptions(publicsuffix.DefaultList, hostname, nil)
|
||||
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("domain in public suffix list, cannot set cookies: %w", err)
|
||||
}
|
||||
|
||||
return hostname, nil
|
||||
}
|
||||
|
||||
domain := strings.Join(parts[1:], ".")
|
||||
@@ -37,33 +46,12 @@ func GetCookieDomain(u string) (string, error) {
|
||||
_, err = publicsuffix.DomainFromListWithOptions(publicsuffix.DefaultList, domain, nil)
|
||||
|
||||
if err != nil {
|
||||
return "", errors.New("domain in public suffix list, cannot set cookies")
|
||||
return "", fmt.Errorf("domain in public suffix list, cannot set cookies: %w", err)
|
||||
}
|
||||
|
||||
return domain, nil
|
||||
}
|
||||
|
||||
func GetStandaloneCookieDomain(u string) (string, error) {
|
||||
parsed, err := url.Parse(u)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
host := parsed.Hostname()
|
||||
|
||||
if netIP := net.ParseIP(host); netIP != nil {
|
||||
return "", errors.New("ip addresses not allowed")
|
||||
}
|
||||
|
||||
parts := strings.Split(host, ".")
|
||||
|
||||
if len(parts) < 2 {
|
||||
return "", errors.New("invalid app url")
|
||||
}
|
||||
|
||||
return host, nil
|
||||
}
|
||||
|
||||
func ParseFileToLine(content string) string {
|
||||
lines := strings.Split(content, "\n")
|
||||
users := make([]string, 0)
|
||||
|
||||
@@ -11,50 +11,71 @@ func TestGetRootDomain(t *testing.T) {
|
||||
// Normal case
|
||||
domain := "http://sub.tinyauth.app"
|
||||
expected := "tinyauth.app"
|
||||
result, err := utils.GetCookieDomain(domain)
|
||||
result, err := utils.GetCookieDomain(domain, true)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, expected, result)
|
||||
|
||||
// Domain with multiple subdomains
|
||||
domain = "http://b.c.tinyauth.app"
|
||||
expected = "c.tinyauth.app"
|
||||
result, err = utils.GetCookieDomain(domain)
|
||||
result, err = utils.GetCookieDomain(domain, true)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, expected, result)
|
||||
|
||||
// Invalid domain (only TLD)
|
||||
domain = "com"
|
||||
_, err = utils.GetCookieDomain(domain)
|
||||
assert.ErrorContains(t, err, "invalid app url, must be at least second level domain")
|
||||
_, err = utils.GetCookieDomain(domain, true)
|
||||
assert.EqualError(t, err, "invalid app url, must be in format subdomain.domain.tld or domain.tld")
|
||||
|
||||
// IP address
|
||||
domain = "http://10.10.10.10"
|
||||
_, err = utils.GetCookieDomain(domain)
|
||||
_, err = utils.GetCookieDomain(domain, true)
|
||||
assert.ErrorContains(t, err, "ip addresses not allowed")
|
||||
|
||||
// Invalid URL
|
||||
domain = "http://[::1]:namedport"
|
||||
_, err = utils.GetCookieDomain(domain)
|
||||
_, err = utils.GetCookieDomain(domain, true)
|
||||
assert.ErrorContains(t, err, "parse \"http://[::1]:namedport\": invalid port \":namedport\" after host")
|
||||
|
||||
// URL with scheme and path
|
||||
domain = "https://sub.tinyauth.app/path"
|
||||
expected = "tinyauth.app"
|
||||
result, err = utils.GetCookieDomain(domain)
|
||||
result, err = utils.GetCookieDomain(domain, true)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, expected, result)
|
||||
|
||||
// URL with port
|
||||
domain = "http://sub.tinyauth.app:8080"
|
||||
expected = "tinyauth.app"
|
||||
result, err = utils.GetCookieDomain(domain)
|
||||
result, err = utils.GetCookieDomain(domain, true)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, expected, result)
|
||||
|
||||
// Domain managed by ICANN
|
||||
domain = "http://example.co.uk"
|
||||
_, err = utils.GetCookieDomain(domain)
|
||||
assert.Error(t, err, "domain in public suffix list, cannot set cookies")
|
||||
_, err = utils.GetCookieDomain(domain, true)
|
||||
assert.ErrorContains(t, err, "domain in public suffix list, cannot set cookies")
|
||||
|
||||
// Domain without subdomain
|
||||
domain = "http://tinyauth.app"
|
||||
expected = "tinyauth.app"
|
||||
result, err = utils.GetCookieDomain(domain, true)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, expected, result)
|
||||
|
||||
// Case insensitivity
|
||||
domain = "http://Sub.Tinyauth.App"
|
||||
expected = "tinyauth.app"
|
||||
result, err = utils.GetCookieDomain(domain, true)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, expected, result)
|
||||
|
||||
// Subdomains disabled
|
||||
domain = "http://sub.tinyauth.app"
|
||||
expected = "sub.tinyauth.app"
|
||||
result, err = utils.GetCookieDomain(domain, false)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, expected, result)
|
||||
}
|
||||
|
||||
func TestParseFileToLine(t *testing.T) {
|
||||
@@ -125,48 +146,3 @@ func TestFilter(t *testing.T) {
|
||||
resultStr := utils.Filter(sliceStr, testFuncStr)
|
||||
assert.Equal(t, expectedStr, resultStr)
|
||||
}
|
||||
|
||||
func TestGetStandaloneCookieDomain(t *testing.T) {
|
||||
// Normal case
|
||||
domain := "http://tinyauth.app"
|
||||
expected := "tinyauth.app"
|
||||
result, err := utils.GetStandaloneCookieDomain(domain)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, expected, result)
|
||||
|
||||
// URL with subdomain (full hostname is returned, no subdomain stripping)
|
||||
domain = "http://sub.tinyauth.app"
|
||||
expected = "sub.tinyauth.app"
|
||||
result, err = utils.GetStandaloneCookieDomain(domain)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, expected, result)
|
||||
|
||||
// URL with port (port should be stripped)
|
||||
domain = "http://tinyauth.app:8080"
|
||||
expected = "tinyauth.app"
|
||||
result, err = utils.GetStandaloneCookieDomain(domain)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, expected, result)
|
||||
|
||||
// URL with path
|
||||
domain = "https://tinyauth.app/some/path"
|
||||
expected = "tinyauth.app"
|
||||
result, err = utils.GetStandaloneCookieDomain(domain)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, expected, result)
|
||||
|
||||
// IP address
|
||||
domain = "http://10.10.10.10"
|
||||
_, err = utils.GetStandaloneCookieDomain(domain)
|
||||
assert.ErrorContains(t, err, "ip addresses not allowed")
|
||||
|
||||
// Invalid domain (only TLD)
|
||||
domain = "com"
|
||||
_, err = utils.GetStandaloneCookieDomain(domain)
|
||||
assert.ErrorContains(t, err, "invalid app url")
|
||||
|
||||
// Invalid URL
|
||||
domain = "http://[::1]:namedport"
|
||||
_, err = utils.GetStandaloneCookieDomain(domain)
|
||||
assert.ErrorContains(t, err, "parse \"http://[::1]:namedport\": invalid port \":namedport\" after host")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user