Compare commits

..
Author SHA1 Message Date
Stavros b61ca4e7a0 New translations en.json (Czech)
[ci skip]
2026-08-04 14:35:41 +03:00
Stavros 3d9c69cd27 New translations en.json (Czech)
[ci skip]
2026-08-04 13:04:05 +03:00
Stavros 43f49bf571 New translations en.json (Japanese)
[ci skip]
2026-07-30 12:26:30 +03:00
Stavros 4911d92b0e New translations en.json (Japanese)
[ci skip]
2026-07-21 05:56:26 +03:00
Stavros 610abacf49 New translations en.json (Chinese Simplified)
[ci skip]
2026-07-11 13:11:06 +03:00
Stavros 71703e4b23 New translations en.json (Serbian (Cyrillic))
[ci skip]
2026-07-11 13:11:04 +03:00
Stavros 615940f26d New translations en.json (Polish)
[ci skip]
2026-07-11 13:11:02 +03:00
Stavros 70da5f90c9 New translations en.json (Dutch)
[ci skip]
2026-07-11 13:11:00 +03:00
Stavros 9a430cd299 New translations en.json (Chinese Simplified)
[ci skip]
2026-07-11 13:05:36 +03:00
Stavros 98348ca31e New translations en.json (Serbian (Cyrillic))
[ci skip]
2026-07-11 13:05:33 +03:00
Stavros 9194b62fbb New translations en.json (Polish)
[ci skip]
2026-07-11 13:05:30 +03:00
Stavros c41e99eeef New translations en.json (Dutch)
[ci skip]
2026-07-11 13:05:29 +03:00
17 changed files with 141 additions and 686 deletions
+2 -4
View File
@@ -4,10 +4,6 @@
# The base URL where the app is hosted. # The base URL where the app is hosted.
TINYAUTH_APPURL= 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 # database config
@@ -237,6 +233,8 @@ TINYAUTH_TAILSCALE_APITOKENFILE=
TINYAUTH_TAILSCALE_TAILNET= TINYAUTH_TAILSCALE_TAILNET=
# Cache duration for Tailscale device and user lists in seconds. # Cache duration for Tailscale device and user lists in seconds.
TINYAUTH_TAILSCALE_CACHEDURATION=300 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 # log config
-1
View File
@@ -39,7 +39,6 @@ RUN go mod download
COPY ./cmd ./cmd COPY ./cmd ./cmd
COPY ./internal ./internal COPY ./internal ./internal
COPY ./pkg ./pkg
COPY --from=frontend-builder /frontend/dist ./internal/assets/dist COPY --from=frontend-builder /frontend/dist ./internal/assets/dist
RUN CGO_ENABLED=0 go build -tags "${BUILD_TAGS}" -ldflags "${LDFLAGS} \ RUN CGO_ENABLED=0 go build -tags "${BUILD_TAGS}" -ldflags "${LDFLAGS} \
-1
View File
@@ -12,7 +12,6 @@ RUN go install github.com/go-delve/delve/cmd/dlv@v1.26.3
COPY ./cmd ./cmd COPY ./cmd ./cmd
COPY ./internal ./internal COPY ./internal ./internal
COPY ./pkg ./pkg
COPY ./air.toml ./ COPY ./air.toml ./
EXPOSE 3000 EXPOSE 3000
-1
View File
@@ -39,7 +39,6 @@ RUN go mod download
COPY ./cmd ./cmd/ COPY ./cmd ./cmd/
COPY ./internal ./internal COPY ./internal ./internal
COPY ./pkg ./pkg
COPY --from=frontend-builder /frontend/dist ./internal/assets/dist COPY --from=frontend-builder /frontend/dist ./internal/assets/dist
RUN CGO_ENABLED=0 go build -tags "${BUILD_TAGS}" -ldflags "${LDFLAGS} \ RUN CGO_ENABLED=0 go build -tags "${BUILD_TAGS}" -ldflags "${LDFLAGS} \
+3 -3
View File
@@ -23,16 +23,16 @@
"axios": "^1.18.1", "axios": "^1.18.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"i18next": "^26.3.6", "i18next": "^26.3.4",
"i18next-browser-languagedetector": "^8.2.1", "i18next-browser-languagedetector": "^8.2.1",
"i18next-resources-to-backend": "^1.2.1", "i18next-resources-to-backend": "^1.2.1",
"lucide-react": "^1.24.0", "lucide-react": "^1.23.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"radix-ui": "^1.6.2", "radix-ui": "^1.6.2",
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
"react-hook-form": "^7.81.0", "react-hook-form": "^7.81.0",
"react-i18next": "^17.0.9", "react-i18next": "^17.0.8",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-router": "^8.2.0", "react-router": "^8.2.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
+18 -18
View File
@@ -42,8 +42,8 @@ importers:
specifier: ^2.1.1 specifier: ^2.1.1
version: 2.1.1 version: 2.1.1
i18next: i18next:
specifier: ^26.3.6 specifier: ^26.3.4
version: 26.3.6(typescript@6.0.3) version: 26.3.4(typescript@6.0.3)
i18next-browser-languagedetector: i18next-browser-languagedetector:
specifier: ^8.2.1 specifier: ^8.2.1
version: 8.2.1 version: 8.2.1
@@ -51,8 +51,8 @@ importers:
specifier: ^1.2.1 specifier: ^1.2.1
version: 1.2.1 version: 1.2.1
lucide-react: lucide-react:
specifier: ^1.24.0 specifier: ^1.23.0
version: 1.24.0(react@19.2.7) version: 1.23.0(react@19.2.7)
next-themes: next-themes:
specifier: ^0.4.6 specifier: ^0.4.6
version: 0.4.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7) version: 0.4.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -69,8 +69,8 @@ importers:
specifier: ^7.81.0 specifier: ^7.81.0
version: 7.81.0(react@19.2.7) version: 7.81.0(react@19.2.7)
react-i18next: react-i18next:
specifier: ^17.0.9 specifier: ^17.0.8
version: 17.0.9(i18next@26.3.6(typescript@6.0.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3) version: 17.0.8(i18next@26.3.4(typescript@6.0.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3)
react-markdown: react-markdown:
specifier: ^10.1.0 specifier: ^10.1.0
version: 10.1.0(@types/react@19.2.17)(react@19.2.7) version: 10.1.0(@types/react@19.2.17)(react@19.2.7)
@@ -1772,10 +1772,10 @@ packages:
i18next-resources-to-backend@1.2.1: i18next-resources-to-backend@1.2.1:
resolution: {integrity: sha512-okHbVA+HZ7n1/76MsfhPqDou0fptl2dAlhRDu2ideXloRRduzHsqDOznJBef+R3DFZnbvWoBW+KxJ7fnFjd6Yw==} resolution: {integrity: sha512-okHbVA+HZ7n1/76MsfhPqDou0fptl2dAlhRDu2ideXloRRduzHsqDOznJBef+R3DFZnbvWoBW+KxJ7fnFjd6Yw==}
i18next@26.3.6: i18next@26.3.4:
resolution: {integrity: sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==} resolution: {integrity: sha512-pa7m0d7pBDqGHZxljT+WPFeyFgQ7P7SciPPo1tTqYuO0z4sqADYhwnBESmmGp/wEof1inwdls/k8ZgTg8rxFHA==}
peerDependencies: peerDependencies:
typescript: ^5 || ^6 || ^7 typescript: ^5 || ^6
peerDependenciesMeta: peerDependenciesMeta:
typescript: typescript:
optional: true optional: true
@@ -1957,8 +1957,8 @@ packages:
lru-cache@5.1.1: lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
lucide-react@1.24.0: lucide-react@1.23.0:
resolution: {integrity: sha512-YT6mBD8lGKkg4nM39enlm94/sfJIiW0YKUT60fBy4YK8tai31ylg1VhGNWxkpSKHo9UagfnZqwIff3HTDQwXeA==} resolution: {integrity: sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw==}
peerDependencies: peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
@@ -2173,14 +2173,14 @@ packages:
peerDependencies: peerDependencies:
react: ^16.8.0 || ^17 || ^18 || ^19 react: ^16.8.0 || ^17 || ^18 || ^19
react-i18next@17.0.9: react-i18next@17.0.8:
resolution: {integrity: sha512-buLzOSqHtXxjf+qgSrLWNTXVZ1jSwO6kUv3uJqSP1roGBPgNnbhFm7OmdVwWcgf2gIbUyP0J333uPyx+Btsi3w==} resolution: {integrity: sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==}
peerDependencies: peerDependencies:
i18next: '>= 26.2.0' i18next: '>= 26.2.0'
react: '>= 16.8.0' react: '>= 16.8.0'
react-dom: '*' react-dom: '*'
react-native: '*' react-native: '*'
typescript: ^5 || ^6 || ^7 typescript: ^5 || ^6
peerDependenciesMeta: peerDependenciesMeta:
react-dom: react-dom:
optional: true optional: true
@@ -4220,7 +4220,7 @@ snapshots:
dependencies: dependencies:
'@babel/runtime': 7.29.2 '@babel/runtime': 7.29.2
i18next@26.3.6(typescript@6.0.3): i18next@26.3.4(typescript@6.0.3):
optionalDependencies: optionalDependencies:
typescript: 6.0.3 typescript: 6.0.3
@@ -4347,7 +4347,7 @@ snapshots:
dependencies: dependencies:
yallist: 3.1.1 yallist: 3.1.1
lucide-react@1.24.0(react@19.2.7): lucide-react@1.23.0(react@19.2.7):
dependencies: dependencies:
react: 19.2.7 react: 19.2.7
@@ -4736,11 +4736,11 @@ snapshots:
dependencies: dependencies:
react: 19.2.7 react: 19.2.7
react-i18next@17.0.9(i18next@26.3.6(typescript@6.0.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3): react-i18next@17.0.8(i18next@26.3.4(typescript@6.0.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3):
dependencies: dependencies:
'@babel/runtime': 7.29.7 '@babel/runtime': 7.29.7
html-parse-stringify: 3.0.1 html-parse-stringify: 3.0.1
i18next: 26.3.6(typescript@6.0.3) i18next: 26.3.4(typescript@6.0.3)
react: 19.2.7 react: 19.2.7
use-sync-external-store: 1.6.0(react@19.2.7) use-sync-external-store: 1.6.0(react@19.2.7)
optionalDependencies: optionalDependencies:
+42 -42
View File
@@ -15,16 +15,16 @@
"loginOauthSuccessTitle": "Přesměrování", "loginOauthSuccessTitle": "Přesměrování",
"loginOauthSuccessSubtitle": "Přesměrování k poskytovateli OAuth", "loginOauthSuccessSubtitle": "Přesměrování k poskytovateli OAuth",
"loginOauthAutoRedirectTitle": "Automatické přesměrování OAuth", "loginOauthAutoRedirectTitle": "Automatické přesměrování OAuth",
"loginOauthAutoRedirectSubtitle": "You will be automatically redirected to your OAuth provider to authenticate.", "loginOauthAutoRedirectSubtitle": "Pro ověření budete automaticky přesměrování na svého poskytovatele OAuth.",
"loginOauthAutoRedirectButton": "Redirect now", "loginOauthAutoRedirectButton": "Přesměrovat nyní",
"continueTitle": "Pokračovat", "continueTitle": "Pokračovat",
"continueRedirectingTitle": "Přesměrování...", "continueRedirectingTitle": "Přesměrování...",
"continueRedirectingSubtitle": "Brzy budete přesměrováni do aplikace", "continueRedirectingSubtitle": "Brzy budete přesměrováni do aplikace",
"continueRedirectManually": "Redirect me manually", "continueRedirectManually": "Přesměrovat ručně",
"continueInsecureRedirectTitle": "Nezabezpečené přesměrování", "continueInsecureRedirectTitle": "Nezabezpečené přesměrování",
"continueInsecureRedirectSubtitle": "Pokoušíte se přesměrovat z <code>https</code> na <code>http</code>, které není bezpečné. Opravdu chcete pokračovat?", "continueInsecureRedirectSubtitle": "Pokoušíte se přesměrovat z <code>https</code> na <code>http</code>, které není bezpečné. Opravdu chcete pokračovat?",
"continueUntrustedRedirectTitle": "Untrusted redirect", "continueUntrustedRedirectTitle": "Nedůvěryhodné přesměrování",
"continueUntrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain (<code>{{cookieDomain}}</code>). Are you sure you want to continue?", "continueUntrustedRedirectSubtitle": "Pokoušíte se přesměrovat na doménu, která neodpovídá vaší nakonfigurované doméně (<code>{{cookieDomain}}</code>). Opravdu chcete pokračovat?",
"logoutFailTitle": "Odhlášení se nezdařilo", "logoutFailTitle": "Odhlášení se nezdařilo",
"logoutFailSubtitle": "Zkuste to prosím znovu", "logoutFailSubtitle": "Zkuste to prosím znovu",
"logoutSuccessTitle": "Odhlášen", "logoutSuccessTitle": "Odhlášen",
@@ -51,53 +51,53 @@
"forgotPasswordTitle": "Zapomněli jste heslo?", "forgotPasswordTitle": "Zapomněli jste heslo?",
"failedToFetchProvidersTitle": "Nepodařilo se načíst poskytovatele ověřování. Zkontrolujte prosím konfiguraci.", "failedToFetchProvidersTitle": "Nepodařilo se načíst poskytovatele ověřování. Zkontrolujte prosím konfiguraci.",
"errorTitle": "Došlo k chybě", "errorTitle": "Došlo k chybě",
"errorSubtitleInfo": "The following error occurred while processing your request:", "errorSubtitleInfo": "Při zpracování požadavku došlo k následující chybě:",
"errorSubtitle": "Nastala chyba při pokusu o provedení této akce. Pro více informací prosím zkontrolujte konzolu.", "errorSubtitle": "Nastala chyba při pokusu o provedení této akce. Pro více informací prosím zkontrolujte konzolu.",
"forgotPasswordMessage": "Heslo můžete obnovit změnou proměnné `USERS`.", "forgotPasswordMessage": "Heslo můžete obnovit změnou proměnné `USERS`.",
"fieldRequired": "Toto pole je povinné", "fieldRequired": "Toto pole je povinné",
"invalidInput": "Neplatný údaj", "invalidInput": "Neplatný údaj",
"domainWarningTitle": "Invalid Domain", "domainWarningTitle": "Neplatná doména",
"domainWarningSubtitle": "You are accessing this instance from an incorrect domain. If you proceed, you may encounter issues with authentication.", "domainWarningSubtitle": "Pro přístup k této instanci používáte špatnou doménu. Při pokračování mohou nastat problémy s autentizací.",
"domainWarningCurrent": "Current:", "domainWarningCurrent": "Aktuální:",
"domainWarningExpected": "Expected:", "domainWarningExpected": "Vyžadovaná:",
"ignoreTitle": "Ignore", "ignoreTitle": "Ignorovat",
"goToCorrectDomainTitle": "Go to correct domain", "goToCorrectDomainTitle": "Přejít na správnou doménu",
"authorizeTitle": "Authorize", "authorizeTitle": "Autorizovat",
"authorizeCardTitle": "Continue to {{app}}?", "authorizeCardTitle": "Pokračovat do {{app}}?",
"authorizeSubtitle": "Would you like to continue to this app? Please carefully review the permissions requested by the app.", "authorizeSubtitle": "Chcete pokračovat do této aplikace? Pečlivě si zkontrolujte jí vyžadovaná oprávnění, prosím.",
"authorizeSubtitleOAuth": "Would you like to continue to this app?", "authorizeSubtitleOAuth": "Chcete pokračovat do této aplikace?",
"authorizeLoadingTitle": "Loading...", "authorizeLoadingTitle": "Načítání...",
"authorizeLoadingSubtitle": "Please wait while we load the client information.", "authorizeLoadingSubtitle": "Please wait while we load the client information.",
"authorizeSuccessTitle": "Authorized", "authorizeSuccessTitle": "Autorizováno",
"authorizeSuccessSubtitle": "You will be redirected to the app in a few seconds.", "authorizeSuccessSubtitle": "Během několika vteřin budete přesměrováni do aplikace.",
"authorizeErrorClientInfo": "An error occurred while loading the client information. Please try again later.", "authorizeErrorClientInfo": "An error occurred while loading the client information. Please try again later.",
"authorizeErrorInvalidParams": "The request is missing required parameters or has invalid parameters. Please check the URL and try again.", "authorizeErrorInvalidParams": "Parametry požadavku jsou chybné/neplatné. Prosím, zkontrolujte URL a zkuste znovu.",
"openidScopeName": "OpenID Connect", "openidScopeName": "OpenID Connect",
"openidScopeDescription": "Allows the app to access your OpenID Connect information.", "openidScopeDescription": "Allows the app to access your OpenID Connect information.",
"emailScopeName": "Email", "emailScopeName": "E-mail",
"emailScopeDescription": "Allows the app to access your email address.", "emailScopeDescription": "Povolí aplikaci přístup k Vaší e-mailové adrese.",
"profileScopeName": "Profile", "profileScopeName": "Profil",
"profileScopeDescription": "Allows the app to access your profile information.", "profileScopeDescription": "Povolí aplikaci přístup k informacím o Vašem profilu.",
"groupsScopeName": "Groups", "groupsScopeName": "Skupiny",
"groupsScopeDescription": "Allows the app to access your group information.", "groupsScopeDescription": "Povolí aplikaci přístup k informacím o Vaší skupině.",
"backToLoginButton": "Back to login", "backToLoginButton": "Zpět na přihlášení",
"phoneScopeName": "Phone", "phoneScopeName": "Telefon",
"phoneScopeDescription": "Allows the app to access your phone number.", "phoneScopeDescription": "Povolí aplikaci přístup k Vašemu telefonnímu číslu.",
"addressScopeName": "Address", "addressScopeName": "Adresa",
"addressScopeDescription": "Allows the app to access your address.", "addressScopeDescription": "Povolí aplikaci přístup k Vaší adrese.",
"loginTailscaleTitle": "Continue with Tailscale", "loginTailscaleTitle": "Pokračovat přes Tailscale",
"loginTailscaleDescription": "You appear to be accessing Tinyauth from an authorized Tailscale device. Would you like to continue with your Tailscale connection?", "loginTailscaleDescription": "You appear to be accessing Tinyauth from an authorized Tailscale device. Would you like to continue with your Tailscale connection?",
"loginTailscaleDeviceName": "Device name:", "loginTailscaleDeviceName": "Název zařízení:",
"loginTailscaleOtherMethod": "Login with another method", "loginTailscaleOtherMethod": "Přihlásit jinou metodou",
"loginTailscaleSuccess": "Successfully authenticated with Tailscale.", "loginTailscaleSuccess": "Úspěšně přihlášeno přes Tailscale.",
"loginTailscaleFail": "Failed to authenticate with Tailscale. Please try again or use another login method.", "loginTailscaleFail": "Chyba při přihlášení přes Tailscale. Zkuste znovu, případně se přihlašte jiným způsobem, prosím.",
"logoutTailscaleSubtitle": "You are currently logged in with Tailscale on your device <code>{{deviceName}}</code>. Click the button below to logout.", "logoutTailscaleSubtitle": "You are currently logged in with Tailscale on your device <code>{{deviceName}}</code>. Click the button below to logout.",
"quickActionsLanguage": "Language", "quickActionsLanguage": "Jazyk",
"quickActionsTheme": "Theme", "quickActionsTheme": "Motiv",
"quickActionsThemeLight": "Light", "quickActionsThemeLight": "Světlý",
"quickActionsThemeDark": "Dark", "quickActionsThemeDark": "Tmavý",
"quickActionsThemeSystem": "System", "quickActionsThemeSystem": "Systémový",
"quickActionsLogout": "Logout", "quickActionsLogout": "Odhlásit se",
"quickActionsTitle": "Quick Actions", "quickActionsTitle": "Quick Actions",
"quickActionsProviderLocal": "Local", "quickActionsProviderLocal": "Local",
"quickActionsProviderLDAP": "LDAP", "quickActionsProviderLDAP": "LDAP",
+1 -4
View File
@@ -46,14 +46,11 @@ func generateExampleEnv() {
func buildEnvEntry(child reflect.StructField, childValue reflect.Value, parentPath string, entries *[]EnvEntry) { func buildEnvEntry(child reflect.StructField, childValue reflect.Value, parentPath string, entries *[]EnvEntry) {
desc := child.Tag.Get("description") desc := child.Tag.Get("description")
tag := child.Tag.Get("yaml") tag := child.Tag.Get("yaml")
gen := child.Tag.Get("gen")
if tag == "-" && gen != "include" { if tag == "-" {
return return
} }
tag = strings.TrimSuffix(tag, ",omitempty")
value := childValue.Interface() value := childValue.Interface()
entry := EnvEntry{ entry := EnvEntry{
+19 -63
View File
@@ -1,6 +1,7 @@
package main package main
import ( import (
"bytes"
"errors" "errors"
"fmt" "fmt"
"io/fs" "io/fs"
@@ -12,13 +13,9 @@ import (
"github.com/tinyauthapp/tinyauth/internal/model" "github.com/tinyauthapp/tinyauth/internal/model"
) )
type ConfigOptions struct {
Env string
Flag string
YAML string
}
type MarkdownEntry struct { type MarkdownEntry struct {
Options ConfigOptions Env string
Flag string
Description string Description string
Default any Default any
} }
@@ -50,27 +47,16 @@ func generateMarkdown() {
func buildMdEntry(child reflect.StructField, childValue reflect.Value, parentPath string, entries *[]MarkdownEntry) { func buildMdEntry(child reflect.StructField, childValue reflect.Value, parentPath string, entries *[]MarkdownEntry) {
desc := child.Tag.Get("description") desc := child.Tag.Get("description")
tag := child.Tag.Get("yaml") tag := child.Tag.Get("yaml")
gen := child.Tag.Get("gen")
if tag == "-" && gen != "include" { if tag == "-" {
return return
} }
tag = strings.TrimSuffix(tag, ",omitempty")
value := childValue.Interface() 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{ entry := MarkdownEntry{
Options: configOptions, Env: strings.ToUpper(strings.ReplaceAll(parentPath, ".", "_")) + strings.ToUpper(child.Name),
Flag: fmt.Sprintf("--%s%s", strings.TrimPrefix(parentPath, "tinyauth."), strings.ToLower(child.Name)),
Description: desc, Description: desc,
} }
@@ -97,14 +83,11 @@ func buildMdMapEntry(child reflect.StructField, parentPath string, entries *[]Ma
} }
tag := child.Tag.Get("yaml") tag := child.Tag.Get("yaml")
gen := child.Tag.Get("gen")
if tag == "-" && gen != "include" { if tag == "-" {
return return
} }
tag = strings.TrimSuffix(tag, ",omitempty")
mapPath := parentPath + tag + ".[name]." mapPath := parentPath + tag + ".[name]."
valueType := fieldType.Elem() valueType := fieldType.Elem()
@@ -119,54 +102,27 @@ func buildMdChildPath(parent string, child string) string {
} }
func compileMd(entries []MarkdownEntry) []byte { func compileMd(entries []MarkdownEntry) []byte {
buffer := strings.Builder{} buffer := bytes.Buffer{}
buffer.WriteString("{/* This part is automatically generated by gen/gen_md.go in the main repository. DO NOT EDIT. */}\n\n") buffer.WriteString("<!--- This file is automatically generated by gen/gen_md.go. Do not edit manually. --->\n\n")
buffer.WriteString("import { Tabs, TabItem } from '@astrojs/starlight/components';\n\n") buffer.WriteString("# Tinyauth configuration reference\n\n")
buffer.WriteString("<Tabs>\n") buffer.WriteString("| Environment | Flag | Description | Default |\n")
buffer.WriteString("| - | - | - | - |\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 := "" previousSection := ""
for _, entry := range entries { for _, entry := range entries {
section := strings.Split(entry.Options.YAML, ".")[0] if strings.Count(entry.Env, "_") > 1 {
var option string section := strings.Split(strings.TrimPrefix(entry.Env, "TINYAUTH_"), "_")[0]
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 { if section != previousSection {
buffer.WriteString("\n ### " + strings.ToLower(section) + "\n\n") buffer.WriteString("\n## " + strings.ToLower(section) + "\n\n")
buffer.WriteString(" | Option | Description | Default |\n") buffer.WriteString("| Environment | Flag | Description | Default |\n")
buffer.WriteString(" | - | - | - |\n") buffer.WriteString("| - | - | - | - |\n")
previousSection = section previousSection = section
} }
} }
fmt.Fprintf(buffer, " | `%s` | %s | %s |\n", option, entry.Description, entry.Default) fmt.Fprintf(&buffer, "| `%s` | `%s` | %s | %s |\n", entry.Env, entry.Flag, entry.Description, entry.Default)
} }
buffer.WriteString(" </TabItem>\n") return buffer.Bytes()
} }
+36 -29
View File
@@ -1,9 +1,9 @@
package controller package controller
import ( import (
"errors"
"fmt" "fmt"
"net/http" "net/http"
"net/url"
"strings" "strings"
"time" "time"
@@ -12,7 +12,6 @@ import (
"github.com/tinyauthapp/tinyauth/internal/service" "github.com/tinyauthapp/tinyauth/internal/service"
"github.com/tinyauthapp/tinyauth/internal/utils" "github.com/tinyauthapp/tinyauth/internal/utils"
"github.com/tinyauthapp/tinyauth/internal/utils/logger" "github.com/tinyauthapp/tinyauth/internal/utils/logger"
"github.com/tinyauthapp/tinyauth/pkg/validators"
"go.uber.org/dig" "go.uber.org/dig"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -312,46 +311,54 @@ func (controller *OAuthController) getCookieDomain() string {
} }
func (controller *OAuthController) isRedirectSafe(redirectURI string) bool { func (controller *OAuthController) isRedirectSafe(redirectURI string) bool {
v := validators.NewDomainValidator(validators.DomainValidatorOptions{ u, err := url.Parse(redirectURI)
WithScheme: true,
WithPort: true,
})
_, err := v.SafeHostname(controller.runtime.AppURL)
if err != nil { if err != nil {
controller.log.App.Error().Err(err).Msg("App URL is invalid, cannot validate redirect URI") controller.log.App.Error().Err(err).Msg("Failed to parse redirect URI")
return false return false
} }
err = v.Validate(redirectURI, controller.runtime.AppURL) if u.Scheme == "" || u.Host == "" {
controller.log.App.Warn().Msg("Redirect URI has invalid scheme or host")
return false
}
if err == nil { 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()) {
return true 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 { if !controller.config.Auth.SubdomainsEnabled {
return false return false
} }
v = validators.NewDomainValidator(validators.DomainValidatorOptions{}) if strings.HasSuffix(strings.ToLower(u.Hostname()), "."+strings.ToLower(controller.runtime.CookieDomain)) {
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 return true
} }
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"github.com/tinyauthapp/tinyauth/internal/utils/logger" "github.com/tinyauthapp/tinyauth/internal/utils/logger"
) )
func TestOAuthController_isRedirectSafe(t *testing.T) { func TestOAuthControllerIsRedirectSafe(t *testing.T) {
log := logger.NewLogger().WithTestConfig() log := logger.NewLogger().WithTestConfig()
log.Init() log.Init()
+15 -39
View File
@@ -89,30 +89,21 @@ func (controller *UserController) loginHandler(c *gin.Context) {
search, err := controller.auth.SearchUser(req.Username) search, err := controller.auth.SearchUser(req.Username)
if err != nil { if err != nil {
controller.constantTime(func() constantTimeRes { if errors.Is(err, service.ErrUserNotFound) {
if errors.Is(err, service.ErrUserNotFound) { controller.log.App.Warn().Str("username", req.Username).Msg("User not found during login attempt")
controller.log.App.Warn().Str("username", req.Username).Msg("User not found during login attempt") controller.auth.RecordLoginAttempt(req.Username, false)
controller.auth.RecordLoginAttempt(req.Username, false) controller.log.AuditLoginFailure(req.Username, "unknown", c.ClientIP(), "user not found")
controller.log.AuditLoginFailure(req.Username, "unknown", c.ClientIP(), "user not found") c.JSON(401, gin.H{
return constantTimeRes{ "status": 401,
Code: 401, "message": "Unauthorized",
Res: gin.H{ })
"status": 401, return
"message": "Unauthorized", }
}, 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,
controller.log.App.Error().Err(err).Str("username", req.Username).Msg("Error searching for user during login attempt") "message": "Internal Server Error",
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 return
} }
@@ -475,18 +466,3 @@ func (controller *UserController) tailscaleHandler(c *gin.Context) {
"message": "Login successful", "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,8 +103,6 @@ func NewDefaultConfiguration(runtimeEnv RuntimeEnv) *Config {
type Config struct { type Config struct {
AppURL string `description:"The base URL where the app is hosted." yaml:"appUrl,omitempty"` 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"` Database DatabaseConfig `description:"Database configuration." yaml:"database,omitempty"`
Analytics AnalyticsConfig `description:"Analytics configuration." yaml:"analytics,omitempty"` Analytics AnalyticsConfig `description:"Analytics configuration." yaml:"analytics,omitempty"`
Resources ResourcesConfig `description:"Resources configuration." yaml:"resources,omitempty"` Resources ResourcesConfig `description:"Resources configuration." yaml:"resources,omitempty"`
@@ -117,7 +115,9 @@ type Config struct {
LDAP LDAPConfig `description:"LDAP configuration." yaml:"ldap,omitempty"` LDAP LDAPConfig `description:"LDAP configuration." yaml:"ldap,omitempty"`
Experimental ExperimentalConfig `description:"Experimental features, use with caution." yaml:"experimental,omitempty"` Experimental ExperimentalConfig `description:"Experimental features, use with caution." yaml:"experimental,omitempty"`
Tailscale TailscaleConfig `description:"Tailscale configuration." yaml:"tailscale,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"` Log LogConfig `description:"Logging configuration." yaml:"log,omitempty"`
ConfigFile string `description:"Path to config file." yaml:"-"`
} }
type DatabaseConfig struct { type DatabaseConfig struct {
+2 -10
View File
@@ -1,12 +1,10 @@
package service package service
import ( import (
"errors"
"strings" "strings"
"github.com/tinyauthapp/tinyauth/internal/model" "github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/utils/logger" "github.com/tinyauthapp/tinyauth/internal/utils/logger"
"github.com/tinyauthapp/tinyauth/pkg/validators"
"go.uber.org/dig" "go.uber.org/dig"
) )
@@ -40,19 +38,13 @@ func NewAccessControlsService(i AccessControlServiceInput) *AccessControlsServic
func (service *AccessControlsService) lookupStaticACLs(domain string) *model.App { func (service *AccessControlsService) lookupStaticACLs(domain string) *model.App {
var nameMatch *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) // First try to find a matching app by domain, then fallback to matching by app name (subdomain)
for app, config := range service.config.Apps { for app, config := range service.config.Apps {
err := v.Validate(config.Config.Domain, domain) if config.Config.Domain == domain {
if err == nil {
service.log.App.Debug().Str("name", app).Msg("Found matching container by domain") service.log.App.Debug().Str("name", app).Msg("Found matching container by domain")
return &config return &config
} }
if !errors.Is(err, validators.ErrHostnameMismatch) { if strings.SplitN(domain, ".", 2)[0] == app {
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") service.log.App.Debug().Str("name", app).Msg("Found matching container by app name")
nameMatch = &config nameMatch = &config
} }
-1
View File
@@ -24,7 +24,6 @@ func NewOAuthService(config model.OAuthServiceConfig, id string, ctx context.Con
httpClient := &http.Client{ httpClient := &http.Client{
Timeout: 30 * time.Second, Timeout: 30 * time.Second,
Transport: &http.Transport{ Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
TLSClientConfig: &tls.Config{ TLSClientConfig: &tls.Config{
InsecureSkipVerify: config.Insecure, InsecureSkipVerify: config.Insecure,
MinVersion: tls.VersionTLS12, MinVersion: tls.VersionTLS12,
-179
View File
@@ -1,179 +0,0 @@
// 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
@@ -1,288 +0,0 @@
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)
})
}
}