Compare commits

...
Author SHA1 Message Date
Stavros 996316b524 chore: remove gateway extractors 2026-09-21 14:19:05 +03:00
Stavros 90e898abbf Merge branch 'main' into feat/k8s_gateways 2026-09-21 14:11:52 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
4341445290 chore(deps): bump docker/setup-buildx-action from 4.3.0 to 4.4.0 (#1146)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-20 21:29:55 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
856c9b3d5b chore(deps): bump go.opentelemetry.io/otel/sdk from 1.43.0 to 1.45.0 (#1142)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-20 21:29:14 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
a10b24dfe0 chore(deps): bump codecov/codecov-action from 7.0.0 to 7.1.0 (#1138)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-20 21:28:54 +03:00
Stavros 34456b94b1 chore: add missing acls service modification for domain normalization 2026-09-20 21:22:56 +03:00
StavrosandCodex 45e165f742 tests: add tests for kubernetes service and extractors
Co-authored-by: Codex <noreply@openai.com>
2026-09-20 21:21:36 +03:00
Stavros b43cf76d9b refactor: use typed objects for kubernetes 2026-09-20 21:07:06 +03:00
Stavros 5a685c4a6b Merge branch 'main' into feat/k8s_gateways 2026-09-20 18:51:23 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
653b747829 chore(deps): bump the minor-patch group in /frontend with 6 updates (#1121)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-07 20:03:52 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
ba79a30f2e chore(deps): bump the minor-patch group with 3 updates (#1120)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-07 20:03:34 +03:00
Stavros 8c5094bbcd chore: remove instapods sponsor 2026-09-07 19:59:50 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
c28d9fe55e chore(deps): bump the minor-patch group in /frontend with 3 updates (#1117)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-06 20:00:12 +03:00
StavrosandGitHub f58a6dccf5 New Crowdin updates (#1118) 2026-09-06 19:59:49 +03:00
StavrosandOpenAI 0cf64e8598 fix: use constant-time secret comparisons
Co-Authored-By: OpenAI <noreply@openai.com>
2026-09-06 19:56:58 +03:00
ContreandClaude Sonnet 4.6 e1b1e722e7 fix(acl): only let a label provider define ACLs for domains it routes
The app name fallback matches any domain that starts with the app name,
so an app named myapp served on myapp.example.com also defined the ACLs
of myapp.evil.com. Behind a proxy with a catch-all route, a request can
be authorized against the wrong app that way.

Label providers now receive the domain being authorized. The Kubernetes
provider keeps the hosts of every Ingress, HTTPRoute and GRPCRoute it
watches and withholds the apps of the resources that do not route the
domain, which bounds the name fallback to the hosts a resource actually
serves. Wildcard hostnames keep matching as a suffix, so nested
subdomains stay resolvable by app name.

Container labels carry no routing information, so the Docker provider
cannot narrow its results down and keeps yielding every app.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-19 12:18:02 +02:00
ContreandClaude Sonnet 4.6 3a216e95e2 Merge branch 'main' into feat/k8s_gateways
Reapply the Gateway API support on top of the KubernetesService rework
from main, which moved the service to ding-managed watchers and a
Lookup based LabelProvider, and started requiring an app to match a host
the resource actually routes.

Ingresses declare their hosts in spec.rules[].host while HTTPRoutes and
GRPCRoutes use spec.hostnames, so host extraction is now dispatched per
resource kind. Route hostnames may carry the Gateway API wildcard label,
which is matched as a suffix, and routes without hostnames are skipped
since the hosts of the gateway listeners they attach to cannot be
resolved from the route alone.

The cache key gains the resource kind because an Ingress and an
HTTPRoute may share a name within a namespace, and the catch-all path
warning is extended to HTTPRoute path matches.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-19 12:03:13 +02:00
Contre 2769725775 feat(k8s): Support for GRPCRoute 2026-05-18 14:48:01 +02:00
Contre 419da20681 feat(k8s): Support new Gateway api 2026-05-18 11:44:45 +02:00
17 changed files with 1133 additions and 1324 deletions
+1 -1
View File
@@ -62,6 +62,6 @@ jobs:
run: go test -coverprofile=coverage.txt -v ./...
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
uses: codecov/codecov-action@0b35c9ecc4f0529d0eb674914510c22f85b196b4 # v7.1.0
with:
token: ${{ secrets.CODECOV_TOKEN }}
+6 -6
View File
@@ -174,7 +174,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
- name: Build and push
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
@@ -233,7 +233,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
- name: Build and push
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
@@ -292,7 +292,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
- name: Build and push
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
@@ -351,7 +351,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
- name: Build and push
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
@@ -406,7 +406,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
- name: Docker meta
id: meta
@@ -445,7 +445,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
- name: Docker meta
id: meta
+6 -6
View File
@@ -146,7 +146,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
- name: Build and push
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
@@ -203,7 +203,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
- name: Build and push
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
@@ -260,7 +260,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
- name: Build and push
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
@@ -317,7 +317,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
- name: Build and push
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
@@ -373,7 +373,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
- name: Docker meta
id: meta
@@ -414,7 +414,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
- name: Docker meta
id: meta
-1
View File
@@ -81,7 +81,6 @@ A huge thank you to [selfh.st](https://selfh.st) for their generous donation to
- [JetBrains for Open-Source](https://jb.gg/OpenSource)
- [CodeRabbit AI](https://www.coderabbit.ai)
- [InstaPods](https://instapods.com) - [Deploy Tinyauth from 3$/month](https://app.instapods.com/dashboard/pods/create?app=tinyauth&ref=tinyauth)
## Acknowledgements
+8 -8
View File
@@ -23,16 +23,16 @@
"axios": "^1.20.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"i18next": "^26.4.0",
"i18next": "^26.4.2",
"i18next-browser-languagedetector": "^8.2.1",
"i18next-resources-to-backend": "^1.2.3",
"lucide-react": "^1.38.0",
"lucide-react": "^1.40.0",
"next-themes": "^0.4.6",
"radix-ui": "^1.6.7",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-hook-form": "^7.87.0",
"react-i18next": "^17.0.12",
"react-i18next": "^17.0.13",
"react-markdown": "^10.1.0",
"react-router": "^8.3.1",
"sonner": "^2.0.8",
@@ -43,18 +43,18 @@
"devDependencies": {
"@eslint/js": "^10.0.1",
"@tanstack/eslint-plugin-query": "^5.102.8",
"@types/node": "^26.4.0",
"@types/node": "^26.4.1",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.5",
"@types/react-dom": "^19.2.7",
"@vitejs/plugin-react": "^6.1.1",
"eslint": "^10.9.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.5",
"globals": "^17.11.0",
"eslint-plugin-react-refresh": "^0.5.6",
"globals": "^17.12.0",
"rollup-plugin-visualizer": "^7.1.1",
"tw-animate-css": "^1.4.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.68.0",
"typescript-eslint": "^8.69.0",
"vite": "^8.2.2"
}
}
+408 -320
View File
File diff suppressed because it is too large Load Diff
+98 -98
View File
@@ -1,104 +1,104 @@
{
"loginTitle": "Welcome back, login with",
"loginTitleSimple": "Welcome back, please login",
"loginDivider": "Or",
"loginUsername": "Username",
"loginPassword": "Password",
"loginSubmit": "Login",
"loginFailTitle": "Failed to log in",
"loginFailSubtitle": "Please check your username and password",
"loginFailRateLimit": "You failed to login too many times. Please try again later",
"loginSuccessTitle": "Logged in",
"loginSuccessSubtitle": "Welcome back!",
"loginOauthFailTitle": "An error occurred",
"loginOauthFailSubtitle": "Failed to get OAuth URL",
"loginOauthSuccessTitle": "Redirecting",
"loginOauthSuccessSubtitle": "Redirecting to your OAuth provider",
"loginOauthAutoRedirectTitle": "OAuth Auto Redirect",
"loginOauthAutoRedirectSubtitle": "You will be automatically redirected to your OAuth provider to authenticate.",
"loginOauthAutoRedirectButton": "Redirect now",
"continueTitle": "Continue",
"continueRedirectingTitle": "Redirecting...",
"continueRedirectingSubtitle": "You should be redirected to the app soon",
"continueRedirectManually": "Redirect me manually",
"continueInsecureRedirectTitle": "Insecure redirect",
"continueInsecureRedirectSubtitle": "You are trying to redirect from <code>https</code> to <code>http</code> which is not secure. Are you sure you want to continue?",
"continueUntrustedRedirectTitle": "Untrusted redirect",
"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?",
"logoutFailTitle": "Failed to log out",
"logoutFailSubtitle": "Please try again",
"logoutSuccessTitle": "Logged out",
"logoutSuccessSubtitle": "You have been logged out",
"logoutTitle": "Logout",
"logoutUsernameSubtitle": "You are currently logged in as <code>{{username}}</code>. Click the button below to logout.",
"logoutOauthSubtitle": "You are currently logged in as <code>{{username}}</code> using the {{provider}} OAuth provider. Click the button below to logout.",
"notFoundTitle": "Page not found",
"notFoundSubtitle": "The page you are looking for does not exist.",
"notFoundButton": "Go home",
"totpFailTitle": "Failed to verify code",
"totpFailSubtitle": "Please check your code and try again",
"totpSuccessTitle": "Verified",
"totpSuccessSubtitle": "Redirecting to your app",
"totpTitle": "Enter your TOTP code",
"totpSubtitle": "Please enter the code from your authenticator app.",
"unauthorizedTitle": "Unauthorized",
"unauthorizedResourceSubtitle": "The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.",
"unauthorizedLoginSubtitle": "The user with username <code>{{username}}</code> is not authorized to login.",
"unauthorizedGroupsSubtitle": "The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.",
"unauthorizedIpSubtitle": "Your IP address <code>{{ip}}</code> is not authorized to access the resource <code>{{resource}}</code>.",
"unauthorizedButton": "Try again",
"cancelTitle": "Cancel",
"forgotPasswordTitle": "Forgot your password?",
"failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.",
"errorTitle": "An error occurred",
"errorSubtitleInfo": "The following error occurred while processing your request:",
"loginTitle": "Benvingut de nou, inicieu la sessió amb",
"loginTitleSimple": "Benvingut de nou, si us plau, inicieu sessió",
"loginDivider": "O",
"loginUsername": "Nom dusuari",
"loginPassword": "Contrasenya",
"loginSubmit": "Iniciar sessió",
"loginFailTitle": "No s'ha pogut iniciar sessió",
"loginFailSubtitle": "Si us plau, comproveu el vostre nom d'usuari i contrasenya",
"loginFailRateLimit": "Heu fallat en iniciar sessió massa vegades. Si us plau, proveu-ho més tard",
"loginSuccessTitle": "Heu iniciat sessió",
"loginSuccessSubtitle": "Benvingut de nou!",
"loginOauthFailTitle": "S'ha produït un error",
"loginOauthFailSubtitle": "Errada en obtenir l'URL OAuth",
"loginOauthSuccessTitle": "Redirigint",
"loginOauthSuccessSubtitle": "Redirigint al vostre proveïdor OAuth",
"loginOauthAutoRedirectTitle": "Autoredirecció OAuth",
"loginOauthAutoRedirectSubtitle": "Sereu automàticament redirigits al vostre proveïdor OAuth per a autenticar-vos.",
"loginOauthAutoRedirectButton": "Redirigeix ara",
"continueTitle": "Continuar",
"continueRedirectingTitle": "S'està redirigint...",
"continueRedirectingSubtitle": "Hauríeu de ser redirigits aviat a l'app",
"continueRedirectManually": "Redirigiu-me manualment",
"continueInsecureRedirectTitle": "Redirecció no segura",
"continueInsecureRedirectSubtitle": "Esteu intentant redirigir des de <code>https</code>a<code>http</code> que no és segur. Esteu segurs de voler continuar?",
"continueUntrustedRedirectTitle": "Redirecció no confiable",
"continueUntrustedRedirectSubtitle": "Esteu intentant redirigir a un domini que no coincideix amb el vostre domini (<code>{{cookieDomain}}</code>). Esteu segurs de voler continuar?",
"logoutFailTitle": "Errada en tancar la sessió",
"logoutFailSubtitle": "Si us plau, torneu-ho a provar",
"logoutSuccessTitle": "Sessió tancada",
"logoutSuccessSubtitle": "S'ha tancat la sessió",
"logoutTitle": "Tanca la sessió",
"logoutUsernameSubtitle": "Heu iniciat sessió com a <code>{{username}}</code>. Cliqueu el botó de sota per a tancar la sessió.",
"logoutOauthSubtitle": "Heu iniciat la sessió actualment com a <code>{{username}}</code> fent servir {{provider}} com a proveïdor OAuth. Cliqueu el botó de sota per a tancar la sessió.",
"notFoundTitle": "Pàgina no trobada",
"notFoundSubtitle": "La pàgina que cerques no existeix.",
"notFoundButton": "Torna a l'inici",
"totpFailTitle": "Errada en verificar el codi",
"totpFailSubtitle": "Si us plau, comprova el codi i torna-ho a provar",
"totpSuccessTitle": "Verificat",
"totpSuccessSubtitle": "Redirigint a la teva app",
"totpTitle": "Introdueixi el codi TOTP",
"totpSubtitle": "Si us plau, introdueix el codi de la teva aplicació d'autenticació.",
"unauthorizedTitle": "No autoritzat",
"unauthorizedResourceSubtitle": "L'usuari amb el nom <code>{{username}}</code> no està autoritzat a accedir al recurs <code>{{resource}}</code>.",
"unauthorizedLoginSubtitle": "L'usuari amb el nom <code>{{username}}</code> no està autoritzat a iniciar la sessió.",
"unauthorizedGroupsSubtitle": "L'usuari amb el nom <code>{{username}}</code> no està als grups requerits per al recurs <code>{{resource}}</code>.",
"unauthorizedIpSubtitle": "La vostra adreça IP <code>{{ip}}</code> no està autoritzada a accedir al recurs <code>{{resource}}</code>.",
"unauthorizedButton": "Tornar-ho a provar",
"cancelTitle": "Cancel·lar",
"forgotPasswordTitle": "Heu oblidat la vostra contrasenya?",
"failedToFetchProvidersTitle": "Errada en carregar els proveïdors d'autenticació. Si us plau, verifiqueu la vostra configuració.",
"errorTitle": "S'ha produït un error",
"errorSubtitleInfo": "Ha ocorregut l'error següent mentre es processava la vostra petició:",
"errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information.",
"forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable.",
"fieldRequired": "This field is required",
"invalidInput": "Invalid input",
"domainWarningTitle": "Invalid Domain",
"domainWarningSubtitle": "You are accessing this instance from an incorrect domain. If you proceed, you may encounter issues with authentication.",
"domainWarningCurrent": "Current:",
"domainWarningExpected": "Expected:",
"ignoreTitle": "Ignore",
"goToCorrectDomainTitle": "Go to correct domain",
"authorizeTitle": "Authorize",
"authorizeCardTitle": "Continue to {{app}}?",
"authorizeSubtitle": "Would you like to continue to this app? Please carefully review the permissions requested by the app.",
"authorizeSubtitleOAuth": "Would you like to continue to this app?",
"authorizeLoadingTitle": "Loading...",
"authorizeLoadingSubtitle": "Please wait while we load the client information.",
"authorizeSuccessTitle": "Authorized",
"authorizeSuccessSubtitle": "You will be redirected to the app in a few seconds.",
"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.",
"forgotPasswordMessage": "Podeu reiniciar la vostra contrasenya canviant la variable d'entorn `USERS`.",
"fieldRequired": "Aquest camp és obligatori",
"invalidInput": "Entrada no vàlida",
"domainWarningTitle": "Domini invàlid",
"domainWarningSubtitle": "Esteu visitant aquesta instància des d'un domini incorrecte. Si procediu, podríeu trobar-os amb problemes d'autenticació.",
"domainWarningCurrent": "Actual:",
"domainWarningExpected": "Esperat:",
"ignoreTitle": "Ignorar",
"goToCorrectDomainTitle": "Anar al domini correcte",
"authorizeTitle": "Autoritzar",
"authorizeCardTitle": "Continuar a {{app}}?",
"authorizeSubtitle": "Voldríeu continuar a aquesta app? Si us plau, reviseu amb cura els permisos sol·licitats per a l'app.",
"authorizeSubtitleOAuth": "Voldríeu continuar a aquesta app?",
"authorizeLoadingTitle": "S'està carregant...",
"authorizeLoadingSubtitle": "Si us plau, espereu mentre carreguem la informació del client.",
"authorizeSuccessTitle": "Autoritzat",
"authorizeSuccessSubtitle": "Sereu redirigits a l'app en pocs segons.",
"authorizeErrorClientInfo": "Ha ocorregut un error mentre es carregava la informació del client. Si us plau, proveu-ho més tard.",
"authorizeErrorInvalidParams": "La petició manca dels paràmetres requerits o té paràmetres invàlids. Si us plau, comproveu l'URL i torneu-ho a provar.",
"openidScopeName": "OpenID Connect",
"openidScopeDescription": "Allows the app to access your OpenID Connect information.",
"emailScopeName": "Email",
"emailScopeDescription": "Allows the app to access your email address.",
"profileScopeName": "Profile",
"profileScopeDescription": "Allows the app to access your profile information.",
"groupsScopeName": "Groups",
"groupsScopeDescription": "Allows the app to access your group information.",
"backToLoginButton": "Back to login",
"phoneScopeName": "Phone",
"phoneScopeDescription": "Allows the app to access your phone number.",
"addressScopeName": "Address",
"addressScopeDescription": "Allows the app to access your address.",
"loginTailscaleTitle": "Continue with Tailscale",
"loginTailscaleDescription": "You appear to be accessing Tinyauth from an authorized Tailscale device. Would you like to continue with your Tailscale connection?",
"loginTailscaleDeviceName": "Device name:",
"loginTailscaleOtherMethod": "Login with another method",
"loginTailscaleSuccess": "Successfully authenticated with Tailscale.",
"loginTailscaleFail": "Failed to authenticate with Tailscale. Please try again or use another login method.",
"logoutTailscaleSubtitle": "You are currently logged in with Tailscale on your device <code>{{deviceName}}</code>. Click the button below to logout.",
"quickActionsLanguage": "Language",
"quickActionsTheme": "Theme",
"quickActionsThemeLight": "Light",
"quickActionsThemeDark": "Dark",
"quickActionsThemeSystem": "System",
"quickActionsLogout": "Logout",
"quickActionsTitle": "Quick Actions",
"openidScopeDescription": "Permet a l'app l'accés a la vostra informació OpenID Connect.",
"emailScopeName": "Correu electrònic",
"emailScopeDescription": "Permet a l'app l'accés a la vostra adreça de correu electrònic.",
"profileScopeName": "Perfil",
"profileScopeDescription": "Permet a l'app l'accés a la vostra informació de perfil.",
"groupsScopeName": "Grups",
"groupsScopeDescription": "Permet a l'app l'accés la vostra informació de grup.",
"backToLoginButton": "Tornar a l'inici de sessió",
"phoneScopeName": "Telèfon",
"phoneScopeDescription": "Permet a l'app l'accés al vostre número de telèfon.",
"addressScopeName": "Adreça",
"addressScopeDescription": "Permet a l'app l'accés a la vostra adreça.",
"loginTailscaleTitle": "Continuar amb Tailscale",
"loginTailscaleDescription": "Sembla que esteu accedint a Tinyauth des d'un dispositiu Tailscale autoritzat. Voldríeu continuar amb la vostra connexió Tailscale?",
"loginTailscaleDeviceName": "Nom del dispositiu:",
"loginTailscaleOtherMethod": "Iniciar la sessió amb un altre mètode",
"loginTailscaleSuccess": "Autenticat correctament amb Tailscale.",
"loginTailscaleFail": "Errada en autentificar amb Tailscale. Si us plau, intenteu-ho una altra vegada o feu servir un altre mètode d'autenticació.",
"logoutTailscaleSubtitle": "Heu iniciat sessió actualment amb Tailscale en el vostre dispositiu <code>{{deviceName}}</code>. Cliqueu el botó de sota per a tancar la sessió.",
"quickActionsLanguage": "Idioma",
"quickActionsTheme": "Tema",
"quickActionsThemeLight": "Clar",
"quickActionsThemeDark": "Fosc",
"quickActionsThemeSystem": "Sistema",
"quickActionsLogout": "Sortir de la sessió",
"quickActionsTitle": "Accions ràpides",
"quickActionsProviderLocal": "Local",
"quickActionsProviderLDAP": "LDAP",
"quickActionsProviderOAuth": "{{provider}} OAuth"
+13 -11
View File
@@ -8,7 +8,7 @@ require (
github.com/cenkalti/backoff/v5 v5.0.3
github.com/docker/docker v28.5.2+incompatible
github.com/gin-gonic/gin v1.12.0
github.com/go-jose/go-jose/v4 v4.1.4
github.com/go-jose/go-jose/v4 v4.1.5
github.com/go-ldap/ldap/v3 v3.4.14
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/golang-migrate/migrate/v4 v4.19.1
@@ -23,14 +23,15 @@ 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.55.0
golang.org/x/crypto v0.56.0
golang.org/x/net v0.58.0
golang.org/x/oauth2 v0.36.0
golang.org/x/tools v0.49.0
gopkg.in/yaml.v3 v3.0.1
k8s.io/api v0.37.0
k8s.io/apimachinery v0.37.0
k8s.io/client-go v0.37.0
modernc.org/sqlite v1.57.0
modernc.org/sqlite v1.58.0
)
require (
@@ -74,13 +75,14 @@ require (
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/logr v1.4.4 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/google/gnostic-models v0.7.1 // indirect
github.com/huandu/xstrings v1.5.0 // indirect
github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
@@ -123,12 +125,12 @@ require (
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel v1.45.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/sdk v1.43.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.opentelemetry.io/otel/metric v1.45.0 // indirect
go.opentelemetry.io/otel/sdk v1.45.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.45.0 // indirect
go.opentelemetry.io/otel/trace v1.45.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/arch v0.22.0 // indirect
@@ -145,9 +147,9 @@ require (
k8s.io/klog/v2 v2.140.0 // indirect
k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad // indirect
k8s.io/utils v0.0.0-20260626114624-be93311217bd // indirect
modernc.org/libc v1.74.4 // indirect
modernc.org/libc v1.75.6 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/memory v1.12.1 // indirect
rsc.io/qr v0.2.0 // indirect
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
sigs.k8s.io/randfill v1.0.0 // indirect
+30 -30
View File
@@ -115,13 +115,13 @@ github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-asn1-ber/asn1-ber v1.5.8 h1:H9AZkK22UOmfX8J84ubyaZxKJZ3FMHVwn8swoMML7iQ=
github.com/go-asn1-ber/asn1-ber v1.5.8/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-jose/go-jose/v4 v4.1.5 h1:RjgjO2LOtWOJKUC5wpwY9LR3B3vwVAz6JS2YHfYU6eA=
github.com/go-jose/go-jose/v4 v4.1.5/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-ldap/ldap/v3 v3.4.14 h1:D6PYdEgsaVzsXyr6w/yDC06Ria4uUhWm+Rb+er8lfAs=
github.com/go-ldap/ldap/v3 v3.4.14/go.mod h1:S4eJUMUNjDkE0ZJtIZdybwyb03sGGLW6gxXT1Hs8VKA=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-openapi/jsonpointer v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s=
@@ -168,8 +168,8 @@ github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63Y
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c=
github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
@@ -329,20 +329,20 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU=
go.opentelemetry.io/otel v1.45.0/go.mod h1:XZxIqPapzEYnhNSScF5DIqXhm/rYi0FzCe2XddAwZfQ=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.opentelemetry.io/otel/metric v1.45.0 h1:7Eg1uH7CJ5cXv9is6tnBe1FI6rj1nwUdbFypRm3br/M=
go.opentelemetry.io/otel/metric v1.45.0/go.mod h1:HAPbm1nd3p1PmFH7v2dR+6BjXxw+Lq4a2+pndMAm08s=
go.opentelemetry.io/otel/sdk v1.45.0 h1:4VVSMgQ83dUgW2aoX5f6JgLvHwIvzcuLnF9lUdCSpCw=
go.opentelemetry.io/otel/sdk v1.45.0/go.mod h1:Sr40LgXV7DsKMMJMKOhUWOgMWTfAaqvm2kF0g7ilwuA=
go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o=
go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA=
go.opentelemetry.io/otel/trace v1.45.0 h1:l/mP6Uv7oNO7/TblbhpbgMidxhq1uO/rPsikOyVhxag=
go.opentelemetry.io/otel/trace v1.45.0/go.mod h1:qoJJA2xNMnxRrdISU/kLtfUH2wNeQbiv+jhs/CxI8bc=
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4=
@@ -355,8 +355,8 @@ go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
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.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y=
golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I=
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.39.0 h1:UF5zwQdCRRUpHfyPwr7d4UrGiVeldIsogtzWVnczL74=
@@ -410,30 +410,30 @@ k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad h1:oXImqH8mQNk7PmvzKhmN3d
k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad/go.mod h1:0/mqHCVhlumdJ3BhCfnjSZQE037nAhNodh1/hK0T8/I=
k8s.io/utils v0.0.0-20260626114624-be93311217bd h1:Ea7fgQ5we8Y9T0OX5o0dAHzQOBRI07D/dEYRaB9ZZEs=
k8s.io/utils v0.0.0-20260626114624-be93311217bd/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
modernc.org/cc/v4 v4.29.2 h1:h6+9ciCnPKutf4I03CvheAvDLX7+IHlqR6Iy6J+cgd8=
modernc.org/cc/v4 v4.29.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.35.0 h1:F+TUsmw09QxLzmi3aeYYGxjAXarmZaKgj3mKQHNaA8w=
modernc.org/ccgo/v4 v4.35.0/go.mod h1:qrVGs9S3Sr2Ztcg9ve+kTAYMp5a3YvWjo+SoN06kJ5I=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/gc/v3 v3.1.5 h1:21ldfPfRYE31Tb7B3mwAK8gy1AxP4+dKjrOQPfqakoc=
modernc.org/gc/v3 v3.1.5/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k=
modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co=
modernc.org/libc v1.75.6 h1:yKk8qo+Di4gkmvRboK8ocCqH22FiUCR6jRy2OwtCRus=
modernc.org/libc v1.75.6/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.57.0 h1:qNQP6xnx5M0ISNtlnxoOX0+cD5bJ0/gr9aMmndFczzg=
modernc.org/sqlite v1.57.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ=
modernc.org/sqlite v1.58.0 h1:38u40/bwkfM7f0Myhosl+SEMltSDxnGdQf8o6Kjmys0=
modernc.org/sqlite v1.58.0/go.mod h1:rsD2CckafgObKC4DhBlGBf+RiHxkc3hINGt1Xw32tVY=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+5 -1
View File
@@ -1,6 +1,8 @@
package controller
import (
"crypto/sha256"
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
@@ -545,7 +547,9 @@ func (controller *OIDCController) Token(c *gin.Context) {
return
}
if client.ClientSecret != creds.ClientSecret {
clientSecretHash := sha256.Sum256([]byte(client.ClientSecret))
providedSecretHash := sha256.Sum256([]byte(creds.ClientSecret))
if subtle.ConstantTimeCompare(clientSecretHash[:], providedSecretHash[:]) != 1 {
controller.log.App.Warn().Str("clientId", creds.ClientID).Msg("Invalid client secret")
c.JSON(400, gin.H{
"error": "invalid_client",
+14 -8
View File
@@ -12,8 +12,12 @@ import (
"go.uber.org/dig"
)
// LabelProvider looks up the apps it knows about for the given domain. A
// provider that knows which hosts its apps are served on MUST only yield the
// ones that are actually served on domain, so that an unrelated app cannot
// claim it by name.
type LabelProvider interface {
Lookup(locator func(name string, app *model.App) bool) error
Lookup(domain string, locator func(name string, app *model.App) bool) error
}
type AccessControlsService struct {
@@ -42,7 +46,7 @@ func NewAccessControlsService(i AccessControlServiceInput) *AccessControlsServic
}
}
func (service *AccessControlsService) ensureAscii(str string) bool {
func ensureAscii(str string) bool {
for i := 0; i < len(str); i++ {
if str[i] > unicode.MaxASCII {
return false
@@ -51,7 +55,7 @@ func (service *AccessControlsService) ensureAscii(str string) bool {
return true
}
func (service *AccessControlsService) normalizeDomain(domain string) string {
func normalizeDomain(domain string) string {
if host, _, err := net.SplitHostPort(domain); err == nil {
domain = host
}
@@ -60,11 +64,11 @@ func (service *AccessControlsService) normalizeDomain(domain string) string {
}
func (service *AccessControlsService) getACLs(domain string, lookup func(locator func(name string, app *model.App) bool) error) (*model.App, error) {
if !service.ensureAscii(domain) {
if !ensureAscii(domain) {
return nil, errors.New("domain contains non-ascii characters")
}
normalizedDomain := service.normalizeDomain(domain)
normalizedDomain := normalizeDomain(domain)
if !strings.HasSuffix(normalizedDomain, "."+service.runtime.CookieDomain) && normalizedDomain != service.runtime.CookieDomain {
return nil, fmt.Errorf("domain does not match cookie domain, expected %s (or a subdomain), got %s", service.runtime.CookieDomain, domain)
@@ -76,11 +80,11 @@ func (service *AccessControlsService) getACLs(domain string, lookup func(locator
locatorFunc := func(name string, app *model.App) bool {
if app.Config.Domain != "" {
if !service.ensureAscii(app.Config.Domain) {
if !ensureAscii(app.Config.Domain) {
service.log.App.Warn().Str("name", name).Str("domain", app.Config.Domain).Msg("Domain contains non-ascii characters, skipping")
return false
}
if normalizedDomain == service.normalizeDomain(app.Config.Domain) {
if normalizedDomain == normalizeDomain(app.Config.Domain) {
service.log.App.Debug().Str("name", name).Msg("Found matching container by domain")
domainMatch = app
return true
@@ -145,7 +149,9 @@ func (service *AccessControlsService) GetAccessControls(domain string) (*model.A
// If we have a label provider configured, try to get ACLs from it
if service.labelProvider != nil {
return service.getACLs(domain, service.labelProvider.Lookup)
return service.getACLs(domain, func(locator func(name string, app *model.App) bool) error {
return service.labelProvider.Lookup(domain, locator)
})
}
// No labels
@@ -20,7 +20,7 @@ func newMockProvider(acls map[string]model.App, shouldError bool) *mockProvider
return &mockProvider{acls: acls, shouldError: shouldError}
}
func (m *mockProvider) Lookup(locator func(name string, app *model.App) bool) error {
func (m *mockProvider) Lookup(_ string, locator func(name string, app *model.App) bool) error {
if m.shouldError {
return errors.New("mock error")
}
@@ -153,7 +153,9 @@ func TestAccessControlsService(t *testing.T) {
Config: &model.Config{},
LabelProvider: mock,
})
app, err := acls.getACLs(test.domain, mock.Lookup)
app, err := acls.getACLs(test.domain, func(locator func(name string, app *model.App) bool) error {
return mock.Lookup(test.domain, locator)
})
if test.errorFunc != nil {
test.errorFunc(t, err)
return
@@ -186,11 +188,14 @@ func TestAccessControlsService(t *testing.T) {
// get acls should return an error when the provider fails
mock := newMockProvider(map[string]model.App{}, true)
acls := NewAccessControlsService(AccessControlServiceInput{
Log: log,
Runtime: &runtime,
Config: &model.Config{},
Log: log,
Runtime: &runtime,
Config: &model.Config{},
LabelProvider: mock,
})
_, err := acls.getACLs("example.com", func(locator func(name string, app *model.App) bool) error {
return mock.Lookup("example.com", locator)
})
_, err := acls.getACLs("example.com", mock.Lookup)
assert.Error(t, err)
// get acls should return an error when multiple apps with the same domain exist
+4 -1
View File
@@ -119,7 +119,10 @@ func (docker *DockerService) inspectContainer(containerId string) (container.Ins
return docker.client.ContainerInspect(docker.context, containerId)
}
func (docker *DockerService) Lookup(locator func(name string, app *model.App) bool) error {
// Lookup yields every app labelled on a running container. Container labels
// carry no routing information, so the domain cannot be used to narrow the
// results down and the caller is left to match them.
func (docker *DockerService) Lookup(_ string, locator func(name string, app *model.App) bool) error {
if !docker.isConnected {
docker.log.App.Debug().Msg("Docker service not connected, returning empty labels")
return nil
@@ -0,0 +1,68 @@
package service
import (
"slices"
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
networking "k8s.io/api/networking/v1"
)
type KubernetesIngressExtractor struct {
log *logger.Logger
}
type KubernetesIngressExtractorInput struct {
Log *logger.Logger
}
func NewKubernetesIngressExtractor(i KubernetesIngressExtractorInput) *KubernetesIngressExtractor {
return &KubernetesIngressExtractor{
log: i.Log,
}
}
func (k *KubernetesIngressExtractor) getPaths(rule networking.IngressRule) []string {
var paths []string
if rule.HTTP == nil {
return paths
}
for _, path := range rule.HTTP.Paths {
paths = append(paths, path.Path)
}
return paths
}
func (k *KubernetesIngressExtractor) getHosts(rules []networking.IngressRule) []string {
var hosts []string
for _, rule := range rules {
hosts = append(hosts, rule.Host)
paths := k.getPaths(rule)
if len(paths) == 0 {
continue
}
if !slices.Contains(paths, "/") {
k.log.App.Warn().Strs("hosts", hosts).Strs("paths", paths).Msg("Ingress rule does not contain a catch-all path, another ingress may be able to bypass auth checks if it routes the same host with a different path. Consider adding a catch-all path to this rule to ensure auth checks are applied to all paths for this host.")
}
}
return hosts
}
func (k *KubernetesIngressExtractor) Extract(ingress *networking.Ingress) *ExtractionResult {
annotations := ingress.GetAnnotations()
hosts := k.getHosts(ingress.Spec.Rules)
return &ExtractionResult{
typ: ResourceTypeIngress,
name: ingress.GetName(),
namespace: ingress.GetNamespace(),
hosts: hosts,
annotations: annotations,
}
}
+244 -173
View File
@@ -12,23 +12,111 @@ import (
"github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/utils/decoders"
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
"github.com/tinyauthapp/tinyauth/pkg/validators"
"go.uber.org/dig"
networking "k8s.io/api/networking/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/watch"
"k8s.io/client-go/dynamic"
"k8s.io/client-go/rest"
)
type ingressEntry struct {
type watchedResource struct {
gvr schema.GroupVersionResource
typ ResourceType
}
func (w watchedResource) pretty() string {
return w.gvr.Group + "/" + w.gvr.Version + "/" + w.gvr.Resource
}
type ResourceType string
const (
ResourceTypeIngress ResourceType = "ingress"
)
var supportedResources = []watchedResource{
{
gvr: schema.GroupVersionResource{
Group: "networking.k8s.io",
Version: "v1",
Resource: "ingresses",
},
typ: ResourceTypeIngress,
},
}
func hostMatchesHostname(host string, hostname string) bool {
host = normalizeDomain(host)
hostname = normalizeDomain(hostname)
if suffix, ok := strings.CutPrefix(host, "*."); ok {
return strings.HasSuffix(hostname, "."+suffix)
}
return host == hostname
}
func hostCoversName(host string, name string) bool {
host = strings.ToLower(host)
if strings.HasPrefix(host, "*.") {
return true
}
return strings.HasPrefix(host, strings.ToLower(name+"."))
}
type ExtractionResult struct {
typ ResourceType
name string
namespace string
hosts []string
annotations map[string]string
}
type typedItem struct {
typ ResourceType
ingress *networking.Ingress
}
func convertFromUnstructured[T any](obj *unstructured.Unstructured) (*T, error) {
var typed *T
err := runtime.DefaultUnstructuredConverter.FromUnstructured(obj.Object, &typed)
if err != nil {
var zero *T
return zero, fmt.Errorf("failed to convert ingress to typed object: %w", err)
}
return typed, nil
}
func (ti *typedItem) fromUnstructured(typ ResourceType, obj *unstructured.Unstructured) (*typedItem, error) {
switch typ {
case ResourceTypeIngress:
typed, err := convertFromUnstructured[networking.Ingress](obj)
if err != nil {
return nil, err
}
return &typedItem{
typ: ResourceTypeIngress,
ingress: typed,
}, nil
default:
return nil, fmt.Errorf("unknown resource type %s", typ)
}
}
type resourceEntry struct {
name string
app model.App
}
type ingressKey struct {
type routedApps struct {
hosts []string
entries []resourceEntry
}
type resourceKey struct {
typ ResourceType
namespace string
name string
}
@@ -36,10 +124,14 @@ type ingressKey struct {
type KubernetesService struct {
log *logger.Logger
client dynamic.Interface
connected bool
mu sync.RWMutex
ingressEntries map[ingressKey][]ingressEntry
apps map[resourceKey]routedApps
client dynamic.Interface
mu sync.RWMutex
connected bool
extractors struct {
ingress *KubernetesIngressExtractor
}
}
type KubernetesServiceInput struct {
@@ -61,32 +153,42 @@ func NewKubernetesService(i KubernetesServiceInput) (*KubernetesService, error)
return nil, fmt.Errorf("failed to create kubernetes client: %w", err)
}
gvr := schema.GroupVersionResource{
Group: "networking.k8s.io",
Version: "v1",
Resource: "ingresses",
}
accessCtx, accessCancel := context.WithTimeout(i.Ctx, 5*time.Second)
defer accessCancel()
_, err = client.Resource(gvr).List(accessCtx, metav1.ListOptions{Limit: 1})
if err != nil {
i.Log.App.Warn().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Failed to access Ingress API, Kubernetes label provider will be disabled")
return nil, fmt.Errorf("failed to access ingress api: %w", err)
}
i.Log.App.Debug().Str("api", gvr.GroupVersion().String()).Msg("Successfully accessed Ingress API, starting watcher")
service := &KubernetesService{
log: i.Log,
client: client,
ingressEntries: make(map[ingressKey][]ingressEntry),
log: i.Log,
client: client,
apps: make(map[resourceKey]routedApps),
}
i.Ding.Go(func(ctx context.Context) {
service.watchGVR(gvr, ctx)
}, ding.RingMajor)
service.extractors.ingress = NewKubernetesIngressExtractor(KubernetesIngressExtractorInput{
Log: i.Log,
})
watchedGVRs := make(map[string]bool)
for _, res := range supportedResources {
ctx, cancel := context.WithTimeout(i.Ctx, 5*time.Second)
_, err := client.Resource(res.gvr).List(ctx, metav1.ListOptions{Limit: 1})
cancel()
if err != nil {
// The Gateway API CRDs are not installed on every cluster, so a
// single unreachable resource is not fatal
i.Log.App.Warn().Err(err).Str("res", res.pretty()).Msg("Failed to access resource, skipping watcher")
continue
}
i.Log.App.Debug().Str("res", res.pretty()).Msg("Successfully accessed resource, starting watcher")
i.Ding.Go(func(ctx context.Context) {
service.watchGVR(res, ctx)
}, ding.RingMajor)
watchedGVRs[res.gvr.Resource] = true
}
if len(watchedGVRs) == 0 {
return nil, fmt.Errorf("failed to access any supported kubernetes api (ingresses, httproutes, grpcroutes)")
}
service.connected = true
i.Log.App.Debug().Msg("Kubernetes label provider started successfully")
@@ -94,25 +196,38 @@ func NewKubernetesService(i KubernetesServiceInput) (*KubernetesService, error)
return service, nil
}
func (k *KubernetesService) addIngressEntries(key ingressKey, entries []ingressEntry) {
func (k *KubernetesService) addResourceEntries(key resourceKey, hosts []string, entries []resourceEntry) {
k.mu.Lock()
defer k.mu.Unlock()
k.ingressEntries[key] = entries
k.apps[key] = routedApps{
hosts: hosts,
entries: entries,
}
}
func (k *KubernetesService) removeIngress(key ingressKey) {
func (k *KubernetesService) removeResource(key resourceKey) {
k.mu.Lock()
defer k.mu.Unlock()
delete(k.ingressEntries, key)
delete(k.apps, key)
}
func (k *KubernetesService) getEntry(locator func(name string, app *model.App) bool) {
func (k *KubernetesService) getEntry(domain string, locator func(name string, app *model.App) bool) {
if !ensureAscii(domain) {
k.log.App.Debug().Str("domain", domain).Msg("Domain is invalid, skipping lookup")
return
}
k.mu.RLock()
defer k.mu.RUnlock()
// O(n^2) is not great but the number of ingress entries is expected to be small
for _, entries := range k.ingressEntries {
for _, entry := range entries {
// O(n^2) is not great but the number of resource entries is expected to be small
for _, app := range k.apps {
if !slices.ContainsFunc(app.hosts, func(host string) bool {
return hostMatchesHostname(host, domain)
}) {
continue
}
for _, entry := range app.entries {
if ok := locator(entry.name, &entry.app); ok {
return
}
@@ -120,156 +235,106 @@ func (k *KubernetesService) getEntry(locator func(name string, app *model.App) b
}
}
func (k *KubernetesService) extractPaths(rule map[string]any) ([]string, error) {
http, found, err := unstructured.NestedMap(rule, "http")
if err != nil {
return nil, fmt.Errorf("reading http from rule: %w", err)
}
if !found {
return nil, nil
}
paths, found, err := unstructured.NestedSlice(http, "paths")
if err != nil {
return nil, fmt.Errorf("reading http.paths: %w", err)
}
if !found {
return nil, nil
}
var result []string
for _, p := range paths {
path, ok := p.(map[string]any)
if !ok {
continue
}
if p, ok := path["path"].(string); ok && p != "" {
result = append(result, p)
}
}
return result, nil
}
func (k *KubernetesService) updateFromItem(res watchedResource, typedItem *typedItem) {
var result *ExtractionResult
func (k *KubernetesService) extractHosts(item *unstructured.Unstructured) ([]string, error) {
rules, found, err := unstructured.NestedSlice(item.Object, "spec", "rules")
if err != nil {
return nil, fmt.Errorf("reading spec.rules: %w", err)
}
if !found {
return nil, nil
}
var hosts []string
for _, r := range rules {
rule, ok := r.(map[string]any)
if !ok {
continue
}
if host, ok := rule["host"].(string); ok && host != "" {
hosts = append(hosts, host)
}
paths, err := k.extractPaths(rule)
if err != nil {
// This is purely to warn users
// It doesn't affect our ability to extract hosts, so we won't fail the whole operation
k.log.App.Warn().Err(err).Str("namespace", item.GetNamespace()).Str("name", item.GetName()).Msg("Failed to extract paths from ingress rule")
continue
}
if len(paths) == 0 {
continue
}
if !slices.Contains(paths, "/") {
k.log.App.Warn().Str("namespace", item.GetNamespace()).Str("name", item.GetName()).Strs("paths", paths).Msg("Ingress rule does not contain a catch-all path, another ingress may be able to bypass auth checks if it routes the same host with a different path. Consider adding a catch-all path to this rule to ensure auth checks are applied to all paths for this host.")
}
}
k.log.App.Trace().Strs("hosts", hosts).Msg("Extracted hosts from ingress rules")
return hosts, nil
}
func (k *KubernetesService) updateFromItem(item *unstructured.Unstructured) {
key := ingressKey{
namespace: item.GetNamespace(),
name: item.GetName(),
}
annotations := item.GetAnnotations()
if annotations == nil {
k.removeIngress(key)
if typedItem == nil {
k.log.App.Warn().Str("res", res.pretty()).Msg("Resource is nil, skipping")
return
}
hosts, err := k.extractHosts(item)
switch typedItem.typ {
case ResourceTypeIngress:
if typedItem.ingress == nil {
k.log.App.Warn().Str("res", res.pretty()).Msg("Ingress is nil, skipping")
return
}
result = k.extractors.ingress.Extract(typedItem.ingress)
}
if result == nil {
k.log.App.Warn().Str("res", res.pretty()).Msg("Failed to extract resource, skipping")
return
}
key := resourceKey{
typ: res.typ,
namespace: result.namespace,
name: result.name,
}
if len(result.hosts) == 0 {
k.log.App.Warn().Str("res", res.pretty()).Str("namespace", key.namespace).Str("name", key.name).Msg("No hosts found in resource, skipping")
k.removeResource(key)
return
}
labels, err := decoders.DecodeLabels[model.Apps](result.annotations, "apps")
if err != nil {
k.removeIngress(key)
k.log.App.Warn().Err(err).Str("namespace", key.namespace).Str("name", key.name).Msg("Failed to decode resource labels, skipping")
k.removeResource(key)
return
}
if len(hosts) == 0 {
k.log.App.Warn().Str("namespace", key.namespace).Str("name", key.name).Msg("No hosts found in ingress, skipping")
k.removeIngress(key)
return
}
labels, err := decoders.DecodeLabels[model.Apps](annotations, "apps")
if err != nil {
k.log.App.Warn().Err(err).Str("namespace", key.namespace).Str("name", key.name).Msg("Failed to decode ingress labels, skipping")
k.removeIngress(key)
return
}
var entries []ingressEntry
v := validators.NewDomainValidator(validators.DomainValidatorOptions{})
var entries []resourceEntry
for name, config := range labels.Apps {
if config.Config.Domain != "" {
hostname, err := v.SafeHostname(config.Config.Domain)
if err != nil {
if !ensureAscii(config.Config.Domain) {
k.log.App.Warn().Err(err).Str("namespace", key.namespace).Str("name", key.name).Str("domain", config.Config.Domain).Msg("Domain is invalid, matching will rely on app name")
} else if slices.Contains(hosts, hostname) {
entries = append(entries, ingressEntry{
name: name,
app: config,
})
continue
} else {
if slices.ContainsFunc(result.hosts, func(host string) bool {
return hostMatchesHostname(host, config.Config.Domain)
}) {
entries = append(entries, resourceEntry{
name: name,
app: config,
})
continue
}
}
}
for _, host := range hosts {
if strings.HasPrefix(strings.ToLower(host), strings.ToLower(name+".")) {
entries = append(entries, ingressEntry{
name: name,
app: config,
})
break
}
if slices.ContainsFunc(result.hosts, func(host string) bool {
return hostCoversName(host, name)
}) {
entries = append(entries, resourceEntry{
name: name,
app: config,
})
}
}
if len(entries) == 0 {
k.removeIngress(key)
k.removeResource(key)
return
}
k.addIngressEntries(key, entries)
k.addResourceEntries(key, result.hosts, entries)
}
func (k *KubernetesService) resyncGVR(gvr schema.GroupVersionResource, ctx context.Context) error {
func (k *KubernetesService) resyncGVR(res watchedResource, ctx context.Context) error {
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
list, err := k.client.Resource(gvr).List(ctx, metav1.ListOptions{})
list, err := k.client.Resource(res.gvr).List(ctx, metav1.ListOptions{})
if err != nil {
k.log.App.Warn().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Failed to list resources for resync")
k.log.App.Warn().Err(err).Str("res", res.pretty()).Msg("Failed to list resources for resync")
return err
}
for i := range list.Items {
k.updateFromItem(&list.Items[i])
for _, item := range list.Items {
newTypedItem, err := new(typedItem).fromUnstructured(res.typ, &item)
if err != nil {
k.log.App.Warn().Err(err).Str("res", res.pretty()).Msg("Failed to decode resource, skipping")
continue
}
k.updateFromItem(res, newTypedItem)
}
k.log.App.Debug().Str("api", gvr.GroupVersion().String()).Int("count", len(list.Items)).Msg("Resync complete")
k.log.App.Debug().Str("res", res.pretty()).Int("count", len(list.Items)).Msg("Resync complete")
return nil
}
// runWatcher drains events from an active watcher until it closes or the context is done.
// Returns true if the caller should restart the watcher, false if it should exit.
func (k *KubernetesService) runWatcher(gvr schema.GroupVersionResource, w watch.Interface, resyncTicker *time.Ticker, ctx context.Context) bool {
func (k *KubernetesService) runWatcher(res watchedResource, w watch.Interface, resyncTicker *time.Ticker, ctx context.Context) bool {
for {
select {
case <-ctx.Done():
@@ -277,62 +342,68 @@ func (k *KubernetesService) runWatcher(gvr schema.GroupVersionResource, w watch.
return false
case event, ok := <-w.ResultChan():
if !ok {
k.log.App.Warn().Str("api", gvr.GroupVersion().String()).Msg("Watcher channel closed, restarting watcher")
k.log.App.Warn().Str("res", res.pretty()).Msg("Watcher channel closed, restarting watcher")
w.Stop()
time.Sleep(5 * time.Second)
return true
}
item, ok := event.Object.(*unstructured.Unstructured)
if !ok {
k.log.App.Warn().Str("api", gvr.GroupVersion().String()).Msg("Received unexpected event object, skipping")
k.log.App.Warn().Str("res", res.pretty()).Msg("Received unexpected event object, skipping")
continue
}
newTypedItem, err := new(typedItem).fromUnstructured(res.typ, item)
if err != nil {
k.log.App.Warn().Err(err).Str("res", res.pretty()).Msg("Failed to decode resource, skipping")
continue
}
switch event.Type {
case watch.Added, watch.Modified:
k.updateFromItem(item)
k.updateFromItem(res, newTypedItem)
case watch.Deleted:
k.removeIngress(ingressKey{
k.removeResource(resourceKey{
typ: res.typ,
namespace: item.GetNamespace(),
name: item.GetName(),
})
}
case <-resyncTicker.C:
if err := k.resyncGVR(gvr, ctx); err != nil {
k.log.App.Warn().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Periodic resync failed during watcher run")
if err := k.resyncGVR(res, ctx); err != nil {
k.log.App.Warn().Err(err).Str("res", res.pretty()).Msg("Periodic resync failed during watcher run")
}
}
}
}
func (k *KubernetesService) watchGVR(gvr schema.GroupVersionResource, ctx context.Context) {
func (k *KubernetesService) watchGVR(res watchedResource, ctx context.Context) {
resyncTicker := time.NewTicker(5 * time.Minute)
defer resyncTicker.Stop()
if err := k.resyncGVR(gvr, ctx); err != nil {
k.log.App.Warn().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Initial resync failed, will retry")
if err := k.resyncGVR(res, ctx); err != nil {
k.log.App.Warn().Err(err).Str("res", res.pretty()).Msg("Initial resync failed, will retry")
time.Sleep(30 * time.Second)
}
for {
select {
case <-ctx.Done():
k.log.App.Debug().Str("api", gvr.GroupVersion().String()).Msg("Shutting down kubernetes watcher")
k.log.App.Debug().Str("res", res.pretty()).Msg("Shutting down kubernetes watcher")
return
case <-resyncTicker.C:
if err := k.resyncGVR(gvr, ctx); err != nil {
k.log.App.Warn().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Periodic resync failed, will retry")
if err := k.resyncGVR(res, ctx); err != nil {
k.log.App.Warn().Err(err).Str("res", res.pretty()).Msg("Periodic resync failed, will retry")
}
default:
ctx, cancel := context.WithCancel(ctx)
watcher, err := k.client.Resource(gvr).Watch(ctx, metav1.ListOptions{})
watcher, err := k.client.Resource(res.gvr).Watch(ctx, metav1.ListOptions{})
if err != nil {
k.log.App.Warn().Err(err).Str("api", gvr.GroupVersion().String()).Msg("Failed to start watcher, will retry")
k.log.App.Warn().Err(err).Str("res", res.pretty()).Msg("Failed to start watcher, will retry")
cancel()
time.Sleep(10 * time.Second)
continue
}
k.log.App.Debug().Str("api", gvr.GroupVersion().String()).Msg("Watcher started successfully")
if !k.runWatcher(gvr, watcher, resyncTicker, ctx) {
k.log.App.Debug().Str("res", res.pretty()).Msg("Watcher started successfully")
if !k.runWatcher(res, watcher, resyncTicker, ctx) {
cancel()
return
}
@@ -341,13 +412,13 @@ func (k *KubernetesService) watchGVR(gvr schema.GroupVersionResource, ctx contex
}
}
func (k *KubernetesService) Lookup(locator func(name string, app *model.App) bool) error {
func (k *KubernetesService) Lookup(domain string, locator func(name string, app *model.App) bool) error {
if !k.connected {
k.log.App.Debug().Msg("Kubernetes label provider not started, skipping")
return nil
}
k.getEntry(locator)
k.getEntry(domain, locator)
return nil
}
+215 -653
View File
@@ -4,676 +4,238 @@ import (
"strings"
"testing"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tinyauthapp/tinyauth/internal/model"
"github.com/tinyauthapp/tinyauth/internal/utils/logger"
networking "k8s.io/api/networking/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)
func TestKubernetesService(t *testing.T) {
func watchedResourceForTest(t *testing.T, typ ResourceType) watchedResource {
t.Helper()
for _, resource := range supportedResources {
if resource.typ == typ {
return resource
}
}
t.Fatalf("unsupported resource type %q", typ)
return watchedResource{}
}
func newKubernetesServiceForTest(log *logger.Logger) *KubernetesService {
service := &KubernetesService{
apps: make(map[resourceKey]routedApps),
log: log,
}
service.extractors.ingress = NewKubernetesIngressExtractor(KubernetesIngressExtractorInput{Log: log})
return service
}
func testIngress(name string, annotations map[string]string, hosts ...string) *typedItem {
rules := make([]networking.IngressRule, 0, len(hosts))
for _, host := range hosts {
rules = append(rules, networking.IngressRule{Host: host})
}
return &typedItem{
typ: ResourceTypeIngress,
ingress: &networking.Ingress{
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "default", Annotations: annotations},
Spec: networking.IngressSpec{Rules: rules},
},
}
}
func lookupApp(service *KubernetesService, domain string) *model.App {
var app *model.App
service.getEntry(domain, func(name string, candidate *model.App) bool {
if candidate.Config.Domain == domain || strings.HasPrefix(domain, name+".") {
app = candidate
return true
}
return false
})
return app
}
func TestKubernetesServiceUpdateFromItem(t *testing.T) {
log := logger.NewLogger().WithTestConfig()
log.Init()
type testCase struct {
description string
run func(t *testing.T, svc *KubernetesService)
tests := []struct {
name string
resource ResourceType
item *typedItem
domain string
wantConfigDomain string
allow string
}{
{
name: "Ingress matches a configured domain",
resource: ResourceTypeIngress,
item: testIngress("ingress", map[string]string{
"tinyauth.apps.dashboard.config.domain": "dashboard.example.com",
"tinyauth.apps.dashboard.users.allow": "alice",
}, "dashboard.example.com"),
domain: "dashboard.example.com", wantConfigDomain: "dashboard.example.com", allow: "alice",
},
{
name: "Ingress matches an app name case insensitively",
resource: ResourceTypeIngress,
item: testIngress("ingress", map[string]string{
"tinyauth.apps.dashboard.users.allow": "alice",
}, "Dashboard.example.com"),
domain: "dashboard.example.com", allow: "alice",
},
}
tests := []testCase{
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
service := newKubernetesServiceForTest(log)
service.updateFromItem(watchedResourceForTest(t, test.resource), test.item)
app := lookupApp(service, test.domain)
require.NotNil(t, app)
assert.Equal(t, test.allow, app.Users.Allow)
assert.Equal(t, test.wantConfigDomain, app.Config.Domain)
})
}
}
func TestKubernetesServiceUpdateFromItemRemovesStaleEntries(t *testing.T) {
log := logger.NewLogger().WithTestConfig()
log.Init()
tests := []struct {
name string
resource ResourceType
item *typedItem
}{
{"Ingress without annotations", ResourceTypeIngress, testIngress("route", nil, "app.example.com")},
{"Ingress without hosts", ResourceTypeIngress, testIngress("route", map[string]string{"tinyauth.apps.app.users.allow": "alice"})},
{"Ingress with invalid annotations", ResourceTypeIngress, testIngress("route", map[string]string{"tinyauth.apps.app.users.break": "invalid"}, "app.example.com")},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
service := newKubernetesServiceForTest(log)
key := resourceKey{typ: test.resource, namespace: "default", name: "route"}
service.addResourceEntries(key, []string{"app.example.com"}, []resourceEntry{{
name: "app",
app: model.App{Config: model.AppConfig{Domain: "app.example.com"}},
}})
service.updateFromItem(watchedResourceForTest(t, test.resource), test.item)
assert.Nil(t, lookupApp(service, "app.example.com"))
})
}
}
func TestTypedItemFromUnstructured(t *testing.T) {
tests := []struct {
name string
resource ResourceType
item unstructured.Unstructured
assert func(t *testing.T, item *typedItem)
}{
{
description: "Cache by domain returns app and misses unknown domain",
run: func(t *testing.T, svc *KubernetesService) {
app := model.App{Config: model.AppConfig{Domain: "foo.example.com"}}
svc.addIngressEntries(ingressKey{
namespace: "default",
name: "my-ingress",
}, []ingressEntry{
{
app: app,
name: "foo",
},
})
var got *model.App
svc.getEntry(func(name string, app *model.App) bool {
if app.Config.Domain == "foo.example.com" {
got = app
return true
}
return false
})
require.NotNil(t, got)
assert.Equal(t, "foo.example.com", got.Config.Domain)
},
},
{
description: "RemoveIngress clears domain and app name entries",
run: func(t *testing.T, svc *KubernetesService) {
app := model.App{Config: model.AppConfig{Domain: "foo.example.com"}}
svc.addIngressEntries(ingressKey{
namespace: "default",
name: "my-ingress",
}, []ingressEntry{
{
app: app,
name: "foo",
},
})
var got *model.App
svc.getEntry(func(name string, app *model.App) bool {
if app.Config.Domain == "foo.example.com" {
got = app
return true
}
return false
})
require.NotNil(t, got)
assert.Equal(t, "foo.example.com", got.Config.Domain)
got = nil
svc.removeIngress(ingressKey{
namespace: "default",
name: "my-ingress",
})
svc.getEntry(func(name string, app *model.App) bool {
if app.Config.Domain == "foo.example.com" {
got = app
return true
}
return false
})
assert.Nil(t, got)
},
},
{
description: "AddIngressApps replaces stale entries for the same ingress",
run: func(t *testing.T, svc *KubernetesService) {
old := model.App{Config: model.AppConfig{Domain: "old.example.com"}}
svc.addIngressEntries(ingressKey{
namespace: "default",
name: "my-ingress",
}, []ingressEntry{
{
app: old,
name: "foo",
},
})
updated := model.App{Config: model.AppConfig{Domain: "new.example.com"}}
svc.addIngressEntries(ingressKey{
namespace: "default",
name: "my-ingress",
}, []ingressEntry{
{
app: updated,
name: "foo",
},
})
var got *model.App
svc.getEntry(func(name string, app *model.App) bool {
if app.Config.Domain == "old.example.com" {
got = app
return true
}
return false
})
assert.Nil(t, got)
svc.getEntry(func(name string, app *model.App) bool {
if app.Config.Domain == "new.example.com" {
got = app
return true
}
return false
})
require.NotNil(t, got)
assert.Equal(t, "new.example.com", got.Config.Domain)
},
},
{
description: "GetLabels returns app from cache when connected",
run: func(t *testing.T, svc *KubernetesService) {
svc.connected = true
app := model.App{Config: model.AppConfig{Domain: "hit.example.com"}}
svc.addIngressEntries(ingressKey{
namespace: "default",
name: "my-ingress",
}, []ingressEntry{
{
app: app,
name: "foo",
},
})
var got *model.App
err := svc.Lookup(func(name string, app *model.App) bool {
if app.Config.Domain == "hit.example.com" {
got = app
return true
}
return false
})
require.NoError(t, err)
require.NotNil(t, got)
assert.Equal(t, "hit.example.com", got.Config.Domain)
},
},
{
description: "GetLabels returns empty app on cache miss when started",
run: func(t *testing.T, svc *KubernetesService) {
svc.connected = true
var got *model.App
err := svc.Lookup(func(name string, app *model.App) bool {
if app.Config.Domain == "notfound.example.com" {
got = app
return true
}
return false
})
require.NoError(t, err)
require.Nil(t, got)
},
},
{
description: "GetLabels resolves app by app name",
run: func(t *testing.T, svc *KubernetesService) {
svc.connected = true
app := model.App{Path: model.AppPath{Allow: "/foo"}}
svc.addIngressEntries(ingressKey{
namespace: "default",
name: "my-ingress",
}, []ingressEntry{
{
app: app,
name: "foo",
},
})
var got *model.App
err := svc.Lookup(func(name string, app *model.App) bool {
if strings.HasPrefix("foo.internal.example.com", "foo.") {
got = app
return true
}
return false
})
require.NoError(t, err)
require.NotNil(t, got)
assert.Equal(t, "/foo", got.Path.Allow)
},
},
{
description: "GetLabels returns empty app when service not yet started",
run: func(t *testing.T, svc *KubernetesService) {
var got *model.App
err := svc.Lookup(func(name string, app *model.App) bool {
return false
})
require.NoError(t, err)
assert.Nil(t, got)
},
},
{
description: "UpdateFromItem parses annotations and populates cache",
run: func(t *testing.T, svc *KubernetesService) {
item := unstructured.Unstructured{}
item.SetNamespace("default")
item.SetName("test-ingress")
item.SetAnnotations(map[string]string{
"tinyauth.apps.myapp.config.domain": "myapp.example.com",
"tinyauth.apps.myapp.users.allow": "alice",
})
item.Object["spec"] = map[string]any{
"rules": []any{
map[string]any{
"host": "myapp.example.com",
},
},
}
svc.updateFromItem(&item)
var got *model.App
svc.getEntry(func(name string, app *model.App) bool {
if app.Config.Domain == "myapp.example.com" {
got = app
return true
}
return false
})
require.NotNil(t, got)
assert.Equal(t, "myapp.example.com", got.Config.Domain)
assert.Equal(t, "alice", got.Users.Allow)
},
},
{
description: "Update from item skips annotations with no hosts",
run: func(t *testing.T, svc *KubernetesService) {
item := unstructured.Unstructured{}
item.SetNamespace("default")
item.SetName("test-ingress")
item.SetAnnotations(map[string]string{
"tinyauth.apps.myapp.config.domain": "myapp.example.com",
})
svc.updateFromItem(&item)
var got *model.App
svc.getEntry(func(name string, app *model.App) bool {
if app.Config.Domain == "myapp.example.com" {
got = app
return true
}
return false
})
assert.Nil(t, got)
},
},
{
description: "UpdateFromItem fails when label parsing fails",
run: func(t *testing.T, svc *KubernetesService) {
item := unstructured.Unstructured{}
item.SetNamespace("default")
item.SetName("test-ingress")
item.SetAnnotations(map[string]string{
"tinyauth.apps.myapp.config.domain": "myapp.example.com",
"tinyauth.apps.myapp.users.break": "i-dont-exist",
})
item.Object["spec"] = map[string]any{
"rules": []any{
map[string]any{
"host": "myapp.example.com",
},
},
}
svc.updateFromItem(&item)
var got *model.App
svc.getEntry(func(name string, app *model.App) bool {
if app.Config.Domain == "myapp.example.com" {
got = app
return true
}
return false
})
require.Nil(t, got)
},
},
{
description: "UpdateFromItem with no annotations removes existing cache entries",
run: func(t *testing.T, svc *KubernetesService) {
app := model.App{Config: model.AppConfig{Domain: "todelete.example.com"}}
svc.addIngressEntries(ingressKey{
namespace: "default",
name: "my-ingress",
}, []ingressEntry{
{
app: app,
name: "foo",
},
})
item := unstructured.Unstructured{}
item.SetNamespace("default")
item.SetName("my-ingress")
svc.updateFromItem(&item)
var got *model.App
svc.getEntry(func(name string, app *model.App) bool {
if app.Config.Domain == "todelete.example.com" {
got = app
return true
}
return false
})
assert.Nil(t, got)
},
},
{
description: "ExtractPaths returns all non empty paths from a rule",
run: func(t *testing.T, svc *KubernetesService) {
rule := map[string]any{
"http": map[string]any{
"paths": []any{
map[string]any{"path": "/"},
map[string]any{"path": "/api"},
map[string]any{"path": ""},
map[string]any{"pathType": "Prefix"},
"not-a-map",
},
},
}
paths, err := svc.extractPaths(rule)
require.NoError(t, err)
assert.Equal(t, []string{"/", "/api"}, paths)
},
},
{
description: "ExtractPaths returns nothing when http or paths are missing",
run: func(t *testing.T, svc *KubernetesService) {
paths, err := svc.extractPaths(map[string]any{})
require.NoError(t, err)
assert.Empty(t, paths)
paths, err = svc.extractPaths(map[string]any{
"http": map[string]any{},
})
require.NoError(t, err)
assert.Empty(t, paths)
},
},
{
description: "ExtractPaths errors when http is not a map",
run: func(t *testing.T, svc *KubernetesService) {
paths, err := svc.extractPaths(map[string]any{
"http": "invalid",
})
require.Error(t, err)
assert.Nil(t, paths)
},
},
{
description: "ExtractPaths errors when paths is not a slice",
run: func(t *testing.T, svc *KubernetesService) {
paths, err := svc.extractPaths(map[string]any{
"http": map[string]any{
"paths": "invalid",
},
})
require.Error(t, err)
assert.Nil(t, paths)
},
},
{
description: "ExtractHosts returns hosts from all rules",
run: func(t *testing.T, svc *KubernetesService) {
item := unstructured.Unstructured{}
item.SetNamespace("default")
item.SetName("test-ingress")
require.NoError(t, unstructured.SetNestedSlice(item.Object, []any{
map[string]any{
"host": "foo.example.com",
"http": map[string]any{
"paths": []any{
map[string]any{"path": "/"},
},
},
},
map[string]any{
"host": "bar.example.com",
},
map[string]any{
"host": "",
},
"not-a-map",
}, "spec", "rules"))
hosts, err := svc.extractHosts(&item)
require.NoError(t, err)
assert.Equal(t, []string{"foo.example.com", "bar.example.com"}, hosts)
},
},
{
description: "ExtractHosts still returns hosts when a rule has no catch all path",
run: func(t *testing.T, svc *KubernetesService) {
item := unstructured.Unstructured{}
item.SetNamespace("default")
item.SetName("test-ingress")
require.NoError(t, unstructured.SetNestedSlice(item.Object, []any{
map[string]any{
"host": "foo.example.com",
"http": map[string]any{
"paths": []any{
map[string]any{"path": "/api"},
},
},
},
}, "spec", "rules"))
hosts, err := svc.extractHosts(&item)
require.NoError(t, err)
assert.Equal(t, []string{"foo.example.com"}, hosts)
},
},
{
description: "ExtractHosts still returns hosts when path extraction fails",
run: func(t *testing.T, svc *KubernetesService) {
item := unstructured.Unstructured{}
item.SetNamespace("default")
item.SetName("test-ingress")
require.NoError(t, unstructured.SetNestedSlice(item.Object, []any{
map[string]any{
"host": "foo.example.com",
"http": "invalid",
},
}, "spec", "rules"))
hosts, err := svc.extractHosts(&item)
require.NoError(t, err)
assert.Equal(t, []string{"foo.example.com"}, hosts)
},
},
{
description: "ExtractHosts returns nothing when spec.rules is missing",
run: func(t *testing.T, svc *KubernetesService) {
item := unstructured.Unstructured{}
item.SetNamespace("default")
item.SetName("test-ingress")
hosts, err := svc.extractHosts(&item)
require.NoError(t, err)
assert.Empty(t, hosts)
},
},
{
description: "ExtractHosts errors when spec.rules is not a slice",
run: func(t *testing.T, svc *KubernetesService) {
item := unstructured.Unstructured{}
item.SetNamespace("default")
item.SetName("test-ingress")
require.NoError(t, unstructured.SetNestedField(item.Object, "invalid", "spec", "rules"))
hosts, err := svc.extractHosts(&item)
require.Error(t, err)
assert.Nil(t, hosts)
},
},
{
description: "UpdateFromItem registers app when its domain matches an ingress host",
run: func(t *testing.T, svc *KubernetesService) {
item := unstructured.Unstructured{}
item.SetNamespace("default")
item.SetName("test-ingress")
item.SetAnnotations(map[string]string{
"tinyauth.apps.myapp.config.domain": "myapp.example.com",
})
require.NoError(t, unstructured.SetNestedSlice(item.Object, []any{
map[string]any{
"host": "myapp.example.com",
},
}, "spec", "rules"))
svc.updateFromItem(&item)
var got *model.App
svc.getEntry(func(name string, app *model.App) bool {
if name == "myapp" {
got = app
return true
}
return false
})
require.NotNil(t, got)
assert.Equal(t, "myapp.example.com", got.Config.Domain)
},
},
{
description: "UpdateFromItem registers app when its name matches an ingress host prefix",
run: func(t *testing.T, svc *KubernetesService) {
item := unstructured.Unstructured{}
item.SetNamespace("default")
item.SetName("test-ingress")
item.SetAnnotations(map[string]string{
"tinyauth.apps.myapp.users.allow": "alice",
})
require.NoError(t, unstructured.SetNestedSlice(item.Object, []any{
map[string]any{
"host": "MyApp.example.com",
},
}, "spec", "rules"))
svc.updateFromItem(&item)
var got *model.App
svc.getEntry(func(name string, app *model.App) bool {
if name == "myapp" {
got = app
return true
}
return false
})
require.NotNil(t, got)
assert.Equal(t, "alice", got.Users.Allow)
},
},
{
description: "UpdateFromItem skips apps that match neither host nor name",
run: func(t *testing.T, svc *KubernetesService) {
item := unstructured.Unstructured{}
item.SetNamespace("default")
item.SetName("test-ingress")
item.SetAnnotations(map[string]string{
"tinyauth.apps.myapp.config.domain": "myapp.example.com",
})
require.NoError(t, unstructured.SetNestedSlice(item.Object, []any{
map[string]any{
"host": "other.example.com",
},
}, "spec", "rules"))
svc.updateFromItem(&item)
var got *model.App
svc.getEntry(func(name string, app *model.App) bool {
got = app
return true
})
assert.Nil(t, got)
},
},
{
description: "UpdateFromItem falls back to app name when the domain is invalid",
run: func(t *testing.T, svc *KubernetesService) {
item := unstructured.Unstructured{}
item.SetNamespace("default")
item.SetName("test-ingress")
item.SetAnnotations(map[string]string{
"tinyauth.apps.myapp.config.domain": "not a domain",
})
require.NoError(t, unstructured.SetNestedSlice(item.Object, []any{
map[string]any{
"host": "myapp.example.com",
},
}, "spec", "rules"))
svc.updateFromItem(&item)
var got *model.App
svc.getEntry(func(name string, app *model.App) bool {
if name == "myapp" {
got = app
return true
}
return false
})
require.NotNil(t, got)
},
},
{
description: "UpdateFromItem removes entries when host extraction fails",
run: func(t *testing.T, svc *KubernetesService) {
key := ingressKey{
namespace: "default",
name: "test-ingress",
}
svc.addIngressEntries(key, []ingressEntry{
{
app: model.App{Config: model.AppConfig{Domain: "stale.example.com"}},
name: "foo",
},
})
item := unstructured.Unstructured{}
item.SetNamespace(key.namespace)
item.SetName(key.name)
item.SetAnnotations(map[string]string{
"tinyauth.apps.myapp.config.domain": "myapp.example.com",
})
require.NoError(t, unstructured.SetNestedField(item.Object, "invalid", "spec", "rules"))
svc.updateFromItem(&item)
var got *model.App
svc.getEntry(func(name string, app *model.App) bool {
got = app
return true
})
assert.Nil(t, got)
},
},
{
description: "UpdateFromItem removes entries when annotations are not decodable",
run: func(t *testing.T, svc *KubernetesService) {
key := ingressKey{
namespace: "default",
name: "test-ingress",
}
svc.addIngressEntries(key, []ingressEntry{
{
app: model.App{Config: model.AppConfig{Domain: "stale.example.com"}},
name: "foo",
},
})
item := unstructured.Unstructured{}
item.SetNamespace(key.namespace)
item.SetName(key.name)
item.SetAnnotations(map[string]string{
"tinyauth.apps.myapp.config.oauthWhitelist": "[",
})
svc.updateFromItem(&item)
var got *model.App
svc.getEntry(func(name string, app *model.App) bool {
got = app
return true
})
assert.Nil(t, got)
name: "Ingress",
resource: ResourceTypeIngress,
item: unstructured.Unstructured{Object: map[string]any{
"metadata": map[string]any{"name": "ingress", "namespace": "default"},
"spec": map[string]any{"rules": []any{map[string]any{"host": "app.example.com"}}},
}},
assert: func(t *testing.T, item *typedItem) {
require.NotNil(t, item.ingress)
assert.Equal(t, "app.example.com", item.ingress.Spec.Rules[0].Host)
},
},
}
for _, test := range tests {
t.Run(test.description, func(t *testing.T) {
svc := &KubernetesService{
ingressEntries: make(map[ingressKey][]ingressEntry),
log: log,
}
test.run(t, svc)
t.Run(test.name, func(t *testing.T) {
item, err := new(typedItem).fromUnstructured(test.resource, &test.item)
require.NoError(t, err)
assert.Equal(t, test.resource, item.typ)
test.assert(t, item)
})
}
}
func TestKubernetesServiceLookup(t *testing.T) {
log := logger.NewLogger().WithTestConfig()
log.Init()
tests := []struct {
name string
connected bool
domain string
wantApp bool
}{
{"Returns a matching app when connected", true, "app.example.com", true},
{"Skips the cache before the service is connected", false, "app.example.com", false},
{"Skips an invalid domain", true, "app.example.com\xC3\xA9", false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
service := newKubernetesServiceForTest(log)
service.connected = test.connected
service.addResourceEntries(resourceKey{typ: ResourceTypeIngress, namespace: "default", name: "route"}, []string{"app.example.com"}, []resourceEntry{{
name: "app",
app: model.App{Config: model.AppConfig{Domain: "app.example.com"}},
}})
var app *model.App
err := service.Lookup(test.domain, func(_ string, candidate *model.App) bool {
app = candidate
return true
})
require.NoError(t, err)
assert.Equal(t, test.wantApp, app != nil)
})
}
}
func TestKubernetesServiceKeepsResourceTypesSeparate(t *testing.T) {
log := logger.NewLogger().WithTestConfig()
log.Init()
service := newKubernetesServiceForTest(log)
resources := []struct {
resource ResourceType
item *typedItem
domain string
}{
{ResourceTypeIngress, testIngress("shared", map[string]string{"tinyauth.apps.ingress.config.domain": "ingress.example.com"}, "ingress.example.com"), "ingress.example.com"},
}
for _, resource := range resources {
service.updateFromItem(watchedResourceForTest(t, resource.resource), resource.item)
}
for _, resource := range resources {
assert.NotNil(t, lookupApp(service, resource.domain))
}
}
func TestKubernetesHostMatching(t *testing.T) {
tests := []struct {
name string
host string
domain string
want bool
}{
{"Exact host", "app.example.com", "app.example.com", true},
{"Case insensitive exact host", "App.Example.com", "app.example.com", true},
{"Wildcard host", "*.example.com", "deep.app.example.com", true},
{"Wildcard does not match its apex", "*.example.com", "example.com", false},
{"Different host", "app.example.com", "other.example.com", false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
assert.Equal(t, test.want, hostMatchesHostname(test.host, test.domain))
})
}
}
+2 -1
View File
@@ -6,6 +6,7 @@ import (
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/subtle"
"crypto/x509"
"encoding/base64"
"encoding/json"
@@ -882,7 +883,7 @@ func (service *OIDCService) ValidatePKCE(codeChallenge string, codeVerifier stri
if codeChallenge == "" {
return true
}
return codeChallenge == service.hashAndEncodePKCE(codeVerifier)
return subtle.ConstantTimeCompare([]byte(codeChallenge), []byte(service.hashAndEncodePKCE(codeVerifier))) == 1
}
func (service *OIDCService) hashAndEncodePKCE(codeVerifier string) string {