Compare commits

..
Author SHA1 Message Date
Stavros 5c2cb08a7a fix: use constant time in user lookups 2026-07-14 15:14:26 +03:00
StavrosandGitHub e75605b2c5 refactor: move domain check into small helper util (#1000) 2026-07-14 14:20:15 +03:00
Florian IlchandGitHub 79bcccbe43 feat: support proxy environment variables for OAuth requests (#999) 2026-07-14 13:18:28 +03:00
StavrosandGitHub b62bb2d37a refactor: generate mdx docs with tabs for each config option (#996) 2026-07-13 18:07:47 +03:00
Stavros 791adbb54d fix: remove omitempty suffix from docs gen 2026-07-11 17:18:35 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
4cd4d67bda chore(deps): bump the minor-patch group in /frontend with 3 updates (#991)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-11 16:23:04 +03:00
Stavros 009ae30b47 Update Crowdin configuration file
[ci skip]
2026-07-11 13:05:07 +03:00
Stavros 11b534abc7 chore: add 1 day cooldown for dependabot updates 2026-07-11 00:25:17 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
c575376215 chore(deps): bump the minor-patch group across 1 directory with 7 updates (#990)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-11 00:21:04 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
4c23f55940 chore(deps): bump the minor-patch group across 1 directory with 2 updates (#983)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-10 17:48:57 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
5a61958815 chore(deps): bump github/codeql-action/upload-sarif from 4.36.3 to 4.37.0 (#984)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-10 17:48:15 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
e29550857c chore(deps): bump docker/login-action from 4.3.0 to 4.4.0 (#982)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-10 17:47:48 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
acad7f08e6 chore(deps): bump node from 26.4-alpine3.23 to 26.5-alpine3.23 (#981)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-10 17:46:43 +03:00
StavrosGitHubcoderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>CodeRabbit
405d91065e New Crowdin updates (#986)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
2026-07-09 20:16:17 +03:00
27 changed files with 1185 additions and 548 deletions
+4 -2
View File
@@ -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
@@ -233,8 +237,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
+8
View File
@@ -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
+6 -6
View File
@@ -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 }}
+6 -6
View File
@@ -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 }}
+1 -1
View File
@@ -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
View File
@@ -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} \
+1
View File
@@ -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
+2 -1
View File
@@ -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} \
+5 -11
View File
@@ -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
+10 -10
View File
@@ -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"
}
}
+496 -406
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -86,7 +86,7 @@
"addressScopeName": "Adres",
"addressScopeDescription": "Geeft de app toegang tot je adres.",
"loginTailscaleTitle": "Doorgaan met Tailscale",
"loginTailscaleDescription": "Je lijkt toegang te hebben tot Tinyauth vanaf een geautoriseerd Tailscale-apparaat. Wil je doorgaan met jeTailscale-verbinding?",
"loginTailscaleDescription": "Je lijkt toegang te hebben tot Tinyauth vanaf een geautoriseerd Tailscale-apparaat. Wil je doorgaan met je Tailscale-verbinding?",
"loginTailscaleDeviceName": "Apparaatnaam:",
"loginTailscaleOtherMethod": "Op een andere manier inloggen",
"loginTailscaleSuccess": "Succesvol geauthenticeerd met Tailscale.",
+1 -1
View File
@@ -77,7 +77,7 @@
"emailScopeName": "E-mail",
"emailScopeDescription": "Zezwala aplikacji na dostęp do adresów e-mail.",
"profileScopeName": "Profil",
"profileScopeDescription": "Zezwala aplikacji na dostęp do informacji o porfilu.",
"profileScopeDescription": "Zezwala aplikacji na dostęp do informacji o profilu.",
"groupsScopeName": "Grupy",
"groupsScopeDescription": "Zezwala aplikacji na dostęp do informacji o grupie.",
"backToLoginButton": "Wróć do logowania",
+1 -1
View File
@@ -99,7 +99,7 @@
"quickActionsThemeSystem": "Систем",
"quickActionsLogout": "Одјава",
"quickActionsTitle": "Брзе радње",
"quickActionsProviderLocal": "Lokalno",
"quickActionsProviderLocal": "Локално",
"quickActionsProviderLDAP": "LDAP",
"quickActionsProviderOAuth": "{{provider}} OAuth"
}
+1 -1
View File
@@ -98,7 +98,7 @@
"quickActionsThemeDark": "暗色主题",
"quickActionsThemeSystem": "跟随系统",
"quickActionsLogout": "登出",
"quickActionsTitle": "快速行動",
"quickActionsTitle": "快速操作",
"quickActionsProviderLocal": "本地",
"quickActionsProviderLDAP": "LDAP",
"quickActionsProviderOAuth": "{{provider}} OAuth"
+4 -1
View File
@@ -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
View File
@@ -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")
}
+8 -8
View File
@@ -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
+16 -16
View File
@@ -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=
+29 -36
View File
@@ -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
}
+1 -1
View File
@@ -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()
+39 -15
View File
@@ -89,21 +89,30 @@ func (controller *UserController) loginHandler(c *gin.Context) {
search, err := controller.auth.SearchUser(req.Username)
if err != nil {
if errors.Is(err, service.ErrUserNotFound) {
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,
"message": "Unauthorized",
})
return
}
controller.log.App.Error().Err(err).Str("username", req.Username).Msg("Error searching for user during login attempt")
c.JSON(500, gin.H{
"status": 500,
"message": "Internal Server Error",
})
controller.constantTime(func() constantTimeRes {
if errors.Is(err, service.ErrUserNotFound) {
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")
return constantTimeRes{
Code: 401,
Res: gin.H{
"status": 401,
"message": "Unauthorized",
},
}
}
controller.log.App.Error().Err(err).Str("username", req.Username).Msg("Error searching for user during login attempt")
return constantTimeRes{
Code: 500,
Res: gin.H{
"status": 500,
"message": "Internal Server Error",
},
}
}, func(res constantTimeRes) {
c.JSON(res.Code, res.Res)
}, time.Millisecond*45)
return
}
@@ -466,3 +475,18 @@ func (controller *UserController) tailscaleHandler(c *gin.Context) {
"message": "Login successful",
})
}
type constantTimeRes struct {
Code int
Res any
}
func (controller *UserController) constantTime(f func() constantTimeRes, rf func(res constantTimeRes), targetTime time.Duration) {
tStart := time.Now()
res := f()
tEnd := time.Now()
if tEnd.Sub(tStart) < targetTime {
time.Sleep(targetTime - tEnd.Sub(tStart))
}
rf(res)
}
+2 -2
View File
@@ -103,6 +103,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 +117,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 {
+10 -2
View File
@@ -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
}
+1
View File
@@ -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,
+179
View File
@@ -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())
}
+288
View File
@@ -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)
})
}
}