mirror of
https://github.com/tinyauthapp/tinyauth.git
synced 2026-08-13 04:53:32 +08:00
Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb1aa72754 | ||
|
|
9f9a87eaac | ||
|
|
21389021bf | ||
|
|
bf83708067 | ||
|
|
afacc5cb96 | ||
|
|
86c6c71b4e | ||
|
|
b327dd1365 | ||
|
|
6b77777d59 | ||
|
|
90ff78b3b8 | ||
|
|
4eeede6bcb | ||
|
|
d8ee3e77f8 | ||
|
|
423ad7b7a0 | ||
|
|
226df14ad0 | ||
|
|
3cf3cc9090 | ||
|
|
589fe22138 | ||
|
|
1165c91fcc | ||
|
|
84117ce109 | ||
|
|
9eabfe89fd | ||
|
|
dade1e2c8f | ||
|
|
6e095fd4f2 | ||
|
|
2a0d7bd37a | ||
|
|
c22925c2fb | ||
|
|
d946926c36 | ||
|
|
8881116360 | ||
|
|
e75605b2c5 | ||
|
|
79bcccbe43 | ||
|
|
b62bb2d37a | ||
|
|
791adbb54d | ||
|
|
4cd4d67bda | ||
|
|
009ae30b47 | ||
|
|
11b534abc7 | ||
|
|
c575376215 | ||
|
|
4c23f55940 | ||
|
|
5a61958815 | ||
|
|
e29550857c | ||
|
|
acad7f08e6 |
+4
-4
@@ -4,6 +4,10 @@
|
||||
|
||||
# The base URL where the app is hosted.
|
||||
TINYAUTH_APPURL=
|
||||
# Path to config file.
|
||||
TINYAUTH_CONFIGFILE=
|
||||
# Label provider to use for ACLs (auto, docker, kubernetes or none to disable). auto detects the environment.
|
||||
TINYAUTH_LABELPROVIDER="auto"
|
||||
|
||||
# database config
|
||||
|
||||
@@ -97,8 +101,6 @@ 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.
|
||||
@@ -233,8 +235,6 @@ TINYAUTH_TAILSCALE_APITOKENFILE=
|
||||
TINYAUTH_TAILSCALE_TAILNET=
|
||||
# Cache duration for Tailscale device and user lists in seconds.
|
||||
TINYAUTH_TAILSCALE_CACHEDURATION=300
|
||||
# Label provider to use for ACLs (auto, docker, kubernetes or none to disable). auto detects the environment.
|
||||
TINYAUTH_LABELPROVIDER="auto"
|
||||
|
||||
# log config
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ updates:
|
||||
- "minor"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
cooldown:
|
||||
default-days: 1
|
||||
|
||||
- package-ecosystem: "gomod"
|
||||
directory: "/"
|
||||
@@ -19,13 +21,19 @@ updates:
|
||||
- "minor"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
cooldown:
|
||||
default-days: 1
|
||||
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
cooldown:
|
||||
default-days: 1
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
cooldown:
|
||||
default-days: 1
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
name: Run e2e tests
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
test:
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # ratchet:actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # ratchet:pnpm/action-setup@v6
|
||||
with:
|
||||
package_json_file: ./e2e/package.json
|
||||
|
||||
- name: Set up Docker
|
||||
uses: docker/setup-docker-action@6d7cfa65f60a9dda7b46e5513fa982536f3c9877 # ratchet:docker/setup-docker-action@v5
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm ci
|
||||
working-directory: e2e
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: pnpm exec playwright install --with-deps
|
||||
working-directory: e2e
|
||||
|
||||
- name: Run Playwright tests
|
||||
run: pnpm test
|
||||
working-directory: e2e
|
||||
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # ratchet:actions/upload-artifact@v4
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: playwright-report
|
||||
path: e2e/playwright-report/
|
||||
retention-days: 5
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
REPO: ${{ github.event.repository.name }}
|
||||
|
||||
- name: Create release
|
||||
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3
|
||||
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3
|
||||
with:
|
||||
prerelease: true
|
||||
tag_name: nightly
|
||||
@@ -167,7 +167,7 @@ jobs:
|
||||
images: ghcr.io/${{ github.repository_owner }}/tinyauth
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -226,7 +226,7 @@ jobs:
|
||||
images: ghcr.io/${{ github.repository_owner }}/tinyauth
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -285,7 +285,7 @@ jobs:
|
||||
images: ghcr.io/${{ github.repository_owner }}/tinyauth
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -344,7 +344,7 @@ jobs:
|
||||
images: ghcr.io/${{ github.repository_owner }}/tinyauth
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -399,7 +399,7 @@ jobs:
|
||||
merge-multiple: true
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -438,7 +438,7 @@ jobs:
|
||||
merge-multiple: true
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -476,7 +476,7 @@ jobs:
|
||||
merge-multiple: true
|
||||
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3
|
||||
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3
|
||||
with:
|
||||
files: binaries/*
|
||||
tag_name: nightly
|
||||
|
||||
@@ -139,7 +139,7 @@ jobs:
|
||||
images: ghcr.io/${{ github.repository_owner }}/tinyauth
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -196,7 +196,7 @@ jobs:
|
||||
images: ghcr.io/${{ github.repository_owner }}/tinyauth
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -253,7 +253,7 @@ jobs:
|
||||
images: ghcr.io/${{ github.repository_owner }}/tinyauth
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -310,7 +310,7 @@ jobs:
|
||||
images: ghcr.io/${{ github.repository_owner }}/tinyauth
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -366,7 +366,7 @@ jobs:
|
||||
merge-multiple: true
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -407,7 +407,7 @@ jobs:
|
||||
merge-multiple: true
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -449,6 +449,6 @@ jobs:
|
||||
merge-multiple: true
|
||||
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3
|
||||
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3
|
||||
with:
|
||||
files: binaries/*
|
||||
|
||||
@@ -38,6 +38,6 @@ jobs:
|
||||
retention-days: 5
|
||||
|
||||
- name: Upload to code-scanning
|
||||
uses: github/codeql-action/upload-sarif@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4
|
||||
uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
||||
+2
-1
@@ -1,5 +1,5 @@
|
||||
# Site builder
|
||||
FROM node:26.4-alpine3.23 AS frontend-builder
|
||||
FROM node:26.5-alpine3.23 AS frontend-builder
|
||||
|
||||
WORKDIR /frontend
|
||||
|
||||
@@ -39,6 +39,7 @@ RUN go mod download
|
||||
|
||||
COPY ./cmd ./cmd
|
||||
COPY ./internal ./internal
|
||||
COPY ./pkg ./pkg
|
||||
COPY --from=frontend-builder /frontend/dist ./internal/assets/dist
|
||||
|
||||
RUN CGO_ENABLED=0 go build -tags "${BUILD_TAGS}" -ldflags "${LDFLAGS} \
|
||||
|
||||
@@ -12,6 +12,7 @@ RUN go install github.com/go-delve/delve/cmd/dlv@v1.26.3
|
||||
|
||||
COPY ./cmd ./cmd
|
||||
COPY ./internal ./internal
|
||||
COPY ./pkg ./pkg
|
||||
COPY ./air.toml ./
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Site builder
|
||||
FROM node:26.4-alpine3.23 AS frontend-builder
|
||||
FROM node:26.5-alpine3.23 AS frontend-builder
|
||||
|
||||
WORKDIR /frontend
|
||||
|
||||
@@ -39,6 +39,7 @@ RUN go mod download
|
||||
|
||||
COPY ./cmd ./cmd/
|
||||
COPY ./internal ./internal
|
||||
COPY ./pkg ./pkg
|
||||
COPY --from=frontend-builder /frontend/dist ./internal/assets/dist
|
||||
|
||||
RUN CGO_ENABLED=0 go build -tags "${BUILD_TAGS}" -ldflags "${LDFLAGS} \
|
||||
|
||||
@@ -62,18 +62,19 @@ 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:
|
||||
A big thank-you to the following individuals and companies
|
||||
for their support for Tinyauth.
|
||||
|
||||
### Companies and Organizations
|
||||
|
||||
- [JetBrains for Open-Source](https://jb.gg/OpenSource)
|
||||
- [CodeRabbit AI](https://www.coderabbit.ai)
|
||||
- [Dosu](https://dosu.dev/)
|
||||
- [InstaPods](https://instapods.com) - [Deploy Tinyauth from 3$/month](https://app.instapods.com/dashboard/pods/create?app=tinyauth&ref=tinyauth)
|
||||
|
||||
### Individuals
|
||||
|
||||
<!-- 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 -->
|
||||
|
||||
@@ -81,9 +82,4 @@ A big thank you to the following people for providing me with more coffee:
|
||||
|
||||
- **Freepik** for providing the police hat and badge.
|
||||
- **Renee French** for the original gopher logo.
|
||||
- **Coderabbit AI** for providing free AI code reviews.
|
||||
- **Syrhu** for providing the background image of the app.
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#tinyauthapp/tinyauth&Date)
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.6 MiB After Width: | Height: | Size: 3.7 MiB |
+5
-11
@@ -1,12 +1,6 @@
|
||||
"base_path": "."
|
||||
"base_url": "https://api.crowdin.com"
|
||||
|
||||
"preserve_hierarchy": true
|
||||
|
||||
base_path: .
|
||||
base_url: 'https://api.crowdin.com'
|
||||
preserve_hierarchy: 1
|
||||
files:
|
||||
[
|
||||
{
|
||||
"source": "/frontend/src/lib/i18n/locales/en.json",
|
||||
"translation": "/frontend/src/lib/i18n/locales/%locale%.json",
|
||||
},
|
||||
]
|
||||
- source: /frontend/src/lib/i18n/locales/en.json
|
||||
translation: /frontend/src/lib/i18n/locales/%locale%.json
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# playwright files
|
||||
/node_modules/
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
/playwright/.auth/
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
auto_https off
|
||||
}
|
||||
|
||||
http://whoami.127.0.0.1.sslip.io {
|
||||
forward_auth tinyauth:3000 {
|
||||
uri /api/auth/caddy
|
||||
copy_headers Remote-User Remote-Name Remote-Email Remote-Groups
|
||||
}
|
||||
reverse_proxy whoami:80
|
||||
}
|
||||
|
||||
http://tinyauth.127.0.0.1.sslip.io {
|
||||
reverse_proxy tinyauth:3000
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
appUrl: http://tinyauth.127.0.0.1.sslip.io
|
||||
|
||||
log:
|
||||
level: debug
|
||||
|
||||
auth:
|
||||
users:
|
||||
# user1:password,user2:password,user3:password:token
|
||||
- user1:$2a$10$h1laww4k5a4bJcG5KwE3nO45YKSC4mOKHxbcccgxr3Y7H9zHlQe8e
|
||||
- user2:$2a$10$h1laww4k5a4bJcG5KwE3nO45YKSC4mOKHxbcccgxr3Y7H9zHlQe8e
|
||||
- user3:$2a$10$h1laww4k5a4bJcG5KwE3nO45YKSC4mOKHxbcccgxr3Y7H9zHlQe8e:MVR4JQWNXYKNM6HHJEYEFP2O74QIIEJE
|
||||
# disable rate limits for multiple workers to work
|
||||
loginMaxRetries: 0
|
||||
|
||||
apps:
|
||||
whoami:
|
||||
config:
|
||||
domain: whoami.127.0.0.1.sslip.io
|
||||
path:
|
||||
allow: /foo
|
||||
users:
|
||||
allow: user1
|
||||
@@ -0,0 +1,24 @@
|
||||
services:
|
||||
caddy:
|
||||
image: caddy:2.11.4
|
||||
pull_policy: missing
|
||||
ports:
|
||||
- 80:80
|
||||
volumes:
|
||||
- ./conf:/etc/caddy
|
||||
|
||||
whoami:
|
||||
image: traefik/whoami:v1.11.0
|
||||
pull_policy: missing
|
||||
|
||||
tinyauth:
|
||||
build:
|
||||
context: ../
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
- VERSION=e2e
|
||||
- BUILD_TAGS=nomsgpack
|
||||
- LDFLAGS=-s -w
|
||||
command: ["--configfile", "/app/config.yaml"]
|
||||
volumes:
|
||||
- ./config.e2e.yaml:/app/config.yaml:ro
|
||||
@@ -0,0 +1,47 @@
|
||||
import {expect, Page} from '@playwright/test';
|
||||
import { OTP } from 'otplib';
|
||||
|
||||
export class LoginFixture {
|
||||
constructor(public readonly page: Page) {}
|
||||
|
||||
async run(username: string, password: string) {
|
||||
await expect(this.page.getByText('Welcome back, please login')).toBeVisible();
|
||||
await this.page.getByLabel('Username').fill(username);
|
||||
await this.page.getByLabel('Password').fill(password);
|
||||
await this.page.getByRole('button', { name: 'Login' }).click();
|
||||
}
|
||||
|
||||
async expectSuccess(username: string) {
|
||||
await expect(this.page.getByText(`You are currently logged in as ${username}.`)).toBeVisible()
|
||||
}
|
||||
}
|
||||
|
||||
export class LogoutFixture {
|
||||
constructor(public readonly page: Page) {}
|
||||
|
||||
async run() {
|
||||
await expect(this.page.getByText('Click the button below to logout.')).toBeVisible();
|
||||
await this.page.getByRole('button', { name: 'Logout' }).click();
|
||||
}
|
||||
|
||||
async expectSuccess() {
|
||||
await expect(this.page.getByText('Welcome back, please login')).toBeVisible();
|
||||
}
|
||||
}
|
||||
|
||||
export class TOTPFixture {
|
||||
constructor(public readonly page: Page) {}
|
||||
|
||||
async run(secret: string) {
|
||||
await expect(this.page.getByText('Enter your TOTP code')).toBeVisible();
|
||||
const otp = new OTP();
|
||||
const token = await otp.generate({ secret });
|
||||
await this.page.getByPlaceholder('XXXXXX').fill(token);
|
||||
// we shouldn't need to click continue, it will auto submit
|
||||
// await this.page.getByRole('button', { name: 'Continue' }).click();
|
||||
}
|
||||
|
||||
async expectSuccess(username: string) {
|
||||
await expect(this.page.getByText(`You are currently logged in as ${username}.`)).toBeVisible()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "e2e",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"down": "docker compose -f docker-compose.e2e.yml down",
|
||||
"up": "docker compose -f docker-compose.e2e.yml up --build --force-recreate --remove-orphans",
|
||||
"test": "playwright test",
|
||||
"report": "playwright show-report"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devEngines": {
|
||||
"packageManager": {
|
||||
"name": "pnpm",
|
||||
"version": "^11.1.2",
|
||||
"onFail": "download"
|
||||
}
|
||||
},
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@types/node": "^26.1.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"otplib": "^13.4.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './specs',
|
||||
fullyParallel: true,
|
||||
forbidOnly: false,
|
||||
retries: 0,
|
||||
workers: 4,
|
||||
reporter: 'html',
|
||||
use: {
|
||||
trace: 'on-first-retry',
|
||||
video: 'on',
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
{
|
||||
name: 'firefox',
|
||||
use: { ...devices['Desktop Firefox'] },
|
||||
},
|
||||
|
||||
{
|
||||
name: 'webkit',
|
||||
use: { ...devices['Desktop Safari'] },
|
||||
},
|
||||
{
|
||||
name: 'Mobile Chrome',
|
||||
use: { ...devices['Pixel 5'] },
|
||||
},
|
||||
{
|
||||
name: 'Mobile Safari',
|
||||
use: { ...devices['iPhone 12'] },
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: 'pnpm run up',
|
||||
url: 'http://tinyauth.127.0.0.1.sslip.io/api/healthz',
|
||||
reuseExistingServer: true,
|
||||
timeout: 5 * 60 * 1000,
|
||||
gracefulShutdown: {
|
||||
signal: 'SIGINT',
|
||||
timeout: 1000,
|
||||
},
|
||||
stderr: 'pipe',
|
||||
stdout: 'pipe',
|
||||
},
|
||||
});
|
||||
Generated
+338
@@ -0,0 +1,338 @@
|
||||
---
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
configDependencies: {}
|
||||
packageManagerDependencies:
|
||||
'@pnpm/exe':
|
||||
specifier: ^11.1.2
|
||||
version: 11.13.1
|
||||
pnpm:
|
||||
specifier: ^11.1.2
|
||||
version: 11.13.1
|
||||
|
||||
packages:
|
||||
|
||||
'@pnpm/exe@11.13.1':
|
||||
resolution: {integrity: sha512-P4euEK6lOFnd5oTHEc5M/HhvyF4XUhTnVsklEcM6rmY0QJxPD6xbT+u1+gskEIBp4nSRorz20IJQtAU1Nerggg==}
|
||||
hasBin: true
|
||||
|
||||
'@pnpm/linux-arm64@11.13.1':
|
||||
resolution: {integrity: sha512-wB8zloqrYrudPyuA5qbuTCnJGe4eETPwqOjoPjoyyyvA4zFI5XfLpxgqOOcaY5UJBoqzckcGpRVDwhSRfsQ/6A==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@pnpm/linux-x64@11.13.1':
|
||||
resolution: {integrity: sha512-A+wnEvzfWEvanXiwww3tnOPmtjPSrrf5tOP6vk8+K0BRFEe/Df0oPytm2nWgGcn5iwPnqtr1Btkof913McnSPA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@pnpm/linuxstatic-arm64@11.13.1':
|
||||
resolution: {integrity: sha512-k4t65VeqRX4COMFe45TF58CVmCpmAsKZShaR1HobmUeleo98mWTctggKolrA2MHcVUeSS+12yB5Urb3uDazhmw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@pnpm/linuxstatic-x64@11.13.1':
|
||||
resolution: {integrity: sha512-A65GqPzwCl0bAMk3kRWfbjSRBm5RRaqR2oMxV/9AYZrwO0X9yEfngbLBISCPHjt6/Qe4nH7DFemyhy6yODYwEw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@pnpm/macos-arm64@11.13.1':
|
||||
resolution: {integrity: sha512-MJvOtyGOWSfBoqdVEfAH8ljmHs13mt82k/UxN4f+q7koDxJRR2n4Nie6Og6RwbnbaubCz0Fh2bTeL1+MxDSFpA==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@pnpm/win-arm64@11.13.1':
|
||||
resolution: {integrity: sha512-kl/g1cCKOJPe4HntspyrAJW0LRco0UHnVfxHSspezo4Zj4AanJAZ8WzLqfa6/w3lBSKHTEN4x0pb3m4J7B7Vpw==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@pnpm/win-x64@11.13.1':
|
||||
resolution: {integrity: sha512-Bcb14NeBlbHS2Gq1qr8VnCiAz5eC1lYzXOls7zH0bnV0Taaj4/xyfm0HVO4dn9R2TQtVtz1qnBZHHL9PDFumqQ==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@reflink/reflink-darwin-arm64@0.1.19':
|
||||
resolution: {integrity: sha512-ruy44Lpepdk1FqDz38vExBY/PVUsjxZA+chd9wozjUH9JjuDT/HEaQYA6wYN9mf041l0yLVar6BCZuWABJvHSA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@reflink/reflink-darwin-x64@0.1.19':
|
||||
resolution: {integrity: sha512-By85MSWrMZa+c26TcnAy8SDk0sTUkYlNnwknSchkhHpGXOtjNDUOxJE9oByBnGbeuIE1PiQsxDG3Ud+IVV9yuA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@reflink/reflink-linux-arm64-gnu@0.1.19':
|
||||
resolution: {integrity: sha512-7P+er8+rP9iNeN+bfmccM4hTAaLP6PQJPKWSA4iSk2bNvo6KU6RyPgYeHxXmzNKzPVRcypZQTpFgstHam6maVg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@reflink/reflink-linux-arm64-musl@0.1.19':
|
||||
resolution: {integrity: sha512-37iO/Dp6m5DDaC2sf3zPtx/hl9FV3Xze4xoYidrxxS9bgP3S8ALroxRK6xBG/1TtfXKTvolvp+IjrUU6ujIGmA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@reflink/reflink-linux-x64-gnu@0.1.19':
|
||||
resolution: {integrity: sha512-jbI8jvuYCaA3MVUdu8vLoLAFqC+iNMpiSuLbxlAgg7x3K5bsS8nOpTRnkLF7vISJ+rVR8W+7ThXlXlUQ93ulkw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@reflink/reflink-linux-x64-musl@0.1.19':
|
||||
resolution: {integrity: sha512-e9FBWDe+lv7QKAwtKOt6A2W/fyy/aEEfr0g6j/hWzvQcrzHCsz07BNQYlNOjTfeytrtLU7k449H1PI95jA4OjQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@reflink/reflink-win32-arm64-msvc@0.1.19':
|
||||
resolution: {integrity: sha512-09PxnVIQcd+UOn4WAW73WU6PXL7DwGS6wPlkMhMg2zlHHG65F3vHepOw06HFCq+N42qkaNAc8AKIabWvtk6cIQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@reflink/reflink-win32-x64-msvc@0.1.19':
|
||||
resolution: {integrity: sha512-E//yT4ni2SyhwP8JRjVGWr3cbnhWDiPLgnQ66qqaanjjnMiu3O/2tjCPQXlcGc/DEYofpDc9fvhv6tALQsMV9w==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@reflink/reflink@0.1.19':
|
||||
resolution: {integrity: sha512-DmCG8GzysnCZ15bres3N5AHCmwBwYgp0As6xjhQ47rAUTUXxJiK+lLUxaGsX3hd/30qUpVElh05PbGuxRPgJwA==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
detect-libc@2.1.2:
|
||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
pnpm@11.13.1:
|
||||
resolution: {integrity: sha512-svx2g7imUlQU59E+G6KMqt3elr9m7FQL+ut+cCuB8+C+TR8pXt9/n+A5Z0Co3ORQnFgt33mJH0VD/qMtN2RfJQ==}
|
||||
engines: {node: '>=22.13'}
|
||||
hasBin: true
|
||||
|
||||
snapshots:
|
||||
|
||||
'@pnpm/exe@11.13.1':
|
||||
dependencies:
|
||||
'@reflink/reflink': 0.1.19
|
||||
detect-libc: 2.1.2
|
||||
optionalDependencies:
|
||||
'@pnpm/linux-arm64': 11.13.1
|
||||
'@pnpm/linux-x64': 11.13.1
|
||||
'@pnpm/linuxstatic-arm64': 11.13.1
|
||||
'@pnpm/linuxstatic-x64': 11.13.1
|
||||
'@pnpm/macos-arm64': 11.13.1
|
||||
'@pnpm/win-arm64': 11.13.1
|
||||
'@pnpm/win-x64': 11.13.1
|
||||
|
||||
'@pnpm/linux-arm64@11.13.1':
|
||||
optional: true
|
||||
|
||||
'@pnpm/linux-x64@11.13.1':
|
||||
optional: true
|
||||
|
||||
'@pnpm/linuxstatic-arm64@11.13.1':
|
||||
optional: true
|
||||
|
||||
'@pnpm/linuxstatic-x64@11.13.1':
|
||||
optional: true
|
||||
|
||||
'@pnpm/macos-arm64@11.13.1':
|
||||
optional: true
|
||||
|
||||
'@pnpm/win-arm64@11.13.1':
|
||||
optional: true
|
||||
|
||||
'@pnpm/win-x64@11.13.1':
|
||||
optional: true
|
||||
|
||||
'@reflink/reflink-darwin-arm64@0.1.19':
|
||||
optional: true
|
||||
|
||||
'@reflink/reflink-darwin-x64@0.1.19':
|
||||
optional: true
|
||||
|
||||
'@reflink/reflink-linux-arm64-gnu@0.1.19':
|
||||
optional: true
|
||||
|
||||
'@reflink/reflink-linux-arm64-musl@0.1.19':
|
||||
optional: true
|
||||
|
||||
'@reflink/reflink-linux-x64-gnu@0.1.19':
|
||||
optional: true
|
||||
|
||||
'@reflink/reflink-linux-x64-musl@0.1.19':
|
||||
optional: true
|
||||
|
||||
'@reflink/reflink-win32-arm64-msvc@0.1.19':
|
||||
optional: true
|
||||
|
||||
'@reflink/reflink-win32-x64-msvc@0.1.19':
|
||||
optional: true
|
||||
|
||||
'@reflink/reflink@0.1.19':
|
||||
optionalDependencies:
|
||||
'@reflink/reflink-darwin-arm64': 0.1.19
|
||||
'@reflink/reflink-darwin-x64': 0.1.19
|
||||
'@reflink/reflink-linux-arm64-gnu': 0.1.19
|
||||
'@reflink/reflink-linux-arm64-musl': 0.1.19
|
||||
'@reflink/reflink-linux-x64-gnu': 0.1.19
|
||||
'@reflink/reflink-linux-x64-musl': 0.1.19
|
||||
'@reflink/reflink-win32-arm64-msvc': 0.1.19
|
||||
'@reflink/reflink-win32-x64-msvc': 0.1.19
|
||||
|
||||
detect-libc@2.1.2: {}
|
||||
|
||||
pnpm@11.13.1: {}
|
||||
|
||||
---
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
otplib:
|
||||
specifier: ^13.4.1
|
||||
version: 13.4.1
|
||||
devDependencies:
|
||||
'@playwright/test':
|
||||
specifier: ^1.61.1
|
||||
version: 1.61.1
|
||||
'@types/node':
|
||||
specifier: ^26.1.1
|
||||
version: 26.1.1
|
||||
|
||||
packages:
|
||||
|
||||
'@noble/hashes@2.2.0':
|
||||
resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==}
|
||||
engines: {node: '>= 20.19.0'}
|
||||
|
||||
'@otplib/core@13.4.1':
|
||||
resolution: {integrity: sha512-KIXgK1hNtWJEBMTastbe1bpmuais+3f+ATeO8TkMs2rNkfGO1FbQy8+/UWVEu3TR/iTJerU0idkPudaPmLP2BA==}
|
||||
|
||||
'@otplib/hotp@13.4.1':
|
||||
resolution: {integrity: sha512-g9q04SwpG5ZtMnVkUcgcoAlwCH4YLROZN1qhyBwgkBzqYYVSYhpP6gSGaxGHwePLt1c+e6NqDlgIZN+e1/XPuA==}
|
||||
|
||||
'@otplib/plugin-base32-scure@13.4.1':
|
||||
resolution: {integrity: sha512-Fs/r5qisC05SRhT6xWXaypB6PVC0vgWf6zztmi0J5RnQ09OJiPDWCJFH6cDm6ANsrdvB9di7X+Jb7L13BoEbUA==}
|
||||
|
||||
'@otplib/plugin-crypto-noble@13.4.1':
|
||||
resolution: {integrity: sha512-PJfVW8/1hdS6CfxLheKPZSLTwDq4TijZbN4yRjxlv0ODdzmxpM+wGwWr1JXMdy0xJPxLziydQD5gdVqrR4/gAg==}
|
||||
|
||||
'@otplib/totp@13.4.1':
|
||||
resolution: {integrity: sha512-QOkBVPrf6AM4qZaReZPSk9/I8ATVdZpIISJz115MqeVtcrbcr5llPZ0J7804tpnjnp1vCRkI5Qjd47HhgVteBQ==}
|
||||
|
||||
'@otplib/uri@13.4.1':
|
||||
resolution: {integrity: sha512-xaIm7bvICMhoB2rZIR5luiaMdssWR5nY5nXnR1fdezUgZuEO58D6zrGzLp7pQuBmlpmL0HagnscDQFoskp9yiA==}
|
||||
|
||||
'@playwright/test@1.61.1':
|
||||
resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
'@scure/base@2.2.0':
|
||||
resolution: {integrity: sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==}
|
||||
|
||||
'@types/node@26.1.1':
|
||||
resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==}
|
||||
|
||||
fsevents@2.3.2:
|
||||
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
otplib@13.4.1:
|
||||
resolution: {integrity: sha512-o5CxfDw6bh7hoDv0NUUIcc0RqzJ9ipfUrzeKheKJ+vs4rXZnDlA9n4a/7R1cDjpmLjKLix4BgNVRmoDkm5rLSQ==}
|
||||
|
||||
playwright-core@1.61.1:
|
||||
resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
playwright@1.61.1:
|
||||
resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
undici-types@8.3.0:
|
||||
resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@noble/hashes@2.2.0': {}
|
||||
|
||||
'@otplib/core@13.4.1': {}
|
||||
|
||||
'@otplib/hotp@13.4.1':
|
||||
dependencies:
|
||||
'@otplib/core': 13.4.1
|
||||
'@otplib/uri': 13.4.1
|
||||
|
||||
'@otplib/plugin-base32-scure@13.4.1':
|
||||
dependencies:
|
||||
'@otplib/core': 13.4.1
|
||||
'@scure/base': 2.2.0
|
||||
|
||||
'@otplib/plugin-crypto-noble@13.4.1':
|
||||
dependencies:
|
||||
'@noble/hashes': 2.2.0
|
||||
'@otplib/core': 13.4.1
|
||||
|
||||
'@otplib/totp@13.4.1':
|
||||
dependencies:
|
||||
'@otplib/core': 13.4.1
|
||||
'@otplib/hotp': 13.4.1
|
||||
'@otplib/uri': 13.4.1
|
||||
|
||||
'@otplib/uri@13.4.1':
|
||||
dependencies:
|
||||
'@otplib/core': 13.4.1
|
||||
|
||||
'@playwright/test@1.61.1':
|
||||
dependencies:
|
||||
playwright: 1.61.1
|
||||
|
||||
'@scure/base@2.2.0': {}
|
||||
|
||||
'@types/node@26.1.1':
|
||||
dependencies:
|
||||
undici-types: 8.3.0
|
||||
|
||||
fsevents@2.3.2:
|
||||
optional: true
|
||||
|
||||
otplib@13.4.1:
|
||||
dependencies:
|
||||
'@otplib/core': 13.4.1
|
||||
'@otplib/hotp': 13.4.1
|
||||
'@otplib/plugin-base32-scure': 13.4.1
|
||||
'@otplib/plugin-crypto-noble': 13.4.1
|
||||
'@otplib/totp': 13.4.1
|
||||
'@otplib/uri': 13.4.1
|
||||
|
||||
playwright-core@1.61.1: {}
|
||||
|
||||
playwright@1.61.1:
|
||||
dependencies:
|
||||
playwright-core: 1.61.1
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.2
|
||||
|
||||
undici-types@8.3.0: {}
|
||||
@@ -0,0 +1,28 @@
|
||||
import {expect, test} from '@playwright/test';
|
||||
import {LoginFixture} from "../fixtures/auth.fixtures";
|
||||
|
||||
test('should be able to login to app with forward-auth', async ({ page }) => {
|
||||
const loginFixture = new LoginFixture(page);
|
||||
await page.goto('http://whoami.127.0.0.1.sslip.io');
|
||||
// redirect to tinyauth
|
||||
await expect(page.getByText('Welcome back, please login')).toBeVisible()
|
||||
await loginFixture.run('user1', 'password')
|
||||
// redirect to app
|
||||
await expect(page.getByText('whoami.127.0.0.1.sslip.io')).toBeVisible()
|
||||
});
|
||||
|
||||
test('non authorized user should not be able to access app', async ({ page }) => {
|
||||
const loginFixture = new LoginFixture(page);
|
||||
await page.goto('http://whoami.127.0.0.1.sslip.io');
|
||||
// redirect to tinyauth
|
||||
await expect(page.getByText('Welcome back, please login')).toBeVisible()
|
||||
// user2 is not authorized to access app
|
||||
await loginFixture.run('user2', 'password')
|
||||
// redirect to app
|
||||
await expect(page.getByText('The user with username user2 is not authorized to access the resource whoami.')).toBeVisible()
|
||||
})
|
||||
|
||||
test('allowed path should skip authentication', async ({ page }) => {
|
||||
await page.goto('http://whoami.127.0.0.1.sslip.io/foo');
|
||||
await expect(page.getByText('whoami.127.0.0.1.sslip.io')).toBeVisible()
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {LoginFixture, LogoutFixture, TOTPFixture} from "../fixtures/auth.fixtures";
|
||||
|
||||
test('should be able to login', async ({ page }) => {
|
||||
const loginFixture = new LoginFixture(page);
|
||||
await page.goto('http://tinyauth.127.0.0.1.sslip.io');
|
||||
await loginFixture.run('user1', 'password')
|
||||
await loginFixture.expectSuccess('user1')
|
||||
});
|
||||
|
||||
test('should fail to login with wrong credentials', async ({ page }) => {
|
||||
const loginFixture = new LoginFixture(page);
|
||||
await page.goto('http://tinyauth.127.0.0.1.sslip.io');
|
||||
await loginFixture.run('user27267', 'password')
|
||||
const toast = page.locator('[data-sonner-toast]').first();
|
||||
await expect(toast).toBeVisible();
|
||||
await expect(toast).toContainText('Failed to log in');
|
||||
});
|
||||
|
||||
|
||||
test('should be able to logout', async ({ page }) => {
|
||||
const loginFixture = new LoginFixture(page);
|
||||
await page.goto('http://tinyauth.127.0.0.1.sslip.io');
|
||||
await loginFixture.run('user1', 'password')
|
||||
await loginFixture.expectSuccess('user1')
|
||||
const logoutFixture = new LogoutFixture(page);
|
||||
await logoutFixture.run()
|
||||
})
|
||||
|
||||
test('should be able to login with totp', async ({ page }) => {
|
||||
const loginFixture = new LoginFixture(page);
|
||||
const totpFixture = new TOTPFixture(page);
|
||||
await page.goto('http://tinyauth.127.0.0.1.sslip.io');
|
||||
await loginFixture.run('user3', 'password')
|
||||
await totpFixture.run('MVR4JQWNXYKNM6HHJEYEFP2O74QIIEJE')
|
||||
await loginFixture.expectSuccess('user3');
|
||||
});
|
||||
|
||||
test('should fail to login with wrong totp', async ({ page }) => {
|
||||
const loginFixture = new LoginFixture(page);
|
||||
const totpFixture = new TOTPFixture(page);
|
||||
await page.goto('http://tinyauth.127.0.0.1.sslip.io');
|
||||
await loginFixture.run('user3', 'password')
|
||||
await totpFixture.run('VZVMOMQCBN24DJ5VRFAL5TJAZGBHXMN3')
|
||||
const toast = page.locator('[data-sonner-toast]').first();
|
||||
await expect(toast).toBeVisible();
|
||||
await expect(toast).toContainText('Failed to verify code');
|
||||
});
|
||||
+10
-10
@@ -13,9 +13,9 @@
|
||||
"packageManager": "pnpm@11.1.2",
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.19",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.20",
|
||||
"@radix-ui/react-label": "^2.1.11",
|
||||
"@radix-ui/react-select": "^2.3.2",
|
||||
"@radix-ui/react-select": "^2.3.3",
|
||||
"@radix-ui/react-separator": "^1.1.11",
|
||||
"@radix-ui/react-slot": "^1.3.0",
|
||||
"@tailwindcss/vite": "^4.3.2",
|
||||
@@ -23,18 +23,18 @@
|
||||
"axios": "^1.18.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"i18next": "^26.3.4",
|
||||
"i18next": "^26.3.6",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"i18next-resources-to-backend": "^1.2.1",
|
||||
"lucide-react": "^1.23.0",
|
||||
"lucide-react": "^1.24.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"radix-ui": "^1.6.1",
|
||||
"radix-ui": "^1.6.2",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-hook-form": "^7.81.0",
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-i18next": "^17.0.9",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router": "^8.1.0",
|
||||
"react-router": "^8.2.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.3.2",
|
||||
@@ -43,7 +43,7 @@
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tanstack/eslint-plugin-query": "^5.101.2",
|
||||
"@types/node": "^26.1.0",
|
||||
"@types/node": "^26.1.1",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
@@ -54,7 +54,7 @@
|
||||
"rollup-plugin-visualizer": "^7.0.1",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.62.1",
|
||||
"vite": "^8.1.3"
|
||||
"typescript-eslint": "^8.63.0",
|
||||
"vite": "^8.1.4"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+496
-406
File diff suppressed because it is too large
Load Diff
+4
-1
@@ -46,11 +46,14 @@ func generateExampleEnv() {
|
||||
func buildEnvEntry(child reflect.StructField, childValue reflect.Value, parentPath string, entries *[]EnvEntry) {
|
||||
desc := child.Tag.Get("description")
|
||||
tag := child.Tag.Get("yaml")
|
||||
gen := child.Tag.Get("gen")
|
||||
|
||||
if tag == "-" {
|
||||
if tag == "-" && gen != "include" {
|
||||
return
|
||||
}
|
||||
|
||||
tag = strings.TrimSuffix(tag, ",omitempty")
|
||||
|
||||
value := childValue.Interface()
|
||||
|
||||
entry := EnvEntry{
|
||||
|
||||
+63
-19
@@ -1,7 +1,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
@@ -13,9 +12,13 @@ import (
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
)
|
||||
|
||||
type ConfigOptions struct {
|
||||
Env string
|
||||
Flag string
|
||||
YAML string
|
||||
}
|
||||
type MarkdownEntry struct {
|
||||
Env string
|
||||
Flag string
|
||||
Options ConfigOptions
|
||||
Description string
|
||||
Default any
|
||||
}
|
||||
@@ -47,16 +50,27 @@ func generateMarkdown() {
|
||||
func buildMdEntry(child reflect.StructField, childValue reflect.Value, parentPath string, entries *[]MarkdownEntry) {
|
||||
desc := child.Tag.Get("description")
|
||||
tag := child.Tag.Get("yaml")
|
||||
gen := child.Tag.Get("gen")
|
||||
|
||||
if tag == "-" {
|
||||
if tag == "-" && gen != "include" {
|
||||
return
|
||||
}
|
||||
|
||||
tag = strings.TrimSuffix(tag, ",omitempty")
|
||||
|
||||
value := childValue.Interface()
|
||||
|
||||
configOptions := ConfigOptions{
|
||||
Env: strings.ToUpper(strings.ReplaceAll(parentPath, ".", "_")) + strings.ToUpper(child.Name),
|
||||
Flag: fmt.Sprintf("--%s%s", strings.TrimPrefix(parentPath, "tinyauth."), strings.ToLower(child.Name)),
|
||||
}
|
||||
|
||||
if tag != "-" && tag != "" {
|
||||
configOptions.YAML = strings.TrimPrefix(parentPath, "tinyauth.") + tag
|
||||
}
|
||||
|
||||
entry := MarkdownEntry{
|
||||
Env: strings.ToUpper(strings.ReplaceAll(parentPath, ".", "_")) + strings.ToUpper(child.Name),
|
||||
Flag: fmt.Sprintf("--%s%s", strings.TrimPrefix(parentPath, "tinyauth."), strings.ToLower(child.Name)),
|
||||
Options: configOptions,
|
||||
Description: desc,
|
||||
}
|
||||
|
||||
@@ -83,11 +97,14 @@ func buildMdMapEntry(child reflect.StructField, parentPath string, entries *[]Ma
|
||||
}
|
||||
|
||||
tag := child.Tag.Get("yaml")
|
||||
gen := child.Tag.Get("gen")
|
||||
|
||||
if tag == "-" {
|
||||
if tag == "-" && gen != "include" {
|
||||
return
|
||||
}
|
||||
|
||||
tag = strings.TrimSuffix(tag, ",omitempty")
|
||||
|
||||
mapPath := parentPath + tag + ".[name]."
|
||||
valueType := fieldType.Elem()
|
||||
|
||||
@@ -102,27 +119,54 @@ func buildMdChildPath(parent string, child string) string {
|
||||
}
|
||||
|
||||
func compileMd(entries []MarkdownEntry) []byte {
|
||||
buffer := bytes.Buffer{}
|
||||
buffer := strings.Builder{}
|
||||
|
||||
buffer.WriteString("<!--- This file is automatically generated by gen/gen_md.go. Do not edit manually. --->\n\n")
|
||||
buffer.WriteString("# Tinyauth configuration reference\n\n")
|
||||
buffer.WriteString("| Environment | Flag | Description | Default |\n")
|
||||
buffer.WriteString("| - | - | - | - |\n")
|
||||
buffer.WriteString("{/* This part is automatically generated by gen/gen_md.go in the main repository. DO NOT EDIT. */}\n\n")
|
||||
buffer.WriteString("import { Tabs, TabItem } from '@astrojs/starlight/components';\n\n")
|
||||
buffer.WriteString("<Tabs>\n")
|
||||
|
||||
renderTabItem(&buffer, entries, "Environment")
|
||||
renderTabItem(&buffer, entries, "Flags")
|
||||
renderTabItem(&buffer, entries, "YAML")
|
||||
|
||||
buffer.WriteString("</Tabs>\n")
|
||||
|
||||
return []byte(buffer.String())
|
||||
}
|
||||
|
||||
func renderTabItem(buffer *strings.Builder, entries []MarkdownEntry, section string) {
|
||||
buffer.WriteString(fmt.Sprintf(" <TabItem label=\"%s\">\n", section))
|
||||
buffer.WriteString(" ### main\n\n")
|
||||
buffer.WriteString(" | Option | Description | Default |\n")
|
||||
buffer.WriteString(" | - | - | - |\n")
|
||||
|
||||
configType := strings.ToLower(section)
|
||||
previousSection := ""
|
||||
|
||||
for _, entry := range entries {
|
||||
if strings.Count(entry.Env, "_") > 1 {
|
||||
section := strings.Split(strings.TrimPrefix(entry.Env, "TINYAUTH_"), "_")[0]
|
||||
section := strings.Split(entry.Options.YAML, ".")[0]
|
||||
var option string
|
||||
switch configType {
|
||||
case "yaml":
|
||||
option = entry.Options.YAML
|
||||
case "flags":
|
||||
option = entry.Options.Flag
|
||||
case "environment":
|
||||
option = entry.Options.Env
|
||||
}
|
||||
if option == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Count(entry.Options.YAML, ".") >= 1 {
|
||||
if section != previousSection {
|
||||
buffer.WriteString("\n## " + strings.ToLower(section) + "\n\n")
|
||||
buffer.WriteString("| Environment | Flag | Description | Default |\n")
|
||||
buffer.WriteString("| - | - | - | - |\n")
|
||||
buffer.WriteString("\n ### " + strings.ToLower(section) + "\n\n")
|
||||
buffer.WriteString(" | Option | Description | Default |\n")
|
||||
buffer.WriteString(" | - | - | - |\n")
|
||||
previousSection = section
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(&buffer, "| `%s` | `%s` | %s | %s |\n", entry.Env, entry.Flag, entry.Description, entry.Default)
|
||||
fmt.Fprintf(buffer, " | `%s` | %s | %s |\n", option, entry.Description, entry.Default)
|
||||
}
|
||||
|
||||
return buffer.Bytes()
|
||||
buffer.WriteString(" </TabItem>\n")
|
||||
}
|
||||
|
||||
@@ -23,9 +23,9 @@ require (
|
||||
github.com/tinyauthapp/paerser v0.0.0-20260410140347-85c3740d6298
|
||||
github.com/weppos/publicsuffix-go v0.50.3
|
||||
go.uber.org/dig v1.19.0
|
||||
golang.org/x/crypto v0.53.0
|
||||
golang.org/x/crypto v0.54.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
golang.org/x/tools v0.47.0
|
||||
golang.org/x/tools v0.48.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
k8s.io/apimachinery v0.36.2
|
||||
k8s.io/client-go v0.36.2
|
||||
@@ -134,12 +134,12 @@ require (
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
golang.org/x/arch v0.22.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||
golang.org/x/mod v0.37.0 // indirect
|
||||
golang.org/x/net v0.56.0 // indirect
|
||||
golang.org/x/sync v0.21.0 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
golang.org/x/term v0.44.0 // indirect
|
||||
golang.org/x/text v0.38.0 // indirect
|
||||
golang.org/x/mod v0.38.0 // indirect
|
||||
golang.org/x/net v0.57.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/term v0.45.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect
|
||||
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||
|
||||
@@ -336,29 +336,29 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
|
||||
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
|
||||
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/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
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.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||
google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M=
|
||||
|
||||
@@ -30,6 +30,16 @@ func (app *BootstrapApp) setupRouter() error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set trusted proxies: %w", err)
|
||||
}
|
||||
|
||||
app.runtime.TrustedProxiesConfigured = true
|
||||
} else {
|
||||
err := engine.SetTrustedProxies(nil)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set trusted proxies: %w", err)
|
||||
}
|
||||
|
||||
app.log.App.Warn().Msg("Trusted proxies are not configured, IP access controls will NOT work")
|
||||
}
|
||||
|
||||
middlewareProvideFor := []any{
|
||||
|
||||
@@ -85,7 +85,8 @@ func (app *BootstrapApp) getLabelProvider() (service.LabelProvider, error) {
|
||||
err := app.dig.Provide(service.NewKubernetesService)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to provide kubernetes service: %w", err)
|
||||
app.log.App.Warn().Err(err).Msg("Failed to provide kubernetes service")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
err = app.dig.Invoke(func(k *service.KubernetesService) error {
|
||||
@@ -94,7 +95,8 @@ func (app *BootstrapApp) getLabelProvider() (service.LabelProvider, error) {
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to invoke kubernetes service: %w", err)
|
||||
app.log.App.Warn().Err(err).Msg("Failed to invoke kubernetes service")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Kubernetes will fail to initialize with an error if it cannot connect to the cluster
|
||||
@@ -114,7 +116,8 @@ func (app *BootstrapApp) getLabelProvider() (service.LabelProvider, error) {
|
||||
err := app.dig.Provide(service.NewDockerService)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to provide docker service: %w", err)
|
||||
app.log.App.Warn().Err(err).Msg("Failed to provide docker service")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
err = app.dig.Invoke(func(d *service.DockerService) error {
|
||||
@@ -123,7 +126,8 @@ func (app *BootstrapApp) getLabelProvider() (service.LabelProvider, error) {
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to invoke docker service: %w", err)
|
||||
app.log.App.Warn().Err(err).Msg("Failed to invoke docker service")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if app.services.dockerService == nil {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/tinyauthapp/tinyauth/internal/service"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
"github.com/tinyauthapp/tinyauth/pkg/validators"
|
||||
"go.uber.org/dig"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -311,54 +312,46 @@ func (controller *OAuthController) getCookieDomain() string {
|
||||
}
|
||||
|
||||
func (controller *OAuthController) isRedirectSafe(redirectURI string) bool {
|
||||
u, err := url.Parse(redirectURI)
|
||||
v := validators.NewDomainValidator(validators.DomainValidatorOptions{
|
||||
WithScheme: true,
|
||||
WithPort: true,
|
||||
})
|
||||
|
||||
_, err := v.SafeHostname(controller.runtime.AppURL)
|
||||
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to parse redirect URI")
|
||||
controller.log.App.Error().Err(err).Msg("App URL is invalid, cannot validate redirect URI")
|
||||
return false
|
||||
}
|
||||
|
||||
if u.Scheme == "" || u.Host == "" {
|
||||
controller.log.App.Warn().Msg("Redirect URI has invalid scheme or host")
|
||||
return false
|
||||
}
|
||||
err = v.Validate(redirectURI, controller.runtime.AppURL)
|
||||
|
||||
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 u.Scheme == "https" {
|
||||
return "443"
|
||||
}
|
||||
return "80"
|
||||
}
|
||||
|
||||
if getEffectivePort(u) != getEffectivePort(au) {
|
||||
controller.log.App.Warn().Msg("Redirect URI port does not match app URL port")
|
||||
return false
|
||||
}
|
||||
|
||||
if strings.EqualFold(u.Hostname(), au.Hostname()) {
|
||||
if err == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
controller.log.App.Debug().Err(err).Msg("Failed to validate redirect URI")
|
||||
|
||||
if errors.Is(err, validators.ErrInvalidURL) ||
|
||||
errors.Is(err, validators.ErrSchemeMismatch) ||
|
||||
errors.Is(err, validators.ErrPortMismatch) {
|
||||
return false
|
||||
}
|
||||
|
||||
if !controller.config.Auth.SubdomainsEnabled {
|
||||
return false
|
||||
}
|
||||
|
||||
if strings.HasSuffix(strings.ToLower(u.Hostname()), "."+strings.ToLower(controller.runtime.CookieDomain)) {
|
||||
v = validators.NewDomainValidator(validators.DomainValidatorOptions{})
|
||||
|
||||
hostname, err := v.SafeHostname(redirectURI)
|
||||
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to get safe hostname from redirect URI")
|
||||
return false
|
||||
}
|
||||
|
||||
if strings.HasSuffix(hostname, "."+strings.ToLower(controller.runtime.CookieDomain)) {
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
)
|
||||
|
||||
func TestOAuthControllerIsRedirectSafe(t *testing.T) {
|
||||
func TestOAuthController_isRedirectSafe(t *testing.T) {
|
||||
log := logger.NewLogger().WithTestConfig()
|
||||
log.Init()
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -343,27 +344,31 @@ func (controller *OIDCController) authorizeComplete(c *gin.Context) {
|
||||
// Create the authorization code
|
||||
code := controller.oidc.CreateCode(*authorizeReq, *userContext)
|
||||
|
||||
queries, err := query.Values(AuthorizeCallback{
|
||||
Code: code,
|
||||
State: authorizeReq.State,
|
||||
})
|
||||
cu, err := url.Parse(authorizeReq.RedirectURI)
|
||||
|
||||
if err != nil {
|
||||
controller.authorizeError(c, authorizeErrorParams{
|
||||
err: err,
|
||||
reason: "Failed to build query",
|
||||
reasonPublic: "Failed to build query",
|
||||
callback: authorizeReq.RedirectURI,
|
||||
callbackError: "server_error",
|
||||
state: authorizeReq.State,
|
||||
json: true,
|
||||
err: err,
|
||||
reason: "Failed to parse redirect URI",
|
||||
reasonPublic: "Failed to parse redirect URI",
|
||||
json: true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
q := cu.Query()
|
||||
|
||||
q.Set("code", code)
|
||||
|
||||
if authorizeReq.State != "" {
|
||||
q.Set("state", authorizeReq.State)
|
||||
}
|
||||
|
||||
cu.RawQuery = q.Encode()
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"status": 200,
|
||||
"redirect_uri": fmt.Sprintf("%s?%s", authorizeReq.RedirectURI, queries.Encode()),
|
||||
"redirect_uri": cu.String(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -639,37 +644,37 @@ func (controller *OIDCController) authorizeError(c *gin.Context, params authoriz
|
||||
controller.log.App.Error().Err(params.err).Str("reason", params.reason).Msg("Authorization error")
|
||||
|
||||
if params.callback != "" {
|
||||
errorQueries := CallbackError{
|
||||
Error: params.callbackError,
|
||||
}
|
||||
|
||||
if params.reasonPublic != "" {
|
||||
errorQueries.ErrorDescription = params.reasonPublic
|
||||
}
|
||||
|
||||
if params.state != "" {
|
||||
errorQueries.State = params.state
|
||||
}
|
||||
|
||||
queries, err := query.Values(errorQueries)
|
||||
cu, err := url.Parse(params.callback)
|
||||
|
||||
if err != nil {
|
||||
controller.log.App.Error().Err(err).Msg("Failed to build callback error query")
|
||||
controller.log.App.Error().Err(err).Msg("Failed to parse callback URL")
|
||||
c.AbortWithStatus(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
redirectUrl := fmt.Sprintf("%s?%s", params.callback, queries.Encode())
|
||||
q := cu.Query()
|
||||
|
||||
q.Set("error", params.callbackError)
|
||||
|
||||
if params.reasonPublic != "" {
|
||||
q.Set("error_description", params.reasonPublic)
|
||||
}
|
||||
|
||||
if params.state != "" {
|
||||
q.Set("state", params.state)
|
||||
}
|
||||
|
||||
cu.RawQuery = q.Encode()
|
||||
|
||||
if params.json {
|
||||
c.JSON(200, gin.H{
|
||||
"status": 200,
|
||||
"redirect_uri": redirectUrl,
|
||||
"redirect_uri": cu.String(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.Redirect(http.StatusFound, redirectUrl)
|
||||
c.Redirect(http.StatusFound, cu.String())
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -111,9 +111,10 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) {
|
||||
clientIP := c.ClientIP()
|
||||
|
||||
aclsCtx := &service.ACLContext{
|
||||
ACLs: acls,
|
||||
IP: net.ParseIP(clientIP),
|
||||
Path: proxyCtx.Path,
|
||||
ACLs: acls,
|
||||
IP: net.ParseIP(clientIP),
|
||||
Path: proxyCtx.Path,
|
||||
TrustedProxiesConfigured: controller.runtime.TrustedProxiesConfigured,
|
||||
}
|
||||
|
||||
if controller.policyEngine.Evaluate(service.RuleIPBypassed, aclsCtx) {
|
||||
|
||||
@@ -708,7 +708,7 @@ func TestProxyController(t *testing.T) {
|
||||
Log: log,
|
||||
})
|
||||
|
||||
authService := service.NewAuthService(service.AuthServiceInput{
|
||||
authService, err := service.NewAuthService(service.AuthServiceInput{
|
||||
Log: log,
|
||||
Config: &cfg,
|
||||
Runtime: &runtime,
|
||||
@@ -721,6 +721,8 @@ func TestProxyController(t *testing.T) {
|
||||
PolicyEngine: policyEngine,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.description, func(t *testing.T) {
|
||||
router := gin.Default()
|
||||
|
||||
@@ -72,26 +72,12 @@ func (controller *UserController) loginHandler(c *gin.Context) {
|
||||
|
||||
controller.log.App.Debug().Str("username", req.Username).Msg("Login attempt")
|
||||
|
||||
isLocked, remaining := controller.auth.IsAccountLocked(req.Username)
|
||||
|
||||
if isLocked {
|
||||
controller.log.App.Warn().Str("username", req.Username).Msg("Account is locked due to too many failed login attempts")
|
||||
controller.log.AuditLoginFailure(req.Username, "local", c.ClientIP(), "account locked")
|
||||
c.Writer.Header().Add("x-tinyauth-lock-locked", "true")
|
||||
c.Writer.Header().Add("x-tinyauth-lock-reset", time.Now().Add(time.Duration(remaining)*time.Second).Format(time.RFC3339))
|
||||
c.JSON(429, gin.H{
|
||||
"status": 429,
|
||||
"message": fmt.Sprintf("Too many failed login attempts. Try again in %d seconds", remaining),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
search, err := controller.auth.SearchUser(req.Username)
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrUserNotFound) {
|
||||
controller.auth.DummyPasswordCheck(req.Password)
|
||||
controller.log.App.Warn().Str("username", req.Username).Msg("User not found during login attempt")
|
||||
controller.auth.RecordLoginAttempt(req.Username, false)
|
||||
controller.log.AuditLoginFailure(req.Username, "unknown", c.ClientIP(), "user not found")
|
||||
c.JSON(401, gin.H{
|
||||
"status": 401,
|
||||
@@ -107,14 +93,24 @@ func (controller *UserController) loginHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
isLocked, remaining := controller.auth.IsAccountLocked(req.Username)
|
||||
|
||||
if isLocked {
|
||||
controller.log.App.Warn().Str("username", req.Username).Msg("Account is locked due to too many failed login attempts")
|
||||
controller.log.AuditLoginFailure(req.Username, search.Type.String(), c.ClientIP(), "account locked")
|
||||
c.Writer.Header().Add("x-tinyauth-lock-locked", "true")
|
||||
c.Writer.Header().Add("x-tinyauth-lock-reset", time.Now().Add(time.Duration(remaining)*time.Second).Format(time.RFC3339))
|
||||
c.JSON(429, gin.H{
|
||||
"status": 429,
|
||||
"message": fmt.Sprintf("Too many failed login attempts. Try again in %d seconds", remaining),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := controller.auth.CheckUserPassword(*search, req.Password); err != nil {
|
||||
controller.log.App.Warn().Str("username", req.Username).Msg("Invalid password during login attempt")
|
||||
controller.auth.RecordLoginAttempt(req.Username, false)
|
||||
if search.Type == model.UserLocal {
|
||||
controller.log.AuditLoginFailure(req.Username, "local", c.ClientIP(), "invalid password")
|
||||
} else {
|
||||
controller.log.AuditLoginFailure(req.Username, "ldap", c.ClientIP(), "invalid password")
|
||||
}
|
||||
controller.log.AuditLoginFailure(req.Username, search.Type.String(), c.ClientIP(), "invalid password")
|
||||
c.JSON(401, gin.H{
|
||||
"status": 401,
|
||||
"message": "Unauthorized",
|
||||
@@ -215,11 +211,7 @@ func (controller *UserController) loginHandler(c *gin.Context) {
|
||||
|
||||
controller.log.App.Info().Str("username", req.Username).Msg("Login successful")
|
||||
|
||||
if search.Type == model.UserLocal {
|
||||
controller.log.AuditLoginSuccess(req.Username, "local", c.ClientIP())
|
||||
} else {
|
||||
controller.log.AuditLoginSuccess(req.Username, "ldap", c.ClientIP())
|
||||
}
|
||||
controller.log.AuditLoginSuccess(req.Username, search.Type.String(), c.ClientIP())
|
||||
|
||||
controller.auth.RecordLoginAttempt(req.Username, true)
|
||||
|
||||
|
||||
@@ -542,7 +542,8 @@ func TestUserController(t *testing.T) {
|
||||
Runtime: &runtime,
|
||||
Ctx: ctx,
|
||||
})
|
||||
authService := service.NewAuthService(service.AuthServiceInput{
|
||||
|
||||
authService, err := service.NewAuthService(service.AuthServiceInput{
|
||||
Log: log,
|
||||
Config: &cfg,
|
||||
Runtime: &runtime,
|
||||
@@ -555,6 +556,8 @@ func TestUserController(t *testing.T) {
|
||||
PolicyEngine: policyEngine,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
|
||||
beforeEach := func() {
|
||||
// Clear failed login attempts before each test
|
||||
authService.ClearLoginAttempts()
|
||||
|
||||
@@ -2,6 +2,7 @@ package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -244,6 +245,9 @@ func (m *ContextMiddleware) basicAuth(username string, password string) (*model.
|
||||
search, err := m.auth.SearchUser(username)
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrUserNotFound) {
|
||||
m.auth.DummyPasswordCheck(password)
|
||||
}
|
||||
return nil, nil, fmt.Errorf("error searching for user: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -264,7 +264,8 @@ func TestContextMiddleware(t *testing.T) {
|
||||
Runtime: &runtime,
|
||||
Ctx: ctx,
|
||||
})
|
||||
authService := service.NewAuthService(service.AuthServiceInput{
|
||||
|
||||
authService, err := service.NewAuthService(service.AuthServiceInput{
|
||||
Log: log,
|
||||
Config: &cfg,
|
||||
Runtime: &runtime,
|
||||
@@ -277,6 +278,8 @@ func TestContextMiddleware(t *testing.T) {
|
||||
PolicyEngine: policyEngine,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
|
||||
contextMiddleware := NewContextMiddleware(ContextMiddlewareInput{
|
||||
Log: log,
|
||||
RuntimeConfig: &runtime,
|
||||
|
||||
@@ -49,7 +49,6 @@ func NewDefaultConfiguration(runtimeEnv RuntimeEnv) *Config {
|
||||
ACLs: ACLsConfig{
|
||||
Policy: "allow",
|
||||
},
|
||||
LockdownEnabled: true,
|
||||
},
|
||||
UI: UIConfig{
|
||||
Title: "Tinyauth",
|
||||
@@ -103,6 +102,8 @@ func NewDefaultConfiguration(runtimeEnv RuntimeEnv) *Config {
|
||||
|
||||
type Config struct {
|
||||
AppURL string `description:"The base URL where the app is hosted." yaml:"appUrl,omitempty"`
|
||||
ConfigFile string `description:"Path to config file." yaml:"-" gen:"include"`
|
||||
LabelProvider string `description:"Label provider to use for ACLs (auto, docker, kubernetes or none to disable). auto detects the environment." yaml:"labelProvider,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"`
|
||||
@@ -115,9 +116,7 @@ type Config struct {
|
||||
LDAP LDAPConfig `description:"LDAP configuration." yaml:"ldap,omitempty"`
|
||||
Experimental ExperimentalConfig `description:"Experimental features, use with caution." yaml:"experimental,omitempty"`
|
||||
Tailscale TailscaleConfig `description:"Tailscale configuration." yaml:"tailscale,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 {
|
||||
@@ -151,7 +150,6 @@ type AuthConfig struct {
|
||||
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"`
|
||||
}
|
||||
|
||||
+12
-11
@@ -1,17 +1,18 @@
|
||||
package model
|
||||
|
||||
type RuntimeConfig struct {
|
||||
AppURL string
|
||||
UUID string
|
||||
CookieDomain string
|
||||
SessionCookieName string
|
||||
CSRFCookieName string
|
||||
RedirectCookieName string
|
||||
OAuthSessionCookieName string
|
||||
LocalUsers []LocalUser
|
||||
OAuthProviders map[string]OAuthServiceConfig
|
||||
OAuthWhitelist []string
|
||||
ConfiguredProviders []Provider
|
||||
AppURL string
|
||||
UUID string
|
||||
CookieDomain string
|
||||
SessionCookieName string
|
||||
CSRFCookieName string
|
||||
RedirectCookieName string
|
||||
OAuthSessionCookieName string
|
||||
LocalUsers []LocalUser
|
||||
OAuthProviders map[string]OAuthServiceConfig
|
||||
OAuthWhitelist []string
|
||||
ConfiguredProviders []Provider
|
||||
TrustedProxiesConfigured bool
|
||||
}
|
||||
|
||||
type Provider struct {
|
||||
|
||||
@@ -7,6 +7,16 @@ const (
|
||||
UserLDAP
|
||||
)
|
||||
|
||||
func (t UserSearchType) String() string {
|
||||
switch t {
|
||||
case UserLocal:
|
||||
return "local"
|
||||
case UserLDAP:
|
||||
return "ldap"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
type LDAPUser struct {
|
||||
DN string
|
||||
Groups []string
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
@@ -43,6 +44,10 @@ func (rule *UserAllowedRule) Evaluate(ctx *ACLContext) Effect {
|
||||
rule.Log.App.Debug().Msg("User is an OAuth user, checking OAuth whitelist")
|
||||
match, err := utils.CheckFilter(ctx.ACLs.OAuth.Whitelist, ctx.UserContext.OAuth.Email)
|
||||
if err != nil {
|
||||
if errors.Is(err, utils.ErrFilterEmpty) {
|
||||
rule.Log.App.Debug().Msg("OAuth whitelist is empty, abstaining")
|
||||
return EffectAbstain
|
||||
}
|
||||
rule.Log.App.Warn().Err(err).Str("item", ctx.UserContext.OAuth.Email).Msg("Invalid entry in OAuth whitelist")
|
||||
return EffectDeny
|
||||
}
|
||||
@@ -72,7 +77,7 @@ func (rule *UserAllowedRule) Evaluate(ctx *ACLContext) Effect {
|
||||
match, err := utils.CheckFilter(ctx.ACLs.Users.Allow, ctx.UserContext.GetUsername())
|
||||
|
||||
if err != nil {
|
||||
if err == utils.ErrFilterEmpty {
|
||||
if errors.Is(err, utils.ErrFilterEmpty) {
|
||||
return EffectAbstain
|
||||
}
|
||||
rule.Log.App.Warn().Err(err).Str("item", ctx.UserContext.GetUsername()).Msg("Invalid entry in users allow list")
|
||||
@@ -215,6 +220,10 @@ type IPAllowedRule struct {
|
||||
}
|
||||
|
||||
func (rule *IPAllowedRule) Evaluate(ctx *ACLContext) Effect {
|
||||
if !ctx.TrustedProxiesConfigured {
|
||||
return EffectAllow // We can't block the proxy
|
||||
}
|
||||
|
||||
// merge global and per-app block/allow lists
|
||||
blockedIps := append([]string{}, rule.Config.Auth.IP.Block...)
|
||||
allowedIPs := append([]string{}, rule.Config.Auth.IP.Allow...)
|
||||
@@ -263,6 +272,10 @@ type IPBypassedRule struct {
|
||||
}
|
||||
|
||||
func (rule *IPBypassedRule) Evaluate(ctx *ACLContext) Effect {
|
||||
if !ctx.TrustedProxiesConfigured {
|
||||
return EffectDeny
|
||||
}
|
||||
|
||||
// merge global and per-app bypass lists
|
||||
bypassList := append([]string{}, rule.Config.Auth.IP.Bypass...)
|
||||
if ctx.ACLs != nil {
|
||||
|
||||
@@ -44,6 +44,21 @@ func TestUserAllowedRule(t *testing.T) {
|
||||
},
|
||||
expected: EffectAbstain,
|
||||
},
|
||||
{
|
||||
name: "abstains when filter is empty",
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{
|
||||
OAuth: model.AppOAuth{Whitelist: ""},
|
||||
},
|
||||
UserContext: &model.UserContext{
|
||||
Provider: model.ProviderOAuth,
|
||||
OAuth: &model.OAuthContext{
|
||||
BaseContext: model.BaseContext{Username: "alice"},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: EffectAbstain,
|
||||
},
|
||||
{
|
||||
name: "allows OAuth user when email matches whitelist",
|
||||
ctx: &ACLContext{
|
||||
@@ -611,11 +626,20 @@ func TestIPAllowedRule(t *testing.T) {
|
||||
ctx *ACLContext
|
||||
expected Effect
|
||||
}{
|
||||
{
|
||||
name: "when trusted proxies are not configured, IP is allowed",
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{},
|
||||
IP: net.ParseIP("10.0.0.1"),
|
||||
},
|
||||
expected: EffectAllow,
|
||||
},
|
||||
{
|
||||
name: "allows when ACLs are nil and no global lists configured",
|
||||
ctx: &ACLContext{
|
||||
ACLs: nil,
|
||||
IP: net.ParseIP("10.0.0.1"),
|
||||
ACLs: nil,
|
||||
IP: net.ParseIP("10.0.0.1"),
|
||||
TrustedProxiesConfigured: true,
|
||||
},
|
||||
expected: EffectAllow,
|
||||
},
|
||||
@@ -625,7 +649,8 @@ func TestIPAllowedRule(t *testing.T) {
|
||||
ACLs: &model.App{
|
||||
IP: model.AppIP{Block: []string{"10.0.0.1"}},
|
||||
},
|
||||
IP: net.ParseIP("10.0.0.1"),
|
||||
IP: net.ParseIP("10.0.0.1"),
|
||||
TrustedProxiesConfigured: true,
|
||||
},
|
||||
expected: EffectDeny,
|
||||
},
|
||||
@@ -637,8 +662,9 @@ func TestIPAllowedRule(t *testing.T) {
|
||||
},
|
||||
},
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{},
|
||||
IP: net.ParseIP("10.0.0.5"),
|
||||
ACLs: &model.App{},
|
||||
IP: net.ParseIP("10.0.0.5"),
|
||||
TrustedProxiesConfigured: true,
|
||||
},
|
||||
expected: EffectDeny,
|
||||
},
|
||||
@@ -648,7 +674,8 @@ func TestIPAllowedRule(t *testing.T) {
|
||||
ACLs: &model.App{
|
||||
IP: model.AppIP{Allow: []string{"192.168.1.0/24"}},
|
||||
},
|
||||
IP: net.ParseIP("192.168.1.10"),
|
||||
IP: net.ParseIP("192.168.1.10"),
|
||||
TrustedProxiesConfigured: true,
|
||||
},
|
||||
expected: EffectAllow,
|
||||
},
|
||||
@@ -660,8 +687,9 @@ func TestIPAllowedRule(t *testing.T) {
|
||||
},
|
||||
},
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{},
|
||||
IP: net.ParseIP("192.168.1.10"),
|
||||
ACLs: &model.App{},
|
||||
IP: net.ParseIP("192.168.1.10"),
|
||||
TrustedProxiesConfigured: true,
|
||||
},
|
||||
expected: EffectAllow,
|
||||
},
|
||||
@@ -671,15 +699,17 @@ func TestIPAllowedRule(t *testing.T) {
|
||||
ACLs: &model.App{
|
||||
IP: model.AppIP{Allow: []string{"192.168.1.0/24"}},
|
||||
},
|
||||
IP: net.ParseIP("10.0.0.1"),
|
||||
IP: net.ParseIP("10.0.0.1"),
|
||||
TrustedProxiesConfigured: true,
|
||||
},
|
||||
expected: EffectDeny,
|
||||
},
|
||||
{
|
||||
name: "allows when no block or allow lists are configured",
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{},
|
||||
IP: net.ParseIP("10.0.0.1"),
|
||||
ACLs: &model.App{},
|
||||
IP: net.ParseIP("10.0.0.1"),
|
||||
TrustedProxiesConfigured: true,
|
||||
},
|
||||
expected: EffectAllow,
|
||||
},
|
||||
@@ -692,7 +722,8 @@ func TestIPAllowedRule(t *testing.T) {
|
||||
Allow: []string{"10.0.0.1"},
|
||||
},
|
||||
},
|
||||
IP: net.ParseIP("10.0.0.1"),
|
||||
IP: net.ParseIP("10.0.0.1"),
|
||||
TrustedProxiesConfigured: true,
|
||||
},
|
||||
expected: EffectDeny,
|
||||
},
|
||||
@@ -705,7 +736,8 @@ func TestIPAllowedRule(t *testing.T) {
|
||||
Allow: []string{"10.0.0.1"},
|
||||
},
|
||||
},
|
||||
IP: net.ParseIP("10.0.0.1"),
|
||||
IP: net.ParseIP("10.0.0.1"),
|
||||
TrustedProxiesConfigured: true,
|
||||
},
|
||||
expected: EffectAllow,
|
||||
},
|
||||
@@ -735,12 +767,23 @@ func TestIPBypassedRule(t *testing.T) {
|
||||
ctx *ACLContext
|
||||
expected Effect
|
||||
}{
|
||||
{
|
||||
name: "when trusted proxies are not configured, IP is not bypassed",
|
||||
rule: defaultIPBR,
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{},
|
||||
IP: net.ParseIP("10.0.0.1"),
|
||||
TrustedProxiesConfigured: false,
|
||||
},
|
||||
expected: EffectDeny,
|
||||
},
|
||||
{
|
||||
name: "deny when ACLs are nil and no global bypass",
|
||||
rule: defaultIPBR,
|
||||
ctx: &ACLContext{
|
||||
ACLs: nil,
|
||||
IP: net.ParseIP("10.0.0.1"),
|
||||
ACLs: nil,
|
||||
IP: net.ParseIP("10.0.0.1"),
|
||||
TrustedProxiesConfigured: true,
|
||||
},
|
||||
expected: EffectDeny,
|
||||
},
|
||||
@@ -748,8 +791,9 @@ func TestIPBypassedRule(t *testing.T) {
|
||||
name: "allows when ACLs are nil but IP matches global bypass",
|
||||
rule: globBypassIPBR,
|
||||
ctx: &ACLContext{
|
||||
ACLs: nil,
|
||||
IP: net.ParseIP("10.0.0.5"),
|
||||
ACLs: nil,
|
||||
IP: net.ParseIP("10.0.0.5"),
|
||||
TrustedProxiesConfigured: true,
|
||||
},
|
||||
expected: EffectAllow,
|
||||
},
|
||||
@@ -757,8 +801,9 @@ func TestIPBypassedRule(t *testing.T) {
|
||||
name: "denies when ACLs are nil and IP does not match global bypass",
|
||||
rule: globBypassIPBR,
|
||||
ctx: &ACLContext{
|
||||
ACLs: nil,
|
||||
IP: net.ParseIP("192.168.1.1"),
|
||||
ACLs: nil,
|
||||
IP: net.ParseIP("192.168.1.1"),
|
||||
TrustedProxiesConfigured: true,
|
||||
},
|
||||
expected: EffectDeny,
|
||||
},
|
||||
@@ -769,7 +814,8 @@ func TestIPBypassedRule(t *testing.T) {
|
||||
ACLs: &model.App{
|
||||
IP: model.AppIP{Bypass: []string{"10.0.0.0/24"}},
|
||||
},
|
||||
IP: net.ParseIP("10.0.0.5"),
|
||||
IP: net.ParseIP("10.0.0.5"),
|
||||
TrustedProxiesConfigured: true,
|
||||
},
|
||||
expected: EffectAllow,
|
||||
},
|
||||
@@ -780,7 +826,8 @@ func TestIPBypassedRule(t *testing.T) {
|
||||
ACLs: &model.App{
|
||||
IP: model.AppIP{Bypass: []string{"172.16.0.0/24"}},
|
||||
},
|
||||
IP: net.ParseIP("10.0.0.5"),
|
||||
IP: net.ParseIP("10.0.0.5"),
|
||||
TrustedProxiesConfigured: true,
|
||||
},
|
||||
expected: EffectAllow,
|
||||
},
|
||||
@@ -791,7 +838,8 @@ func TestIPBypassedRule(t *testing.T) {
|
||||
ACLs: &model.App{
|
||||
IP: model.AppIP{Bypass: []string{"10.0.0.0/24"}},
|
||||
},
|
||||
IP: net.ParseIP("10.0.0.5"),
|
||||
IP: net.ParseIP("10.0.0.5"),
|
||||
TrustedProxiesConfigured: true,
|
||||
},
|
||||
expected: EffectAllow,
|
||||
},
|
||||
@@ -802,7 +850,8 @@ func TestIPBypassedRule(t *testing.T) {
|
||||
ACLs: &model.App{
|
||||
IP: model.AppIP{Bypass: []string{"10.0.0.0/24"}},
|
||||
},
|
||||
IP: net.ParseIP("192.168.1.1"),
|
||||
IP: net.ParseIP("192.168.1.1"),
|
||||
TrustedProxiesConfigured: true,
|
||||
},
|
||||
expected: EffectDeny,
|
||||
},
|
||||
@@ -810,8 +859,9 @@ func TestIPBypassedRule(t *testing.T) {
|
||||
name: "denies when bypass list is empty",
|
||||
rule: defaultIPBR,
|
||||
ctx: &ACLContext{
|
||||
ACLs: &model.App{},
|
||||
IP: net.ParseIP("10.0.0.1"),
|
||||
ACLs: &model.App{},
|
||||
IP: net.ParseIP("10.0.0.1"),
|
||||
TrustedProxiesConfigured: true,
|
||||
},
|
||||
expected: EffectDeny,
|
||||
},
|
||||
@@ -822,7 +872,8 @@ func TestIPBypassedRule(t *testing.T) {
|
||||
ACLs: &model.App{
|
||||
IP: model.AppIP{Bypass: []string{"not-an-ip", "10.0.0.1"}},
|
||||
},
|
||||
IP: net.ParseIP("10.0.0.1"),
|
||||
IP: net.ParseIP("10.0.0.1"),
|
||||
TrustedProxiesConfigured: true,
|
||||
},
|
||||
expected: EffectAllow,
|
||||
},
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/tinyauthapp/tinyauth/internal/model"
|
||||
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
|
||||
"github.com/tinyauthapp/tinyauth/pkg/validators"
|
||||
"go.uber.org/dig"
|
||||
)
|
||||
|
||||
@@ -38,13 +40,19 @@ func NewAccessControlsService(i AccessControlServiceInput) *AccessControlsServic
|
||||
func (service *AccessControlsService) lookupStaticACLs(domain string) *model.App {
|
||||
var nameMatch *model.App
|
||||
|
||||
v := validators.NewDomainValidator(validators.DomainValidatorOptions{})
|
||||
|
||||
// First try to find a matching app by domain, then fallback to matching by app name (subdomain)
|
||||
for app, config := range service.config.Apps {
|
||||
if config.Config.Domain == domain {
|
||||
err := v.Validate(config.Config.Domain, domain)
|
||||
if err == nil {
|
||||
service.log.App.Debug().Str("name", app).Msg("Found matching container by domain")
|
||||
return &config
|
||||
}
|
||||
if strings.SplitN(domain, ".", 2)[0] == app {
|
||||
if !errors.Is(err, validators.ErrHostnameMismatch) {
|
||||
service.log.App.Debug().Str("name", app).Err(err).Msg("Domain validation failed")
|
||||
}
|
||||
if strings.HasPrefix(strings.ToLower(domain), strings.ToLower(app+".")) {
|
||||
service.log.App.Debug().Str("name", app).Msg("Found matching container by app name")
|
||||
nameMatch = &config
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"math/big"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/steveiliop56/ding"
|
||||
@@ -69,21 +68,13 @@ type AuthService struct {
|
||||
tailscale *TailscaleService
|
||||
policyEngine *PolicyEngine
|
||||
|
||||
lockdown struct {
|
||||
active bool
|
||||
until time.Time
|
||||
ctx context.Context
|
||||
cancelFunc context.CancelFunc
|
||||
mu sync.RWMutex
|
||||
}
|
||||
dummyHash string
|
||||
|
||||
caches struct {
|
||||
login *CacheStore[LoginAttempt]
|
||||
oauth *CacheStore[OAuthPendingSession]
|
||||
ldap *CacheStore[[]string]
|
||||
}
|
||||
|
||||
maxLoginLimits int
|
||||
}
|
||||
|
||||
type AuthServiceInput struct {
|
||||
@@ -101,7 +92,7 @@ type AuthServiceInput struct {
|
||||
PolicyEngine *PolicyEngine
|
||||
}
|
||||
|
||||
func NewAuthService(i AuthServiceInput) *AuthService {
|
||||
func NewAuthService(i AuthServiceInput) (*AuthService, error) {
|
||||
service := &AuthService{
|
||||
log: i.Log,
|
||||
runtime: i.Runtime,
|
||||
@@ -114,18 +105,18 @@ func NewAuthService(i AuthServiceInput) *AuthService {
|
||||
policyEngine: i.PolicyEngine,
|
||||
}
|
||||
|
||||
// get the max login limits based on the number of users and the configured max retries
|
||||
service.maxLoginLimits = service.calculateLockdownLimit()
|
||||
// dummy hash
|
||||
dummyHash, err := bcrypt.GenerateFromPassword([]byte(utils.GenerateString(8)), bcrypt.DefaultCost)
|
||||
|
||||
loginCacheSize := 0
|
||||
|
||||
if !service.config.Auth.LockdownEnabled {
|
||||
loginCacheSize = service.maxLoginLimits
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate dummy hash: %w", err)
|
||||
}
|
||||
|
||||
service.dummyHash = string(dummyHash)
|
||||
|
||||
// caches setup
|
||||
oauthCache := NewCacheStore[OAuthPendingSession](256)
|
||||
loginCache := NewCacheStore[LoginAttempt](loginCacheSize)
|
||||
loginCache := NewCacheStore[LoginAttempt](service.calculateLockdownLimit())
|
||||
ldapCache := NewCacheStore[[]string](1024)
|
||||
|
||||
service.caches.oauth = oauthCache
|
||||
@@ -148,7 +139,28 @@ func NewAuthService(i AuthServiceInput) *AuthService {
|
||||
}
|
||||
}, ding.RingMinor)
|
||||
|
||||
return service
|
||||
i.Ding.Go(func(ctx context.Context) {
|
||||
ticker := time.NewTicker(15 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
service.log.App.Debug().Msg("Updating login cache limits")
|
||||
service.caches.login.SetMaxSize(service.calculateLockdownLimit())
|
||||
service.log.App.Debug().Msg("Login cache limits updated")
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
}, ding.RingMinor)
|
||||
|
||||
return service, nil
|
||||
}
|
||||
|
||||
func (auth *AuthService) DummyPasswordCheck(password string) {
|
||||
bcrypt.CompareHashAndPassword([]byte(auth.dummyHash), []byte(password))
|
||||
}
|
||||
|
||||
func (auth *AuthService) SearchUser(username string) (*model.UserSearch, error) {
|
||||
@@ -245,10 +257,6 @@ func (auth *AuthService) GetLDAPUser(userDN string) (*model.LDAPUser, error) {
|
||||
}
|
||||
|
||||
func (auth *AuthService) IsAccountLocked(identifier string) (bool, int) {
|
||||
if locked, remaining := auth.IsInLockdown(); locked {
|
||||
return true, remaining
|
||||
}
|
||||
|
||||
if auth.config.Auth.LoginMaxRetries <= 0 || auth.config.Auth.LoginTimeout <= 0 {
|
||||
return false, 0
|
||||
}
|
||||
@@ -271,14 +279,6 @@ func (auth *AuthService) RecordLoginAttempt(identifier string, success bool) {
|
||||
return
|
||||
}
|
||||
|
||||
if !success && auth.config.Auth.LockdownEnabled && auth.caches.login.Size() >= auth.maxLoginLimits {
|
||||
if locked, _ := auth.IsInLockdown(); locked {
|
||||
return
|
||||
}
|
||||
go auth.lockdownMode()
|
||||
return
|
||||
}
|
||||
|
||||
auth.caches.login.WithLock(func(actions CacheStoreActions[LoginAttempt]) {
|
||||
entry, ok := actions.Get(identifier)
|
||||
|
||||
@@ -344,7 +344,7 @@ func (auth *AuthService) CreateSession(ctx context.Context, data repository.Sess
|
||||
return nil, fmt.Errorf("tailscale service not configured, cannot create session for tailscale user")
|
||||
}
|
||||
|
||||
uuid, err := uuid.NewRandom()
|
||||
u, err := uuid.NewRandom()
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate session uuid: %w", err)
|
||||
@@ -361,7 +361,7 @@ func (auth *AuthService) CreateSession(ctx context.Context, data repository.Sess
|
||||
expiresAt := time.Now().Add(time.Duration(expiry) * time.Second)
|
||||
|
||||
session := repository.CreateSessionParams{
|
||||
UUID: uuid.String(),
|
||||
UUID: u.String(),
|
||||
Username: data.Username,
|
||||
Email: data.Email,
|
||||
Name: data.Name,
|
||||
@@ -616,62 +616,7 @@ func (auth *AuthService) GetOAuthPendingSession(sessionId string) (*OAuthPending
|
||||
return &session, nil
|
||||
}
|
||||
|
||||
func (auth *AuthService) lockdownMode() {
|
||||
auth.lockdown.mu.Lock()
|
||||
|
||||
if auth.lockdown.active {
|
||||
auth.lockdown.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(auth.ctx)
|
||||
|
||||
auth.log.App.Warn().Msg("Too many failed login attempts, entering lockdown mode")
|
||||
|
||||
auth.lockdown.active = true
|
||||
auth.lockdown.ctx = ctx
|
||||
auth.lockdown.cancelFunc = cancel
|
||||
|
||||
d := time.Duration(auth.config.Auth.LoginTimeout) * time.Second
|
||||
auth.lockdown.until = time.Now().Add(d)
|
||||
timer := time.NewTimer(d)
|
||||
|
||||
auth.lockdown.mu.Unlock()
|
||||
|
||||
defer cancel()
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
case <-timer.C:
|
||||
// Timer expired, end lockdown
|
||||
case <-ctx.Done():
|
||||
// Context cancelled, end lockdown
|
||||
}
|
||||
|
||||
auth.lockdown.mu.Lock()
|
||||
|
||||
auth.log.App.Info().Msg("Exiting lockdown mode")
|
||||
|
||||
auth.caches.login.Clear()
|
||||
auth.lockdown.active = false
|
||||
auth.lockdown.until = time.Time{}
|
||||
auth.lockdown.ctx = nil
|
||||
auth.lockdown.cancelFunc = nil
|
||||
|
||||
auth.lockdown.mu.Unlock()
|
||||
}
|
||||
|
||||
func (auth *AuthService) IsInLockdown() (bool, int) {
|
||||
auth.lockdown.mu.RLock()
|
||||
defer auth.lockdown.mu.RUnlock()
|
||||
if auth.lockdown.active {
|
||||
remaining := int(time.Until(auth.lockdown.until).Seconds())
|
||||
return true, remaining
|
||||
}
|
||||
return false, 0
|
||||
}
|
||||
|
||||
// mostly a testing function, not useful for anything else
|
||||
// ClearLoginAttempts is a testing function, not useful for anything else
|
||||
func (auth *AuthService) ClearLoginAttempts() {
|
||||
auth.caches.login.Clear()
|
||||
}
|
||||
|
||||
@@ -195,3 +195,19 @@ func (cs *CacheStore[T]) Clear() {
|
||||
cs.cache = make(map[string]cacheEntry[T])
|
||||
cs.order = make([]string, 0)
|
||||
}
|
||||
|
||||
func (cs *CacheStore[T]) SetMaxSize(maxSize int) {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
cs.maxSize = maxSize
|
||||
for len(cs.cache) > maxSize {
|
||||
if !cs.evictOne() {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
func (cs *CacheStore[T]) GetMaxSize() int {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
return cs.maxSize
|
||||
}
|
||||
|
||||
@@ -316,6 +316,21 @@ func TestCacheStoreSizeAndClear(t *testing.T) {
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestCacheStoreWithMaxSize(t *testing.T) {
|
||||
cs := NewCacheStore[string](0)
|
||||
assert.Equal(t, 0, cs.Size())
|
||||
|
||||
for i := 0; i < 100; i++ {
|
||||
cs.Set(strconv.Itoa(i), strconv.Itoa(i), 0)
|
||||
}
|
||||
|
||||
assert.Equal(t, 100, cs.Size())
|
||||
|
||||
cs.SetMaxSize(10)
|
||||
|
||||
assert.Equal(t, 10, cs.Size())
|
||||
}
|
||||
|
||||
func TestCacheStoreWithLock(t *testing.T) {
|
||||
cs := NewCacheStore[int](0)
|
||||
cs.Set("counter", 1, 0)
|
||||
|
||||
@@ -24,6 +24,7 @@ func NewOAuthService(config model.OAuthServiceConfig, id string, ctx context.Con
|
||||
httpClient := &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
TLSClientConfig: &tls.Config{
|
||||
InsecureSkipVerify: config.Insecure,
|
||||
MinVersion: tls.VersionTLS12,
|
||||
|
||||
@@ -29,10 +29,11 @@ type Rule interface {
|
||||
}
|
||||
|
||||
type ACLContext struct {
|
||||
ACLs *model.App
|
||||
UserContext *model.UserContext
|
||||
IP net.IP
|
||||
Path string
|
||||
ACLs *model.App
|
||||
UserContext *model.UserContext
|
||||
IP net.IP
|
||||
Path string
|
||||
TrustedProxiesConfigured bool
|
||||
}
|
||||
|
||||
type PolicyEngine struct {
|
||||
|
||||
@@ -163,9 +163,10 @@ func CreateTestConfigs(t *testing.T) (model.Config, model.RuntimeConfig) {
|
||||
},
|
||||
},
|
||||
},
|
||||
CookieDomain: "example.com",
|
||||
AppURL: "https://tinyauth.example.com",
|
||||
SessionCookieName: "tinyauth-session",
|
||||
CookieDomain: "example.com",
|
||||
AppURL: "https://tinyauth.example.com",
|
||||
SessionCookieName: "tinyauth-session",
|
||||
TrustedProxiesConfigured: true,
|
||||
}
|
||||
|
||||
return config, runtime
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
// Package validators provides validators for various types of data.
|
||||
//
|
||||
// Domain validator is a simple utility that ensures two domains are exact
|
||||
// matches while ensuring that techniques used to bypass such checks do
|
||||
// not impact the validation.
|
||||
|
||||
package validators
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/net/idna"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidURL = fmt.Errorf("invalid url")
|
||||
ErrSchemeMismatch = fmt.Errorf("scheme mismatch")
|
||||
ErrPortMismatch = fmt.Errorf("port mismatch")
|
||||
ErrHostnameMismatch = fmt.Errorf("hostname mismatch")
|
||||
)
|
||||
|
||||
// DomainValidatorOptions is a set of options for DomainValidator.
|
||||
type DomainValidatorOptions struct {
|
||||
// Ensure domains have the same scheme.
|
||||
WithScheme bool
|
||||
// Ensure domains have the same port.
|
||||
WithPort bool
|
||||
// Specify a list of allowed schemes IF WithScheme is set to true.
|
||||
// Leave empty to allow any scheme.
|
||||
AllowedSchemes []string
|
||||
}
|
||||
|
||||
// DomainValidator is a simple utility that ensures two domains are exact
|
||||
// matches while ensuring that techniques used to bypass such checks do
|
||||
// not impact the validation.
|
||||
type DomainValidator struct {
|
||||
opts DomainValidatorOptions
|
||||
}
|
||||
|
||||
// NewDomainValidator creates a new DomainValidator.
|
||||
func NewDomainValidator(opts DomainValidatorOptions) *DomainValidator {
|
||||
return &DomainValidator{
|
||||
opts: opts,
|
||||
}
|
||||
}
|
||||
|
||||
func (v *DomainValidator) getURL(i string) (*url.URL, error) {
|
||||
u, err := url.Parse(i)
|
||||
|
||||
if !v.opts.WithScheme && (err != nil || u.Host == "") {
|
||||
u, err = url.Parse("tinyauth://" + i)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse input url: %w", err)
|
||||
}
|
||||
|
||||
if u.Host == "" {
|
||||
return nil, ErrInvalidURL
|
||||
}
|
||||
|
||||
if v.opts.WithPort && !v.opts.WithScheme && u.Port() == "" {
|
||||
return nil, fmt.Errorf("port validation is enabled but port is missing in input url and schemes are not enabled")
|
||||
}
|
||||
|
||||
if v.opts.WithScheme {
|
||||
// Empty scheme means that we parsed the url with the tinyauth:// placeholder
|
||||
if u.Scheme == "tinyauth" {
|
||||
return nil, fmt.Errorf("input url is missing scheme")
|
||||
}
|
||||
if len(v.opts.AllowedSchemes) > 0 && !slices.Contains(v.opts.AllowedSchemes, u.Scheme) {
|
||||
return nil, fmt.Errorf("scheme %s not allowed", u.Scheme)
|
||||
}
|
||||
}
|
||||
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (v *DomainValidator) getEffectivePort(u *url.URL) (string, bool) {
|
||||
if u.Port() != "" {
|
||||
return u.Port(), true
|
||||
}
|
||||
switch u.Scheme {
|
||||
case "http":
|
||||
return "80", true
|
||||
case "https":
|
||||
return "443", true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func (v *DomainValidator) formatHostname(hostname string) (string, error) {
|
||||
hostname = strings.ToLower(hostname)
|
||||
hostname = strings.TrimSuffix(hostname, ".")
|
||||
if net.ParseIP(hostname) != nil {
|
||||
return "", fmt.Errorf("ip addresses are not supported")
|
||||
}
|
||||
hostname, err := idna.Lookup.ToASCII(hostname)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to convert hostname to ascii: %w", err)
|
||||
}
|
||||
return hostname, nil
|
||||
}
|
||||
|
||||
// Validate ensures that two domains are exact matches with the
|
||||
// options defined in the DomainValidatorOptions. It ensures that the
|
||||
// inputs are proper URLs and contain a host. It lowercases the hostnames
|
||||
// and removes the trailing dot. Finally, it checks that the hostnames are
|
||||
// equal unless WithScheme or WithPort is set to true where it also
|
||||
// validates the scheme and port respectively.
|
||||
func (v *DomainValidator) Validate(expected, actual string) error {
|
||||
eu, err := v.getURL(expected)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
au, err := v.getURL(actual)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if v.opts.WithScheme {
|
||||
if eu.Scheme != au.Scheme {
|
||||
return ErrSchemeMismatch
|
||||
}
|
||||
}
|
||||
|
||||
if v.opts.WithPort {
|
||||
eup, ok := v.getEffectivePort(eu)
|
||||
if !ok {
|
||||
return fmt.Errorf("failed to get effective port for url: %s", eu.String())
|
||||
}
|
||||
aup, ok := v.getEffectivePort(au)
|
||||
if !ok {
|
||||
return fmt.Errorf("failed to get effective port for url: %s", au.String())
|
||||
}
|
||||
if eup != aup {
|
||||
return ErrPortMismatch
|
||||
}
|
||||
}
|
||||
|
||||
euf, err := v.formatHostname(eu.Hostname())
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
auf, err := v.formatHostname(au.Hostname())
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if euf != auf {
|
||||
return ErrHostnameMismatch
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SafeHostname uses the internal validation for domains that Validator uses
|
||||
// to parse a hostname. It ensures the input URL is a valid URL, that a host
|
||||
// is present and that the hostname is lowercased and without a trailing dot.
|
||||
func (v *DomainValidator) SafeHostname(input string) (string, error) {
|
||||
u, err := v.getURL(input)
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return v.formatHostname(u.Hostname())
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
package validators
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDomainValidator_SafeHostname(t *testing.T) {
|
||||
type testCase struct {
|
||||
description string
|
||||
options DomainValidatorOptions
|
||||
input string
|
||||
expected string
|
||||
errorFunc func(t *testing.T, e error)
|
||||
}
|
||||
|
||||
tests := []testCase{
|
||||
{
|
||||
description: "Empty url fails",
|
||||
errorFunc: func(t *testing.T, e error) {
|
||||
assert.ErrorIs(t, e, ErrInvalidURL)
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Invalid url fails",
|
||||
input: "foo:foo",
|
||||
errorFunc: func(t *testing.T, e error) {
|
||||
assert.ErrorContains(t, e, "failed to parse input url")
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Domain without scheme should parse if scheme is disabled",
|
||||
input: "example.com",
|
||||
expected: "example.com",
|
||||
},
|
||||
{
|
||||
description: "Domain without scheme should not parse if scheme is enabled",
|
||||
options: DomainValidatorOptions{WithScheme: true},
|
||||
input: "example.com",
|
||||
errorFunc: func(t *testing.T, e error) {
|
||||
assert.ErrorIs(t, e, ErrInvalidURL)
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Domain with scheme and disallowed scheme should fail",
|
||||
options: DomainValidatorOptions{WithScheme: true, AllowedSchemes: []string{"https"}},
|
||||
input: "foo://example.com",
|
||||
errorFunc: func(t *testing.T, e error) {
|
||||
assert.ErrorContains(t, e, "foo not allowed")
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Domain with scheme and allowed scheme should pass",
|
||||
options: DomainValidatorOptions{WithScheme: true, AllowedSchemes: []string{"https"}},
|
||||
input: "https://example.com",
|
||||
expected: "example.com",
|
||||
},
|
||||
{
|
||||
description: "Domain should get lowercased",
|
||||
input: "EXAMPLE.COM",
|
||||
expected: "example.com",
|
||||
},
|
||||
{
|
||||
description: "DNS dot should be removed",
|
||||
input: "example.com.",
|
||||
expected: "example.com",
|
||||
},
|
||||
{
|
||||
description: "IPv4 address should fail",
|
||||
input: "127.0.0.1",
|
||||
errorFunc: func(t *testing.T, e error) {
|
||||
assert.ErrorContains(t, e, "ip addresses are not supported")
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "IPv6 address should fail",
|
||||
input: "[::1]",
|
||||
errorFunc: func(t *testing.T, e error) {
|
||||
assert.ErrorContains(t, e, "ip addresses are not supported")
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Domains with unicode characters should be allowed",
|
||||
input: "bücher.example.com",
|
||||
expected: "xn--bcher-kva.example.com",
|
||||
},
|
||||
{
|
||||
description: "Invalid IDNA domain should fail",
|
||||
input: "ab--cd.example.com",
|
||||
errorFunc: func(t *testing.T, e error) {
|
||||
assert.ErrorContains(t, e, "invalid label")
|
||||
},
|
||||
},
|
||||
{
|
||||
// Placeholder should not be used by users and is reserved for the validator.
|
||||
// Using it is like not using any scheme for the validator, and thus it will fail
|
||||
// with schemes enabled.
|
||||
description: "Placeholder scheme supplied directly should fail",
|
||||
options: DomainValidatorOptions{WithScheme: true, AllowedSchemes: []string{"https"}},
|
||||
input: "tinyauth://example.com",
|
||||
errorFunc: func(t *testing.T, e error) {
|
||||
assert.ErrorContains(t, e, "input url is missing scheme")
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.description, func(t *testing.T) {
|
||||
v := NewDomainValidator(test.options)
|
||||
res, err := v.SafeHostname(test.input)
|
||||
if test.errorFunc != nil {
|
||||
test.errorFunc(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, test.expected, res)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainValidator_Validate(t *testing.T) {
|
||||
type testCase struct {
|
||||
description string
|
||||
options DomainValidatorOptions
|
||||
expected string
|
||||
actual string
|
||||
errorFunc func(t *testing.T, e error)
|
||||
}
|
||||
|
||||
tests := []testCase{
|
||||
{
|
||||
description: "Invalid expected domain fails checks",
|
||||
expected: "foo:foo",
|
||||
actual: "bar.com",
|
||||
errorFunc: func(t *testing.T, e error) {
|
||||
assert.ErrorContains(t, e, "failed to parse input url:")
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Invalid check domain fails checks",
|
||||
expected: "example.com",
|
||||
actual: "foo:foo",
|
||||
errorFunc: func(t *testing.T, e error) {
|
||||
assert.ErrorContains(t, e, "failed to parse input url:")
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Valid domains with non-matching schemes should fail",
|
||||
options: DomainValidatorOptions{WithScheme: true, AllowedSchemes: []string{"https", "http"}},
|
||||
expected: "https://example.com",
|
||||
actual: "http://example.com",
|
||||
errorFunc: func(t *testing.T, e error) {
|
||||
assert.ErrorIs(t, e, ErrSchemeMismatch)
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Valid domains with matching schemes should pass",
|
||||
options: DomainValidatorOptions{WithScheme: true, AllowedSchemes: []string{"https", "http"}},
|
||||
expected: "https://example.com",
|
||||
actual: "https://example.com",
|
||||
},
|
||||
{
|
||||
description: "Port validation without ports and schemes disabled should fail",
|
||||
options: DomainValidatorOptions{WithPort: true},
|
||||
expected: "example.com",
|
||||
actual: "example.com",
|
||||
errorFunc: func(t *testing.T, e error) {
|
||||
assert.ErrorContains(t, e, "port validation is enabled but port is missing in input url and schemes are not enabled")
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Port validation with no port and http should pass",
|
||||
options: DomainValidatorOptions{WithPort: true, WithScheme: true, AllowedSchemes: []string{"http"}},
|
||||
expected: "http://example.com",
|
||||
actual: "http://example.com",
|
||||
},
|
||||
{
|
||||
description: "Port validation with no port and https should pass",
|
||||
options: DomainValidatorOptions{WithPort: true, WithScheme: true, AllowedSchemes: []string{"https"}},
|
||||
expected: "https://example.com",
|
||||
actual: "https://example.com",
|
||||
},
|
||||
{
|
||||
description: "Port validation with port and no scheme should pass with same port",
|
||||
options: DomainValidatorOptions{WithPort: true},
|
||||
expected: "example.com:8080",
|
||||
actual: "example.com:8080",
|
||||
},
|
||||
{
|
||||
description: "Domains with unknown scheme and port enabled but no port should fail",
|
||||
options: DomainValidatorOptions{WithPort: true, WithScheme: true},
|
||||
expected: "ssh://example.com:22",
|
||||
actual: "ssh://example.com",
|
||||
errorFunc: func(t *testing.T, e error) {
|
||||
assert.ErrorContains(t, e, "failed to get effective port for url")
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Domains with unknown scheme and port enabled but no port should fail, reverse",
|
||||
options: DomainValidatorOptions{WithPort: true, WithScheme: true},
|
||||
expected: "ssh://example.com",
|
||||
actual: "ssh://example.com:22",
|
||||
errorFunc: func(t *testing.T, e error) {
|
||||
assert.ErrorContains(t, e, "failed to get effective port for url")
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Port validation with port and no scheme should fail with different port",
|
||||
options: DomainValidatorOptions{WithPort: true},
|
||||
expected: "example.com:8080",
|
||||
actual: "example.com:8081",
|
||||
errorFunc: func(t *testing.T, e error) {
|
||||
assert.ErrorIs(t, e, ErrPortMismatch)
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Failure to format expected domain should fail",
|
||||
expected: "ab--cd.example.com",
|
||||
actual: "example.com",
|
||||
errorFunc: func(t *testing.T, e error) {
|
||||
assert.ErrorContains(t, e, "idna: invalid label")
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Failure to format check domain should fail",
|
||||
expected: "example.com",
|
||||
actual: "ab--cd.example.com",
|
||||
errorFunc: func(t *testing.T, e error) {
|
||||
assert.ErrorContains(t, e, "idna: invalid label")
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Valid domains with matching schemes and ports should pass",
|
||||
options: DomainValidatorOptions{WithScheme: true, AllowedSchemes: []string{"https", "http"}, WithPort: true},
|
||||
expected: "https://example.com:8080",
|
||||
actual: "https://example.com:8080",
|
||||
},
|
||||
{
|
||||
description: "Valid domains with matching schemes should pass",
|
||||
options: DomainValidatorOptions{WithScheme: true, AllowedSchemes: []string{"https", "http"}},
|
||||
expected: "https://example.com",
|
||||
actual: "https://example.com",
|
||||
},
|
||||
{
|
||||
description: "Valid domains with matching ports should pass",
|
||||
options: DomainValidatorOptions{WithPort: true},
|
||||
expected: "example.com:8080",
|
||||
actual: "example.com:8080",
|
||||
},
|
||||
{
|
||||
description: "Valid domains without ports or schemes should pass",
|
||||
actual: "example.com",
|
||||
expected: "example.com",
|
||||
},
|
||||
{
|
||||
description: "Unicode valid domains should pass",
|
||||
expected: "xn--bcher-kva.example.com",
|
||||
actual: "bücher.example.com",
|
||||
},
|
||||
{
|
||||
description: "Unicode valid domains should pass (reverse)",
|
||||
expected: "bücher.example.com",
|
||||
actual: "xn--bcher-kva.example.com",
|
||||
},
|
||||
{
|
||||
description: "Non matching hostnames should fail",
|
||||
expected: "example.com",
|
||||
actual: "foo.com",
|
||||
errorFunc: func(t *testing.T, e error) {
|
||||
assert.ErrorIs(t, e, ErrHostnameMismatch)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.description, func(t *testing.T) {
|
||||
v := NewDomainValidator(test.options)
|
||||
err := v.Validate(test.expected, test.actual)
|
||||
if test.errorFunc != nil {
|
||||
test.errorFunc(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user