Compare commits

...
Author SHA1 Message Date
Stavros 634964d10a fix: fix deadlock in oidc service 2026-08-14 17:27:05 +03:00
Stavros f665d55bbf fix: review comments and remove non needed tests 2026-08-14 17:06:53 +03:00
Stavros da7e0e39ba feat: use db store for consent 2026-08-13 21:35:20 +03:00
Stavros 1d17917fca refactor: use crypto approach to store authorize status 2026-08-13 17:30:06 +03:00
Stavros 0f6bfcaf6b Merge branch 'main' into feat/oidc-consent-screen
# Conflicts:
#	internal/controller/oidc_controller.go
#	internal/model/runtime.go
2026-08-13 16:03:15 +03:00
StavrosandGitHub 03841cf838 New Crowdin updates (#1058) 2026-08-13 15:55:25 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Stavros
37272e4f4a chore(deps): bump JamesIves/github-sponsors-readme-action from 1.6.0 to 1.6.1 (#1069)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Stavros <steveiliop56@gmail.com>
2026-08-13 15:53:37 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
e4bc5e9f42 chore(deps): bump the minor-patch group across 1 directory with 14 updates (#1074)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-13 15:53:21 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Stavros
20595b6793 chore(deps): bump github/codeql-action/upload-sarif from 4.37.4 to 4.37.6 (#1065)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Stavros <steveiliop56@gmail.com>
2026-08-13 15:52:04 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
01d316f27f chore(deps): bump modernc.org/sqlite from 1.55.0 to 1.56.0 in the minor-patch group (#1064)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-13 15:51:56 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
e0394781f7 chore(deps): bump node from 26.5-alpine3.23 to 26.7-alpine3.23 (#1067)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-13 15:50:00 +03:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
a0d24cf566 chore(deps): bump pnpm/action-setup from 6.0.9 to 6.0.10 (#1062)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-13 15:46:59 +03:00
tsushanthandGitHub f9939538b9 Remote-Name header returns capitalized uid instead of LDAP cn — cn attribute never fetched (#1072) 2026-08-13 15:37:03 +03:00
Stavros ff271e7f18 feat: do not show oidc consent screen every time 2026-07-10 01:56:12 +03:00
43 changed files with 1821 additions and 881 deletions
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
with:
package_json_file: ./frontend/package.json
+2 -2
View File
@@ -63,7 +63,7 @@ jobs:
ref: nightly
- name: Setup pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
with:
package_json_file: ./frontend/package.json
@@ -112,7 +112,7 @@ jobs:
ref: nightly
- name: Setup pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
with:
package_json_file: ./frontend/package.json
+2 -2
View File
@@ -41,7 +41,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
with:
package_json_file: ./frontend/package.json
@@ -87,7 +87,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
with:
package_json_file: ./frontend/package.json
+1 -1
View File
@@ -38,6 +38,6 @@ jobs:
retention-days: 5
- name: Upload to code-scanning
uses: github/codeql-action/upload-sarif@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4
uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
with:
sarif_file: results.sarif
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Generate Sponsors
uses: JamesIves/github-sponsors-readme-action@2fd9142e765f755780202122261dc85e78459405 # v1
uses: JamesIves/github-sponsors-readme-action@02650b8cd445fc16dfef73195f9c406dce041623 # v1.6.1
with:
token: ${{ secrets.SPONSORS_GENERATOR_PAT }}
active-only: false
+1 -1
View File
@@ -1,5 +1,5 @@
# Site builder
FROM node:26.5-alpine3.23 AS frontend-builder
FROM node:26.7-alpine3.23 AS frontend-builder
WORKDIR /frontend
+1 -1
View File
@@ -1,5 +1,5 @@
# Site builder
FROM node:26.5-alpine3.23 AS frontend-builder
FROM node:26.7-alpine3.23 AS frontend-builder
WORKDIR /frontend
+14 -14
View File
@@ -12,7 +12,7 @@
},
"packageManager": "pnpm@11.1.2",
"dependencies": {
"@hookform/resolvers": "^5.5.7",
"@hookform/resolvers": "^5.7.1",
"@radix-ui/react-dropdown-menu": "^2.1.24",
"@radix-ui/react-label": "^2.1.15",
"@radix-ui/react-select": "^2.3.7",
@@ -20,22 +20,22 @@
"@radix-ui/react-slot": "^1.3.3",
"@tailwindcss/vite": "^4.3.3",
"@tanstack/react-query": "^5.101.4",
"axios": "^1.18.1",
"axios": "^1.19.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"i18next": "^26.3.6",
"i18next-browser-languagedetector": "^8.2.1",
"i18next-resources-to-backend": "^1.2.2",
"lucide-react": "^1.27.0",
"i18next-resources-to-backend": "^1.2.3",
"lucide-react": "^1.31.0",
"next-themes": "^0.4.6",
"radix-ui": "^1.6.7",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-hook-form": "^7.83.0",
"react-hook-form": "^7.85.0",
"react-i18next": "^17.0.11",
"react-markdown": "^10.1.0",
"react-router": "^8.3.0",
"sonner": "^2.0.7",
"sonner": "^2.0.8",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.3.3",
"zod": "^4.3.6"
@@ -43,18 +43,18 @@
"devDependencies": {
"@eslint/js": "^10.0.1",
"@tanstack/eslint-plugin-query": "^5.101.4",
"@types/node": "^26.1.2",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.4",
"eslint": "^10.8.0",
"@types/node": "^26.2.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@vitejs/plugin-react": "^6.0.5",
"eslint": "^10.8.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.3",
"globals": "^17.8.0",
"globals": "^17.9.0",
"rollup-plugin-visualizer": "^7.0.1",
"tw-animate-css": "^1.4.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.65.0",
"vite": "^8.1.5"
"typescript-eslint": "^8.66.0",
"vite": "^8.2.1"
}
}
+941 -783
View File
File diff suppressed because it is too large Load Diff
+44 -44
View File
@@ -15,16 +15,16 @@
"loginOauthSuccessTitle": "Přesměrování",
"loginOauthSuccessSubtitle": "Přesměrování k poskytovateli OAuth",
"loginOauthAutoRedirectTitle": "Automatické přesměrování OAuth",
"loginOauthAutoRedirectSubtitle": "You will be automatically redirected to your OAuth provider to authenticate.",
"loginOauthAutoRedirectButton": "Redirect now",
"loginOauthAutoRedirectSubtitle": "Pro ověření budete automaticky přesměrování na svého poskytovatele OAuth.",
"loginOauthAutoRedirectButton": "Přesměrovat nyní",
"continueTitle": "Pokračovat",
"continueRedirectingTitle": "Přesměrování...",
"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í",
"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",
"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?",
"continueUntrustedRedirectTitle": "Nedůvěryhodné přesměrování",
"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",
"logoutFailSubtitle": "Zkuste to prosím znovu",
"logoutSuccessTitle": "Odhlášen",
@@ -51,53 +51,53 @@
"forgotPasswordTitle": "Zapomněli jste heslo?",
"failedToFetchProvidersTitle": "Nepodařilo se načíst poskytovatele ověřování. Zkontrolujte prosím konfiguraci.",
"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.",
"forgotPasswordMessage": "Heslo můžete obnovit změnou proměnné `USERS`.",
"fieldRequired": "Toto pole je povinné",
"invalidInput": "Neplatný údaj",
"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...",
"domainWarningTitle": "Neplatná doména",
"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": "Aktuální:",
"domainWarningExpected": "Vyžadovaná:",
"ignoreTitle": "Ignorovat",
"goToCorrectDomainTitle": "Přejít na správnou doménu",
"authorizeTitle": "Autorizovat",
"authorizeCardTitle": "Pokračovat do {{app}}?",
"authorizeSubtitle": "Chcete pokračovat do této aplikace? Pečlivě si zkontrolujte jí vyžadovaná oprávnění, prosím.",
"authorizeSubtitleOAuth": "Chcete pokračovat do této aplikace?",
"authorizeLoadingTitle": "Načítání...",
"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.",
"authorizeSuccessTitle": "Autorizováno",
"authorizeSuccessSubtitle": "Během několika vteřin budete přesměrováni do aplikace.",
"authorizeErrorClientInfo": "Při načítání klientských informací došlo k chybě. Zkuste to prosím později.",
"authorizeErrorInvalidParams": "Parametry požadavku jsou chybné/neplatné. Prosím, zkontrolujte URL a zkuste znovu.",
"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",
"openidScopeDescription": "Povolí aplikaci přístup k Vaším informacím OpenID Connect.",
"emailScopeName": "E-mail",
"emailScopeDescription": "Povolí aplikaci přístup k Vaší e-mailové adrese.",
"profileScopeName": "Profil",
"profileScopeDescription": "Povolí aplikaci přístup k informacím o Vašem profilu.",
"groupsScopeName": "Skupiny",
"groupsScopeDescription": "Povolí aplikaci přístup k informacím o Vaší skupině.",
"backToLoginButton": "Zpět na přihlášení",
"phoneScopeName": "Telefon",
"phoneScopeDescription": "Povolí aplikaci přístup k Vašemu telefonnímu číslu.",
"addressScopeName": "Adresa",
"addressScopeDescription": "Povolí aplikaci přístup k Vaší adrese.",
"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?",
"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.",
"loginTailscaleDeviceName": "Název zařízení:",
"loginTailscaleOtherMethod": "Přihlásit jinou metodou",
"loginTailscaleSuccess": "Úspěšně přihlášeno přes Tailscale.",
"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.",
"quickActionsLanguage": "Language",
"quickActionsTheme": "Theme",
"quickActionsThemeLight": "Light",
"quickActionsThemeDark": "Dark",
"quickActionsThemeSystem": "System",
"quickActionsLogout": "Logout",
"quickActionsLanguage": "Jazyk",
"quickActionsTheme": "Motiv",
"quickActionsThemeLight": "Světlý",
"quickActionsThemeDark": "Tmavý",
"quickActionsThemeSystem": "Systémový",
"quickActionsLogout": "Odhlásit se",
"quickActionsTitle": "Quick Actions",
"quickActionsProviderLocal": "Local",
"quickActionsProviderLDAP": "LDAP",
+1 -2
View File
@@ -190,8 +190,7 @@ export const AuthorizePage = () => {
<CardFooter className="flex flex-col items-stretch gap-3">
<Button
onClick={() => authorizeMutate()}
loading={authorizePending}
disabled={shouldAutoAuthorize}
loading={authorizePending || shouldAutoAuthorize}
>
{t("authorizeTitle")}
</Button>
+3 -3
View File
@@ -30,7 +30,7 @@ require (
gopkg.in/yaml.v3 v3.0.1
k8s.io/apimachinery v0.36.3
k8s.io/client-go v0.36.3
modernc.org/sqlite v1.55.0
modernc.org/sqlite v1.56.0
)
require (
@@ -92,7 +92,7 @@ require (
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/mattn/go-runewidth v0.0.23 // indirect
github.com/mattn/go-sqlite3 v1.14.32 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
@@ -147,7 +147,7 @@ require (
k8s.io/klog/v2 v2.140.0 // indirect
k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect
modernc.org/libc v1.74.1 // indirect
modernc.org/libc v1.74.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
rsc.io/qr v0.2.0 // indirect
+10 -11
View File
@@ -154,8 +154,8 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX
github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=
github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
@@ -208,8 +208,8 @@ github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
@@ -348,7 +348,6 @@ golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
@@ -392,8 +391,8 @@ k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a h1:xCeOEAOoGYl2jnJoHkC3hk
k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0=
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU=
k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
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/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
@@ -404,8 +403,8 @@ 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/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ=
modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os=
modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k=
modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co=
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=
@@ -414,8 +413,8 @@ modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.55.0 h1:hIFh0MCH0rGinQ/4KYb5/UbCkRkb+UP+OkLCVWa5MTM=
modernc.org/sqlite v1.55.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
modernc.org/sqlite v1.56.0 h1:/D8e2RfFqoy/Zc6PuC76U28zFwmI/sYx1Kjm4yEn9e0=
modernc.org/sqlite v1.56.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ=
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=
@@ -0,0 +1 @@
DROP TABLE IF EXISTS "oidc_consents";
@@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS "oidc_consents" (
"username" TEXT NOT NULL,
"client_id" TEXT NOT NULL,
"scope" TEXT NOT NULL,
"created_at" BIGINT NOT NULL,
PRIMARY KEY ("username", "client_id")
);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS "oidc_consents";
@@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS "oidc_consents" (
"username" TEXT NOT NULL,
"client_id" TEXT NOT NULL,
"scope" TEXT NOT NULL,
"created_at" INTEGER NOT NULL,
PRIMARY KEY ("username", "client_id")
);
-2
View File
@@ -179,8 +179,6 @@ func (app *BootstrapApp) Setup() error {
cookieId := strings.Split(app.runtime.UUID, "-")[0] // first 8 characters of the uuid should be good enough
app.runtime.SessionCookieName = fmt.Sprintf("%s-%s", model.SessionCookieName, cookieId)
app.runtime.CSRFCookieName = fmt.Sprintf("%s-%s", model.CSRFCookieName, cookieId)
app.runtime.RedirectCookieName = fmt.Sprintf("%s-%s", model.RedirectCookieName, cookieId)
app.runtime.OAuthSessionCookieName = fmt.Sprintf("%s-%s", model.OAuthSessionCookieName, cookieId)
// database
+45
View File
@@ -242,6 +242,16 @@ func (controller *OIDCController) authorize(c *gin.Context) {
}
}
if userContext != nil && userContext.Authenticated && values.OIDCPrompt != service.OIDCPromptLogin {
consent, err := controller.oidc.GetOIDCConsent(c, userContext.GetUsername(), req.ClientID)
if err != nil {
controller.log.App.Warn().Err(err).Msg("Failed to get OIDC consent")
} else if consent != nil && scopesGranted(consent.Scope, req.Scope) {
values.OIDCPrompt = service.OIDCPromptNone
}
}
queries, err := query.Values(values)
if err != nil {
@@ -320,6 +330,19 @@ func (controller *OIDCController) authorizeComplete(c *gin.Context) {
return
}
// Get the client
client, ok := controller.oidc.GetClient(authorizeReq.ClientID)
if !ok {
controller.authorizeError(c, authorizeErrorParams{
err: errors.New("client not found"),
reason: "Client not found",
reasonPublic: "The client is not configured",
json: true,
})
return
}
// We no longer need the ticket
controller.oidc.DeleteAuthorizeRequestTicket(req.Ticket)
@@ -356,6 +379,11 @@ func (controller *OIDCController) authorizeComplete(c *gin.Context) {
return
}
// Store the consent granted by the user for this client
if _, err := controller.oidc.UpsertOIDCConsent(c, userContext.GetUsername(), authorizeReq.Scope, client.ClientID); err != nil {
controller.log.App.Warn().Err(err).Msg("Failed to store OIDC consent")
}
q := cu.Query()
q.Set("code", code)
@@ -756,3 +784,20 @@ func (controller *OIDCController) resolveNormalParams(c *gin.Context) (*service.
return &req, nil
}
// scopesGranted reports whether every scope in requested is present in the
// space-separated granted scope string.
func scopesGranted(granted, requested string) bool {
grantedScopes := strings.Split(granted, " ")
for _, scope := range strings.Split(requested, " ") {
if scope == "" {
continue
}
if !slices.Contains(grantedScopes, scope) {
return false
}
}
return true
}
@@ -170,6 +170,102 @@ func TestOIDCController(t *testing.T) {
assert.Contains(t, location, "oidc_name="+url.QueryEscape("Test Client"))
},
},
{
description: "Authorize skips the consent screen when all requested scopes were already granted",
middlewares: []gin.HandlerFunc{authedUser},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
_, err := store.UpsertOIDCConsent(ctx, repository.UpsertOIDCConsentParams{
Username: "testuser", ClientID: "some-client-id",
Scope: "openid profile", CreatedAt: time.Now().Unix(),
})
require.NoError(t, err)
q := url.Values{}
q.Set("scope", "openid profile")
q.Set("response_type", "code")
q.Set("client_id", "some-client-id")
q.Set("redirect_uri", "https://test.example.com/callback")
req := httptest.NewRequest("GET", "/authorize?"+q.Encode(), nil)
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusFound, recorder.Code)
location := recorder.Header().Get("Location")
assert.True(t, strings.HasPrefix(location, oidcService.GetIssuer()+"/oidc/authorize?"))
assert.Contains(t, location, "oidc_prompt=none")
},
},
{
description: "Authorize shows the consent screen when a new scope is requested",
middlewares: []gin.HandlerFunc{authedUser},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
_, err := store.UpsertOIDCConsent(ctx, repository.UpsertOIDCConsentParams{
Username: "testuser", ClientID: "some-client-id",
Scope: "openid", CreatedAt: time.Now().Unix(),
})
require.NoError(t, err)
q := url.Values{}
q.Set("scope", "openid profile")
q.Set("response_type", "code")
q.Set("client_id", "some-client-id")
q.Set("redirect_uri", "https://test.example.com/callback")
req := httptest.NewRequest("GET", "/authorize?"+q.Encode(), nil)
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusFound, recorder.Code)
location := recorder.Header().Get("Location")
assert.True(t, strings.HasPrefix(location, oidcService.GetIssuer()+"/oidc/authorize?"))
assert.NotContains(t, location, "oidc_prompt=none")
},
},
{
description: "Authorize skips the consent screen for a subset of already granted scopes",
middlewares: []gin.HandlerFunc{authedUser},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
_, err := store.UpsertOIDCConsent(ctx, repository.UpsertOIDCConsentParams{
Username: "testuser", ClientID: "some-client-id",
Scope: "openid profile email", CreatedAt: time.Now().Unix(),
})
require.NoError(t, err)
q := url.Values{}
q.Set("scope", "openid profile")
q.Set("response_type", "code")
q.Set("client_id", "some-client-id")
q.Set("redirect_uri", "https://test.example.com/callback")
req := httptest.NewRequest("GET", "/authorize?"+q.Encode(), nil)
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusFound, recorder.Code)
location := recorder.Header().Get("Location")
assert.True(t, strings.HasPrefix(location, oidcService.GetIssuer()+"/oidc/authorize?"))
assert.Contains(t, location, "oidc_prompt=none")
},
},
{
description: "Authorize shows the consent screen when no consent was granted yet",
middlewares: []gin.HandlerFunc{authedUser},
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
require.NoError(t, store.DeleteOIDCConsentByClientID(ctx, "some-client-id"))
q := url.Values{}
q.Set("scope", "openid profile")
q.Set("response_type", "code")
q.Set("client_id", "some-client-id")
q.Set("redirect_uri", "https://test.example.com/callback")
req := httptest.NewRequest("GET", "/authorize?"+q.Encode(), nil)
router.ServeHTTP(recorder, req)
assert.Equal(t, http.StatusFound, recorder.Code)
location := recorder.Header().Get("Location")
assert.True(t, strings.HasPrefix(location, oidcService.GetIssuer()+"/oidc/authorize?"))
assert.NotContains(t, location, "oidc_prompt=none")
},
},
{
description: "Authorize redirects to error screen when the request object is invalid",
run: func(t *testing.T, router *gin.Engine, recorder *httptest.ResponseRecorder) {
+3
View File
@@ -194,6 +194,9 @@ func (controller *UserController) loginHandler(c *gin.Context) {
if search.Email != "" {
sessionCookie.Email = search.Email
}
if search.Name != "" {
sessionCookie.Name = search.Name
}
}
cookie, err := controller.auth.CreateSession(c, sessionCookie)
+10 -2
View File
@@ -204,7 +204,11 @@ func (m *ContextMiddleware) cookieAuth(ctx context.Context, uuid string, ip stri
}
userContext.LDAP.Groups = user.Groups
userContext.LDAP.Name = utils.Capitalize(userContext.LDAP.Username)
if search.Name != "" {
userContext.LDAP.Name = search.Name
} else {
userContext.LDAP.Name = utils.Capitalize(userContext.LDAP.Username)
}
userContext.LDAP.Email = utils.CompileUserEmail(userContext.LDAP.Username, m.runtime.CookieDomain)
if search.Email != "" {
@@ -291,10 +295,14 @@ func (m *ContextMiddleware) basicAuth(username string, password string) (*model.
return nil, nil, fmt.Errorf("error retrieving ldap user details: %w", err)
}
name := search.Name
if name == "" {
name = utils.Capitalize(username)
}
userContext.LDAP = &model.LDAPContext{
BaseContext: model.BaseContext{
Username: username,
Name: utils.Capitalize(username),
Name: name,
},
Groups: user.Groups,
}
-2
View File
@@ -20,8 +20,6 @@ var OverrideProviders = map[string]string{
var ReservedProviderNames = []string{"local", "ldap", "tailscale"}
const SessionCookieName = "tinyauth-session"
const CSRFCookieName = "tinyauth-csrf"
const RedirectCookieName = "tinyauth-redirect"
const OAuthSessionCookieName = "tinyauth-oauth"
const GracefulShutdownTimeout = 5 // seconds
-2
View File
@@ -5,8 +5,6 @@ type RuntimeConfig struct {
UUID string
CookieDomain string
SessionCookieName string
CSRFCookieName string
RedirectCookieName string
OAuthSessionCookieName string
LocalUsers []LocalUser
OAuthProviders map[string]OAuthServiceConfig
+1
View File
@@ -32,5 +32,6 @@ type LocalUser struct {
type UserSearch struct {
Username string
Email string // used for LDAP, we can't throw it to LDAPUser because it would need another cache or an LDAP lookup every time
Name string // used for LDAP cn attribute
Type UserSearchType
}
+74
View File
@@ -277,6 +277,80 @@ func TestMemoryStore(t *testing.T) {
assert.NoError(t, err)
},
},
{
description: "Upsert creates a consent for each user+client pair",
run: func(t *testing.T, s repository.Store) {
_, err := s.UpsertOIDCConsent(ctx, repository.UpsertOIDCConsentParams{
Username: "alice", ClientID: "client-a", Scope: "openid profile", CreatedAt: 1,
})
require.NoError(t, err)
_, err = s.UpsertOIDCConsent(ctx, repository.UpsertOIDCConsentParams{
Username: "alice", ClientID: "client-b", Scope: "openid email", CreatedAt: 2,
})
require.NoError(t, err)
consents, err := s.ListOIDCConsents(ctx)
require.NoError(t, err)
assert.Len(t, consents, 2)
gotA, err := s.GetOIDCConsentByUsernameAndClientID(ctx, repository.GetOIDCConsentByUsernameAndClientIDParams{Username: "alice", ClientID: "client-a"})
require.NoError(t, err)
assert.Equal(t, "openid profile", gotA.Scope)
gotB, err := s.GetOIDCConsentByUsernameAndClientID(ctx, repository.GetOIDCConsentByUsernameAndClientIDParams{Username: "alice", ClientID: "client-b"})
require.NoError(t, err)
assert.Equal(t, "openid email", gotB.Scope)
},
},
{
description: "Upsert overwrites the same consent row",
run: func(t *testing.T, s repository.Store) {
_, err := s.UpsertOIDCConsent(ctx, repository.UpsertOIDCConsentParams{
Username: "alice", ClientID: "client-a", Scope: "openid", CreatedAt: 1,
})
require.NoError(t, err)
_, err = s.UpsertOIDCConsent(ctx, repository.UpsertOIDCConsentParams{
Username: "alice", ClientID: "client-a", Scope: "openid email", CreatedAt: 2,
})
require.NoError(t, err)
consents, err := s.ListOIDCConsents(ctx)
require.NoError(t, err)
assert.Len(t, consents, 1)
got, err := s.GetOIDCConsentByUsernameAndClientID(ctx, repository.GetOIDCConsentByUsernameAndClientIDParams{Username: "alice", ClientID: "client-a"})
require.NoError(t, err)
assert.Equal(t, "openid email", got.Scope)
},
},
{
description: "Get consent by username and client not found",
run: func(t *testing.T, s repository.Store) {
_, err := s.GetOIDCConsentByUsernameAndClientID(ctx, repository.GetOIDCConsentByUsernameAndClientIDParams{Username: "alice", ClientID: "client-a"})
assert.ErrorIs(t, err, repository.ErrNotFound)
},
},
{
description: "Delete consent by client id",
run: func(t *testing.T, s repository.Store) {
_, err := s.UpsertOIDCConsent(ctx, repository.UpsertOIDCConsentParams{
Username: "alice", ClientID: "client-a", Scope: "openid", CreatedAt: 1,
})
require.NoError(t, err)
_, err = s.UpsertOIDCConsent(ctx, repository.UpsertOIDCConsentParams{
Username: "alice", ClientID: "client-b", Scope: "openid", CreatedAt: 2,
})
require.NoError(t, err)
require.NoError(t, s.DeleteOIDCConsentByClientID(ctx, "client-a"))
consents, err := s.ListOIDCConsents(ctx)
require.NoError(t, err)
assert.Len(t, consents, 1)
assert.Equal(t, "client-b", consents[0].ClientID)
},
},
}
for _, test := range tests {
@@ -94,3 +94,46 @@ func (s *Store) DeleteExpiredOIDCSessions(_ context.Context, arg repository.Dele
}
return nil
}
func consentKey(username, clientID string) string {
return username + "\x00" + clientID
}
func (s *Store) UpsertOIDCConsent(_ context.Context, arg repository.UpsertOIDCConsentParams) (repository.OidcConsent, error) {
s.mu.Lock()
defer s.mu.Unlock()
oc := repository.OidcConsent(arg)
s.oidcConsents[consentKey(arg.Username, arg.ClientID)] = oc
return oc, nil
}
func (s *Store) GetOIDCConsentByUsernameAndClientID(_ context.Context, arg repository.GetOIDCConsentByUsernameAndClientIDParams) (repository.OidcConsent, error) {
s.mu.RLock()
defer s.mu.RUnlock()
oc, ok := s.oidcConsents[consentKey(arg.Username, arg.ClientID)]
if !ok {
return repository.OidcConsent{}, repository.ErrNotFound
}
return oc, nil
}
func (s *Store) DeleteOIDCConsentByClientID(_ context.Context, clientID string) error {
s.mu.Lock()
defer s.mu.Unlock()
for key, oc := range s.oidcConsents {
if oc.ClientID == clientID {
delete(s.oidcConsents, key)
}
}
return nil
}
func (s *Store) ListOIDCConsents(_ context.Context) ([]repository.OidcConsent, error) {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]repository.OidcConsent, 0, len(s.oidcConsents))
for _, oc := range s.oidcConsents {
out = append(out, oc)
}
return out, nil
}
+2
View File
@@ -12,6 +12,7 @@ type Store struct {
mu sync.RWMutex
sessions map[string]repository.Session
oidcSessions map[string]repository.OidcSession
oidcConsents map[string]repository.OidcConsent
}
// New returns a new empty in-memory Store.
@@ -19,5 +20,6 @@ func New() repository.Store {
return &Store{
sessions: make(map[string]repository.Session),
oidcSessions: make(map[string]repository.OidcSession),
oidcConsents: make(map[string]repository.OidcConsent),
}
}
+19
View File
@@ -84,3 +84,22 @@ type DeleteExpiredOIDCSessionsParams struct {
TokenExpiresAt int64
RefreshTokenExpiresAt int64
}
type OidcConsent struct {
Username string
ClientID string
Scope string
CreatedAt int64
}
type UpsertOIDCConsentParams struct {
Username string
ClientID string
Scope string
CreatedAt int64
}
type GetOIDCConsentByUsernameAndClientIDParams struct {
Username string
ClientID string
}
+7
View File
@@ -4,6 +4,13 @@
package postgres
type OidcConsent struct {
Username string
ClientID string
Scope string
CreatedAt int64
}
type OidcSession struct {
Sub string
AccessTokenHash string
@@ -80,6 +80,16 @@ func (q *Queries) DeleteExpiredOIDCSessions(ctx context.Context, arg DeleteExpir
return err
}
const deleteOIDCConsentByClientID = `-- name: DeleteOIDCConsentByClientID :exec
DELETE FROM "oidc_consents"
WHERE "client_id" = $1
`
func (q *Queries) DeleteOIDCConsentByClientID(ctx context.Context, clientID string) error {
_, err := q.db.ExecContext(ctx, deleteOIDCConsentByClientID, clientID)
return err
}
const deleteOIDCSessionBySub = `-- name: DeleteOIDCSessionBySub :exec
DELETE FROM "oidc_sessions"
WHERE "sub" = $1
@@ -90,6 +100,28 @@ func (q *Queries) DeleteOIDCSessionBySub(ctx context.Context, sub string) error
return err
}
const getOIDCConsentByUsernameAndClientID = `-- name: GetOIDCConsentByUsernameAndClientID :one
SELECT username, client_id, scope, created_at FROM "oidc_consents"
WHERE "username" = $1 AND "client_id" = $2
`
type GetOIDCConsentByUsernameAndClientIDParams struct {
Username string
ClientID string
}
func (q *Queries) GetOIDCConsentByUsernameAndClientID(ctx context.Context, arg GetOIDCConsentByUsernameAndClientIDParams) (OidcConsent, error) {
row := q.db.QueryRowContext(ctx, getOIDCConsentByUsernameAndClientID, arg.Username, arg.ClientID)
var i OidcConsent
err := row.Scan(
&i.Username,
&i.ClientID,
&i.Scope,
&i.CreatedAt,
)
return i, err
}
const getOIDCSessionByAccessTokenHash = `-- name: GetOIDCSessionByAccessTokenHash :one
SELECT sub, access_token_hash, refresh_token_hash, scope, client_id, token_expires_at, refresh_token_expires_at, nonce, userinfo_json FROM "oidc_sessions"
WHERE "access_token_hash" = $1
@@ -156,6 +188,38 @@ func (q *Queries) GetOIDCSessionBySub(ctx context.Context, sub string) (OidcSess
return i, err
}
const listOIDCConsents = `-- name: ListOIDCConsents :many
SELECT username, client_id, scope, created_at FROM "oidc_consents"
`
func (q *Queries) ListOIDCConsents(ctx context.Context) ([]OidcConsent, error) {
rows, err := q.db.QueryContext(ctx, listOIDCConsents)
if err != nil {
return nil, err
}
defer rows.Close()
var items []OidcConsent
for rows.Next() {
var i OidcConsent
if err := rows.Scan(
&i.Username,
&i.ClientID,
&i.Scope,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const updateOIDCSession = `-- name: UpdateOIDCSession :one
UPDATE "oidc_sessions" SET
"access_token_hash" = $1,
@@ -208,3 +272,43 @@ func (q *Queries) UpdateOIDCSession(ctx context.Context, arg UpdateOIDCSessionPa
)
return i, err
}
const upsertOIDCConsent = `-- name: UpsertOIDCConsent :one
INSERT INTO "oidc_consents" (
"username",
"client_id",
"scope",
"created_at"
) VALUES (
$1, $2, $3, $4
)
ON CONFLICT ("username", "client_id")
DO UPDATE SET
"scope" = excluded.scope,
"created_at" = excluded.created_at
RETURNING username, client_id, scope, created_at
`
type UpsertOIDCConsentParams struct {
Username string
ClientID string
Scope string
CreatedAt int64
}
func (q *Queries) UpsertOIDCConsent(ctx context.Context, arg UpsertOIDCConsentParams) (OidcConsent, error) {
row := q.db.QueryRowContext(ctx, upsertOIDCConsent,
arg.Username,
arg.ClientID,
arg.Scope,
arg.CreatedAt,
)
var i OidcConsent
err := row.Scan(
&i.Username,
&i.ClientID,
&i.Scope,
&i.CreatedAt,
)
return i, err
}
+32
View File
@@ -56,6 +56,10 @@ func (s *Store) DeleteExpiredSessions(ctx context.Context, expiry int64) error {
return mapErr(s.q.DeleteExpiredSessions(ctx, expiry))
}
func (s *Store) DeleteOIDCConsentByClientID(ctx context.Context, clientID string) error {
return mapErr(s.q.DeleteOIDCConsentByClientID(ctx, clientID))
}
func (s *Store) DeleteOIDCSessionBySub(ctx context.Context, sub string) error {
return mapErr(s.q.DeleteOIDCSessionBySub(ctx, sub))
}
@@ -64,6 +68,14 @@ func (s *Store) DeleteSession(ctx context.Context, uuid string) error {
return mapErr(s.q.DeleteSession(ctx, uuid))
}
func (s *Store) GetOIDCConsentByUsernameAndClientID(ctx context.Context, arg repository.GetOIDCConsentByUsernameAndClientIDParams) (repository.OidcConsent, error) {
r, err := s.q.GetOIDCConsentByUsernameAndClientID(ctx, GetOIDCConsentByUsernameAndClientIDParams(arg))
if err != nil {
return repository.OidcConsent{}, mapErr(err)
}
return repository.OidcConsent(r), nil
}
func (s *Store) GetOIDCSessionByAccessTokenHash(ctx context.Context, accessTokenHash string) (repository.OidcSession, error) {
r, err := s.q.GetOIDCSessionByAccessTokenHash(ctx, accessTokenHash)
if err != nil {
@@ -96,6 +108,18 @@ func (s *Store) GetSession(ctx context.Context, uuid string) (repository.Session
return repository.Session(r), nil
}
func (s *Store) ListOIDCConsents(ctx context.Context) ([]repository.OidcConsent, error) {
rows, err := s.q.ListOIDCConsents(ctx)
if err != nil {
return nil, mapErr(err)
}
out := make([]repository.OidcConsent, len(rows))
for i, row := range rows {
out[i] = repository.OidcConsent(row)
}
return out, nil
}
func (s *Store) UpdateOIDCSession(ctx context.Context, arg repository.UpdateOIDCSessionParams) (repository.OidcSession, error) {
r, err := s.q.UpdateOIDCSession(ctx, UpdateOIDCSessionParams(arg))
if err != nil {
@@ -111,3 +135,11 @@ func (s *Store) UpdateSession(ctx context.Context, arg repository.UpdateSessionP
}
return repository.Session(r), nil
}
func (s *Store) UpsertOIDCConsent(ctx context.Context, arg repository.UpsertOIDCConsentParams) (repository.OidcConsent, error) {
r, err := s.q.UpsertOIDCConsent(ctx, UpsertOIDCConsentParams(arg))
if err != nil {
return repository.OidcConsent{}, mapErr(err)
}
return repository.OidcConsent(r), nil
}
+7
View File
@@ -4,6 +4,13 @@
package sqlite
type OidcConsent struct {
Username string
ClientID string
Scope string
CreatedAt int64
}
type OidcSession struct {
Sub string
AccessTokenHash string
@@ -80,6 +80,16 @@ func (q *Queries) DeleteExpiredOIDCSessions(ctx context.Context, arg DeleteExpir
return err
}
const deleteOIDCConsentByClientID = `-- name: DeleteOIDCConsentByClientID :exec
DELETE FROM "oidc_consents"
WHERE "client_id" = ?
`
func (q *Queries) DeleteOIDCConsentByClientID(ctx context.Context, clientID string) error {
_, err := q.db.ExecContext(ctx, deleteOIDCConsentByClientID, clientID)
return err
}
const deleteOIDCSessionBySub = `-- name: DeleteOIDCSessionBySub :exec
DELETE FROM "oidc_sessions"
WHERE "sub" = ?
@@ -90,6 +100,28 @@ func (q *Queries) DeleteOIDCSessionBySub(ctx context.Context, sub string) error
return err
}
const getOIDCConsentByUsernameAndClientID = `-- name: GetOIDCConsentByUsernameAndClientID :one
SELECT username, client_id, scope, created_at FROM "oidc_consents"
WHERE "username" = ? AND "client_id" = ?
`
type GetOIDCConsentByUsernameAndClientIDParams struct {
Username string
ClientID string
}
func (q *Queries) GetOIDCConsentByUsernameAndClientID(ctx context.Context, arg GetOIDCConsentByUsernameAndClientIDParams) (OidcConsent, error) {
row := q.db.QueryRowContext(ctx, getOIDCConsentByUsernameAndClientID, arg.Username, arg.ClientID)
var i OidcConsent
err := row.Scan(
&i.Username,
&i.ClientID,
&i.Scope,
&i.CreatedAt,
)
return i, err
}
const getOIDCSessionByAccessTokenHash = `-- name: GetOIDCSessionByAccessTokenHash :one
SELECT sub, access_token_hash, refresh_token_hash, scope, client_id, token_expires_at, refresh_token_expires_at, nonce, userinfo_json FROM "oidc_sessions"
WHERE "access_token_hash" = ?
@@ -156,6 +188,38 @@ func (q *Queries) GetOIDCSessionBySub(ctx context.Context, sub string) (OidcSess
return i, err
}
const listOIDCConsents = `-- name: ListOIDCConsents :many
SELECT username, client_id, scope, created_at FROM "oidc_consents"
`
func (q *Queries) ListOIDCConsents(ctx context.Context) ([]OidcConsent, error) {
rows, err := q.db.QueryContext(ctx, listOIDCConsents)
if err != nil {
return nil, err
}
defer rows.Close()
var items []OidcConsent
for rows.Next() {
var i OidcConsent
if err := rows.Scan(
&i.Username,
&i.ClientID,
&i.Scope,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const updateOIDCSession = `-- name: UpdateOIDCSession :one
UPDATE "oidc_sessions" SET
"access_token_hash" = ?,
@@ -208,3 +272,43 @@ func (q *Queries) UpdateOIDCSession(ctx context.Context, arg UpdateOIDCSessionPa
)
return i, err
}
const upsertOIDCConsent = `-- name: UpsertOIDCConsent :one
INSERT INTO "oidc_consents" (
"username",
"client_id",
"scope",
"created_at"
) VALUES (
?, ?, ?, ?
)
ON CONFLICT ("username", "client_id")
DO UPDATE SET
"scope" = excluded.scope,
"created_at" = excluded.created_at
RETURNING username, client_id, scope, created_at
`
type UpsertOIDCConsentParams struct {
Username string
ClientID string
Scope string
CreatedAt int64
}
func (q *Queries) UpsertOIDCConsent(ctx context.Context, arg UpsertOIDCConsentParams) (OidcConsent, error) {
row := q.db.QueryRowContext(ctx, upsertOIDCConsent,
arg.Username,
arg.ClientID,
arg.Scope,
arg.CreatedAt,
)
var i OidcConsent
err := row.Scan(
&i.Username,
&i.ClientID,
&i.Scope,
&i.CreatedAt,
)
return i, err
}
+32
View File
@@ -56,6 +56,10 @@ func (s *Store) DeleteExpiredSessions(ctx context.Context, expiry int64) error {
return mapErr(s.q.DeleteExpiredSessions(ctx, expiry))
}
func (s *Store) DeleteOIDCConsentByClientID(ctx context.Context, clientID string) error {
return mapErr(s.q.DeleteOIDCConsentByClientID(ctx, clientID))
}
func (s *Store) DeleteOIDCSessionBySub(ctx context.Context, sub string) error {
return mapErr(s.q.DeleteOIDCSessionBySub(ctx, sub))
}
@@ -64,6 +68,14 @@ func (s *Store) DeleteSession(ctx context.Context, uuid string) error {
return mapErr(s.q.DeleteSession(ctx, uuid))
}
func (s *Store) GetOIDCConsentByUsernameAndClientID(ctx context.Context, arg repository.GetOIDCConsentByUsernameAndClientIDParams) (repository.OidcConsent, error) {
r, err := s.q.GetOIDCConsentByUsernameAndClientID(ctx, GetOIDCConsentByUsernameAndClientIDParams(arg))
if err != nil {
return repository.OidcConsent{}, mapErr(err)
}
return repository.OidcConsent(r), nil
}
func (s *Store) GetOIDCSessionByAccessTokenHash(ctx context.Context, accessTokenHash string) (repository.OidcSession, error) {
r, err := s.q.GetOIDCSessionByAccessTokenHash(ctx, accessTokenHash)
if err != nil {
@@ -96,6 +108,18 @@ func (s *Store) GetSession(ctx context.Context, uuid string) (repository.Session
return repository.Session(r), nil
}
func (s *Store) ListOIDCConsents(ctx context.Context) ([]repository.OidcConsent, error) {
rows, err := s.q.ListOIDCConsents(ctx)
if err != nil {
return nil, mapErr(err)
}
out := make([]repository.OidcConsent, len(rows))
for i, row := range rows {
out[i] = repository.OidcConsent(row)
}
return out, nil
}
func (s *Store) UpdateOIDCSession(ctx context.Context, arg repository.UpdateOIDCSessionParams) (repository.OidcSession, error) {
r, err := s.q.UpdateOIDCSession(ctx, UpdateOIDCSessionParams(arg))
if err != nil {
@@ -111,3 +135,11 @@ func (s *Store) UpdateSession(ctx context.Context, arg repository.UpdateSessionP
}
return repository.Session(r), nil
}
func (s *Store) UpsertOIDCConsent(ctx context.Context, arg repository.UpsertOIDCConsentParams) (repository.OidcConsent, error) {
r, err := s.q.UpsertOIDCConsent(ctx, UpsertOIDCConsentParams(arg))
if err != nil {
return repository.OidcConsent{}, mapErr(err)
}
return repository.OidcConsent(r), nil
}
+6
View File
@@ -27,4 +27,10 @@ type Store interface {
GetOIDCSessionByRefreshTokenHash(ctx context.Context, refreshTokenHash string) (OidcSession, error)
GetOIDCSessionBySub(ctx context.Context, sub string) (OidcSession, error)
UpdateOIDCSession(ctx context.Context, arg UpdateOIDCSessionParams) (OidcSession, error)
// OIDC Consents
UpsertOIDCConsent(ctx context.Context, arg UpsertOIDCConsentParams) (OidcConsent, error)
GetOIDCConsentByUsernameAndClientID(ctx context.Context, arg GetOIDCConsentByUsernameAndClientIDParams) (OidcConsent, error)
DeleteOIDCConsentByClientID(ctx context.Context, clientID string) error
ListOIDCConsents(ctx context.Context) ([]OidcConsent, error)
}
+2 -1
View File
@@ -173,7 +173,7 @@ func (auth *AuthService) SearchUser(username string) (*model.UserSearch, error)
}
if auth.ldap != nil {
userDN, email, err := auth.ldap.GetUserInfo(username)
userDN, email, cn, err := auth.ldap.GetUserInfo(username)
if err != nil {
return nil, fmt.Errorf("failed to get ldap user: %w", err)
@@ -182,6 +182,7 @@ func (auth *AuthService) SearchUser(username string) (*model.UserSearch, error)
return &model.UserSearch{
Username: userDN,
Email: email,
Name: cn,
Type: model.UserLDAP,
}, nil
}
+5 -5
View File
@@ -146,7 +146,7 @@ func (ldap *LdapService) connect() (*ldapgo.Conn, error) {
return ldap.conn, nil
}
func (ldap *LdapService) GetUserInfo(username string) (dn string, email string, err error) {
func (ldap *LdapService) GetUserInfo(username string) (dn string, email string, cn string, err error) {
escapedUsername := ldapgo.EscapeFilter(username)
filter := fmt.Sprintf(ldap.config.LDAP.SearchFilter, escapedUsername)
@@ -154,7 +154,7 @@ func (ldap *LdapService) GetUserInfo(username string) (dn string, email string,
ldap.config.LDAP.BaseDN,
ldapgo.ScopeWholeSubtree, ldapgo.NeverDerefAliases, 0, 0, false,
filter,
[]string{"dn", "mail"},
[]string{"dn", "mail", "cn"},
nil,
)
@@ -163,15 +163,15 @@ func (ldap *LdapService) GetUserInfo(username string) (dn string, email string,
searchResult, err := ldap.conn.Search(searchRequest)
if err != nil {
return "", "", err
return "", "", "", err
}
if len(searchResult.Entries) != 1 {
return "", "", fmt.Errorf("multiple or no entries found for user %s", username)
return "", "", "", fmt.Errorf("multiple or no entries found for user %s", username)
}
entry := searchResult.Entries[0]
return entry.DN, entry.GetAttributeValue("mail"), nil
return entry.DN, entry.GetAttributeValue("mail"), entry.GetAttributeValue("cn"), nil
}
func (ldap *LdapService) GetUserCount() (int, error) {
+122 -1
View File
@@ -16,6 +16,7 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"time"
"slices"
@@ -163,6 +164,10 @@ type OIDCService struct {
usedCode *cache.CacheStore[UsedCodeEntry]
authorize *cache.CacheStore[AuthorizeRequest]
}
mus struct {
consent sync.RWMutex
}
}
type OIDCServiceInput struct {
@@ -336,6 +341,11 @@ func NewOIDCService(i OIDCServiceInput) (*OIDCService, error) {
issuer: issuer,
}
// Remove consents for clients that are no longer configured
if err := service.reconcileOIDCConsents(context.Background()); err != nil {
i.Log.App.Warn().Err(err).Msg("Failed to reconcile OIDC consents")
}
// Start cleanup routine
i.Ding.Go(service.cleanupRoutine, ding.RingMinor)
@@ -920,7 +930,7 @@ func (service *OIDCService) DeleteAuthorizeRequestTicket(ticket string) {
service.caches.authorize.Delete(ticket)
}
// TODO: support signed request objects in the future
// DecodeAuthorizeJWT TODO: support signed request objects in the future
func (service *OIDCService) DecodeAuthorizeJWT(tokenString string) (*AuthorizeRequest, error) {
var claims jwt.MapClaims
@@ -970,3 +980,114 @@ func (service *OIDCService) GetPrompt(prompt string) []OIDCPrompt {
return parsedPromps
}
func (service *OIDCService) getOIDCConsentUnsafe(ctx context.Context, username, clientId string) (*repository.OidcConsent, error) {
entry, err := service.queries.GetOIDCConsentByUsernameAndClientID(ctx, repository.GetOIDCConsentByUsernameAndClientIDParams{
Username: username,
ClientID: clientId,
})
if err != nil {
if errors.Is(err, repository.ErrNotFound) {
return nil, nil
}
return nil, fmt.Errorf("failed to get oidc consent: %w", err)
}
return &entry, nil
}
func (service *OIDCService) GetOIDCConsent(ctx context.Context, username, clientId string) (*repository.OidcConsent, error) {
service.mus.consent.RLock()
defer service.mus.consent.RUnlock()
return service.getOIDCConsentUnsafe(ctx, username, clientId)
}
func (service *OIDCService) UpsertOIDCConsent(ctx context.Context, username, scope, clientId string) (repository.OidcConsent, error) {
service.mus.consent.Lock()
defer service.mus.consent.Unlock()
existing, err := service.getOIDCConsentUnsafe(ctx, username, clientId)
if err != nil {
return repository.OidcConsent{}, err
}
merged := scope
if existing != nil {
merged = mergeScopes(existing.Scope, scope)
}
entry := repository.UpsertOIDCConsentParams{
Username: username,
Scope: merged,
ClientID: clientId,
CreatedAt: time.Now().Unix(),
}
consent, err := service.queries.UpsertOIDCConsent(ctx, entry)
if err != nil {
service.log.App.Error().Err(err).Msg("Failed to upsert OIDC consent")
return repository.OidcConsent{}, err
}
return consent, nil
}
func (service *OIDCService) reconcileOIDCConsents(ctx context.Context) error {
consents, err := service.queries.ListOIDCConsents(ctx)
if err != nil {
return fmt.Errorf("failed to list oidc consents: %w", err)
}
cleaned := make(map[string]struct{})
for _, consent := range consents {
if _, ok := cleaned[consent.ClientID]; ok {
continue
}
cleaned[consent.ClientID] = struct{}{}
if _, ok := service.clients[consent.ClientID]; ok {
continue
}
service.log.App.Info().Str("clientId", consent.ClientID).Msg("Removed OIDC client no longer in configuration, deleting its consents")
if err := service.queries.DeleteOIDCConsentByClientID(ctx, consent.ClientID); err != nil {
service.log.App.Warn().Err(err).Str("clientId", consent.ClientID).Msg("Failed to delete OIDC consents for removed client")
}
}
return nil
}
func mergeScopes(existing, requested string) string {
set := make(map[string]struct{})
for _, scope := range strings.Split(existing, " ") {
if scope != "" {
set[scope] = struct{}{}
}
}
for _, scope := range strings.Split(requested, " ") {
if scope != "" {
set[scope] = struct{}{}
}
}
scopes := make([]string, 0, len(set))
for scope := range set {
scopes = append(scopes, scope)
}
slices.Sort(scopes)
return strings.Join(scopes, " ")
}
+26
View File
@@ -46,3 +46,29 @@ UPDATE "oidc_sessions" SET
"userinfo_json" = $8
WHERE "sub" = $9
RETURNING *;
-- name: UpsertOIDCConsent :one
INSERT INTO "oidc_consents" (
"username",
"client_id",
"scope",
"created_at"
) VALUES (
$1, $2, $3, $4
)
ON CONFLICT ("username", "client_id")
DO UPDATE SET
"scope" = excluded.scope,
"created_at" = excluded.created_at
RETURNING *;
-- name: GetOIDCConsentByUsernameAndClientID :one
SELECT * FROM "oidc_consents"
WHERE "username" = $1 AND "client_id" = $2;
-- name: DeleteOIDCConsentByClientID :exec
DELETE FROM "oidc_consents"
WHERE "client_id" = $1;
-- name: ListOIDCConsents :many
SELECT * FROM "oidc_consents";
+9
View File
@@ -9,3 +9,12 @@ CREATE TABLE IF NOT EXISTS "oidc_sessions" (
"nonce" TEXT NOT NULL DEFAULT '',
"userinfo_json" TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS "oidc_consents" (
"username" TEXT NOT NULL,
"client_id" TEXT NOT NULL,
"scope" TEXT NOT NULL,
"created_at" BIGINT NOT NULL,
PRIMARY KEY ("username", "client_id")
);
+26
View File
@@ -46,3 +46,29 @@ UPDATE "oidc_sessions" SET
"userinfo_json" = ?
WHERE "sub" = ?
RETURNING *;
-- name: UpsertOIDCConsent :one
INSERT INTO "oidc_consents" (
"username",
"client_id",
"scope",
"created_at"
) VALUES (
?, ?, ?, ?
)
ON CONFLICT ("username", "client_id")
DO UPDATE SET
"scope" = excluded.scope,
"created_at" = excluded.created_at
RETURNING *;
-- name: GetOIDCConsentByUsernameAndClientID :one
SELECT * FROM "oidc_consents"
WHERE "username" = ? AND "client_id" = ?;
-- name: DeleteOIDCConsentByClientID :exec
DELETE FROM "oidc_consents"
WHERE "client_id" = ?;
-- name: ListOIDCConsents :many
SELECT * FROM "oidc_consents";
+8
View File
@@ -9,3 +9,11 @@ CREATE TABLE IF NOT EXISTS "oidc_sessions" (
"nonce" TEXT NOT NULL DEFAULT "",
"userinfo_json" TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS "oidc_consents" (
"username" TEXT NOT NULL,
"client_id" TEXT NOT NULL,
"scope" TEXT NOT NULL,
"created_at" INTEGER NOT NULL,
PRIMARY KEY ("username", "client_id")
);