Compare commits

..

71 Commits

Author SHA1 Message Date
Kyle Mendell
9834a08843 release: 2.6.1 2026-04-21 12:10:30 -05:00
Marc
4f40352497 chore(translations): Add catalan language (#1436)
Co-authored-by: Marc <marc@radiovoltrega.com>
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
2026-04-21 12:07:50 -05:00
Kyle Mendell
975d3c79c6 fix: restore login screen background from not showing up 2026-04-21 11:55:52 -05:00
Alessandro (Ale) Segala
2f0338211d chore: update golangci-lint (#1440) 2026-04-21 11:53:06 -05:00
Elias Schneider
9c1a8b3c87 chore(translations): update translations via Crowdin (#1437) 2026-04-20 00:15:45 -05:00
Kyle Mendell
ce4b89da65 chore: ignore webauthn type for swagger generation 2026-04-19 16:48:27 -05:00
Kyle Mendell
0d40bf29ab release: 2.6.0 2026-04-19 15:48:46 -05:00
Elias Schneider
ff26c4273a refactor: pass context to shutdownServer 2026-04-19 20:14:20 +02:00
Elias Schneider
444f7ff2b0 feat: return not found. on /setup if already completed 2026-04-19 20:13:19 +02:00
Elias Schneider
a0cb574313 refactor: reduce complexity of ValidateEnvConfig and initRouter 2026-04-19 18:36:34 +02:00
Elias Schneider
978ac87def fix: access token renewal bypasses important checks 2026-04-19 18:27:44 +02:00
Robert Jaakke
59fe481af9 feat: add OpenID Connect prompt Parameter Handling (#1299)
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2026-04-19 18:03:08 +02:00
Elias Schneider
4f09de2cfc chore(translations): add Catalan language files 2026-04-19 15:52:33 +02:00
Ingmar Stein
8f48d10d55 feat: add TLS support for HTTP/2 server (#1429)
Co-authored-by: Alessandro (Ale) Segala <43508+ItalyPaleAle@users.noreply.github.com>
2026-04-19 14:04:22 +02:00
Elias Schneider
5c4d7ff877 feat: add auth method claim (amr) to tokens (#1433) 2026-04-18 22:31:24 +02:00
Elias Schneider
c5a4ffa523 chore: security upgrade alpine from latest to 3.23.4 (#1432)
Co-authored-by: snyk-bot <snyk-bot@snyk.io>
2026-04-18 17:06:50 +02:00
Elias Schneider
6449b28b24 chore: Security upgrade alpine from latest to 3.23.4 (#1431)
Co-authored-by: snyk-bot <snyk-bot@snyk.io>
2026-04-18 17:06:31 +02:00
Elias Schneider
90fbfd7038 chore(translations): update translations via Crowdin (#1425) 2026-04-15 15:41:48 -05:00
Elias Schneider
9ec4683d18 fix: improve form input layout if description next to it is multi col 2026-04-12 19:54:07 +02:00
Elias Schneider
027e6f078d fix: prevent flickering if no background image is set on login page 2026-04-12 19:48:40 +02:00
jose_d
33cceeafa8 feat: add ability to revoke passkeys of users as admin (#1386)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: jose-d <7630424+jose-d@users.noreply.github.com>
Co-authored-by: Alessandro (Ale) Segala <43508+ItalyPaleAle@users.noreply.github.com>
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2026-04-12 16:29:42 +00:00
Elias Schneider
544f4e63d8 chore(translations): update translations via Crowdin (#1417) 2026-04-12 15:56:01 +02:00
刘祺
86152d996c fix(ldap): resolve posixGroup memberUid as bare usernames (#1408) (#1422) 2026-04-12 15:55:48 +02:00
Elias Schneider
fbdb93f1a7 tests: combobox not closed in e2e test 2026-04-10 23:26:18 +02:00
Elias Schneider
f8f7222468 chore: upgrade dependencies 2026-04-10 19:09:49 +02:00
U Bhalraam (Raam)
f79a86cded fix: use valid Tailwind v4 transition class for auth animation squares (#1415) 2026-04-10 16:47:34 +00:00
Vlad Mocanu
626adbf14c fix(storage): strip Root prefix from S3 List() returned paths (#1413) 2026-04-08 19:01:34 +00:00
Alessandro (Ale) Segala
2b94535ade fix: disable callback URLs with protocols "javascript" and "data" (#1397) 2026-04-02 17:01:44 -07:00
github-actions[bot]
e825a58b39 chore: update AAGUIDs (#1403)
Co-authored-by: stonith404 <58886915+stonith404@users.noreply.github.com>
2026-03-31 21:25:06 -05:00
Elias Schneider
b85a81f9b1 chore(translations): update translations via Crowdin (#1399) 2026-03-29 16:02:02 +02:00
Kyle Mendell
a06d9d21e4 release: 2.5.0 2026-03-26 13:15:22 -05:00
Elias Schneider
cbecbd088f chore(translations): update translations via Crowdin (#1395) 2026-03-26 13:02:01 -05:00
Kyle Mendell
3c42a713ce chore: upgrade dependencies 2026-03-26 12:59:18 -05:00
Kyle Mendell
e7e0176316 chore: upgrade dependencies 2026-03-26 12:57:25 -05:00
Chotow
0551502586 feat: add TRUSTED_PLATFORM environment variable for gin (#1372) 2026-03-26 12:44:31 -05:00
Kyle Mendell
5251cd9799 chore: ignore linter on app image bootstrap 2026-03-26 12:44:03 -05:00
Raito00
673e5841aa chore(translations): Improve Latvian translations in lv.json (#1382) 2026-03-26 12:41:01 -05:00
taoso
dc6558522e fix: allow one-char username on signup (#1378) 2026-03-26 12:36:54 -05:00
taoso
724c41cb7a fix: empty background restore after reboot (#1379) 2026-03-26 12:33:30 -05:00
Elias Schneider
fc52bd4efb chore(translations): update translations via Crowdin (#1393) 2026-03-26 12:32:42 -05:00
dependabot[bot]
2701754e73 chore(deps): bump google.golang.org/grpc from 1.79.1 to 1.79.3 in /backend in the go_modules group across 1 directory (#1391)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-26 12:31:40 -05:00
Owen Voke
3700bd942d feat: add token_endpoint_auth_methods_supported to .well-known (#1388) 2026-03-23 12:51:32 -05:00
Alessandro (Ale) Segala
2b5401dd2f fix: show a warning when SQLite DB is stored on NFS/SMB/FUSE (#1381) 2026-03-23 12:50:54 -05:00
Chotow
95e9af4bbf fix: avoid fmt.Sprintf on custom GeoLiteDBUrl without %s placeholder (#1384) 2026-03-18 12:41:14 +00:00
Kyle Mendell
0c039cc88c fix: derive LDAP admin access from group membership (#1374) 2026-03-11 21:13:04 -05:00
taoso
192f71a13c feat: allow clearing background image (#1290)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
Co-authored-by: Kyle Mendell <ksm@ofkm.us>
2026-03-08 14:45:04 -05:00
taoso
f90f21b620 feat: allow use of svg, png, and ico images types for favicon (#1289)
Co-authored-by: Alessandro (Ale) Segala <43508+ItalyPaleAle@users.noreply.github.com>
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
2026-03-08 19:25:49 +00:00
Alessandro (Ale) Segala
d71966f996 refactor: separate querying LDAP and updating DB during sync (#1371) 2026-03-08 14:03:58 -05:00
GameTec-live
cad80e7d74 fix: move tooltip inside of form input to prevent shifting (#1369) 2026-03-08 15:41:08 +01:00
Alessandro (Ale) Segala
832b7fbff4 fix: better error messages when there's another instance of Pocket ID running (#1370) 2026-03-08 15:37:38 +01:00
Elias Schneider
e3905cf315 ci/cd: add pr quality action 2026-03-08 15:35:23 +01:00
Elias Schneider
b59e35cb59 release: 2.4.0 2026-03-07 18:36:42 +01:00
Elias Schneider
a675d075d1 fix: use URL keyboard type for callback URL inputs 2026-03-07 18:34:57 +01:00
Alessandro (Ale) Segala
2f56d16f98 fix: various fixes in background jobs (#1362)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2026-03-07 17:07:26 +00:00
Elias Schneider
f4eb8db509 tests: fix wrong seed data in database.json 2026-03-07 18:00:46 +01:00
Elias Schneider
e7bd66d1a7 tests: fix wrong seed data 2026-03-07 17:51:44 +01:00
Elias Schneider
1d06817065 chore(translations): update translations via Crowdin (#1352) 2026-03-07 17:15:01 +01:00
Ken Watanabe
34890235ba Merge commit from fork 2026-03-07 16:59:25 +01:00
Alessandro (Ale) Segala
27ca713cd4 fix: one-time-access-token route should get user ID from URL only (#1358) 2026-03-03 18:53:36 -08:00
Kyle Mendell
e0fc4cc01b chore(translations): add Latvian files 2026-03-03 15:41:42 -06:00
Kyle Mendell
01141b8c0f chore(translations): add Português files 2026-03-03 15:39:48 -06:00
taoso
8fecc22888 feat: allow first name and display name to be optional (#1288)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
2026-03-03 21:37:39 +00:00
Copilot
d7f19ad5e5 fix: wildcard callback URLs blocked by browser-native URL validation (#1359)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: ItalyPaleAle <43508+ItalyPaleAle@users.noreply.github.com>
2026-03-03 13:27:27 -08:00
github-actions[bot]
45bcdb4b1d chore: update AAGUIDs (#1354)
Co-authored-by: stonith404 <58886915+stonith404@users.noreply.github.com>
2026-03-03 15:22:26 -06:00
Alessandro (Ale) Segala
89349dc1ad fix: handle IPv6 addresses in callback URLs (#1355) 2026-03-03 10:52:42 +01:00
Alessandro (Ale) Segala
6159e0bf96 perf: frontend performance optimizations (#1344) 2026-03-01 13:04:38 -08:00
Alessandro (Ale) Segala
4d22c2dbcf fix: federated client credentials not working if sub ≠ client_id (#1342)
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-01 18:48:20 +01:00
dependabot[bot]
590e495c1d chore(deps-dev): bump @sveltejs/kit from 2.53.0 to 2.53.3 in the npm_and_yarn group across 1 directory (#1349) 2026-02-28 09:04:45 -06:00
Elias Schneider
3a339e3319 fix: improve wildcard matching by using go-urlpattern (#1332) 2026-02-28 13:08:35 +00:00
dependabot[bot]
d98db79d5e chore(deps-dev): bump svelte from 5.53.2 to 5.53.5 in the npm_and_yarn group across 1 directory (#1348)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-28 13:52:27 +01:00
Elias Schneider
7d2a9b3345 chore(translations): update translations via Crowdin (#1336) 2026-02-27 08:22:33 -06:00
157 changed files with 7875 additions and 3150 deletions

View File

@@ -34,7 +34,7 @@ jobs:
- name: Run Golangci-lint
uses: golangci/golangci-lint-action@v9.0.0
with:
version: v2.9.0
version: v2.11.4
args: --build-tags=exclude_frontend
working-directory: backend
only-new-issues: ${{ github.event_name == 'pull_request' }}

106
.github/workflows/pr-quality.yml vendored Normal file
View File

@@ -0,0 +1,106 @@
name: PR Quality
permissions:
contents: read
issues: read
pull-requests: write
on:
pull_request_target:
types: [opened, reopened]
jobs:
pr-quality:
runs-on: ubuntu-latest
steps:
- uses: peakoss/anti-slop@v0
with:
# General Settings
max-failures: 4
# PR Branch Checks
allowed-target-branches: "main"
blocked-target-branches: ""
allowed-source-branches: ""
blocked-source-branches: ""
# PR Quality Checks
max-negative-reactions: 0
require-maintainer-can-modify: true
# PR Title Checks
require-conventional-title: true
# PR Description Checks
require-description: true
max-description-length: 2500
max-emoji-count: 0
max-code-references: 0
require-linked-issue: false
blocked-terms: ""
blocked-issue-numbers: ""
# PR Template Checks
require-pr-template: true
strict-pr-template-sections: ""
optional-pr-template-sections: "Issues"
max-additional-pr-template-sections: 3
# Commit Message Checks
max-commit-message-length: 500
require-conventional-commits: false
require-commit-author-match: true
blocked-commit-authors: ""
# File Checks
allowed-file-extensions: ""
allowed-paths: ""
blocked-paths: |
SECURITY.md
LICENSE
require-final-newline: false
max-added-comments: 0
# User Checks
detect-spam-usernames: true
min-account-age: 30
max-daily-forks: 7
min-profile-completeness: 4
# Merge Checks
min-repo-merged-prs: 0
min-repo-merge-ratio: 0
min-global-merge-ratio: 30
global-merge-ratio-exclude-own: false
# Exemptions
exempt-draft-prs: false
exempt-bots: |
actions-user
dependabot[bot]
renovate[bot]
github-actions[bot]
exempt-users: ""
exempt-author-association: "OWNER,MEMBER,COLLABORATOR"
exempt-label: "quality/exempt"
exempt-pr-label: ""
exempt-all-milestones: false
exempt-all-pr-milestones: false
exempt-milestones: ""
exempt-pr-milestones: ""
# PR Success Actions
success-add-pr-labels: "quality/verified"
# PR Failure Actions
failure-remove-pr-labels: ""
failure-remove-all-pr-labels: true
failure-add-pr-labels: "quality/rejected"
failure-pr-message: |
This PR did not pass quality checks so it will be closed.
See the [workflow run](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}/attempts/${{ github.run_attempt }}) for details on which checks failed.
If you believe this is a mistake please let us know.
close-pr: true
lock-pr: false

View File

@@ -1 +1 @@
2.3.0
2.6.1

View File

@@ -1,3 +1,113 @@
## v2.6.1
### Bug Fixes
- restore login screen background from not showing up ([975d3c7](https://github.com/pocket-id/pocket-id/commit/975d3c79c6a882291c69b31d25bfcd8b7896528c) by @kmendell)
### Other
- ignore webauthn type for swagger generation ([ce4b89d](https://github.com/pocket-id/pocket-id/commit/ce4b89da650f025747fd0dd45eab5cebe29f5a93) by @kmendell)
- update golangci-lint ([#1440](https://github.com/pocket-id/pocket-id/pull/1440) by @ItalyPaleAle)
- Add catalan language ([#1436](https://github.com/pocket-id/pocket-id/pull/1436) by @mcasellas)
**Full Changelog**: https://github.com/pocket-id/pocket-id/compare/v2.6.0...v2.6.1
## v2.6.0
### Bug Fixes
- disable callback URLs with protocols "javascript" and "data" ([#1397](https://github.com/pocket-id/pocket-id/pull/1397) by @ItalyPaleAle)
- strip Root prefix from S3 List() returned paths ([#1413](https://github.com/pocket-id/pocket-id/pull/1413) by @vtmocanu)
- use valid Tailwind v4 transition class for auth animation squares ([#1415](https://github.com/pocket-id/pocket-id/pull/1415) by @CoolShades)
- resolve posixGroup memberUid as bare usernames ([#1422](https://github.com/pocket-id/pocket-id/pull/1422) by @gucong3000)
- prevent flickering if no background image is set on login page ([027e6f0](https://github.com/pocket-id/pocket-id/commit/027e6f078da0eec712ae22a04b37c86110cb262b) by @stonith404)
- improve form input layout if description next to it is multi col ([9ec4683](https://github.com/pocket-id/pocket-id/commit/9ec4683d18036ba1945bffd4bce14ec4c2dff7f9) by @stonith404)
- access token renewal bypasses important checks ([978ac87](https://github.com/pocket-id/pocket-id/commit/978ac87deffec58beaccd15aead975e91b94c8a5) by @stonith404)
### Features
- add ability to revoke passkeys of users as admin ([#1386](https://github.com/pocket-id/pocket-id/pull/1386) by @jose-d)
- add auth method claim (`amr`) to tokens ([#1433](https://github.com/pocket-id/pocket-id/pull/1433) by @stonith404)
- add TLS support for HTTP/2 server ([#1429](https://github.com/pocket-id/pocket-id/pull/1429) by @IngmarStein)
- add OpenID Connect `prompt` Parameter Handling ([#1299](https://github.com/pocket-id/pocket-id/pull/1299) by @rjaakke)
- return not found. on `/setup` if already completed ([444f7ff](https://github.com/pocket-id/pocket-id/commit/444f7ff2b0269c12f1dba334a37d7db2007e172f) by @stonith404)
### Other
- update AAGUIDs ([#1403](https://github.com/pocket-id/pocket-id/pull/1403) by @github-actions[bot])
- upgrade dependencies ([f8f7222](https://github.com/pocket-id/pocket-id/commit/f8f7222468dad90f630ae18f7c3fd78e37ba3f77) by @stonith404)
- combobox not closed in e2e test ([fbdb93f](https://github.com/pocket-id/pocket-id/commit/fbdb93f1a768a05e6e3f2c6fd32b5de50a745bc6) by @stonith404)
- Security upgrade alpine from latest to 3.23.4 ([#1431](https://github.com/pocket-id/pocket-id/pull/1431) by @stonith404)
- security upgrade alpine from latest to 3.23.4 ([#1432](https://github.com/pocket-id/pocket-id/pull/1432) by @stonith404)
- add Catalan language files ([4f09de2](https://github.com/pocket-id/pocket-id/commit/4f09de2cfc7d1e92632116821493a670fc7ee80d) by @stonith404)
- reduce complexity of `ValidateEnvConfig` and `initRouter` ([a0cb574](https://github.com/pocket-id/pocket-id/commit/a0cb57431372c2bcc59904342597845e92a42a93) by @stonith404)
- pass context to `shutdownServer` ([ff26c42](https://github.com/pocket-id/pocket-id/commit/ff26c4273a061b7d2c84e7b74f1e0f9e0acc6eb0) by @stonith404)
**Full Changelog**: https://github.com/pocket-id/pocket-id/compare/v2.5.0...v2.6.0
## v2.5.0
### Bug Fixes
- better error messages when there's another instance of Pocket ID running ([#1370](https://github.com/pocket-id/pocket-id/pull/1370) by @ItalyPaleAle)
- move tooltip inside of form input to prevent shifting ([#1369](https://github.com/pocket-id/pocket-id/pull/1369) by @GameTec-live)
- derive LDAP admin access from group membership ([#1374](https://github.com/pocket-id/pocket-id/pull/1374) by @kmendell)
- avoid fmt.Sprintf on custom GeoLiteDBUrl without %s placeholder ([#1384](https://github.com/pocket-id/pocket-id/pull/1384) by @choyri)
- show a warning when SQLite DB is stored on NFS/SMB/FUSE ([#1381](https://github.com/pocket-id/pocket-id/pull/1381) by @ItalyPaleAle)
- empty background restore after reboot ([#1379](https://github.com/pocket-id/pocket-id/pull/1379) by @taoso)
- allow one-char username on signup ([#1378](https://github.com/pocket-id/pocket-id/pull/1378) by @taoso)
### Features
- allow use of svg, png, and ico images types for favicon ([#1289](https://github.com/pocket-id/pocket-id/pull/1289) by @taoso)
- allow clearing background image ([#1290](https://github.com/pocket-id/pocket-id/pull/1290) by @taoso)
- add `token_endpoint_auth_methods_supported` to `.well-known` ([#1388](https://github.com/pocket-id/pocket-id/pull/1388) by @owenvoke)
- add TRUSTED_PLATFORM environment variable for gin ([#1372](https://github.com/pocket-id/pocket-id/pull/1372) by @choyri)
### Other
- add pr quality action ([e3905cf](https://github.com/pocket-id/pocket-id/commit/e3905cf3159fe0370778b0d7d3be64b4246d19be) by @stonith404)
- separate querying LDAP and updating DB during sync ([#1371](https://github.com/pocket-id/pocket-id/pull/1371) by @ItalyPaleAle)
- bump google.golang.org/grpc from 1.79.1 to 1.79.3 in /backend in the go_modules group across 1 directory ([#1391](https://github.com/pocket-id/pocket-id/pull/1391) by @dependabot[bot])
- Improve Latvian translations in lv.json ([#1382](https://github.com/pocket-id/pocket-id/pull/1382) by @Raito00)
- ignore linter on app image bootstrap ([5251cd9](https://github.com/pocket-id/pocket-id/commit/5251cd97994177c96cb6f9ab3f88ca31367b5b55) by @kmendell)
- upgrade dependencies ([e7e0176](https://github.com/pocket-id/pocket-id/commit/e7e0176316857186b9683e2f0cb0686189f86cfb) by @kmendell)
- upgrade dependencies ([3c42a71](https://github.com/pocket-id/pocket-id/commit/3c42a713ce91b4061ffcf86d92cbb19294359ff8) by @kmendell)
**Full Changelog**: https://github.com/pocket-id/pocket-id/compare/v2.4.0...v2.5.0
## v2.4.0
### Bug Fixes
- improve wildcard matching by using `go-urlpattern` ([#1332](https://github.com/pocket-id/pocket-id/pull/1332) by @stonith404)
- federated client credentials not working if sub ≠ client_id ([#1342](https://github.com/pocket-id/pocket-id/pull/1342) by @ItalyPaleAle)
- handle IPv6 addresses in callback URLs ([#1355](https://github.com/pocket-id/pocket-id/pull/1355) by @ItalyPaleAle)
- wildcard callback URLs blocked by browser-native URL validation ([#1359](https://github.com/pocket-id/pocket-id/pull/1359) by @Copilot)
- one-time-access-token route should get user ID from URL only ([#1358](https://github.com/pocket-id/pocket-id/pull/1358) by @ItalyPaleAle)
- various fixes in background jobs ([#1362](https://github.com/pocket-id/pocket-id/pull/1362) by @ItalyPaleAle)
- use URL keyboard type for callback URL inputs ([a675d07](https://github.com/pocket-id/pocket-id/commit/a675d075d1ab9b7ff8160f1cfc35bc0ea1f1980a) by @stonith404)
### Features
- allow first name and display name to be optional ([#1288](https://github.com/pocket-id/pocket-id/pull/1288) by @taoso)
### Other
- bump svelte from 5.53.2 to 5.53.5 in the npm_and_yarn group across 1 directory ([#1348](https://github.com/pocket-id/pocket-id/pull/1348) by @dependabot[bot])
- bump @sveltejs/kit from 2.53.0 to 2.53.3 in the npm_and_yarn group across 1 directory ([#1349](https://github.com/pocket-id/pocket-id/pull/1349) by @dependabot[bot])
- update AAGUIDs ([#1354](https://github.com/pocket-id/pocket-id/pull/1354) by @github-actions[bot])
- add Português files ([01141b8](https://github.com/pocket-id/pocket-id/commit/01141b8c0f2e96a40fd876d3206e49a694fd12c4) by @kmendell)
- add Latvian files ([e0fc4cc](https://github.com/pocket-id/pocket-id/commit/e0fc4cc01bd51e5a97e46aad78a493a668049220) by @kmendell)
- fix wrong seed data ([e7bd66d](https://github.com/pocket-id/pocket-id/commit/e7bd66d1a77c89dde542b4385ba01dc0d432e434) by @stonith404)
- fix wrong seed data in `database.json` ([f4eb8db](https://github.com/pocket-id/pocket-id/commit/f4eb8db50993edacd90e919b39a5c6d9dd4924c7) by @stonith404)
### Performance Improvements
- frontend performance optimizations ([#1344](https://github.com/pocket-id/pocket-id/pull/1344) by @ItalyPaleAle)
**Full Changelog**: https://github.com/pocket-id/pocket-id/compare/v2.3.0...v2.4.0
## v2.3.0
### Bug Fixes

View File

@@ -8,8 +8,10 @@ import (
"fmt"
"io"
"io/fs"
"mime"
"net/http"
"os"
"path"
"strings"
"time"
@@ -58,9 +60,16 @@ func RegisterFrontend(router *gin.Engine, rateLimitMiddleware gin.HandlerFunc) e
return fmt.Errorf("failed to create sub FS: %w", err)
}
cacheMaxAge := time.Hour * 24
fileServer := NewFileServerWithCaching(http.FS(distFS), int(cacheMaxAge.Seconds()))
// Load a map of all files to see which ones are available pre-compressed
preCompressed, err := listPreCompressedAssets(distFS)
if err != nil {
return fmt.Errorf("failed to index pre-compressed frontend assets: %w", err)
}
// Init the file server
fileServer := NewFileServerWithCaching(http.FS(distFS), preCompressed)
// Handler for Gin
handler := func(c *gin.Context) {
path := strings.TrimPrefix(c.Request.URL.Path, "/")
@@ -108,34 +117,138 @@ func RegisterFrontend(router *gin.Engine, rateLimitMiddleware gin.HandlerFunc) e
type FileServerWithCaching struct {
root http.FileSystem
lastModified time.Time
cacheMaxAge int
lastModifiedHeaderValue string
cacheControlHeaderValue string
preCompressed preCompressedMap
}
func NewFileServerWithCaching(root http.FileSystem, maxAge int) *FileServerWithCaching {
func NewFileServerWithCaching(root http.FileSystem, preCompressed preCompressedMap) *FileServerWithCaching {
return &FileServerWithCaching{
root: root,
lastModified: time.Now(),
cacheMaxAge: maxAge,
lastModifiedHeaderValue: time.Now().UTC().Format(http.TimeFormat),
cacheControlHeaderValue: fmt.Sprintf("public, max-age=%d", maxAge),
preCompressed: preCompressed,
}
}
func (f *FileServerWithCaching) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Check if the client has a cached version
if ifModifiedSince := r.Header.Get("If-Modified-Since"); ifModifiedSince != "" {
ifModifiedSinceTime, err := time.Parse(http.TimeFormat, ifModifiedSince)
if err == nil && f.lastModified.Before(ifModifiedSinceTime.Add(1*time.Second)) {
// Client's cached version is up to date
w.WriteHeader(http.StatusNotModified)
return
// First, set cache headers
// Check if the request is for an immutable asset
if isImmutableAsset(r) {
// Set the cache control header as immutable with a long expiration
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
// Check if the client has a cached version
ifModifiedSince := r.Header.Get("If-Modified-Since")
if ifModifiedSince != "" {
ifModifiedSinceTime, err := time.Parse(http.TimeFormat, ifModifiedSince)
if err == nil && f.lastModified.Before(ifModifiedSinceTime.Add(1*time.Second)) {
// Client's cached version is up to date
w.WriteHeader(http.StatusNotModified)
return
}
}
// Cache other assets for up to 24 hours, but set Last-Modified too
w.Header().Set("Last-Modified", f.lastModifiedHeaderValue)
w.Header().Set("Cache-Control", "public, max-age=86400")
}
// Check if the asset is available pre-compressed
_, ok := f.preCompressed[r.URL.Path]
if ok {
// Add a "Vary" with "Accept-Encoding" so CDNs are aware that content is pre-compressed
w.Header().Add("Vary", "Accept-Encoding")
// Select the encoding if any
ext, ce := f.selectEncoding(r)
if ext != "" {
// Set the content type explicitly before changing the path
ct := mime.TypeByExtension(path.Ext(r.URL.Path))
if ct != "" {
w.Header().Set("Content-Type", ct)
}
// Make the serve return the encoded content
w.Header().Set("Content-Encoding", ce)
r.URL.Path += "." + ext
}
}
w.Header().Set("Last-Modified", f.lastModifiedHeaderValue)
w.Header().Set("Cache-Control", f.cacheControlHeaderValue)
http.FileServer(f.root).ServeHTTP(w, r)
}
func (f *FileServerWithCaching) selectEncoding(r *http.Request) (ext string, contentEnc string) {
available, ok := f.preCompressed[r.URL.Path]
if !ok {
return "", ""
}
// Check if the client accepts compressed files
acceptEncoding := strings.TrimSpace(strings.ToLower(r.Header.Get("Accept-Encoding")))
if acceptEncoding == "" {
return "", ""
}
// Prefer brotli over gzip when both are accepted.
if available.br && (acceptEncoding == "*" || acceptEncoding == "br" || strings.Contains(acceptEncoding, "br")) {
return "br", "br"
}
if available.gz && (acceptEncoding == "gzip" || strings.Contains(acceptEncoding, "gzip")) {
return "gz", "gzip"
}
return "", ""
}
func isImmutableAsset(r *http.Request) bool {
switch {
// Fonts
case strings.HasPrefix(r.URL.Path, "/fonts/"):
return true
// Compiled SvelteKit assets
case strings.HasPrefix(r.URL.Path, "/_app/immutable/"):
return true
default:
return false
}
}
type preCompressedMap map[string]struct {
br bool
gz bool
}
func listPreCompressedAssets(distFS fs.FS) (preCompressedMap, error) {
preCompressed := make(preCompressedMap, 0)
err := fs.WalkDir(distFS, ".", func(path string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if d.IsDir() {
return nil
}
switch {
case strings.HasSuffix(path, ".br"):
originalPath := "/" + strings.TrimSuffix(path, ".br")
entry := preCompressed[originalPath]
entry.br = true
preCompressed[originalPath] = entry
case strings.HasSuffix(path, ".gz"):
originalPath := "/" + strings.TrimSuffix(path, ".gz")
entry := preCompressed[originalPath]
entry.gz = true
preCompressed[originalPath] = entry
}
return nil
})
if err != nil {
return nil, err
}
return preCompressed, nil
}

View File

@@ -3,98 +3,101 @@ module github.com/pocket-id/pocket-id/backend
go 1.26.0
require (
github.com/aws/aws-sdk-go-v2 v1.41.1
github.com/aws/aws-sdk-go-v2/config v1.32.9
github.com/aws/aws-sdk-go-v2/credentials v1.19.9
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0
github.com/aws/smithy-go v1.24.1
github.com/caarlos0/env/v11 v11.3.1
github.com/aws/aws-sdk-go-v2 v1.41.5
github.com/aws/aws-sdk-go-v2/config v1.32.14
github.com/aws/aws-sdk-go-v2/credentials v1.19.14
github.com/aws/aws-sdk-go-v2/service/s3 v1.99.0
github.com/aws/smithy-go v1.24.3
github.com/caarlos0/env/v11 v11.4.0
github.com/cenkalti/backoff/v5 v5.0.3
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
github.com/disintegration/imaging v1.6.2
github.com/dunglas/go-urlpattern v0.0.0-20241020164140-716dfa1c80b1
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6
github.com/emersion/go-smtp v0.24.0
github.com/gin-contrib/slog v1.2.0
github.com/gin-gonic/gin v1.11.0
github.com/fsnotify/fsnotify v1.8.0
github.com/gin-contrib/slog v1.2.1
github.com/gin-gonic/gin v1.12.0
github.com/glebarez/go-sqlite v1.22.0
github.com/glebarez/sqlite v1.11.0
github.com/go-co-op/gocron/v2 v2.19.1
github.com/go-ldap/ldap/v3 v3.4.12
github.com/go-playground/validator/v10 v10.30.1
github.com/go-webauthn/webauthn v0.15.0
github.com/go-co-op/gocron/v2 v2.20.0
github.com/go-ldap/ldap/v3 v3.4.13
github.com/go-playground/validator/v10 v10.30.2
github.com/go-webauthn/webauthn v0.16.4
github.com/golang-migrate/migrate/v4 v4.19.1
github.com/google/uuid v1.6.0
github.com/hashicorp/go-uuid v1.0.3
github.com/jinzhu/copier v0.4.0
github.com/joho/godotenv v1.5.1
github.com/lestrrat-go/httprc/v3 v3.0.4
github.com/lestrrat-go/httprc/v3 v3.0.5
github.com/lestrrat-go/jwx/v3 v3.0.13
github.com/lmittmann/tint v1.1.3
github.com/mattn/go-isatty v0.0.20
github.com/mattn/go-isatty v0.0.21
github.com/mileusna/useragent v1.3.5
github.com/orandin/slog-gorm v1.4.0
github.com/oschwald/maxminddb-golang/v2 v2.1.1
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
go.opentelemetry.io/contrib/bridges/otelslog v0.15.0
go.opentelemetry.io/contrib/exporters/autoexport v0.65.0
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.65.0
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0
go.opentelemetry.io/otel v1.40.0
go.opentelemetry.io/otel/log v0.16.0
go.opentelemetry.io/otel/metric v1.40.0
go.opentelemetry.io/otel/sdk v1.40.0
go.opentelemetry.io/otel/sdk/log v0.16.0
go.opentelemetry.io/otel/sdk/metric v1.40.0
go.opentelemetry.io/otel/trace v1.40.0
golang.org/x/crypto v0.48.0
golang.org/x/image v0.36.0
golang.org/x/net v0.50.0
golang.org/x/sync v0.19.0
golang.org/x/text v0.34.0
golang.org/x/time v0.14.0
go.opentelemetry.io/contrib/bridges/otelslog v0.18.0
go.opentelemetry.io/contrib/exporters/autoexport v0.68.0
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.68.0
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0
go.opentelemetry.io/otel v1.43.0
go.opentelemetry.io/otel/log v0.19.0
go.opentelemetry.io/otel/metric v1.43.0
go.opentelemetry.io/otel/sdk v1.43.0
go.opentelemetry.io/otel/sdk/log v0.19.0
go.opentelemetry.io/otel/sdk/metric v1.43.0
go.opentelemetry.io/otel/trace v1.43.0
golang.org/x/crypto v0.50.0
golang.org/x/image v0.39.0
golang.org/x/net v0.53.0
golang.org/x/sync v0.20.0
golang.org/x/text v0.36.0
golang.org/x/time v0.15.0
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.1
)
require (
github.com/Azure/go-ntlmssp v0.1.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.10 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bits-and-blooms/bitset v1.24.4 // indirect
github.com/bytedance/gopkg v0.1.4 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/bytedance/sonic/loader v0.5.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect
github.com/disintegration/gift v1.2.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/gin-contrib/sse v1.1.1 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
github.com/go-logr/logr v1.4.3 // 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-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-webauthn/x v0.2.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/go-webauthn/x v0.2.3 // indirect
github.com/goccy/go-json v0.10.6 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/google/go-github/v39 v39.2.0 // indirect
@@ -104,7 +107,7 @@ require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.8.0 // indirect
github.com/jackc/pgx/v5 v5.9.1 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
@@ -113,59 +116,63 @@ require (
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lestrrat-go/blackmagic v1.0.4 // indirect
github.com/lestrrat-go/dsig v1.0.0 // indirect
github.com/lestrrat-go/dsig v1.2.1 // indirect
github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect
github.com/lestrrat-go/httpcc v1.0.1 // indirect
github.com/lestrrat-go/option/v2 v2.0.0 // indirect
github.com/lib/pq v1.11.2 // indirect
github.com/mattn/go-sqlite3 v1.14.34 // indirect
github.com/lib/pq v1.12.3 // indirect
github.com/mattn/go-sqlite3 v1.14.42 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/nlnwa/whatwg-url v0.6.2 // indirect
github.com/pelletier/go-toml/v2 v2.3.0 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/otlptranslator v1.0.0 // indirect
github.com/prometheus/procfs v0.19.2 // indirect
github.com/prometheus/procfs v0.20.1 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/segmentio/asm v1.2.1 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/tinylib/msgp v1.6.3 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/valyala/fastjson v1.6.10 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/bridges/prometheus v0.65.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.16.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.40.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.40.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 // indirect
go.opentelemetry.io/otel/exporters/prometheus v0.62.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.16.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0 // indirect
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/arch v0.24.0 // indirect
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
golang.org/x/oauth2 v0.35.0 // indirect
golang.org/x/sys v0.41.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect
google.golang.org/grpc v1.79.1 // indirect
go.opentelemetry.io/contrib/bridges/prometheus v0.68.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/prometheus v0.65.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.19.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
golang.org/x/arch v0.26.0 // indirect
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sys v0.43.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d // indirect
google.golang.org/grpc v1.80.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.68.0 // indirect
modernc.org/libc v1.71.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.46.1 // indirect
modernc.org/sqlite v1.48.2 // indirect
)

View File

@@ -6,54 +6,57 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU=
github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4=
github.com/aws/aws-sdk-go-v2/config v1.32.9 h1:ktda/mtAydeObvJXlHzyGpK1xcsLaP16zfUPDGoW90A=
github.com/aws/aws-sdk-go-v2/config v1.32.9/go.mod h1:U+fCQ+9QKsLW786BCfEjYRj34VVTbPdsLP3CHSYXMOI=
github.com/aws/aws-sdk-go-v2/credentials v1.19.9 h1:sWvTKsyrMlJGEuj/WgrwilpoJ6Xa1+KhIpGdzw7mMU8=
github.com/aws/aws-sdk-go-v2/credentials v1.19.9/go.mod h1:+J44MBhmfVY/lETFiKI+klz0Vym2aCmIjqgClMmW82w=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 h1:I0GyV8wiYrP8XpA70g1HBcQO1JlQxCMTW9npl5UbDHY=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17/go.mod h1:tyw7BOl5bBe/oqvoIeECFJjMdzXoa/dfVz3QQ5lgHGA=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 h1:WWLqlh79iO48yLkj1v3ISRNiv+3KdQoZ6JWyfcsyQik=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 h1:JqcdRG//czea7Ppjb+g/n4o8i/R50aTBHkA7vu0lK+k=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17/go.mod h1:CO+WeGmIdj/MlPel2KwID9Gt7CNq4M65HUfBW97liM0=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 h1:Z5EiPIzXKewUQK0QTMkutjiaPVeVYXX7KIqhXu/0fXs=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8/go.mod h1:FsTpJtvC4U1fyDXk7c71XoDv3HlRm8V3NiYLeYLh5YE=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 h1:RuNSMoozM8oXlgLG/n6WLaFGoea7/CddrCfIiSA+xdY=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17/go.mod h1:F2xxQ9TZz5gDWsclCtPQscGpP0VUOc8RqgFM3vDENmU=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 h1:bGeHBsGZx0Dvu/eJC0Lh9adJa3M1xREcndxLNZlve2U=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17/go.mod h1:dcW24lbU0CzHusTE8LLHhRLI42ejmINN8Lcr22bwh/g=
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0 h1:oeu8VPlOre74lBA/PMhxa5vewaMIMmILM+RraSyB8KA=
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0/go.mod h1:5jggDlZ2CLQhwJBiZJb4vfk4f0GxWdEDruWKEJ1xOdo=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 h1:VrhDvQib/i0lxvr3zqlUwLwJP4fpmpyD9wYG1vfSu+Y=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5/go.mod h1:k029+U8SY30/3/ras4G/Fnv/b88N4mAfliNn08Dem4M=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.10 h1:+VTRawC4iVY58pS/lzpo0lnoa/SYNGF4/B/3/U5ro8Y=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.10/go.mod h1:yifAsgBxgJWn3ggx70A3urX2AN49Y5sJTD1UQFlfqBw=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14 h1:0jbJeuEHlwKJ9PfXtpSFc4MF+WIWORdhN1n30ITZGFM=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14/go.mod h1:sTGThjphYE4Ohw8vJiRStAcu3rbjtXRsdNB0TvZ5wwo=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/pJ1jOWYlFDJTjRQ=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ=
github.com/aws/smithy-go v1.24.1 h1:VbyeNfmYkWoxMVpGUAbQumkODcYmfMRfZ8yQiH30SK0=
github.com/aws/smithy-go v1.24.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
github.com/aws/aws-sdk-go-v2/config v1.32.14 h1:opVIRo/ZbbI8OIqSOKmpFaY7IwfFUOCCXBsUpJOwDdI=
github.com/aws/aws-sdk-go-v2/config v1.32.14/go.mod h1:U4/V0uKxh0Tl5sxmCBZ3AecYny4UNlVmObYjKuuaiOo=
github.com/aws/aws-sdk-go-v2/credentials v1.19.14 h1:n+UcGWAIZHkXzYt87uMFBv/l8THYELoX6gVcUvgl6fI=
github.com/aws/aws-sdk-go-v2/credentials v1.19.14/go.mod h1:cJKuyWB59Mqi0jM3nFYQRmnHVQIcgoxjEMAbLkpr62w=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 h1:NUS3K4BTDArQqNu2ih7yeDLaS3bmHD0YndtA6UP884g=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21/go.mod h1:YWNWJQNjKigKY1RHVJCuupeWDrrHjRqHm0N9rdrWzYI=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 h1:rWyie/PxDRIdhNf4DzRk0lvjVOqFJuNnO8WwaIRVxzQ=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22/go.mod h1:zd/JsJ4P7oGfUhXn1VyLqaRZwPmZwg44Jf2dS84Dm3Y=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 h1:JRaIgADQS/U6uXDqlPiefP32yXTda7Kqfx+LgspooZM=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13/go.mod h1:CEuVn5WqOMilYl+tbccq8+N2ieCy0gVn3OtRb0vBNNM=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 h1:ZlvrNcHSFFWURB8avufQq9gFsheUgjVD9536obIknfM=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21/go.mod h1:cv3TNhVrssKR0O/xxLJVRfd2oazSnZnkUeTf6ctUwfQ=
github.com/aws/aws-sdk-go-v2/service/s3 v1.99.0 h1:hlSuz394kV0vhv9drL5lhuEFbEOEP1VyQpy15qWh1Pk=
github.com/aws/aws-sdk-go-v2/service/s3 v1.99.0/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 h1:QKZH0S178gCmFEgst8hN0mCX1KxLgHBKKY/CLqwP8lg=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9/go.mod h1:7yuQJoT+OoH8aqIxw9vwF+8KpvLZ8AWmvmUWHsGQZvI=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 h1:lFd1+ZSEYJZYvv9d6kXzhkZu07si3f+GQ1AaYwa2LUM=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15/go.mod h1:WSvS1NLr7JaPunCXqpJnWk1Bjo7IxzZXrZi1QQCkuqM=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 h1:dzztQ1YmfPrxdrOiuZRMF6fuOwWlWpD2StNLTceKpys=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19/go.mod h1:YO8TrYtFdl5w/4vmjL8zaBSsiNp3w0L1FfKVKenZT7w=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 h1:p8ogvvLugcR/zLBXTXrTkj0RYBUdErbMnAFFp12Lm/U=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10/go.mod h1:60dv0eZJfeVXfbT1tFJinbHrDfSJ2GZl4Q//OSSNAVw=
github.com/aws/smithy-go v1.24.3 h1:XgOAaUgx+HhVBoP4v8n6HCQoTRDhoMghKqw4LNHsDNg=
github.com/aws/smithy-go v1.24.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bits-and-blooms/bitset v1.20.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoGXLwLQOZE=
github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA=
github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/caarlos0/env/v11 v11.4.0 h1:Kcb6t5kIIr4XkoQC9AF2j+8E1Jsrl3Wz/hhm1LtoGAc=
github.com/caarlos0/env/v11 v11.4.0/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
@@ -69,8 +72,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
github.com/disintegration/gift v1.1.2/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
@@ -88,6 +91,8 @@ github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/dunglas/go-urlpattern v0.0.0-20241020164140-716dfa1c80b1 h1:RW22Y3QjGrb97NUA8yupdFcaqg//+hMI2fZrETBvQ4s=
github.com/dunglas/go-urlpattern v0.0.0-20241020164140-716dfa1c80b1/go.mod h1:mnVcdqOeYg0HvT6veRo7wINa1mJ+lC/R4ig2lWcapSI=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
@@ -96,26 +101,28 @@ github.com/emersion/go-smtp v0.24.0 h1:g6AfoF140mvW0vLNPD/LuCBLEAdlxOjIXqbIkJIS6
github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ=
github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/slog v1.2.0 h1:vAxZfr7knD1ZYK5+pMJLP52sZXIkJXkcRPa/0dx9hSk=
github.com/gin-contrib/slog v1.2.0/go.mod h1:vYK6YltmpsEFkO0zfRMLTKHrWS3DwUSn0TMpT+kMagI=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/gin-contrib/slog v1.2.1 h1:tQbsmllW/PNgtvHRdVlI38jLfpN4IFLS7Pb4HgTeiYw=
github.com/gin-contrib/slog v1.2.1/go.mod h1:f/Ke0A3h4DUh0cQnjR2b/l+i0EmVJ+6VY6GIw3RKtxA=
github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko=
github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=
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/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-co-op/gocron/v2 v2.19.1 h1:B4iLeA0NB/2iO3EKQ7NfKn5KsQgZfjb2fkvoZJU3yBI=
github.com/go-co-op/gocron/v2 v2.19.1/go.mod h1:5lEiCKk1oVJV39Zg7/YG10OnaVrDAV5GGR6O0663k6U=
github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4=
github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo=
github.com/go-co-op/gocron/v2 v2.20.0 h1:9IMrnnVSWjfSh3E54gWmWCHbloQJLh6f9+nwyKfLNpc=
github.com/go-co-op/gocron/v2 v2.20.0/go.mod h1:5lEiCKk1oVJV39Zg7/YG10OnaVrDAV5GGR6O0663k6U=
github.com/go-ldap/ldap/v3 v3.4.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ=
github.com/go-ldap/ldap/v3 v3.4.13/go.mod h1:LxsGZV6vbaK0sIvYfsv47rfh4ca0JXokCoKjZxsszv0=
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=
@@ -127,16 +134,16 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ=
github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-webauthn/webauthn v0.15.0 h1:LR1vPv62E0/6+sTenX35QrCmpMCzLeVAcnXeH4MrbJY=
github.com/go-webauthn/webauthn v0.15.0/go.mod h1:hcAOhVChPRG7oqG7Xj6XKN1mb+8eXTGP/B7zBLzkX5A=
github.com/go-webauthn/x v0.2.1 h1:/oB8i0FhSANuoN+YJF5XHMtppa7zGEYaQrrf6ytotjc=
github.com/go-webauthn/x v0.2.1/go.mod h1:Wm0X0zXkzznit4gHj4m82GiBZRMEm+TDUIoJWIQLsE4=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/go-webauthn/webauthn v0.16.4 h1:R9jqR/cYZa7hRquFF7Za/8qoH/K/TIs1/Q/4CyGN+1Q=
github.com/go-webauthn/webauthn v0.16.4/go.mod h1:SU2ljAgToTV/YLPI0C05QS4qn+e04WpB5g1RMfcZfS4=
github.com/go-webauthn/x v0.2.3 h1:8oArS+Rc1SWFLXhE17KZNx258Z4kUSyaDgsSncCO5RA=
github.com/go-webauthn/x v0.2.3/go.mod h1:tM04GF3V6VYq79AZMl7vbj4q6pz9r7L2criWRzbWhPk=
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
@@ -161,6 +168,8 @@ github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfh
github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
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=
@@ -178,8 +187,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc=
github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
@@ -220,26 +229,26 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA=
github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
github.com/lestrrat-go/dsig v1.0.0 h1:OE09s2r9Z81kxzJYRn07TFM9XA4akrUdoMwr0L8xj38=
github.com/lestrrat-go/dsig v1.0.0/go.mod h1:dEgoOYYEJvW6XGbLasr8TFcAxoWrKlbQvmJgCR0qkDo=
github.com/lestrrat-go/dsig v1.2.1 h1:MwxzZhE4+4fguHi+uDALKVlC3Cn+O1QU1Q/F8D7hVIc=
github.com/lestrrat-go/dsig v1.2.1/go.mod h1:RD2eOaidyPvpc7IJQoO3Qq52RWdy8ZcJs8lrOnoa1Kc=
github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7gcrVVMFPOzY=
github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU=
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
github.com/lestrrat-go/httprc/v3 v3.0.4 h1:pXyH2ppK8GYYggygxJ3TvxpCZnbEUWc9qSwRTTApaLA=
github.com/lestrrat-go/httprc/v3 v3.0.4/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0=
github.com/lestrrat-go/httprc/v3 v3.0.5 h1:S+Mb4L2I+bM6JGTibLmxExhyTOqnXjqx+zi9MoXw/TM=
github.com/lestrrat-go/httprc/v3 v3.0.5/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0=
github.com/lestrrat-go/jwx/v3 v3.0.13 h1:AdHKiPIYeCSnOJtvdpipPg/0SuFh9rdkN+HF3O0VdSk=
github.com/lestrrat-go/jwx/v3 v3.0.13/go.mod h1:2m0PV1A9tM4b/jVLMx8rh6rBl7F6WGb3EG2hufN9OQU=
github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss=
github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg=
github.com/lib/pq v1.11.2 h1:x6gxUeu39V0BHZiugWe8LXZYZ+Utk7hSJGThs8sdzfs=
github.com/lib/pq v1.11.2/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/lmittmann/tint v1.1.3 h1:Hv4EaHWXQr+GTFnOU4VKf8UvAtZgn0VuKT+G0wFlO3I=
github.com/lmittmann/tint v1.1.3/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
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-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo=
github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
github.com/mileusna/useragent v1.3.5 h1:SJM5NzBmh/hO+4LGeATKpaEX9+b4vcGg2qXGLiNGDws=
github.com/mileusna/useragent v1.3.5/go.mod h1:3d8TOmwL/5I8pJjyVDteHtgDGcefrFUX4ccGOMKNYYc=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
@@ -257,6 +266,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nlnwa/whatwg-url v0.6.2 h1:jU61lU2ig4LANydbEJmA2nPrtCGiKdtgT0rmMd2VZ/Q=
github.com/nlnwa/whatwg-url v0.6.2/go.mod h1:x0FPXJzzOEieQtsBT/AKvbiBbQ46YlL6Xa7m02M1ECk=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
@@ -265,8 +276,10 @@ github.com/orandin/slog-gorm v1.4.0 h1:FgA8hJufF9/jeNSYoEXmHPPBwET2gwlF3B85JdpsT
github.com/orandin/slog-gorm v1.4.0/go.mod h1:MoZ51+b7xE9lwGNPYEhxcUtRNrYzjdcKvA8QXQQGEPA=
github.com/oschwald/maxminddb-golang/v2 v2.1.1 h1:lA8FH0oOrM4u7mLvowq8IT6a3Q/qEnqRzLQn9eH5ojc=
github.com/oschwald/maxminddb-golang/v2 v2.1.1/go.mod h1:PLdx6PR+siSIoXqqy7C7r3SB3KZnhxWr1Dp6g0Hacl8=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -280,8 +293,8 @@ github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTU
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos=
github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM=
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
@@ -312,6 +325,8 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s=
github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
@@ -320,116 +335,172 @@ github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADT
github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/bridges/otelslog v0.15.0 h1:yOYhGNPZseueTTvWp5iBD3/CthrmvayUXYEX862dDi4=
go.opentelemetry.io/contrib/bridges/otelslog v0.15.0/go.mod h1:CvaNVqIfcybc+7xqZNubbE+26K6P7AKZF/l0lE2kdCk=
go.opentelemetry.io/contrib/bridges/prometheus v0.65.0 h1:I/7S/yWobR3QHFLqHsJ8QOndoiFsj1VgHpQiq43KlUI=
go.opentelemetry.io/contrib/bridges/prometheus v0.65.0/go.mod h1:jPF6gn3y1E+nozCAEQj3c6NZ8KY+tvAgSVfvoOJUFac=
go.opentelemetry.io/contrib/exporters/autoexport v0.65.0 h1:2gApdml7SznX9szEKFjKjM4qGcGSvAybYLBY319XG3g=
go.opentelemetry.io/contrib/exporters/autoexport v0.65.0/go.mod h1:0QqAGlbHXhmPYACG3n5hNzO5DnEqqtg4VcK5pr22RI0=
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.65.0 h1:LSJsvNqhj2sBNFb5NWHbyDK4QJ/skQ2ydjeOZ9OYNZ4=
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.65.0/go.mod h1:0Q5ocj6h/+C6KYq8cnl4tDFVd4I1HBdsJ440aeagHos=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0=
go.opentelemetry.io/contrib/propagators/b3 v1.40.0 h1:xariChe8OOVF3rNlfzGFgQc61npQmXhzZj/i82mxMfg=
go.opentelemetry.io/contrib/propagators/b3 v1.40.0/go.mod h1:72WvbdxbOfXaELEQfonFfOL6osvcVjI7uJEE8C2nkrs=
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.16.0 h1:ZVg+kCXxd9LtAaQNKBxAvJ5NpMf7LpvEr4MIZqb0TMQ=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.16.0/go.mod h1:hh0tMeZ75CCXrHd9OXRYxTlCAdxcXioWHFIpYw2rZu8=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0 h1:djrxvDxAe44mJUrKataUbOhCKhR3F8QCyWucO16hTQs=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0/go.mod h1:dt3nxpQEiSoKvfTVxp3TUg5fHPLhKtbcnN3Z1I1ePD0=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.40.0 h1:NOyNnS19BF2SUDApbOKbDtWZ0IK7b8FJ2uAGdIWOGb0=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.40.0/go.mod h1:VL6EgVikRLcJa9ftukrHu/ZkkhFBSo1lzvdBC9CF1ss=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.40.0 h1:9y5sHvAxWzft1WQ4BwqcvA+IFVUJ1Ya75mSAUnFEVwE=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.40.0/go.mod h1:eQqT90eR3X5Dbs1g9YSM30RavwLF725Ris5/XSXWvqE=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 h1:DvJDOPmSWQHWywQS6lKL+pb8s3gBLOZUtw4N+mavW1I=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0/go.mod h1:EtekO9DEJb4/jRyN4v4Qjc2yA7AtfCBuz2FynRUWTXs=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 h1:wVZXIWjQSeSmMoxF74LzAnpVQOAFDo3pPji9Y4SOFKc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0/go.mod h1:khvBS2IggMFNwZK/6lEeHg/W57h/IX6J4URh57fuI40=
go.opentelemetry.io/otel/exporters/prometheus v0.62.0 h1:krvC4JMfIOVdEuNPTtQ0ZjCiXrybhv+uOHMfHRmnvVo=
go.opentelemetry.io/otel/exporters/prometheus v0.62.0/go.mod h1:fgOE6FM/swEnsVQCqCnbOfRV4tOnWPg7bVeo4izBuhQ=
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.16.0 h1:ivlbaajBWJqhcCPniDqDJmRwj4lc6sRT+dCAVKNmxlQ=
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.16.0/go.mod h1:u/G56dEKDDwXNCVLsbSrllB2o8pbtFLUC4HpR66r2dc=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0 h1:ZrPRak/kS4xI3AVXy8F7pipuDXmDsrO8Lg+yQjBLjw0=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0/go.mod h1:3y6kQCWztq6hyW8Z9YxQDDm0Je9AJoFar2G0yDcmhRk=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0 h1:MzfofMZN8ulNqobCmCAVbqVL5syHw+eB2qPRkCMA/fQ=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0/go.mod h1:E73G9UFtKRXrxhBsHtG00TB5WxX57lpsQzogDkqBTz8=
go.opentelemetry.io/otel/log v0.16.0 h1:DeuBPqCi6pQwtCK0pO4fvMB5eBq6sNxEnuTs88pjsN4=
go.opentelemetry.io/otel/log v0.16.0/go.mod h1:rWsmqNVTLIA8UnwYVOItjyEZDbKIkMxdQunsIhpUMes=
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
go.opentelemetry.io/otel/sdk/log v0.16.0 h1:e/b4bdlQwC5fnGtG3dlXUrNOnP7c8YLVSpSfEBIkTnI=
go.opentelemetry.io/otel/sdk/log v0.16.0/go.mod h1:JKfP3T6ycy7QEuv3Hj8oKDy7KItrEkus8XJE6EoSzw4=
go.opentelemetry.io/otel/sdk/log/logtest v0.16.0 h1:/XVkpZ41rVRTP4DfMgYv1nEtNmf65XPPyAdqV90TMy4=
go.opentelemetry.io/otel/sdk/log/logtest v0.16.0/go.mod h1:iOOPgQr5MY9oac/F5W86mXdeyWZGleIx3uXO98X2R6Y=
go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
go.opentelemetry.io/contrib/bridges/otelslog v0.18.0 h1:hhPGP3zvvy1xWT9RTy970wlniSxFttBIsAK1gvMguJM=
go.opentelemetry.io/contrib/bridges/otelslog v0.18.0/go.mod h1:twJF7inoMza6kxMcF8JOdL3mPmtOZu7GEr34CUNE6Dg=
go.opentelemetry.io/contrib/bridges/prometheus v0.68.0 h1:w3zlHYETbDwXyWHZlyyR58ZC39XGi8rAhkBgUgJ9d5w=
go.opentelemetry.io/contrib/bridges/prometheus v0.68.0/go.mod h1:GR/mClR2nn7vE8RLwxKjoBNg+QtgdDhRzxVa93koy5o=
go.opentelemetry.io/contrib/exporters/autoexport v0.68.0 h1:0D3GFvELGIwQGfC6agLsbrEYSGWZTRTxIXxcQUqrOuk=
go.opentelemetry.io/contrib/exporters/autoexport v0.68.0/go.mod h1:DM2NV7Zb8CcGeVPt6glouY0FAiwZQ/iqgcWExhgWeN8=
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.68.0 h1:5FXSL2s6afUC1bzNzl1iedZZ8yqR7GOhbCoEXtyeK6Q=
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.68.0/go.mod h1:MdHW7tLtkeGJnR4TyOrnd5D0zUGZQB1l84uHCe8hRpE=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo=
go.opentelemetry.io/contrib/propagators/b3 v1.43.0 h1:CETqV3QLLPTy5yNrqyMr41VnAOOD4lsRved7n4QG00A=
go.opentelemetry.io/contrib/propagators/b3 v1.43.0/go.mod h1:Q4mCiCdziYzpNR0g+6UqVotAlCDZdzz6L8jwY4knOrw=
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/exporters/otlp/otlplog/otlploggrpc v0.19.0 h1:Dn8rkudDzY6KV9dr/D/bTUuWgqDf9xe0rr4G2elrn0Y=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0/go.mod h1:gMk9F0xDgyN9M/3Ed5Y1wKcx/9mlU91NXY2SNq7RQuU=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0 h1:HIBTQ3VO5aupLKjC90JgMqpezVXwFuq6Ryjn0/izoag=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0/go.mod h1:ji9vId85hMxqfvICA0Jt8JqEdrXaAkcpkI9HPXya0ro=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 h1:8UQVDcZxOJLtX6gxtDt3vY2WTgvZqMQRzjsqiIHQdkc=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0/go.mod h1:2lmweYCiHYpEjQ/lSJBYhj9jP1zvCvQW4BqL9dnT7FQ=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 h1:w1K+pCJoPpQifuVpsKamUdn9U0zM3xUziVOqsGksUrY=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0/go.mod h1:HBy4BjzgVE8139ieRI75oXm3EcDN+6GhD88JT1Kjvxg=
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/otlptracegrpc v1.43.0 h1:RAE+JPfvEmvy+0LzyUA25/SGawPwIUbZ6u0Wug54sLc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk=
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/exporters/prometheus v0.65.0 h1:jOveH/b4lU9HT7y+Gfamf18BqlOuz2PWEvs8yM7Q6XE=
go.opentelemetry.io/otel/exporters/prometheus v0.65.0/go.mod h1:i1P8pcumauPtUI4YNopea1dhzEMuEqWP1xoUZDylLHo=
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.19.0 h1:GJkybS+crDMdExT/BUNCEgfrmfboztcS6PhvSo88HKM=
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.19.0/go.mod h1:NuAyxRYIG2lKX3YQkB+83StTxM7s52PUUkRRiC0wnYI=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 h1:TC+BewnDpeiAmcscXbGMfxkO+mwYUwE/VySwvw88PfA=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0/go.mod h1:J/ZyF4vfPwsSr9xJSPyQ4LqtcTPULFR64KwTikGLe+A=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 h1:mS47AX77OtFfKG4vtp+84kuGSFZHTyxtXIN269vChY0=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0/go.mod h1:PJnsC41lAGncJlPUniSwM81gc80GkgWJWr3cu2nKEtU=
go.opentelemetry.io/otel/log v0.19.0 h1:KUZs/GOsw79TBBMfDWsXS+KZ4g2Ckzksd1ymzsIEbo4=
go.opentelemetry.io/otel/log v0.19.0/go.mod h1:5DQYeGmxVIr4n0/BcJvF4upsraHjg6vudJJpnkL6Ipk=
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/log v0.19.0 h1:scYVLqT22D2gqXItnWiocLUKGH9yvkkeql5dBDiXyko=
go.opentelemetry.io/otel/sdk/log v0.19.0/go.mod h1:vFBowwXGLlW9AvpuF7bMgnNI95LiW10szrOdvzBHlAg=
go.opentelemetry.io/otel/sdk/log/logtest v0.19.0 h1:BEbF7ZBB6qQloV/Ub1+3NQoOUnVtcGkU3XX4Ws3GQfk=
go.opentelemetry.io/otel/sdk/log/logtest v0.19.0/go.mod h1:Lua81/3yM0wOmoHTokLj9y9ADeA02v1naRrVrkAZuKk=
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/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y=
golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/arch v0.26.0 h1:jZ6dpec5haP/fUv1kLCbuJy6dnRrfX6iVK08lZBFpk4=
golang.org/x/arch v0.26.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
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.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d h1:EocjzKLywydp5uZ5tJ79iP6Q0UjDnyiHkGRWxuPBP8s=
google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:48U2I+QQUYhsFrg2SY6r+nJzeOtjey7j//WBESw+qyQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d h1:/aDRtSZJjyLQzm75d+a1wOJaqyKBMvIAfeQmoa3ORiI=
google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d/go.mod h1:etfGUgejTiadZAUaEP14NP97xi1RGeawqkjDARA/UOs=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d h1:wT2n40TBqFY6wiwazVK9/iTWbsQrgk5ZfCSVFLO9LQA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -442,20 +513,20 @@ gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.30.2 h1:4yPaaq9dXYXZ2V8s1UgrC3KIj580l2N4ClrLwnbv2so=
modernc.org/ccgo/v4 v4.30.2/go.mod h1:yZMnhWEdW0qw3EtCndG1+ldRrVGS+bIwyWmAWzS0XEw=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/cc/v4 v4.27.3 h1:uNCgn37E5U09mTv1XgskEVUJ8ADKpmFMPxzGJ0TSo+U=
modernc.org/cc/v4 v4.27.3/go.mod h1:3YjcbCqhoTTHPycJDRl2WZKKFj0nwcOIPBfEZK0Hdk8=
modernc.org/ccgo/v4 v4.32.4 h1:L5OB8rpEX4ZsXEQwGozRfJyJSFHbbNVOoQ59DU9/KuU=
modernc.org/ccgo/v4 v4.32.4/go.mod h1:lY7f+fiTDHfcv6YlRgSkxYfhs+UvOEEzj49jAn2TOx0=
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.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/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.68.0 h1:PJ5ikFOV5pwpW+VqCK1hKJuEWsonkIJhhIXyuF/91pQ=
modernc.org/libc v1.68.0/go.mod h1:NnKCYeoYgsEqnY3PgvNgAeaJnso968ygU8Z0DxjoEc0=
modernc.org/libc v1.71.0 h1:bu0djXJGhqed3DnBzyzu3sY0fv432lesyz99ecEahyA=
modernc.org/libc v1.71.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ=
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=
@@ -464,8 +535,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/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.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
modernc.org/sqlite v1.48.2 h1:5CnW4uP8joZtA0LedVqLbZV5GD7F/0x91AXeSyjoh5c=
modernc.org/sqlite v1.48.2/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
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=

View File

@@ -12,6 +12,7 @@ import (
"log/slog"
"os"
"path"
"strings"
"github.com/pocket-id/pocket-id/backend/internal/storage"
"github.com/pocket-id/pocket-id/backend/internal/utils"
@@ -20,6 +21,8 @@ import (
// initApplicationImages copies the images from the embedded directory to the storage backend
// and returns a map containing the detected file extensions in the application-images directory.
//
//nolint:gocognit
func initApplicationImages(ctx context.Context, fileStorage storage.FileStorage) (map[string]string, error) {
// Previous versions of images
// If these are found, they are deleted
@@ -76,6 +79,18 @@ func initApplicationImages(ctx context.Context, fileStorage storage.FileStorage)
dstNameToExt[nameWithoutExt] = ext
}
initedPath := path.Join("application-images", ".inited")
if _, _, err := fileStorage.Open(ctx, initedPath); err == nil {
return dstNameToExt, nil
} else if !os.IsNotExist(err) {
return nil, fmt.Errorf("failed to read .inited: %w", err)
} else {
err := fileStorage.Save(ctx, initedPath, strings.NewReader(""))
if err != nil {
return nil, fmt.Errorf("failed to store .inited: %w", err)
}
}
// Copy images from the images directory to the application-images directory if they don't already exist
for _, sourceFile := range sourceFiles {
if sourceFile.IsDir() {

View File

@@ -2,6 +2,7 @@ package bootstrap
import (
"context"
"errors"
"fmt"
"log/slog"
"time"
@@ -11,6 +12,7 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/job"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/storage"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
@@ -60,7 +62,9 @@ func Bootstrap(ctx context.Context) error {
}
waitUntil, err := svc.appLockService.Acquire(ctx, false)
if err != nil {
if errors.Is(err, service.ErrLockUnavailable) {
return errors.New("it appears that there's already one instance of Pocket ID running; running multiple replicas of Pocket ID is currently not supported")
} else if err != nil {
return fmt.Errorf("failed to acquire application lock: %w", err)
}

View File

@@ -34,7 +34,8 @@ func NewDatabase() (db *gorm.DB, err error) {
}
// Run migrations
if err := utils.MigrateDatabase(sqlDb); err != nil {
err = utils.MigrateDatabase(sqlDb)
if err != nil {
return nil, fmt.Errorf("failed to run migrations: %w", err)
}
@@ -42,7 +43,10 @@ func NewDatabase() (db *gorm.DB, err error) {
}
func ConnectDatabase() (db *gorm.DB, err error) {
var dialector gorm.Dialector
var (
dialector gorm.Dialector
sqliteNetworkFilesystem bool
)
// Choose the correct database provider
var onConnFn func(conn *sql.DB)
@@ -63,6 +67,14 @@ func ConnectDatabase() (db *gorm.DB, err error) {
if err := ensureSqliteDatabaseDir(dbPath); err != nil {
return nil, err
}
sqliteNetworkFilesystem, err = utils.IsNetworkedFileSystem(filepath.Dir(dbPath))
if err != nil {
// Log the error only
slog.Warn("Failed to detect filesystem type for the SQLite database directory", slog.String("path", filepath.Dir(dbPath)), slog.Any("error", err))
} else if sqliteNetworkFilesystem {
slog.Warn("⚠️⚠️⚠️ SQLite databases should not be stored on a networked file system like NFS, SMB, or FUSE, as there's a risk of crashes and even database corruption", slog.String("path", filepath.Dir(dbPath)))
}
}
// Before we connect, also make sure that there's a temporary folder for SQLite to write its data

View File

@@ -2,6 +2,7 @@ package bootstrap
import (
"context"
"crypto/tls"
"errors"
"fmt"
"log/slog"
@@ -10,8 +11,11 @@ import (
"os"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/fsnotify/fsnotify"
sloggin "github.com/gin-contrib/slog"
"github.com/gin-gonic/gin"
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
@@ -32,6 +36,53 @@ import (
var registerTestControllers []func(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services)
func initRouter(db *gorm.DB, svc *services) (utils.Service, error) {
r, err := initEngine()
if err != nil {
return nil, err
}
registerRoutes(r, db, svc)
serverConfig, err := initServer(r)
if err != nil {
return nil, err
}
runFn := func(ctx context.Context) error {
return runServer(ctx, serverConfig)
}
return runFn, nil
}
type serverConfig struct {
addr string
certProvider *tlsCertProvider
listener net.Listener
server *http.Server
tlsConfig *tls.Config
}
func initEngine() (*gin.Engine, error) {
setGinMode()
r := gin.New()
initLogger(r)
configureEngine(r)
registerGlobalMiddleware(r)
frontendRateLimitMiddleware := middleware.NewRateLimitMiddleware().Add(rate.Every(100*time.Millisecond), 300)
if err := frontend.RegisterFrontend(r, frontendRateLimitMiddleware); err != nil {
if errors.Is(err, frontend.ErrFrontendNotIncluded) {
slog.Warn("Frontend is not included in the build. Skipping frontend registration.")
return r, nil
}
return nil, fmt.Errorf("failed to register frontend: %w", err)
}
return r, nil
}
func setGinMode() {
// Set the appropriate Gin mode based on the environment
switch common.EnvConfig.AppEnv {
case common.AppEnvProduction:
@@ -41,45 +92,40 @@ func initRouter(db *gorm.DB, svc *services) (utils.Service, error) {
case common.AppEnvTest:
gin.SetMode(gin.TestMode)
}
}
r := gin.New()
initLogger(r)
func configureEngine(r *gin.Engine) {
if !common.EnvConfig.TrustProxy {
_ = r.SetTrustedProxies(nil)
}
if common.EnvConfig.TrustedPlatform != "" {
r.TrustedPlatform = common.EnvConfig.TrustedPlatform
}
if common.EnvConfig.TracingEnabled {
r.Use(otelgin.Middleware(common.Name))
}
}
// Setup global middleware
func registerGlobalMiddleware(r *gin.Engine) {
r.Use(middleware.HeadMiddleware())
r.Use(middleware.NewCacheControlMiddleware().Add())
r.Use(middleware.NewCorsMiddleware().Add())
r.Use(middleware.NewCspMiddleware().Add())
r.Use(middleware.NewErrorHandlerMiddleware().Add())
}
frontendRateLimitMiddleware := middleware.NewRateLimitMiddleware().Add(rate.Every(100*time.Millisecond), 300)
err := frontend.RegisterFrontend(r, frontendRateLimitMiddleware)
if errors.Is(err, frontend.ErrFrontendNotIncluded) {
slog.Warn("Frontend is not included in the build. Skipping frontend registration.")
} else if err != nil {
return nil, fmt.Errorf("failed to register frontend: %w", err)
}
// Initialize middleware for specific routes
func registerRoutes(r *gin.Engine, db *gorm.DB, svc *services) {
authMiddleware := middleware.NewAuthMiddleware(svc.apiKeyService, svc.userService, svc.jwtService)
fileSizeLimitMiddleware := middleware.NewFileSizeLimitMiddleware()
apiRateLimitMiddleware := middleware.NewRateLimitMiddleware().Add(rate.Every(time.Second), 100)
// Set up API routes
apiGroup := r.Group("/api", apiRateLimitMiddleware)
controller.NewApiKeyController(apiGroup, authMiddleware, svc.apiKeyService)
controller.NewWebauthnController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.webauthnService, svc.appConfigService)
controller.NewOidcController(apiGroup, authMiddleware, fileSizeLimitMiddleware, svc.oidcService, svc.jwtService)
controller.NewUserController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.userService, svc.oneTimeAccessService, svc.appConfigService)
controller.NewUserController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.userService, svc.oneTimeAccessService, svc.webauthnService, svc.appConfigService)
controller.NewAppConfigController(apiGroup, authMiddleware, svc.appConfigService, svc.emailService, svc.ldapService)
controller.NewAppImagesController(apiGroup, authMiddleware, svc.appImagesService)
controller.NewAuditLogController(apiGroup, svc.auditLogService, authMiddleware)
@@ -89,30 +135,81 @@ func initRouter(db *gorm.DB, svc *services) (utils.Service, error) {
controller.NewScimController(apiGroup, authMiddleware, svc.scimService)
controller.NewUserSignupController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.userSignUpService, svc.appConfigService)
// Add test controller in non-production environments
if !common.EnvConfig.AppEnv.IsProduction() {
for _, f := range registerTestControllers {
f(apiGroup, db, svc)
}
}
registerTestRoutes(apiGroup, db, svc)
// Set up base routes
baseGroup := r.Group("/", apiRateLimitMiddleware)
controller.NewWellKnownController(baseGroup, svc.jwtService)
// Set up healthcheck routes
// These are not rate-limited
// These are not rate-limited.
controller.NewHealthzController(r)
}
var protocols http.Protocols
func registerTestRoutes(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services) {
if common.EnvConfig.AppEnv.IsProduction() {
return
}
for _, f := range registerTestControllers {
f(apiGroup, db, svc)
}
}
func initServer(r *gin.Engine) (*serverConfig, error) {
protocols, tlsConfig, certProvider, err := initServerProtocols()
if err != nil {
return nil, err
}
network, addr := listenerNetworkAndAddr()
listener, err := net.Listen(network, addr) //nolint:noctx
if err != nil {
return nil, fmt.Errorf("failed to create %s listener: %w", network, err)
}
if err := setUnixSocketMode(network, addr); err != nil {
listener.Close()
return nil, err
}
return &serverConfig{
addr: addr,
certProvider: certProvider,
listener: listener,
server: newHTTPServer(r, protocols),
tlsConfig: tlsConfig,
}, nil
}
func initServerProtocols() (*http.Protocols, *tls.Config, *tlsCertProvider, error) {
protocols := new(http.Protocols)
protocols.SetHTTP1(true)
protocols.SetUnencryptedHTTP2(true)
// Set up the server
srv := &http.Server{
if common.EnvConfig.TLSCertFile == "" || common.EnvConfig.TLSKeyFile == "" {
protocols.SetUnencryptedHTTP2(true)
return protocols, nil, nil, nil
}
protocols.SetHTTP2(true)
certProvider, err := newCertProvider(common.EnvConfig.TLSCertFile, common.EnvConfig.TLSKeyFile)
if err != nil {
return nil, nil, nil, fmt.Errorf("failed to load TLS certificate: %w", err)
}
tlsConfig := &tls.Config{
GetCertificate: certProvider.GetCertificate,
MinVersion: tls.VersionTLS13,
NextProtos: []string{"h2"},
}
slog.Info("TLS enabled")
return protocols, tlsConfig, certProvider, nil
}
func newHTTPServer(r *gin.Engine, protocols *http.Protocols) *http.Server {
return &http.Server{
MaxHeaderBytes: 1 << 20,
ReadHeaderTimeout: 10 * time.Second,
Protocols: &protocols,
Protocols: protocols,
Handler: h2c.NewHandler(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
// HEAD requests don't get matched by Gin routes, so we convert them to GET
// middleware.HeadMiddleware will convert them back to HEAD later
@@ -125,73 +222,119 @@ func initRouter(db *gorm.DB, svc *services) (utils.Service, error) {
r.ServeHTTP(w, req)
}), &http2.Server{}),
}
}
// Set up the listener
network := "tcp"
addr := net.JoinHostPort(common.EnvConfig.Host, common.EnvConfig.Port)
if common.EnvConfig.UnixSocket != "" {
network = "unix"
addr = common.EnvConfig.UnixSocket
os.Remove(addr) // remove dangling the socket file to avoid file-exist error
func listenerNetworkAndAddr() (string, string) {
if common.EnvConfig.UnixSocket == "" {
return "tcp", net.JoinHostPort(common.EnvConfig.Host, common.EnvConfig.Port)
}
listener, err := net.Listen(network, addr) //nolint:noctx
if err != nil {
return nil, fmt.Errorf("failed to create %s listener: %w", network, err)
}
// Set the socket mode if using a Unix socket
if network == "unix" && common.EnvConfig.UnixSocketMode != "" {
mode, err := strconv.ParseUint(common.EnvConfig.UnixSocketMode, 8, 32)
if err != nil {
return nil, fmt.Errorf("failed to parse UNIX socket mode '%s': %w", common.EnvConfig.UnixSocketMode, err)
}
if err := os.Chmod(addr, os.FileMode(mode)); err != nil {
return nil, fmt.Errorf("failed to set UNIX socket mode '%s': %w", common.EnvConfig.UnixSocketMode, err)
}
}
// Service runner function
runFn := func(ctx context.Context) error {
slog.Info("Server listening", slog.String("addr", addr))
// Start the server in a background goroutine
go func() {
defer listener.Close()
// Next call blocks until the server is shut down
srvErr := srv.Serve(listener)
if srvErr != http.ErrServerClosed {
slog.Error("Error starting app server", "error", srvErr)
os.Exit(1)
}
}()
// Notify systemd that we are ready
err = systemd.SdNotifyReady()
if err != nil {
// Log the error only
slog.Warn("Unable to notify systemd that the service is ready", "error", err)
}
// Block until the context is canceled
<-ctx.Done()
// Handle graceful shutdown
// Note we use the background context here as ctx has been canceled already
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
shutdownErr := srv.Shutdown(shutdownCtx) //nolint:contextcheck
shutdownCancel()
if shutdownErr != nil {
// Log the error only (could be context canceled)
slog.Warn("App server shutdown error", "error", shutdownErr)
}
addr := common.EnvConfig.UnixSocket
os.Remove(addr) // remove dangling the socket file to avoid file-exist error
return "unix", addr
}
func setUnixSocketMode(network, addr string) error {
if network != "unix" || common.EnvConfig.UnixSocketMode == "" {
return nil
}
return runFn, nil
mode, err := strconv.ParseUint(common.EnvConfig.UnixSocketMode, 8, 32)
if err != nil {
return fmt.Errorf("failed to parse UNIX socket mode '%s': %w", common.EnvConfig.UnixSocketMode, err)
}
if err := os.Chmod(addr, os.FileMode(mode)); err != nil {
return fmt.Errorf("failed to set UNIX socket mode '%s': %w", common.EnvConfig.UnixSocketMode, err)
}
return nil
}
func runServer(ctx context.Context, config *serverConfig) error {
slog.Info("Server listening", slog.String("addr", config.addr), slog.Bool("tls", config.tlsConfig != nil))
certWatcher, err := startCertWatcher(ctx, config.certProvider)
if err != nil {
return err
}
defer closeCertWatcher(certWatcher)
startHTTPServer(config)
notifySystemdReady()
<-ctx.Done()
// We do not pass the context because it's already been canceled
//nolint:contextcheck
return shutdownServer(config.server)
}
func startCertWatcher(ctx context.Context, certProvider *tlsCertProvider) (*fsnotify.Watcher, error) {
if certProvider == nil {
return nil, nil
}
certWatcher, err := fsnotify.NewWatcher()
if err != nil {
return nil, fmt.Errorf("failed to create certificate watcher: %w", err)
}
if err := certWatcher.Add(common.EnvConfig.TLSCertFile); err != nil {
certWatcher.Close()
return nil, fmt.Errorf("failed to watch TLS certificate: %w", err)
}
if err := certWatcher.Add(common.EnvConfig.TLSKeyFile); err != nil {
certWatcher.Close()
return nil, fmt.Errorf("failed to watch TLS key: %w", err)
}
go certProvider.StartWatching(ctx, certWatcher)
return certWatcher, nil
}
func closeCertWatcher(certWatcher *fsnotify.Watcher) {
if certWatcher != nil {
certWatcher.Close()
}
}
func startHTTPServer(config *serverConfig) {
go func() {
defer config.listener.Close()
listener := config.listener
if config.tlsConfig != nil {
listener = tls.NewListener(config.listener, config.tlsConfig)
}
srvErr := config.server.Serve(listener)
if srvErr != http.ErrServerClosed {
slog.Error("Error starting app server", "error", srvErr)
os.Exit(1)
}
}()
}
func notifySystemdReady() {
err := systemd.SdNotifyReady()
if err != nil {
// Log the error only
slog.Warn("Unable to notify systemd that the service is ready", "error", err)
}
}
func shutdownServer(srv *http.Server) error {
// Note we use the background context here as ctx has been canceled already
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
shutdownErr := srv.Shutdown(shutdownCtx) //nolint:contextcheck
shutdownCancel()
if shutdownErr != nil {
// Log the error only (could be context canceled)
slog.Warn("App server shutdown error", "error", shutdownErr)
}
return nil
}
func initLogger(r *gin.Engine) {
@@ -220,3 +363,99 @@ func initLogger(r *gin.Engine) {
}),
))
}
// tlsCertProvider holds certificates that can be dynamically reloaded
type tlsCertProvider struct {
certMutex sync.RWMutex
cert *tls.Certificate
certFile string
keyFile string
forceReload atomic.Bool
}
// GetCertificate implements tls.GetCertificate interface for dynamic certificate loading
func (p *tlsCertProvider) GetCertificate(_ *tls.ClientHelloInfo) (*tls.Certificate, error) {
if p.forceReload.Load() {
p.certMutex.Lock()
p.forceReload.Store(false)
p.certMutex.Unlock()
}
p.certMutex.RLock()
defer p.certMutex.RUnlock()
return p.cert, nil
}
// newCertProvider creates a new certificate provider with initial certificates loaded
func newCertProvider(certFile, keyFile string) (*tlsCertProvider, error) {
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
return nil, err
}
return &tlsCertProvider{
cert: &cert,
certFile: certFile,
keyFile: keyFile,
}, nil
}
// reloadCertificate reloads the certificate from disk
func (p *tlsCertProvider) reloadCertificate() error {
cert, err := tls.LoadX509KeyPair(p.certFile, p.keyFile)
if err != nil {
return fmt.Errorf("failed to reload TLS certificate: %w", err)
}
p.certMutex.Lock()
p.cert = &cert
p.certMutex.Unlock()
return nil
}
// StartWatching begins monitoring the certificate files for changes with debouncing
func (p *tlsCertProvider) StartWatching(ctx context.Context, watcher *fsnotify.Watcher) {
debounceDuration := 1 * time.Second
reloadTimer := time.NewTimer(debounceDuration)
reloadTimer.Stop()
for {
select {
case <-ctx.Done():
return
case event, ok := <-watcher.Events:
if !ok {
return
}
// Only process write/rename events for certificate/key files
if event.Has(fsnotify.Write | fsnotify.Rename) {
// Reset the debounce timer whenever we get a relevant event
reloadTimer.Stop()
// Drain the channel if there's a pending value
select {
case <-reloadTimer.C:
default:
}
reloadTimer.Reset(debounceDuration)
slog.Debug("TLS file change detected, debouncing", slog.String("path", event.Name))
}
case <-reloadTimer.C:
// Timer fired - no more events in 500ms, so reload
slog.Info("Reloading TLS certificate")
if err := p.reloadCertificate(); err != nil {
slog.Error("Failed to reload TLS certificate", "error", err)
continue
}
p.forceReload.Store(true)
slog.Info("TLS certificate reloaded successfully")
case err, ok := <-watcher.Errors:
if !ok {
return
}
slog.Error("Certificate watcher error", "error", err)
}
}
}

View File

@@ -119,11 +119,10 @@ func acquireImportLock(ctx context.Context, db *gorm.DB, force bool) error {
defer cancel()
waitUntil, err := appLockService.Acquire(opCtx, force)
if err != nil {
if errors.Is(err, service.ErrLockUnavailable) {
//nolint:staticcheck
return errors.New("Pocket ID must be stopped before importing data; please stop the running instance or run with --forcefully-acquire-lock to terminate the other instance")
}
if errors.Is(err, service.ErrLockUnavailable) {
//nolint:staticcheck
return errors.New("Pocket ID must be stopped before importing data; please stop the running instance or run with --forcefully-acquire-lock to terminate the other instance")
} else if err != nil {
return fmt.Errorf("failed to acquire application lock: %w", err)
}

View File

@@ -44,6 +44,7 @@ type EnvConfigSchema struct {
DbProvider DbProvider
DbConnectionString string `env:"DB_CONNECTION_STRING" options:"file"`
TrustProxy bool `env:"TRUST_PROXY"`
TrustedPlatform string `env:"TRUSTED_PLATFORM"`
AuditLogRetentionDays int `env:"AUDIT_LOG_RETENTION_DAYS"`
AnalyticsDisabled bool `env:"ANALYTICS_DISABLED"`
AllowDowngrade bool `env:"ALLOW_DOWNGRADE"`
@@ -69,6 +70,9 @@ type EnvConfigSchema struct {
UnixSocketMode string `env:"UNIX_SOCKET_MODE"`
LocalIPv6Ranges string `env:"LOCAL_IPV6_RANGES"`
TLSCertFile string `env:"TLS_CERT" options:"file"`
TLSKeyFile string `env:"TLS_KEY" options:"file"`
MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY" options:"file"`
GeoLiteDBPath string `env:"GEOLITE_DB_PATH"`
GeoLiteDBUrl string `env:"GEOLITE_DB_URL"`
@@ -141,6 +145,31 @@ func ValidateEnvConfig(config *EnvConfigSchema) error {
return errors.New("ENCRYPTION_KEY must be at least 16 bytes long")
}
prepareDbConfig(config)
if err := validateAppURLs(config); err != nil {
return err
}
if err := validateFileBackend(config); err != nil {
return err
}
if err := validateLocalIPv6Ranges(config.LocalIPv6Ranges); err != nil {
return err
}
if config.AuditLogRetentionDays <= 0 {
return errors.New("AUDIT_LOG_RETENTION_DAYS must be greater than 0")
}
if config.StaticApiKey != "" && len(config.StaticApiKey) < 16 {
return errors.New("STATIC_API_KEY must be at least 16 characters long")
}
return validateTLSConfig(config)
}
func prepareDbConfig(config *EnvConfigSchema) {
switch {
case config.DbConnectionString == "":
config.DbProvider = DbProviderSqlite
@@ -150,64 +179,95 @@ func ValidateEnvConfig(config *EnvConfigSchema) error {
default:
config.DbProvider = DbProviderSqlite
}
}
parsedAppUrl, err := url.Parse(config.AppURL)
if err != nil {
return errors.New("APP_URL is not a valid URL")
}
if parsedAppUrl.Path != "" {
return errors.New("APP_URL must not contain a path")
func validateAppURLs(config *EnvConfigSchema) error {
if err := validateURLWithoutPath(config.AppURL, "APP_URL"); err != nil {
return err
}
// Derive INTERNAL_APP_URL from APP_URL if not set; validate only when provided
if config.InternalAppURL == "" {
config.InternalAppURL = config.AppURL
} else {
parsedInternalAppUrl, err := url.Parse(config.InternalAppURL)
if err != nil {
return errors.New("INTERNAL_APP_URL is not a valid URL")
}
if parsedInternalAppUrl.Path != "" {
return errors.New("INTERNAL_APP_URL must not contain a path")
}
return nil
}
return validateURLWithoutPath(config.InternalAppURL, "INTERNAL_APP_URL")
}
func validateURLWithoutPath(rawURL, envName string) error {
parsedURL, err := url.Parse(rawURL)
if err != nil {
return fmt.Errorf("%s is not a valid URL", envName)
}
if parsedURL.Path != "" {
return fmt.Errorf("%s must not contain a path", envName)
}
return nil
}
func validateFileBackend(config *EnvConfigSchema) error {
switch config.FileBackend {
case "s3", "database":
// All good, these are valid values
return nil
case "", "filesystem":
if config.UploadPath == "" {
config.UploadPath = defaultFsUploadPath
}
return nil
default:
return errors.New("invalid FILE_BACKEND value. Must be 'filesystem', 'database', or 's3'")
}
}
// Validate LOCAL_IPV6_RANGES
ranges := strings.SplitSeq(config.LocalIPv6Ranges, ",")
func validateLocalIPv6Ranges(localIPv6Ranges string) error {
ranges := strings.SplitSeq(localIPv6Ranges, ",")
for rangeStr := range ranges {
rangeStr = strings.TrimSpace(rangeStr)
if rangeStr == "" {
continue
}
_, ipNet, err := net.ParseCIDR(rangeStr)
if err != nil {
return fmt.Errorf("invalid LOCAL_IPV6_RANGES '%s': %w", rangeStr, err)
if err := validateLocalIPv6Range(rangeStr); err != nil {
return err
}
if ipNet.IP.To4() != nil {
return fmt.Errorf("range '%s' is not a valid IPv6 range", rangeStr)
}
}
if config.AuditLogRetentionDays <= 0 {
return errors.New("AUDIT_LOG_RETENTION_DAYS must be greater than 0")
return nil
}
func validateLocalIPv6Range(rangeStr string) error {
_, ipNet, err := net.ParseCIDR(rangeStr)
if err != nil {
return fmt.Errorf("invalid LOCAL_IPV6_RANGES '%s': %w", rangeStr, err)
}
if config.StaticApiKey != "" && len(config.StaticApiKey) < 16 {
return errors.New("STATIC_API_KEY must be at least 16 characters long")
if ipNet.IP.To4() != nil {
return fmt.Errorf("range '%s' is not a valid IPv6 range", rangeStr)
}
return nil
}
func validateTLSConfig(config *EnvConfigSchema) error {
switch {
case config.TLSCertFile != "" && config.TLSKeyFile == "":
return errors.New("TLS_KEY_FILE must be set when TLS_CERT_FILE is set")
case config.TLSCertFile == "" && config.TLSKeyFile != "":
return errors.New("TLS_CERT_FILE must be set when TLS_KEY_FILE is set")
}
if config.TLSCertFile != "" && config.TLSKeyFile != "" {
if _, err := os.Stat(config.TLSCertFile); err != nil {
return fmt.Errorf("TLS_CERT_FILE not found: %w", err)
}
}
if config.TLSCertFile != "" && config.TLSKeyFile != "" {
if _, err := os.Stat(config.TLSKeyFile); err != nil {
return fmt.Errorf("TLS_KEY_FILE not found: %w", err)
}
}
return nil
@@ -287,6 +347,7 @@ func resolveFileBasedEnvVariable(field reflect.Value, fieldType reflect.StructFi
return nil
}
// #nosec G703 - Path is passed by the admin
fileContent, err := os.ReadFile(envVarFileValue)
if err != nil {
return fmt.Errorf("failed to read file for env var %s: %w", envVarFileName, err)

View File

@@ -207,6 +207,58 @@ func TestParseEnvConfig(t *testing.T) {
require.Error(t, err)
assert.ErrorContains(t, err, "invalid FILE_BACKEND value")
})
t.Run("should fail when TLS cert is set without key", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("APP_URL", "http://localhost:3000")
t.Setenv("TLS_CERT", "/path/to/cert.pem")
err := parseAndValidateEnvConfig(t)
require.Error(t, err)
assert.ErrorContains(t, err, "TLS_KEY_FILE must be set when TLS_CERT_FILE is set")
})
t.Run("should fail when TLS key is set without cert", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("APP_URL", "http://localhost:3000")
t.Setenv("TLS_KEY", "/path/to/key.pem")
err := parseAndValidateEnvConfig(t)
require.Error(t, err)
assert.ErrorContains(t, err, "TLS_CERT_FILE must be set when TLS_KEY_FILE is set")
})
t.Run("should fail when TLS cert file does not exist", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("APP_URL", "http://localhost:3000")
t.Setenv("TLS_CERT", "/nonexistent/cert.pem")
keyFile := t.TempDir() + "/key.pem"
require.NoError(t, os.WriteFile(keyFile, []byte("key"), 0600))
t.Setenv("TLS_KEY", keyFile)
err := parseAndValidateEnvConfig(t)
require.Error(t, err)
assert.ErrorContains(t, err, "TLS_CERT_FILE not found")
})
t.Run("should fail when TLS key file does not exist", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("APP_URL", "http://localhost:3000")
certFile := t.TempDir() + "/cert.pem"
require.NoError(t, os.WriteFile(certFile, []byte("cert"), 0600))
t.Setenv("TLS_CERT", certFile)
t.Setenv("TLS_KEY", "/nonexistent/key.pem")
err := parseAndValidateEnvConfig(t)
require.Error(t, err)
assert.ErrorContains(t, err, "TLS_KEY_FILE not found")
})
}
func TestPrepareEnvConfig_FileBasedAndToLower(t *testing.T) {
@@ -220,7 +272,7 @@ func TestPrepareEnvConfig_FileBasedAndToLower(t *testing.T) {
require.NoError(t, err)
dbConnFile := tempDir + "/db_connection.txt"
dbConnContent := "postgres://user:pass@localhost/testdb"
dbConnContent := "postgres://user:pass@localhost/testdb" // #nosec G101 - test credential
err = os.WriteFile(dbConnFile, []byte(dbConnContent), 0600)
require.NoError(t, err)
@@ -254,4 +306,26 @@ func TestPrepareEnvConfig_FileBasedAndToLower(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, binaryKeyContent, config.EncryptionKey)
})
t.Run("should load TLS cert and key file contents", func(t *testing.T) {
config := defaultConfig()
certFile := tempDir + "/cert.pem"
certContent := "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----"
err := os.WriteFile(certFile, []byte(certContent), 0600)
require.NoError(t, err)
keyFile := tempDir + "/key.pem"
keyContent := "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----"
err = os.WriteFile(keyFile, []byte(keyContent), 0600)
require.NoError(t, err)
t.Setenv("TLS_CERT_FILE", certFile)
t.Setenv("TLS_KEY_FILE", keyFile)
err = prepareEnvConfig(&config)
require.NoError(t, err)
assert.Equal(t, certContent, config.TLSCertFile)
assert.Equal(t, keyContent, config.TLSKeyFile)
})
}

View File

@@ -20,7 +20,7 @@ type AlreadyInUseError struct {
func (e *AlreadyInUseError) Error() string {
return e.Property + " is already in use"
}
func (e *AlreadyInUseError) HttpStatusCode() int { return 400 }
func (e *AlreadyInUseError) HttpStatusCode() int { return http.StatusBadRequest }
func (e *AlreadyInUseError) Is(target error) bool {
// Ignore the field property when checking if an error is of the type AlreadyInUseError
@@ -28,29 +28,29 @@ func (e *AlreadyInUseError) Is(target error) bool {
return errors.As(target, &x)
}
type SetupAlreadyCompletedError struct{}
type SetupNotAvailableError struct{}
func (e *SetupAlreadyCompletedError) Error() string { return "setup already completed" }
func (e *SetupAlreadyCompletedError) HttpStatusCode() int { return 400 }
func (e *SetupNotAvailableError) Error() string { return "not found" }
func (e *SetupNotAvailableError) HttpStatusCode() int { return http.StatusNotFound }
type TokenInvalidOrExpiredError struct{}
func (e *TokenInvalidOrExpiredError) Error() string { return "token is invalid or expired" }
func (e *TokenInvalidOrExpiredError) HttpStatusCode() int { return 400 }
func (e *TokenInvalidOrExpiredError) HttpStatusCode() int { return http.StatusUnauthorized }
type DeviceCodeInvalid struct{}
func (e *DeviceCodeInvalid) Error() string {
return "one time access code must be used on the device it was generated for"
}
func (e *DeviceCodeInvalid) HttpStatusCode() int { return 400 }
func (e *DeviceCodeInvalid) HttpStatusCode() int { return http.StatusUnauthorized }
type TokenInvalidError struct{}
func (e *TokenInvalidError) Error() string {
return "Token is invalid"
}
func (e *TokenInvalidError) HttpStatusCode() int { return 400 }
func (e *TokenInvalidError) HttpStatusCode() int { return http.StatusUnauthorized }
type OidcMissingAuthorizationError struct{}
@@ -60,46 +60,51 @@ func (e *OidcMissingAuthorizationError) HttpStatusCode() int { return http.Statu
type OidcGrantTypeNotSupportedError struct{}
func (e *OidcGrantTypeNotSupportedError) Error() string { return "grant type not supported" }
func (e *OidcGrantTypeNotSupportedError) HttpStatusCode() int { return 400 }
func (e *OidcGrantTypeNotSupportedError) HttpStatusCode() int { return http.StatusBadRequest }
type OidcMissingClientCredentialsError struct{}
func (e *OidcMissingClientCredentialsError) Error() string { return "client id or secret not provided" }
func (e *OidcMissingClientCredentialsError) HttpStatusCode() int { return 400 }
func (e *OidcMissingClientCredentialsError) HttpStatusCode() int { return http.StatusBadRequest }
type OidcClientSecretInvalidError struct{}
func (e *OidcClientSecretInvalidError) Error() string { return "invalid client secret" }
func (e *OidcClientSecretInvalidError) HttpStatusCode() int { return 400 }
func (e *OidcClientSecretInvalidError) HttpStatusCode() int { return http.StatusUnauthorized }
type OidcClientAssertionInvalidError struct{}
func (e *OidcClientAssertionInvalidError) Error() string { return "invalid client assertion" }
func (e *OidcClientAssertionInvalidError) HttpStatusCode() int { return 400 }
func (e *OidcClientAssertionInvalidError) HttpStatusCode() int { return http.StatusUnauthorized }
type OidcInvalidAuthorizationCodeError struct{}
func (e *OidcInvalidAuthorizationCodeError) Error() string { return "invalid authorization code" }
func (e *OidcInvalidAuthorizationCodeError) HttpStatusCode() int { return 400 }
func (e *OidcInvalidAuthorizationCodeError) HttpStatusCode() int { return http.StatusBadRequest }
type OidcClientNotFoundError struct{}
func (e *OidcClientNotFoundError) Error() string { return "client not found" }
func (e *OidcClientNotFoundError) HttpStatusCode() int { return http.StatusNotFound }
type OidcMissingCallbackURLError struct{}
func (e *OidcMissingCallbackURLError) Error() string {
return "unable to detect callback url, it might be necessary for an admin to fix this"
}
func (e *OidcMissingCallbackURLError) HttpStatusCode() int { return 400 }
func (e *OidcMissingCallbackURLError) HttpStatusCode() int { return http.StatusBadRequest }
type OidcInvalidCallbackURLError struct{}
func (e *OidcInvalidCallbackURLError) Error() string {
return "invalid callback URL, it might be necessary for an admin to fix this"
}
func (e *OidcInvalidCallbackURLError) HttpStatusCode() int { return 400 }
func (e *OidcInvalidCallbackURLError) HttpStatusCode() int { return http.StatusBadRequest }
type FileTypeNotSupportedError struct{}
func (e *FileTypeNotSupportedError) Error() string { return "file type not supported" }
func (e *FileTypeNotSupportedError) HttpStatusCode() int { return 400 }
func (e *FileTypeNotSupportedError) HttpStatusCode() int { return http.StatusBadRequest }
type FileTooLargeError struct {
MaxSize string
@@ -134,6 +139,20 @@ func (e *TooManyRequestsError) Error() string {
}
func (e *TooManyRequestsError) HttpStatusCode() int { return http.StatusTooManyRequests }
type UserIdNotProvidedError struct{}
func (e *UserIdNotProvidedError) Error() string {
return "User id not provided"
}
func (e *UserIdNotProvidedError) HttpStatusCode() int { return http.StatusBadRequest }
type UserNotFoundError struct{}
func (e *UserNotFoundError) Error() string {
return "User not found"
}
func (e *UserNotFoundError) HttpStatusCode() int { return http.StatusNotFound }
type ClientIdOrSecretNotProvidedError struct{}
func (e *ClientIdOrSecretNotProvidedError) Error() string {
@@ -429,3 +448,27 @@ func (e *InvalidEmailVerificationTokenError) Error() string {
func (e *InvalidEmailVerificationTokenError) HttpStatusCode() int {
return http.StatusBadRequest
}
// OIDC prompt parameter errors - used for redirect error responses
type OidcLoginRequiredError struct{}
func (e *OidcLoginRequiredError) Error() string { return "login_required" }
func (e *OidcLoginRequiredError) HttpStatusCode() int { return http.StatusBadRequest }
type OidcConsentRequiredError struct{}
func (e *OidcConsentRequiredError) Error() string { return "consent_required" }
func (e *OidcConsentRequiredError) HttpStatusCode() int { return http.StatusBadRequest }
type OidcInteractionRequiredError struct{}
func (e *OidcInteractionRequiredError) Error() string { return "interaction_required" }
func (e *OidcInteractionRequiredError) HttpStatusCode() int { return http.StatusBadRequest }
type OidcAccountSelectionRequiredError struct{}
func (e *OidcAccountSelectionRequiredError) Error() string {
return "account_selection_required"
}
func (e *OidcAccountSelectionRequiredError) HttpStatusCode() int { return http.StatusBadRequest }

View File

@@ -2,7 +2,9 @@ package controller
import (
"net/http"
"slices"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
@@ -34,6 +36,7 @@ func NewAppImagesController(
group.PUT("/application-images/favicon", authMiddleware.Add(), controller.updateFaviconHandler)
group.PUT("/application-images/default-profile-picture", authMiddleware.Add(), controller.updateDefaultProfilePicture)
group.DELETE("/application-images/background", authMiddleware.Add(), controller.deleteBackgroundImageHandler)
group.DELETE("/application-images/default-profile-picture", authMiddleware.Add(), controller.deleteDefaultProfilePicture)
}
@@ -192,12 +195,27 @@ func (c *AppImagesController) updateBackgroundImageHandler(ctx *gin.Context) {
ctx.Status(http.StatusNoContent)
}
// deleteBackgroundImageHandler godoc
// @Summary Delete background image
// @Description Delete the application background image
// @Tags Application Images
// @Success 204 "No Content"
// @Router /api/application-images/background [delete]
func (c *AppImagesController) deleteBackgroundImageHandler(ctx *gin.Context) {
if err := c.appImagesService.DeleteImage(ctx.Request.Context(), "background"); err != nil {
_ = ctx.Error(err)
return
}
ctx.Status(http.StatusNoContent)
}
// updateFaviconHandler godoc
// @Summary Update favicon
// @Description Update the application favicon
// @Tags Application Images
// @Accept multipart/form-data
// @Param file formData file true "Favicon file (.ico)"
// @Param file formData file true "Favicon file (.svg/.png/.ico)"
// @Success 204 "No Content"
// @Router /api/application-images/favicon [put]
func (c *AppImagesController) updateFaviconHandler(ctx *gin.Context) {
@@ -208,8 +226,9 @@ func (c *AppImagesController) updateFaviconHandler(ctx *gin.Context) {
}
fileType := utils.GetFileExtension(file.Filename)
if fileType != "ico" {
_ = ctx.Error(&common.WrongFileTypeError{ExpectedFileType: ".ico"})
mimeType := utils.GetImageMimeType(strings.ToLower(fileType))
if !slices.Contains([]string{"image/svg+xml", "image/png", "image/x-icon"}, mimeType) {
_ = ctx.Error(&common.WrongFileTypeError{ExpectedFileType: ".svg or .png or .ico"})
return
}

View File

@@ -63,6 +63,7 @@ func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
// Add device information to the logs
for i, logsDto := range logsDtos {
logsDto.Device = alc.auditLogService.DeviceStringFromUserAgent(logs[i].UserAgent)
logsDto.ActorUsername = logsDto.Data["actorUsername"]
logsDtos[i] = logsDto
}
@@ -101,6 +102,7 @@ func (alc *AuditLogController) listAllAuditLogsHandler(c *gin.Context) {
for i, logsDto := range logsDtos {
logsDto.Device = alc.auditLogService.DeviceStringFromUserAgent(logs[i].UserAgent)
logsDto.Username = logs[i].User.Username
logsDto.ActorUsername = logsDto.Data["actorUsername"]
logsDtos[i] = logsDto
}

View File

@@ -89,13 +89,29 @@ type OidcController struct {
// @Router /api/oidc/authorize [post]
func (oc *OidcController) authorizeHandler(c *gin.Context) {
var input dto.AuthorizeOidcClientRequestDto
if err := c.ShouldBindJSON(&input); err != nil {
err := c.ShouldBindJSON(&input)
if err != nil {
_ = c.Error(err)
return
}
code, callbackURL, err := oc.oidcService.Authorize(c.Request.Context(), input, c.GetString("userID"), c.ClientIP(), c.Request.UserAgent())
code, callbackURL, err := oc.oidcService.Authorize(
c.Request.Context(),
input,
c.GetString("userID"),
c.GetString("authenticationMethod"),
c.ClientIP(),
c.Request.UserAgent(),
)
if err != nil {
// Check if this is a prompt-related error that should be returned as a redirect error
if isOidcPromptError(err) {
c.JSON(http.StatusOK, gin.H{
"error": err.Error(),
"requiresRedirect": true,
})
return
}
_ = c.Error(err)
return
}
@@ -109,6 +125,19 @@ func (oc *OidcController) authorizeHandler(c *gin.Context) {
c.JSON(http.StatusOK, response)
}
// isOidcPromptError checks if an error is a prompt-related OIDC error that should trigger a redirect
func isOidcPromptError(err error) bool {
var loginReq *common.OidcLoginRequiredError
var consentReq *common.OidcConsentRequiredError
var interactionReq *common.OidcInteractionRequiredError
var accountSelectionReq *common.OidcAccountSelectionRequiredError
return errors.As(err, &loginReq) ||
errors.As(err, &consentReq) ||
errors.As(err, &interactionReq) ||
errors.As(err, &accountSelectionReq)
}
// authorizationConfirmationRequiredHandler godoc
// @Summary Check if authorization confirmation is required
// @Description Check if the user needs to confirm authorization for the client
@@ -335,11 +364,13 @@ func (oc *OidcController) introspectTokenHandler(c *gin.Context) {
)
creds.ClientID, creds.ClientSecret, ok = utils.OAuthClientBasicAuth(c.Request)
if !ok {
// If there's no basic auth, check if we have a bearer token
// If there's no basic auth, check if we have a bearer token (used as client assertion)
bearer, ok := utils.BearerAuth(c.Request)
if ok {
creds.ClientAssertionType = service.ClientAssertionTypeJWTBearer
creds.ClientAssertion = bearer
// When using client assertions, client_id can be passed as a form field
creds.ClientID = input.ClientID
}
}
@@ -662,8 +693,13 @@ func (oc *OidcController) updateAllowedUserGroupsHandler(c *gin.Context) {
}
func (oc *OidcController) deviceAuthorizationHandler(c *gin.Context) {
// Per RFC 8628 (OAuth 2.0 Device Authorization Grant), parameters for the device authorization request MUST be sent in the body of the POST request
// Gin's "ShouldBind" by default reads from the query string too, so we need to reset all query string args before invoking ShouldBind
c.Request.URL.RawQuery = ""
var input dto.OidcDeviceAuthorizationRequestDto
if err := c.ShouldBind(&input); err != nil {
err := c.ShouldBind(&input)
if err != nil {
_ = c.Error(err)
return
}
@@ -794,7 +830,14 @@ func (oc *OidcController) verifyDeviceCodeHandler(c *gin.Context) {
ipAddress := c.ClientIP()
userAgent := c.Request.UserAgent()
err := oc.oidcService.VerifyDeviceCode(c.Request.Context(), userCode, c.GetString("userID"), ipAddress, userAgent)
err := oc.oidcService.VerifyDeviceCode(
c.Request.Context(),
userCode,
c.GetString("userID"),
c.GetString("authenticationMethod"),
ipAddress,
userAgent)
if err != nil {
_ = c.Error(err)
return
@@ -850,7 +893,13 @@ func (oc *OidcController) getClientPreviewHandler(c *gin.Context) {
return
}
preview, err := oc.oidcService.GetClientPreview(c.Request.Context(), clientID, userID, strings.Split(scopes, " "))
preview, err := oc.oidcService.GetClientPreview(
c.Request.Context(),
clientID,
userID,
strings.Split(scopes, " "),
c.GetString("authenticationMethod"))
if err != nil {
_ = c.Error(err)
return

View File

@@ -4,6 +4,7 @@ import (
"net/http"
"time"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
"github.com/gin-gonic/gin"
@@ -20,10 +21,11 @@ const defaultOneTimeAccessTokenDuration = 15 * time.Minute
// @Summary User management controller
// @Description Initializes all user-related API endpoints
// @Tags Users
func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, userService *service.UserService, oneTimeAccessService *service.OneTimeAccessService, appConfigService *service.AppConfigService) {
func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, userService *service.UserService, oneTimeAccessService *service.OneTimeAccessService, webAuthnService *service.WebAuthnService, appConfigService *service.AppConfigService) {
uc := UserController{
userService: userService,
oneTimeAccessService: oneTimeAccessService,
webAuthnService: webAuthnService,
appConfigService: appConfigService,
}
@@ -33,8 +35,10 @@ func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
group.POST("/users", authMiddleware.Add(), uc.createUserHandler)
group.PUT("/users/:id", authMiddleware.Add(), uc.updateUserHandler)
group.GET("/users/:id/groups", authMiddleware.Add(), uc.getUserGroupsHandler)
group.GET("/users/:id/webauthn-credentials", authMiddleware.Add(), uc.listUserWebauthnCredentialsHandler)
group.PUT("/users/me", authMiddleware.WithAdminNotRequired().Add(), uc.updateCurrentUserHandler)
group.DELETE("/users/:id", authMiddleware.Add(), uc.deleteUserHandler)
group.DELETE("/users/:id/webauthn-credentials/:credentialId", authMiddleware.Add(), uc.deleteUserWebauthnCredentialHandler)
group.PUT("/users/:id/user-groups", authMiddleware.Add(), uc.updateUserGroups)
@@ -59,6 +63,7 @@ func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
type UserController struct {
userService *service.UserService
oneTimeAccessService *service.OneTimeAccessService
webAuthnService *service.WebAuthnService
appConfigService *service.AppConfigService
}
@@ -86,6 +91,36 @@ func (uc *UserController) getUserGroupsHandler(c *gin.Context) {
c.JSON(http.StatusOK, groupsDto)
}
// listUserWebauthnCredentialsHandler godoc
// @Summary List user passkeys
// @Description Retrieve all WebAuthn credentials for a specific user
// @Tags Users
// @Param id path string true "User ID"
// @Success 200 {array} dto.WebauthnCredentialDto
// @Router /api/users/{id}/webauthn-credentials [get]
func (uc *UserController) listUserWebauthnCredentialsHandler(c *gin.Context) {
userID := c.Param("id")
if _, err := uc.userService.GetUser(c.Request.Context(), userID); err != nil {
_ = c.Error(err)
return
}
credentials, err := uc.webAuthnService.ListCredentials(c.Request.Context(), userID)
if err != nil {
_ = c.Error(err)
return
}
var credentialDtos []dto.WebauthnCredentialDto
if err := dto.MapStructList(credentials, &credentialDtos); err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, credentialDtos)
}
// listUsersHandler godoc
// @Summary List users
// @Description Get a paginated list of users with optional search and sorting
@@ -180,6 +215,31 @@ func (uc *UserController) deleteUserHandler(c *gin.Context) {
c.Status(http.StatusNoContent)
}
// deleteUserWebauthnCredentialHandler godoc
// @Summary Delete user passkey
// @Description Delete a specific WebAuthn credential for a user
// @Tags Users
// @Param id path string true "User ID"
// @Param credentialId path string true "Credential ID"
// @Success 204 "No Content"
// @Router /api/users/{id}/webauthn-credentials/{credentialId} [delete]
func (uc *UserController) deleteUserWebauthnCredentialHandler(c *gin.Context) {
err := uc.webAuthnService.DeleteCredential(
c.Request.Context(),
c.Param("id"),
c.Param("credentialId"),
c.ClientIP(),
c.Request.UserAgent(),
c.GetString("userID"),
)
if err != nil {
_ = c.Error(err)
return
}
c.Status(http.StatusNoContent)
}
// createUserHandler godoc
// @Summary Create user
// @Description Create a new user
@@ -322,22 +382,34 @@ func (uc *UserController) updateCurrentUserProfilePictureHandler(c *gin.Context)
func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context, own bool) {
var input dto.OneTimeAccessTokenCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
err := c.ShouldBindJSON(&input)
if err != nil {
_ = c.Error(err)
return
}
var ttl time.Duration
var (
userID string
ttl time.Duration
)
if own {
input.UserID = c.GetString("userID")
// Get user ID from context and force the default TTL
userID = c.GetString("userID")
ttl = defaultOneTimeAccessTokenDuration
} else {
// Get user ID from URL parameter, and optional TTL from body
userID = c.Param("id")
ttl = input.TTL.Duration
if ttl <= 0 {
ttl = defaultOneTimeAccessTokenDuration
}
}
token, err := uc.oneTimeAccessService.CreateOneTimeAccessToken(c.Request.Context(), input.UserID, ttl)
if userID == "" {
_ = c.Error(&common.UserIdNotProvidedError{})
return
}
token, err := uc.oneTimeAccessService.CreateOneTimeAccessToken(c.Request.Context(), userID, ttl)
if err != nil {
_ = c.Error(err)
return

View File

@@ -7,6 +7,7 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/middleware"
"github.com/pocket-id/pocket-id/backend/internal/service"
@@ -30,6 +31,7 @@ func NewUserSignupController(group *gin.RouterGroup, authMiddleware *middleware.
group.GET("/signup-tokens", authMiddleware.Add(), usc.listSignupTokensHandler)
group.DELETE("/signup-tokens/:id", authMiddleware.Add(), usc.deleteSignupTokenHandler)
group.POST("/signup", rateLimitMiddleware.Add(rate.Every(1*time.Minute), 10), usc.signupHandler)
group.GET("/signup/setup", usc.checkInitialAdminSetupAvailable)
group.POST("/signup/setup", usc.signUpInitialAdmin)
}
@@ -39,6 +41,21 @@ type UserSignupController struct {
appConfigService *service.AppConfigService
}
func (usc *UserSignupController) checkInitialAdminSetupAvailable(c *gin.Context) {
setupCompleted, err := usc.userSignUpService.IsInitialAdminSetupCompleted(c.Request.Context())
if err != nil {
_ = c.Error(err)
return
}
if setupCompleted {
_ = c.Error(&common.SetupNotAvailableError{})
return
}
c.Status(http.StatusNoContent)
}
// signUpInitialAdmin godoc
// @Summary Sign up initial admin user
// @Description Sign up and generate setup access token for initial admin user

View File

@@ -137,7 +137,7 @@ func (wc *WebauthnController) deleteCredentialHandler(c *gin.Context) {
clientIP := c.ClientIP()
userAgent := c.Request.UserAgent()
err := wc.webAuthnService.DeleteCredential(c.Request.Context(), userID, credentialID, clientIP, userAgent)
err := wc.webAuthnService.DeleteCredential(c.Request.Context(), userID, credentialID, clientIP, userAgent, userID)
if err != nil {
_ = c.Error(err)
return

View File

@@ -91,6 +91,8 @@ func (wkc *WellKnownController) computeOIDCConfiguration() ([]byte, error) {
"id_token_signing_alg_values_supported": []string{alg.String()},
"authorization_response_iss_parameter_supported": true,
"code_challenge_methods_supported": []string{"plain", "S256"},
"prompt_values_supported": []string{"none", "login", "consent"},
"token_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post", "none"},
}
return json.Marshal(config)
}

View File

@@ -8,12 +8,13 @@ type AuditLogDto struct {
ID string `json:"id"`
CreatedAt datatype.DateTime `json:"createdAt"`
Event string `json:"event"`
IpAddress string `json:"ipAddress"`
Country string `json:"country"`
City string `json:"city"`
Device string `json:"device"`
UserID string `json:"userID"`
Username string `json:"username"`
Data map[string]string `json:"data"`
Event string `json:"event"`
IpAddress string `json:"ipAddress"`
Country string `json:"country"`
City string `json:"city"`
Device string `json:"device"`
UserID string `json:"userID"`
Username string `json:"username"`
ActorUsername string `json:"actorUsername"`
Data map[string]string `json:"data"`
}

View File

@@ -33,8 +33,8 @@ type OidcClientWithAllowedGroupsCountDto struct {
type OidcClientUpdateDto struct {
Name string `json:"name" binding:"required,max=50" unorm:"nfc"`
CallbackURLs []string `json:"callbackURLs" binding:"omitempty,dive,callback_url"`
LogoutCallbackURLs []string `json:"logoutCallbackURLs" binding:"omitempty,dive,callback_url"`
CallbackURLs []string `json:"callbackURLs" binding:"omitempty,dive,callback_url_pattern"`
LogoutCallbackURLs []string `json:"logoutCallbackURLs" binding:"omitempty,dive,callback_url_pattern"`
IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"`
RequiresReauthentication bool `json:"requiresReauthentication"`
@@ -66,11 +66,12 @@ type OidcClientFederatedIdentityDto struct {
type AuthorizeOidcClientRequestDto struct {
ClientID string `json:"clientID" binding:"required"`
Scope string `json:"scope" binding:"required"`
CallbackURL string `json:"callbackURL"`
CallbackURL string `json:"callbackURL" binding:"omitempty,callback_url"`
Nonce string `json:"nonce"`
CodeChallenge string `json:"codeChallenge"`
CodeChallengeMethod string `json:"codeChallengeMethod"`
ReauthenticationToken string `json:"reauthenticationToken"`
Prompt string `json:"prompt"`
}
type AuthorizeOidcClientResponseDto struct {
@@ -98,7 +99,8 @@ type OidcCreateTokensDto struct {
}
type OidcIntrospectDto struct {
Token string `form:"token" binding:"required"`
Token string `form:"token" binding:"required"`
ClientID string `form:"client_id"`
}
type OidcUpdateAllowedUserGroupsDto struct {

View File

@@ -3,8 +3,7 @@ package dto
import "github.com/pocket-id/pocket-id/backend/internal/utils"
type OneTimeAccessTokenCreateDto struct {
UserID string `json:"userId"`
TTL utils.JSONDuration `json:"ttl" binding:"ttl"`
TTL utils.JSONDuration `json:"ttl" binding:"ttl"`
}
type OneTimeAccessEmailAsUnauthenticatedUserDto struct {

View File

@@ -1,9 +1,9 @@
package dto
type SignUpDto struct {
Username string `json:"username" binding:"required,username,min=2,max=50" unorm:"nfc"`
Username string `json:"username" binding:"required,username,min=1,max=50" unorm:"nfc"`
Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"`
FirstName string `json:"firstName" binding:"required,min=1,max=50" unorm:"nfc"`
FirstName string `json:"firstName" binding:"max=50" unorm:"nfc"`
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
Token string `json:"token"`
}

View File

@@ -23,12 +23,12 @@ type UserDto struct {
}
type UserCreateDto struct {
Username string `json:"username" binding:"required,username,min=2,max=50" unorm:"nfc"`
Username string `json:"username" binding:"required,username,min=1,max=50" unorm:"nfc"`
Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"`
EmailVerified bool `json:"emailVerified"`
FirstName string `json:"firstName" binding:"required,min=1,max=50" unorm:"nfc"`
FirstName string `json:"firstName" binding:"max=50" unorm:"nfc"`
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
DisplayName string `json:"displayName" binding:"required,min=1,max=100" unorm:"nfc"`
DisplayName string `json:"displayName" binding:"max=100" unorm:"nfc"`
IsAdmin bool `json:"isAdmin"`
Locale *string `json:"locale"`
Disabled bool `json:"disabled"`

View File

@@ -33,14 +33,24 @@ func TestUserCreateDto_Validate(t *testing.T) {
},
wantErr: "Field validation for 'Username' failed on the 'required' tag",
},
{
name: "missing first name",
input: UserCreateDto{
Username: "testuser",
Email: new("test@example.com"),
LastName: "Doe",
},
wantErr: "",
},
{
name: "missing display name",
input: UserCreateDto{
Username: "testuser",
Email: new("test@example.com"),
FirstName: "John",
LastName: "Doe",
},
wantErr: "Field validation for 'DisplayName' failed on the 'required' tag",
wantErr: "",
},
{
name: "username contains invalid characters",
@@ -73,7 +83,7 @@ func TestUserCreateDto_Validate(t *testing.T) {
LastName: "Doe",
DisplayName: "John Doe",
},
wantErr: "Field validation for 'FirstName' failed on the 'required' tag",
wantErr: "",
},
{
name: "last name too long",

View File

@@ -15,43 +15,44 @@ import (
// [a-zA-Z0-9] : The username must start with an alphanumeric character
// [a-zA-Z0-9_.@-]* : The rest of the username can contain alphanumeric characters, dots, underscores, hyphens, and "@" symbols
// [a-zA-Z0-9]$ : The username must end with an alphanumeric character
var validateUsernameRegex = regexp.MustCompile("^[a-zA-Z0-9][a-zA-Z0-9_.@-]*[a-zA-Z0-9]$")
// (...)? : This allows single-character usernames (just one alphanumeric character)
var validateUsernameRegex = regexp.MustCompile("^[a-zA-Z0-9]([a-zA-Z0-9_.@-]*[a-zA-Z0-9])?$")
var validateClientIDRegex = regexp.MustCompile("^[a-zA-Z0-9._-]+$")
func init() {
v := binding.Validator.Engine().(*validator.Validate)
engine := binding.Validator.Engine().(*validator.Validate)
// Maximum allowed value for TTLs
const maxTTL = 31 * 24 * time.Hour
if err := v.RegisterValidation("username", func(fl validator.FieldLevel) bool {
return ValidateUsername(fl.Field().String())
}); err != nil {
panic("Failed to register custom validation for username: " + err.Error())
validators := map[string]validator.Func{
"username": func(fl validator.FieldLevel) bool {
return ValidateUsername(fl.Field().String())
},
"client_id": func(fl validator.FieldLevel) bool {
return ValidateClientID(fl.Field().String())
},
"ttl": func(fl validator.FieldLevel) bool {
ttl, ok := fl.Field().Interface().(utils.JSONDuration)
if !ok {
return false
}
// Allow zero, which means the field wasn't set
return ttl.Duration == 0 || (ttl.Duration > time.Second && ttl.Duration <= maxTTL)
},
"callback_url": func(fl validator.FieldLevel) bool {
return ValidateCallbackURL(fl.Field().String())
},
"callback_url_pattern": func(fl validator.FieldLevel) bool {
return ValidateCallbackURLPattern(fl.Field().String())
},
}
if err := v.RegisterValidation("client_id", func(fl validator.FieldLevel) bool {
return ValidateClientID(fl.Field().String())
}); err != nil {
panic("Failed to register custom validation for client_id: " + err.Error())
}
if err := v.RegisterValidation("ttl", func(fl validator.FieldLevel) bool {
ttl, ok := fl.Field().Interface().(utils.JSONDuration)
if !ok {
return false
for k, v := range validators {
err := engine.RegisterValidation(k, v)
if err != nil {
panic("Failed to register custom validation for " + k + ": " + err.Error())
}
// Allow zero, which means the field wasn't set
return ttl.Duration == 0 || (ttl.Duration > time.Second && ttl.Duration <= maxTTL)
}); err != nil {
panic("Failed to register custom validation for ttl: " + err.Error())
}
if err := v.RegisterValidation("callback_url", func(fl validator.FieldLevel) bool {
return ValidateCallbackURL(fl.Field().String())
}); err != nil {
panic("Failed to register custom validation for callback_url: " + err.Error())
}
}
@@ -65,21 +66,24 @@ func ValidateClientID(clientID string) bool {
return validateClientIDRegex.MatchString(clientID)
}
// ValidateCallbackURL validates callback URLs with support for wildcards
func ValidateCallbackURL(raw string) bool {
// Don't validate if it contains a wildcard
if strings.Contains(raw, "*") {
return true
}
u, err := url.Parse(raw)
// ValidateCallbackURL validates the input callback URL
func ValidateCallbackURL(str string) bool {
// Ensure the URL is a valid one and that the protocol is not "javascript:" or "data:"
u, err := url.Parse(str)
if err != nil {
return false
}
if !u.IsAbs() {
switch strings.ToLower(u.Scheme) {
case "javascript", "data":
return false
default:
return true
}
return true
}
// ValidateCallbackURLPattern validates callback URL patterns, with support for wildcards
func ValidateCallbackURLPattern(raw string) bool {
err := utils.ValidateCallbackURLPattern(raw)
return err == nil
}

View File

@@ -20,6 +20,7 @@ func TestValidateUsername(t *testing.T) {
{"starts with symbol", ".username", false},
{"ends with non-alphanumeric", "username-", false},
{"contains space", "user name", false},
{"valid single char", "a", true},
{"empty", "", false},
{"only special chars", "-._@", false},
{"valid long", "a1234567890_b.c-d@e", true},
@@ -56,3 +57,28 @@ func TestValidateClientID(t *testing.T) {
})
}
}
func TestValidateCallbackURL(t *testing.T) {
tests := []struct {
name string
input string
expected bool
}{
{"valid https URL", "https://example.com/callback", true},
{"valid loopback URL", "http://127.0.0.1:49813/callback", true},
{"empty scheme", "//127.0.0.1:49813/callback", true},
{"valid custom scheme", "pocketid://callback", true},
{"invalid malformed URL", "http://[::1", false},
{"invalid missing scheme separator", "://example.com/callback", false},
{"rejects javascript scheme", "javascript:alert(1)", false},
{"rejects mixed case javascript scheme", "JavaScript:alert(1)", false},
{"rejects data scheme", "data:text/html;base64,PGgxPkhlbGxvPC9oMT4=", false},
{"rejects mixed case data scheme", "DaTa:text/html;base64,PGgxPkhlbGxvPC9oMT4=", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expected, ValidateCallbackURL(tt.input))
})
}
}

View File

@@ -10,7 +10,7 @@ type WebauthnCredentialDto struct {
Name string `json:"name"`
CredentialID string `json:"credentialID"`
AttestationType string `json:"attestationType"`
Transport []protocol.AuthenticatorTransport `json:"transport"`
Transport []protocol.AuthenticatorTransport `json:"transport" swaggertype:"array,string"`
BackupEligible bool `json:"backupEligible"`
BackupState bool `json:"backupState"`

View File

@@ -28,7 +28,7 @@ func (s *Scheduler) RegisterAnalyticsJob(ctx context.Context, appConfig *service
appConfig: appConfig,
httpClient: httpClient,
}
return s.RegisterJob(ctx, "SendHeartbeat", gocron.DurationJob(24*time.Hour), jobs.sendHeartbeat, true)
return s.RegisterJob(ctx, "SendHeartbeat", gocron.DurationJob(24*time.Hour), jobs.sendHeartbeat, service.RegisterJobOpts{RunImmediately: true})
}
type AnalyticsJob struct {

View File

@@ -22,7 +22,7 @@ func (s *Scheduler) RegisterApiKeyExpiryJob(ctx context.Context, apiKeyService *
}
// Send every day at midnight
return s.RegisterJob(ctx, "ExpiredApiKeyEmailJob", gocron.CronJob("0 0 * * *", false), jobs.checkAndNotifyExpiringApiKeys, false)
return s.RegisterJob(ctx, "ExpiredApiKeyEmailJob", gocron.CronJob("0 0 * * *", false), jobs.checkAndNotifyExpiringApiKeys, service.RegisterJobOpts{})
}
func (j *ApiKeyEmailJobs) checkAndNotifyExpiringApiKeys(ctx context.Context) error {
@@ -42,7 +42,11 @@ func (j *ApiKeyEmailJobs) checkAndNotifyExpiringApiKeys(ctx context.Context) err
}
err = j.apiKeyService.SendApiKeyExpiringSoonEmail(ctx, key)
if err != nil {
slog.ErrorContext(ctx, "Failed to send expiring API key notification email", slog.String("key", key.ID), slog.Any("error", err))
slog.ErrorContext(ctx, "Failed to send expiring API key notification email",
slog.String("key", key.ID),
slog.String("user", key.User.ID),
slog.Any("error", err),
)
}
}
return nil

View File

@@ -7,28 +7,37 @@ import (
"log/slog"
"time"
"github.com/go-co-op/gocron/v2"
backoff "github.com/cenkalti/backoff/v5"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/service"
)
func (s *Scheduler) RegisterDbCleanupJobs(ctx context.Context, db *gorm.DB) error {
jobs := &DbCleanupJobs{db: db}
// Run every 24 hours (but with some jitter so they don't run at the exact same time), and now
def := gocron.DurationRandomJob(24*time.Hour-2*time.Minute, 24*time.Hour+2*time.Minute)
newBackOff := func() *backoff.ExponentialBackOff {
bo := backoff.NewExponentialBackOff()
bo.Multiplier = 4
bo.RandomizationFactor = 0.1
bo.InitialInterval = time.Second
bo.MaxInterval = 45 * time.Second
return bo
}
// Use exponential backoff for each DB cleanup job so transient query failures are retried automatically rather than causing an immediate job failure
return errors.Join(
s.RegisterJob(ctx, "ClearWebauthnSessions", def, jobs.clearWebauthnSessions, true),
s.RegisterJob(ctx, "ClearOneTimeAccessTokens", def, jobs.clearOneTimeAccessTokens, true),
s.RegisterJob(ctx, "ClearSignupTokens", def, jobs.clearSignupTokens, true),
s.RegisterJob(ctx, "ClearEmailVerificationTokens", def, jobs.clearEmailVerificationTokens, true),
s.RegisterJob(ctx, "ClearOidcAuthorizationCodes", def, jobs.clearOidcAuthorizationCodes, true),
s.RegisterJob(ctx, "ClearOidcRefreshTokens", def, jobs.clearOidcRefreshTokens, true),
s.RegisterJob(ctx, "ClearReauthenticationTokens", def, jobs.clearReauthenticationTokens, true),
s.RegisterJob(ctx, "ClearAuditLogs", def, jobs.clearAuditLogs, true),
s.RegisterJob(ctx, "ClearWebauthnSessions", jobDefWithJitter(24*time.Hour), jobs.clearWebauthnSessions, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
s.RegisterJob(ctx, "ClearOneTimeAccessTokens", jobDefWithJitter(24*time.Hour), jobs.clearOneTimeAccessTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
s.RegisterJob(ctx, "ClearSignupTokens", jobDefWithJitter(24*time.Hour), jobs.clearSignupTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
s.RegisterJob(ctx, "ClearEmailVerificationTokens", jobDefWithJitter(24*time.Hour), jobs.clearEmailVerificationTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
s.RegisterJob(ctx, "ClearOidcAuthorizationCodes", jobDefWithJitter(24*time.Hour), jobs.clearOidcAuthorizationCodes, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
s.RegisterJob(ctx, "ClearOidcRefreshTokens", jobDefWithJitter(24*time.Hour), jobs.clearOidcRefreshTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
s.RegisterJob(ctx, "ClearReauthenticationTokens", jobDefWithJitter(24*time.Hour), jobs.clearReauthenticationTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
s.RegisterJob(ctx, "ClearAuditLogs", jobDefWithJitter(24*time.Hour), jobs.clearAuditLogs, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
)
}

View File

@@ -13,20 +13,26 @@ import (
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/storage"
)
func (s *Scheduler) RegisterFileCleanupJobs(ctx context.Context, db *gorm.DB, fileStorage storage.FileStorage) error {
jobs := &FileCleanupJobs{db: db, fileStorage: fileStorage}
err := s.RegisterJob(ctx, "ClearUnusedDefaultProfilePictures", gocron.DurationJob(24*time.Hour), jobs.clearUnusedDefaultProfilePictures, false)
var errs []error
errs = append(errs,
s.RegisterJob(ctx, "ClearUnusedDefaultProfilePictures", gocron.DurationJob(24*time.Hour), jobs.clearUnusedDefaultProfilePictures, service.RegisterJobOpts{}),
)
// Only necessary for file system storage
if fileStorage.Type() == storage.TypeFileSystem {
err = errors.Join(err, s.RegisterJob(ctx, "ClearOrphanedTempFiles", gocron.DurationJob(12*time.Hour), jobs.clearOrphanedTempFiles, true))
errs = append(errs,
s.RegisterJob(ctx, "ClearOrphanedTempFiles", gocron.DurationJob(12*time.Hour), jobs.clearOrphanedTempFiles, service.RegisterJobOpts{RunImmediately: true}),
)
}
return err
return errors.Join(errs...)
}
type FileCleanupJobs struct {
@@ -68,7 +74,8 @@ func (j *FileCleanupJobs) clearUnusedDefaultProfilePictures(ctx context.Context)
// If these initials aren't used by any user, delete the file
if _, ok := initialsInUse[initials]; !ok {
filePath := path.Join(defaultPicturesDir, filename)
if err := j.fileStorage.Delete(ctx, filePath); err != nil {
err = j.fileStorage.Delete(ctx, filePath)
if err != nil {
slog.ErrorContext(ctx, "Failed to delete unused default profile picture", slog.String("path", filePath), slog.Any("error", err))
} else {
filesDeleted++
@@ -95,8 +102,9 @@ func (j *FileCleanupJobs) clearOrphanedTempFiles(ctx context.Context) error {
return nil
}
if err := j.fileStorage.Delete(ctx, p.Path); err != nil {
slog.ErrorContext(ctx, "Failed to delete temp file", slog.String("path", p.Path), slog.Any("error", err))
rErr := j.fileStorage.Delete(ctx, p.Path)
if rErr != nil {
slog.ErrorContext(ctx, "Failed to delete temp file", slog.String("path", p.Path), slog.Any("error", rErr))
return nil
}
deleted++

View File

@@ -23,7 +23,7 @@ func (s *Scheduler) RegisterGeoLiteUpdateJobs(ctx context.Context, geoLiteServic
jobs := &GeoLiteUpdateJobs{geoLiteService: geoLiteService}
// Run every 24 hours (and right away)
return s.RegisterJob(ctx, "UpdateGeoLiteDB", gocron.DurationJob(24*time.Hour), jobs.updateGoeLiteDB, true)
return s.RegisterJob(ctx, "UpdateGeoLiteDB", gocron.DurationJob(24*time.Hour), jobs.updateGoeLiteDB, service.RegisterJobOpts{RunImmediately: true})
}
func (j *GeoLiteUpdateJobs) updateGoeLiteDB(ctx context.Context) error {

View File

@@ -4,8 +4,6 @@ import (
"context"
"time"
"github.com/go-co-op/gocron/v2"
"github.com/pocket-id/pocket-id/backend/internal/service"
)
@@ -17,8 +15,8 @@ type LdapJobs struct {
func (s *Scheduler) RegisterLdapJobs(ctx context.Context, ldapService *service.LdapService, appConfigService *service.AppConfigService) error {
jobs := &LdapJobs{ldapService: ldapService, appConfigService: appConfigService}
// Register the job to run every hour
return s.RegisterJob(ctx, "SyncLdap", gocron.DurationJob(time.Hour), jobs.syncLdap, true)
// Register the job to run every hour (with some jitter)
return s.RegisterJob(ctx, "SyncLdap", jobDefWithJitter(time.Hour), jobs.syncLdap, service.RegisterJobOpts{RunImmediately: true})
}
func (j *LdapJobs) syncLdap(ctx context.Context) error {

View File

@@ -5,9 +5,13 @@ import (
"errors"
"fmt"
"log/slog"
"time"
backoff "github.com/cenkalti/backoff/v5"
"github.com/go-co-op/gocron/v2"
"github.com/google/uuid"
"github.com/pocket-id/pocket-id/backend/internal/service"
)
type Scheduler struct {
@@ -33,16 +37,12 @@ func (s *Scheduler) RemoveJob(name string) error {
if job.Name() == name {
err := s.scheduler.RemoveJob(job.ID())
if err != nil {
errs = append(errs, fmt.Errorf("failed to unqueue job %q with ID %q: %w", name, job.ID().String(), err))
errs = append(errs, fmt.Errorf("failed to dequeue job %q with ID %q: %w", name, job.ID().String(), err))
}
}
}
if len(errs) > 0 {
return errors.Join(errs...)
}
return nil
return errors.Join(errs...)
}
// Run the scheduler.
@@ -64,7 +64,29 @@ func (s *Scheduler) Run(ctx context.Context) error {
return nil
}
func (s *Scheduler) RegisterJob(ctx context.Context, name string, def gocron.JobDefinition, job func(ctx context.Context) error, runImmediately bool, extraOptions ...gocron.JobOption) error {
func (s *Scheduler) RegisterJob(ctx context.Context, name string, def gocron.JobDefinition, jobFn func(ctx context.Context) error, opts service.RegisterJobOpts) error {
// If a BackOff strategy is provided, wrap the job with retry logic
if opts.BackOff != nil {
origJob := jobFn
jobFn = func(ctx context.Context) error {
_, err := backoff.Retry(
ctx,
func() (struct{}, error) {
return struct{}{}, origJob(ctx)
},
backoff.WithBackOff(opts.BackOff),
backoff.WithNotify(func(err error, d time.Duration) {
slog.WarnContext(ctx, "Job failed, retrying",
slog.String("name", name),
slog.Any("error", err),
slog.Duration("retryIn", d),
)
}),
)
return err
}
}
jobOptions := []gocron.JobOption{
gocron.WithContext(ctx),
gocron.WithName(name),
@@ -91,13 +113,13 @@ func (s *Scheduler) RegisterJob(ctx context.Context, name string, def gocron.Job
),
}
if runImmediately {
if opts.RunImmediately {
jobOptions = append(jobOptions, gocron.JobOption(gocron.WithStartImmediately()))
}
jobOptions = append(jobOptions, extraOptions...)
jobOptions = append(jobOptions, opts.ExtraOptions...)
_, err := s.scheduler.NewJob(def, gocron.NewTask(job), jobOptions...)
_, err := s.scheduler.NewJob(def, gocron.NewTask(jobFn), jobOptions...)
if err != nil {
return fmt.Errorf("failed to register job %q: %w", name, err)
@@ -105,3 +127,9 @@ func (s *Scheduler) RegisterJob(ctx context.Context, name string, def gocron.Job
return nil
}
func jobDefWithJitter(interval time.Duration) gocron.JobDefinition {
const jitter = 5 * time.Minute
return gocron.DurationRandomJob(interval-jitter, interval+jitter)
}

View File

@@ -16,8 +16,8 @@ type ScimJobs struct {
func (s *Scheduler) RegisterScimJobs(ctx context.Context, scimService *service.ScimService) error {
jobs := &ScimJobs{scimService: scimService}
// Register the job to run every hour
return s.RegisterJob(ctx, "SyncScim", gocron.DurationJob(time.Hour), jobs.SyncScim, true)
// Register the job to run every hour (with some jitter)
return s.RegisterJob(ctx, "SyncScim", gocron.DurationJob(time.Hour), jobs.SyncScim, service.RegisterJobOpts{RunImmediately: true})
}
func (j *ScimJobs) SyncScim(ctx context.Context) error {

View File

@@ -74,10 +74,11 @@ func (m *AuthMiddleware) WithApiKeyAuthDisabled() *AuthMiddleware {
func (m *AuthMiddleware) Add() gin.HandlerFunc {
return func(c *gin.Context) {
userID, isAdmin, err := m.jwtMiddleware.Verify(c, m.options.AdminRequired)
userID, isAdmin, authenticationMethod, err := m.jwtMiddleware.Verify(c, m.options.AdminRequired)
if err == nil {
c.Set("userID", userID)
c.Set("userIsAdmin", isAdmin)
c.Set("authenticationMethod", authenticationMethod)
if c.IsAborted() {
return
}

View File

@@ -44,7 +44,7 @@ func TestWithApiKeyAuthDisabled(t *testing.T) {
authMiddleware := NewAuthMiddleware(apiKeyService, userService, jwtService)
user := createUserForAuthMiddlewareTest(t, db)
jwtToken, err := jwtService.GenerateAccessToken(user)
jwtToken, err := jwtService.GenerateAccessToken(user, "")
require.NoError(t, err)
_, apiKeyToken, err := apiKeyService.CreateApiKey(t.Context(), user.ID, dto.ApiKeyCreateDto{
@@ -60,7 +60,7 @@ func TestWithApiKeyAuthDisabled(t *testing.T) {
})
t.Run("rejects API key auth when API key auth is disabled", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/protected", nil)
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/protected", nil)
req.Header.Set("X-API-Key", apiKeyToken)
recorder := httptest.NewRecorder()
@@ -75,7 +75,7 @@ func TestWithApiKeyAuthDisabled(t *testing.T) {
})
t.Run("allows JWT auth when API key auth is disabled", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/protected", nil)
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/protected", nil)
req.Header.Set("Authorization", "Bearer "+jwtToken)
recorder := httptest.NewRecorder()

View File

@@ -18,7 +18,7 @@ func TestCacheControlMiddlewareSetsDefault(t *testing.T) {
c.Status(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/test", http.NoBody)
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/test", http.NoBody)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
@@ -36,7 +36,7 @@ func TestCacheControlMiddlewarePreservesExistingHeader(t *testing.T) {
c.Status(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/custom", http.NoBody)
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/custom", http.NoBody)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)

View File

@@ -20,7 +20,7 @@ func NewJwtAuthMiddleware(jwtService *service.JwtService, userService *service.U
func (m *JwtAuthMiddleware) Add(adminRequired bool) gin.HandlerFunc {
return func(c *gin.Context) {
userID, isAdmin, err := m.Verify(c, adminRequired)
userID, isAdmin, authenticationMethod, err := m.Verify(c, adminRequired)
if err != nil {
c.Abort()
_ = c.Error(err)
@@ -29,11 +29,12 @@ func (m *JwtAuthMiddleware) Add(adminRequired bool) gin.HandlerFunc {
c.Set("userID", userID)
c.Set("userIsAdmin", isAdmin)
c.Set("authenticationMethod", authenticationMethod)
c.Next()
}
}
func (m *JwtAuthMiddleware) Verify(c *gin.Context, adminRequired bool) (subject string, isAdmin bool, err error) {
func (m *JwtAuthMiddleware) Verify(c *gin.Context, adminRequired bool) (subject string, isAdmin bool, authenticationMethod string, err error) {
// Extract the token from the cookie
accessToken, err := c.Cookie(cookie.AccessTokenCookieName)
if err != nil {
@@ -41,33 +42,37 @@ func (m *JwtAuthMiddleware) Verify(c *gin.Context, adminRequired bool) (subject
var ok bool
_, accessToken, ok = strings.Cut(c.GetHeader("Authorization"), " ")
if !ok || accessToken == "" {
return "", false, &common.NotSignedInError{}
return "", false, "", &common.NotSignedInError{}
}
}
token, err := m.jwtService.VerifyAccessToken(accessToken)
if err != nil {
return "", false, &common.NotSignedInError{}
return "", false, "", &common.NotSignedInError{}
}
authenticationMethod, err = service.GetAuthenticationMethod(token)
if err != nil {
return "", false, "", &common.NotSignedInError{}
}
subject, ok := token.Subject()
if !ok {
_ = c.Error(&common.TokenInvalidError{})
return
return "", false, "", &common.TokenInvalidError{}
}
user, err := m.userService.GetUser(c, subject)
if err != nil {
return "", false, &common.NotSignedInError{}
return "", false, "", &common.NotSignedInError{}
}
if user.Disabled {
return "", false, &common.UserDisabledError{}
return "", false, "", &common.UserDisabledError{}
}
if adminRequired && !user.IsAdmin {
return "", false, &common.MissingPermissionError{}
return "", false, "", &common.MissingPermissionError{}
}
return subject, isAdmin, nil
return subject, user.IsAdmin, authenticationMethod, nil
}

View File

@@ -33,6 +33,7 @@ type OidcAuthorizationCode struct {
Code string
Scope string
AuthenticationMethod string
Nonce string
CodeChallenge *string
CodeChallengeMethodSha256 *bool
@@ -77,9 +78,10 @@ func (c OidcClient) HasDarkLogo() bool {
type OidcRefreshToken struct {
Base
Token string
ExpiresAt datatype.DateTime
Scope string
Token string
ExpiresAt datatype.DateTime
Scope string
AuthenticationMethod string
UserID string
User User
@@ -141,12 +143,13 @@ func (cu UrlList) Value() (driver.Value, error) {
type OidcDeviceCode struct {
Base
DeviceCode string
UserCode string
Scope string
Nonce string
ExpiresAt datatype.DateTime
IsAuthorized bool
DeviceCode string
UserCode string
Scope string
AuthenticationMethod string
Nonce string
ExpiresAt datatype.DateTime
IsAuthorized bool
UserID *string
User User

View File

@@ -39,7 +39,7 @@ func (u User) WebAuthnDisplayName() string {
if u.DisplayName != "" {
return u.DisplayName
}
return u.FirstName + " " + u.LastName
return u.FullName()
}
func (u User) WebAuthnIcon() string { return "" }
@@ -76,7 +76,16 @@ func (u User) WebAuthnCredentialDescriptors() (descriptors []protocol.Credential
}
func (u User) FullName() string {
return u.FirstName + " " + u.LastName
fullname := strings.TrimSpace(u.FirstName + " " + u.LastName)
if fullname != "" {
return fullname
}
if u.DisplayName != "" {
return u.DisplayName
}
return u.Username
}
func (u User) Initials() string {

View File

@@ -3,6 +3,7 @@ package service
import (
"context"
"errors"
"fmt"
"time"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
@@ -205,36 +206,33 @@ func (s *ApiKeyService) ListExpiringApiKeys(ctx context.Context, daysAhead int)
}
func (s *ApiKeyService) SendApiKeyExpiringSoonEmail(ctx context.Context, apiKey model.ApiKey) error {
user := apiKey.User
if user.ID == "" {
if err := s.db.WithContext(ctx).First(&user, "id = ?", apiKey.UserID).Error; err != nil {
return err
}
}
if user.Email == nil {
if apiKey.User.Email == nil {
return &common.UserEmailNotSetError{}
}
err := SendEmail(ctx, s.emailService, email.Address{
Name: user.FullName(),
Email: *user.Email,
Name: apiKey.User.FullName(),
Email: *apiKey.User.Email,
}, ApiKeyExpiringSoonTemplate, &ApiKeyExpiringSoonTemplateData{
ApiKeyName: apiKey.Name,
ExpiresAt: apiKey.ExpiresAt.ToTime(),
Name: user.FirstName,
Name: apiKey.User.FirstName,
})
if err != nil {
return err
return fmt.Errorf("error sending notification email: %w", err)
}
// Mark the API key as having had an expiration email sent
return s.db.WithContext(ctx).
err = s.db.WithContext(ctx).
Model(&model.ApiKey{}).
Where("id = ?", apiKey.ID).
Update("expiration_email_sent", true).
Error
if err != nil {
return fmt.Errorf("error recording expiration sent email in database: %w", err)
}
return nil
}
func (s *ApiKeyService) initStaticApiKeyUser(ctx context.Context) (user model.User, err error) {

View File

@@ -265,9 +265,9 @@ func (s *AppConfigService) UpdateAppConfigValues(ctx context.Context, keysAndVal
// We update the in-memory data (in the cfg struct) and collect values to update in the database
// (Note the += 2, as we are iterating through key-value pairs)
dbUpdate := make([]model.AppConfigVariable, 0, len(keysAndValues)/2)
for i := 0; i < len(keysAndValues); i += 2 {
key := keysAndValues[i]
value := keysAndValues[i+1]
for i := 1; i < len(keysAndValues); i += 2 {
key := keysAndValues[i-1]
value := keysAndValues[i]
// Ensure that the field is valid
// We do this by grabbing the default value
@@ -408,6 +408,7 @@ func (s *AppConfigService) loadDbConfigFromEnv(ctx context.Context, tx *gorm.DB)
if attrs == "sensitive" {
fileName := os.Getenv(envVarName + "_FILE")
if fileName != "" {
// #nosec G703 - Value is provided by admin
b, err := os.ReadFile(fileName)
if err != nil {
return nil, fmt.Errorf("failed to read secret '%s' from file '%s': %w", envVarName, fileName, err)

View File

@@ -104,7 +104,7 @@ func newFileHeader(t *testing.T, filename string, content []byte) *multipart.Fil
require.NoError(t, writer.Close())
req := httptest.NewRequest(http.MethodPost, "/", body)
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
_, fileHeader, err := req.FormFile("file")

View File

@@ -73,7 +73,10 @@ func (lv *lockValue) Unmarshal(raw string) error {
// Acquire obtains the lock. When force is true, the lock is stolen from any existing owner.
// If the lock is forcefully acquired, it blocks until the previous lock has expired.
func (s *AppLockService) Acquire(ctx context.Context, force bool) (waitUntil time.Time, err error) {
tx := s.db.Begin()
tx := s.db.WithContext(ctx).Begin()
if tx.Error != nil {
return time.Time{}, fmt.Errorf("begin lock transaction: %w", tx.Error)
}
defer func() {
tx.Rollback()
}()
@@ -93,7 +96,8 @@ func (s *AppLockService) Acquire(ctx context.Context, force bool) (waitUntil tim
var prevLock lockValue
if prevLockRaw != "" {
if err := prevLock.Unmarshal(prevLockRaw); err != nil {
err = prevLock.Unmarshal(prevLockRaw)
if err != nil {
return time.Time{}, fmt.Errorf("decode existing lock value: %w", err)
}
}
@@ -139,7 +143,8 @@ func (s *AppLockService) Acquire(ctx context.Context, force bool) (waitUntil tim
return time.Time{}, fmt.Errorf("lock acquisition failed: %w", res.Error)
}
if err := tx.Commit().Error; err != nil {
err = tx.Commit().Error
if err != nil {
return time.Time{}, fmt.Errorf("commit lock acquisition: %w", err)
}
@@ -174,7 +179,8 @@ func (s *AppLockService) RunRenewal(ctx context.Context) error {
case <-ctx.Done():
return nil
case <-ticker.C:
if err := s.renew(ctx); err != nil {
err := s.renew(ctx)
if err != nil {
return fmt.Errorf("renew lock: %w", err)
}
}
@@ -183,33 +189,43 @@ func (s *AppLockService) RunRenewal(ctx context.Context) error {
// Release releases the lock if it is held by this process.
func (s *AppLockService) Release(ctx context.Context) error {
opCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
db, err := s.db.DB()
if err != nil {
return fmt.Errorf("failed to get DB connection: %w", err)
}
var query string
switch s.db.Name() {
case "sqlite":
query = `
DELETE FROM kv
WHERE key = ?
AND json_extract(value, '$.lock_id') = ?
`
DELETE FROM kv
WHERE key = ?
AND json_extract(value, '$.lock_id') = ?
`
case "postgres":
query = `
DELETE FROM kv
WHERE key = $1
AND value::json->>'lock_id' = $2
`
DELETE FROM kv
WHERE key = $1
AND value::json->>'lock_id' = $2
`
default:
return fmt.Errorf("unsupported database dialect: %s", s.db.Name())
}
res := s.db.WithContext(opCtx).Exec(query, lockKey, s.lockID)
if res.Error != nil {
return fmt.Errorf("release lock failed: %w", res.Error)
opCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
res, err := db.ExecContext(opCtx, query, lockKey, s.lockID)
if err != nil {
return fmt.Errorf("release lock failed: %w", err)
}
if res.RowsAffected == 0 {
count, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("failed to count affected rows: %w", err)
}
if count == 0 {
slog.Warn("Application lock not held by this process, cannot release",
slog.Int64("process_id", s.processID),
slog.String("host_id", s.hostID),
@@ -225,6 +241,11 @@ func (s *AppLockService) Release(ctx context.Context) error {
// renew tries to renew the lock, retrying up to renewRetries times (sleeping 1s between attempts).
func (s *AppLockService) renew(ctx context.Context) error {
db, err := s.db.DB()
if err != nil {
return fmt.Errorf("failed to get DB connection: %w", err)
}
var lastErr error
for attempt := 1; attempt <= renewRetries; attempt++ {
now := time.Now()
@@ -246,42 +267,56 @@ func (s *AppLockService) renew(ctx context.Context) error {
switch s.db.Name() {
case "sqlite":
query = `
UPDATE kv
SET value = ?
WHERE key = ?
AND json_extract(value, '$.lock_id') = ?
AND json_extract(value, '$.expires_at') > ?
`
UPDATE kv
SET value = ?
WHERE key = ?
AND json_extract(value, '$.lock_id') = ?
AND json_extract(value, '$.expires_at') > ?
`
case "postgres":
query = `
UPDATE kv
SET value = $1
WHERE key = $2
AND value::json->>'lock_id' = $3
AND ((value::json->>'expires_at')::bigint > $4)
`
UPDATE kv
SET value = $1
WHERE key = $2
AND value::json->>'lock_id' = $3
AND ((value::json->>'expires_at')::bigint > $4)
`
default:
return fmt.Errorf("unsupported database dialect: %s", s.db.Name())
}
opCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
res := s.db.WithContext(opCtx).Exec(query, raw, lockKey, s.lockID, nowUnix)
res, err := db.ExecContext(opCtx, query, raw, lockKey, s.lockID, nowUnix)
cancel()
switch {
case res.Error != nil:
lastErr = fmt.Errorf("lock renewal failed: %w", res.Error)
case res.RowsAffected == 0:
// Must be after checking res.Error
return ErrLockLost
default:
// Query succeeded, but may have updated 0 rows
if err == nil {
count, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("failed to count affected rows: %w", err)
}
// If no rows were updated, we lost the lock
if count == 0 {
return ErrLockLost
}
// All good
slog.Debug("Renewed application lock",
slog.Int64("process_id", s.processID),
slog.String("host_id", s.hostID),
slog.Duration("duration", time.Since(now)),
)
return nil
}
// If we're here, we have an error that can be retried
slog.Debug("Application lock renewal attempt failed",
slog.Any("error", err),
slog.Duration("duration", time.Since(now)),
)
lastErr = fmt.Errorf("lock renewal failed: %w", err)
// Wait before next attempt or cancel if context is done
if attempt < renewRetries {
select {

View File

@@ -49,6 +49,23 @@ func readLockValue(t *testing.T, db *gorm.DB) lockValue {
return value
}
func lockDatabaseForWrite(t *testing.T, db *gorm.DB) *gorm.DB {
t.Helper()
tx := db.Begin()
require.NoError(t, tx.Error)
// Keep a write transaction open to block other queries.
err := tx.Exec(
`INSERT INTO kv (key, value) VALUES (?, ?) ON CONFLICT(key) DO NOTHING`,
lockKey,
`{"expires_at":0}`,
).Error
require.NoError(t, err)
return tx
}
func TestAppLockServiceAcquire(t *testing.T) {
t.Run("creates new lock when none exists", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
@@ -99,6 +116,66 @@ func TestAppLockServiceAcquire(t *testing.T) {
require.Equal(t, service.hostID, stored.HostID)
require.Greater(t, stored.ExpiresAt, time.Now().Unix())
})
t.Run("force acquisition returns wait duration when stealing active lock", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newTestAppLockService(t, db)
existing := lockValue{
ProcessID: 99,
HostID: "other-host",
LockID: "other-lock-id",
ExpiresAt: time.Now().Add(ttl).Unix(),
}
insertLock(t, db, existing)
waitUntil, err := service.Acquire(context.Background(), true)
require.NoError(t, err)
require.WithinDuration(t, time.Unix(existing.ExpiresAt, 0), waitUntil, time.Second)
})
t.Run("force acquisition does not wait when lock id is unchanged", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newTestAppLockService(t, db)
insertLock(t, db, lockValue{
ProcessID: 99,
HostID: "other-host",
LockID: service.lockID,
ExpiresAt: time.Now().Add(ttl).Unix(),
})
waitUntil, err := service.Acquire(context.Background(), true)
require.NoError(t, err)
require.True(t, waitUntil.IsZero())
})
t.Run("returns error when existing lock value is invalid JSON", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newTestAppLockService(t, db)
raw := "this-is-not-json"
err := db.Create(&model.KV{Key: lockKey, Value: &raw}).Error
require.NoError(t, err)
_, err = service.Acquire(context.Background(), false)
require.ErrorContains(t, err, "decode existing lock value")
})
t.Run("returns context deadline exceeded when database is locked", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newTestAppLockService(t, db)
tx := lockDatabaseForWrite(t, db)
defer tx.Rollback()
ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
defer cancel()
_, err := service.Acquire(ctx, false)
require.ErrorIs(t, err, context.DeadlineExceeded)
require.ErrorContains(t, err, "begin lock transaction")
})
}
func TestAppLockServiceRelease(t *testing.T) {
@@ -134,6 +211,24 @@ func TestAppLockServiceRelease(t *testing.T) {
stored := readLockValue(t, db)
require.Equal(t, existing, stored)
})
t.Run("returns context deadline exceeded when database is locked", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newTestAppLockService(t, db)
_, err := service.Acquire(context.Background(), false)
require.NoError(t, err)
tx := lockDatabaseForWrite(t, db)
defer tx.Rollback()
ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
defer cancel()
err = service.Release(ctx)
require.ErrorIs(t, err, context.DeadlineExceeded)
require.ErrorContains(t, err, "release lock failed")
})
}
func TestAppLockServiceRenew(t *testing.T) {
@@ -186,4 +281,21 @@ func TestAppLockServiceRenew(t *testing.T) {
err = service.renew(context.Background())
require.ErrorIs(t, err, ErrLockLost)
})
t.Run("returns context deadline exceeded when database is locked", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newTestAppLockService(t, db)
_, err := service.Acquire(context.Background(), false)
require.NoError(t, err)
tx := lockDatabaseForWrite(t, db)
defer tx.Rollback()
ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
defer cancel()
err = service.renew(ctx)
require.ErrorIs(t, err, context.DeadlineExceeded)
})
}

View File

@@ -92,7 +92,7 @@ func (s *AuditLogService) CreateNewSignInWithEmail(ctx context.Context, ipAddres
// If the user hasn't logged in from the same device before and email notifications are enabled, send an email
if s.appConfigService.GetDbConfig().EmailLoginNotificationEnabled.IsTrue() && count <= 1 {
// We use a background context here as this is running in a goroutine
// #nosec G118 - We use a background context here as this is running in a goroutine
//nolint:contextcheck
go func() {
span := trace.SpanFromContext(ctx)

View File

@@ -245,20 +245,22 @@ func (s *TestService) SeedDatabase(baseURL string) error {
authCodes := []model.OidcAuthorizationCode{
{
Code: "auth-code",
Scope: "openid profile",
Nonce: "nonce",
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
UserID: users[0].ID,
ClientID: oidcClients[0].ID,
Code: "auth-code",
Scope: "openid profile",
Nonce: "nonce",
AuthenticationMethod: AuthenticationMethodPhishingResistant,
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
UserID: users[0].ID,
ClientID: oidcClients[0].ID,
},
{
Code: "federated",
Scope: "openid profile",
Nonce: "nonce",
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
UserID: users[1].ID,
ClientID: oidcClients[2].ID,
Code: "federated",
Scope: "openid profile",
Nonce: "nonce",
AuthenticationMethod: AuthenticationMethodPhishingResistant,
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
UserID: users[1].ID,
ClientID: oidcClients[3].ID,
},
}
for _, authCode := range authCodes {
@@ -268,11 +270,12 @@ func (s *TestService) SeedDatabase(baseURL string) error {
}
refreshToken := model.OidcRefreshToken{
Token: utils.CreateSha256Hash("ou87UDg249r1StBLYkMEqy9TXDbV5HmGuDpMcZDo"),
ExpiresAt: datatype.DateTime(time.Now().Add(24 * time.Hour)),
Scope: "openid profile email",
UserID: users[0].ID,
ClientID: oidcClients[0].ID,
Token: utils.CreateSha256Hash("ou87UDg249r1StBLYkMEqy9TXDbV5HmGuDpMcZDo"),
AuthenticationMethod: AuthenticationMethodPhishingResistant,
ExpiresAt: datatype.DateTime(time.Now().Add(24 * time.Hour)),
Scope: "openid profile email",
UserID: users[0].ID,
ClientID: oidcClients[0].ID,
}
if err := tx.Create(&refreshToken).Error; err != nil {
return err

View File

@@ -150,7 +150,8 @@ func SendEmail[V any](ctx context.Context, srv *EmailService, toEmail email.Addr
}
// Send the email
if err := srv.sendEmailContent(client, toEmail, c); err != nil {
err = srv.sendEmailContent(client, toEmail, c)
if err != nil {
return fmt.Errorf("send email content: %w", err)
}

View File

@@ -14,6 +14,7 @@ import (
"net/netip"
"os"
"path/filepath"
"strings"
"sync"
"time"
@@ -112,7 +113,11 @@ func (s *GeoLiteService) UpdateDatabase(parentCtx context.Context) error {
}
slog.Info("Updating GeoLite2 City database")
downloadUrl := fmt.Sprintf(common.EnvConfig.GeoLiteDBUrl, common.EnvConfig.MaxMindLicenseKey)
downloadUrl := common.EnvConfig.GeoLiteDBUrl
if strings.Contains(downloadUrl, "%s") {
downloadUrl = fmt.Sprintf(downloadUrl, common.EnvConfig.MaxMindLicenseKey)
}
ctx, cancel := context.WithTimeout(parentCtx, 10*time.Minute)
defer cancel()

View File

@@ -32,6 +32,15 @@ const (
// RefreshTokenClaim is the claim used for the refresh token's value
RefreshTokenClaim = "rt"
// AuthenticationMethodsClaim is the claim used to identify how the user authenticated
AuthenticationMethodsClaim = "amr"
// AuthenticationMethodPhishingResistant identifies phishing-resistant authentication, such as passkeys
AuthenticationMethodPhishingResistant = "phr"
// AuthenticationMethodOneTimePassword identifies one-time password/code authentication
AuthenticationMethodOneTimePassword = "otp"
// OAuthAccessTokenJWTType identifies a JWT as an OAuth access token
OAuthAccessTokenJWTType = "oauth-access-token" //nolint:gosec
@@ -187,7 +196,8 @@ func (s *JwtService) SetKey(privateKey jwk.Key) error {
return nil
}
func (s *JwtService) GenerateAccessToken(user model.User) (string, error) {
func (s *JwtService) GenerateAccessToken(user model.User, authenticationMethod string) (string, error) {
now := time.Now()
token, err := jwt.NewBuilder().
Subject(user.ID).
@@ -215,6 +225,11 @@ func (s *JwtService) GenerateAccessToken(user model.User) (string, error) {
return "", fmt.Errorf("failed to set 'isAdmin' claim in token: %w", err)
}
err = SetAuthenticationMethods(token, authenticationMethod)
if err != nil {
return "", fmt.Errorf("failed to set '%s' claim in token: %w", AuthenticationMethodsClaim, err)
}
alg, _ := s.privateKey.Algorithm()
signed, err := jwt.Sign(token, jwt.WithKey(alg, s.privateKey))
if err != nil {
@@ -243,7 +258,7 @@ func (s *JwtService) VerifyAccessToken(tokenString string) (jwt.Token, error) {
}
// BuildIDToken creates an ID token with all claims
func (s *JwtService) BuildIDToken(userClaims map[string]any, clientID string, nonce string) (jwt.Token, error) {
func (s *JwtService) BuildIDToken(userClaims map[string]any, clientID string, nonce string, authenticationMethod string) (jwt.Token, error) {
now := time.Now()
token, err := jwt.NewBuilder().
Expiration(now.Add(1 * time.Hour)).
@@ -265,6 +280,11 @@ func (s *JwtService) BuildIDToken(userClaims map[string]any, clientID string, no
return nil, fmt.Errorf("failed to set 'type' claim in token: %w", err)
}
err = SetAuthenticationMethods(token, authenticationMethod)
if err != nil {
return nil, fmt.Errorf("failed to set '%s' claim in token: %w", AuthenticationMethodsClaim, err)
}
for k, v := range userClaims {
err = token.Set(k, v)
if err != nil {
@@ -283,8 +303,8 @@ func (s *JwtService) BuildIDToken(userClaims map[string]any, clientID string, no
}
// GenerateIDToken creates and signs an ID token
func (s *JwtService) GenerateIDToken(userClaims map[string]any, clientID string, nonce string) (string, error) {
token, err := s.BuildIDToken(userClaims, clientID, nonce)
func (s *JwtService) GenerateIDToken(userClaims map[string]any, clientID string, nonce string, authenticationMethod string) (string, error) {
token, err := s.BuildIDToken(userClaims, clientID, nonce, authenticationMethod)
if err != nil {
return "", err
}
@@ -332,7 +352,7 @@ func (s *JwtService) VerifyIdToken(tokenString string, acceptExpiredTokens bool)
}
// BuildOAuthAccessToken creates an OAuth access token with all claims
func (s *JwtService) BuildOAuthAccessToken(user model.User, clientID string) (jwt.Token, error) {
func (s *JwtService) BuildOAuthAccessToken(user model.User, clientID string, authenticationMethod string) (jwt.Token, error) {
now := time.Now()
token, err := jwt.NewBuilder().
Subject(user.ID).
@@ -355,12 +375,17 @@ func (s *JwtService) BuildOAuthAccessToken(user model.User, clientID string) (jw
return nil, fmt.Errorf("failed to set 'type' claim in token: %w", err)
}
err = SetAuthenticationMethods(token, authenticationMethod)
if err != nil {
return nil, fmt.Errorf("failed to set '%s' claim in token: %w", AuthenticationMethodsClaim, err)
}
return token, nil
}
// GenerateOAuthAccessToken creates and signs an OAuth access token
func (s *JwtService) GenerateOAuthAccessToken(user model.User, clientID string) (string, error) {
token, err := s.BuildOAuthAccessToken(user, clientID)
func (s *JwtService) GenerateOAuthAccessToken(user model.User, clientID string, authenticationMethod string) (string, error) {
token, err := s.BuildOAuthAccessToken(user, clientID, authenticationMethod)
if err != nil {
return "", err
}
@@ -534,6 +559,27 @@ func GetIsAdmin(token jwt.Token) (bool, error) {
return isAdmin, nil
}
// GetAuthenticationMethod returns the first authentication method in the "amr" claim in the token
func GetAuthenticationMethod(token jwt.Token) (string, error) {
if !token.Has(AuthenticationMethodsClaim) {
return "", nil
}
var rawAuthenticationMethods []any
err := token.Get(AuthenticationMethodsClaim, &rawAuthenticationMethods)
if err != nil {
return "", fmt.Errorf("failed to get '%s' claim from token: %w", AuthenticationMethodsClaim, err)
}
if len(rawAuthenticationMethods) == 0 {
return "", nil
}
authenticationMethod, ok := rawAuthenticationMethods[0].(string)
if !ok {
return "", fmt.Errorf("invalid '%s' claim in token: expected array of strings", AuthenticationMethodsClaim)
}
return authenticationMethod, nil
}
// SetTokenType sets the "type" claim in the token
func SetTokenType(token jwt.Token, tokenType string) error {
if tokenType == "" {
@@ -551,6 +597,14 @@ func SetIsAdmin(token jwt.Token, isAdmin bool) error {
return token.Set(IsAdminClaim, isAdmin)
}
// SetAuthenticationMethods sets the authentication method references claim in the token
func SetAuthenticationMethods(token jwt.Token, authenticationMethod string) error {
if authenticationMethod == "" {
return nil
}
return token.Set(AuthenticationMethodsClaim, []string{authenticationMethod})
}
// SetAudienceString sets the "aud" claim with a value that is a string, and not an array
// This is permitted by RFC 7519, and it's done here for backwards-compatibility
func SetAudienceString(token jwt.Token, audience string) error {

View File

@@ -174,6 +174,7 @@ func TestJwtService_Init(t *testing.T) {
_ = assert.True(t, ok) &&
assert.Equal(t, origKeyID, loadedKeyID, "Loaded key should have the same ID as the original")
})
}
func TestJwtService_GetPublicJWK(t *testing.T) {
@@ -308,7 +309,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
IsAdmin: false,
}
tokenString, err := service.GenerateAccessToken(user)
tokenString, err := service.GenerateAccessToken(user, "")
require.NoError(t, err, "Failed to generate access token")
assert.NotEmpty(t, tokenString, "Token should not be empty")
@@ -321,6 +322,9 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
isAdmin, err := GetIsAdmin(claims)
_ = assert.NoError(t, err, "Failed to get isAdmin claim") &&
assert.False(t, isAdmin, "isAdmin should be false")
authenticationMethod, err := GetAuthenticationMethod(claims)
_ = assert.NoError(t, err, "Failed to get amr claim") &&
assert.Empty(t, authenticationMethod, "amr should be empty when not specified")
audience, ok := claims.Audience()
_ = assert.True(t, ok, "Audience not found in token") &&
assert.Equal(t, []string{service.envConfig.AppURL}, audience, "Audience should contain the app URL")
@@ -344,7 +348,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
IsAdmin: true,
}
tokenString, err := service.GenerateAccessToken(adminUser)
tokenString, err := service.GenerateAccessToken(adminUser, "")
require.NoError(t, err, "Failed to generate access token")
claims, err := service.VerifyAccessToken(tokenString)
@@ -358,6 +362,24 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
assert.Equal(t, adminUser.ID, subject, "Token subject should match user ID")
})
t.Run("sets authentication method references claim when provided", func(t *testing.T) {
service, _, _ := setupJwtService(t, mockConfig)
user := model.User{
Base: model.Base{ID: "user-with-auth-method"},
}
tokenString, err := service.GenerateAccessToken(user, AuthenticationMethodPhishingResistant)
require.NoError(t, err, "Failed to generate access token")
claims, err := service.VerifyAccessToken(tokenString)
require.NoError(t, err, "Failed to verify generated token")
authenticationMethod, err := GetAuthenticationMethod(claims)
_ = assert.NoError(t, err, "Failed to get amr claim") &&
assert.Equal(t, AuthenticationMethodPhishingResistant, authenticationMethod, "amr should match")
})
t.Run("uses session duration from config", func(t *testing.T) {
customMockConfig := NewTestAppConfigService(&model.AppConfig{
SessionDuration: model.AppConfigVariable{Value: "30"}, // 30 minutes
@@ -368,7 +390,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
Base: model.Base{ID: "user456"},
}
tokenString, err := service.GenerateAccessToken(user)
tokenString, err := service.GenerateAccessToken(user, "")
require.NoError(t, err, "Failed to generate access token")
claims, err := service.VerifyAccessToken(tokenString)
@@ -396,7 +418,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
IsAdmin: true,
}
tokenString, err := service.GenerateAccessToken(user)
tokenString, err := service.GenerateAccessToken(user, "")
require.NoError(t, err, "Failed to generate access token with Ed25519 key")
assert.NotEmpty(t, tokenString, "Token should not be empty")
@@ -433,7 +455,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
IsAdmin: true,
}
tokenString, err := service.GenerateAccessToken(user)
tokenString, err := service.GenerateAccessToken(user, "")
require.NoError(t, err, "Failed to generate access token with ECDSA key")
assert.NotEmpty(t, tokenString, "Token should not be empty")
@@ -470,7 +492,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
IsAdmin: true,
}
tokenString, err := service.GenerateAccessToken(user)
tokenString, err := service.GenerateAccessToken(user, "")
require.NoError(t, err, "Failed to generate access token with RSA key")
assert.NotEmpty(t, tokenString, "Token should not be empty")
@@ -508,7 +530,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
}
const clientID = "test-client-123"
tokenString, err := service.GenerateIDToken(userClaims, clientID, "")
tokenString, err := service.GenerateIDToken(userClaims, clientID, "", "")
require.NoError(t, err, "Failed to generate ID token")
assert.NotEmpty(t, tokenString, "Token should not be empty")
@@ -593,7 +615,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
const clientID = "test-client-456"
nonce := "random-nonce-value"
tokenString, err := service.GenerateIDToken(userClaims, clientID, nonce)
tokenString, err := service.GenerateIDToken(userClaims, clientID, nonce, "")
require.NoError(t, err, "Failed to generate ID token with nonce")
publicKey, err := service.GetPublicJWK()
@@ -614,7 +636,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
userClaims := map[string]any{
"sub": "user789",
}
tokenString, err := service.GenerateIDToken(userClaims, "client-789", "")
tokenString, err := service.GenerateIDToken(userClaims, "client-789", "", "")
require.NoError(t, err, "Failed to generate ID token")
service.envConfig.AppURL = "https://wrong-issuer.com"
@@ -640,7 +662,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
}
const clientID = "eddsa-client-123"
tokenString, err := service.GenerateIDToken(userClaims, clientID, "")
tokenString, err := service.GenerateIDToken(userClaims, clientID, "", "")
require.NoError(t, err, "Failed to generate ID token with key")
assert.NotEmpty(t, tokenString, "Token should not be empty")
@@ -677,7 +699,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
}
const clientID = "ecdsa-client-123"
tokenString, err := service.GenerateIDToken(userClaims, clientID, "")
tokenString, err := service.GenerateIDToken(userClaims, clientID, "", "")
require.NoError(t, err, "Failed to generate ID token with key")
assert.NotEmpty(t, tokenString, "Token should not be empty")
@@ -715,7 +737,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
}
const clientID = "rsa-client-123"
tokenString, err := service.GenerateIDToken(userClaims, clientID, "")
tokenString, err := service.GenerateIDToken(userClaims, clientID, "", "")
require.NoError(t, err, "Failed to generate ID token with key")
assert.NotEmpty(t, tokenString, "Token should not be empty")
@@ -745,7 +767,7 @@ func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
}
const clientID = "test-client-123"
tokenString, err := service.GenerateOAuthAccessToken(user, clientID)
tokenString, err := service.GenerateOAuthAccessToken(user, clientID, "")
require.NoError(t, err, "Failed to generate OAuth access token")
assert.NotEmpty(t, tokenString, "Token should not be empty")
@@ -772,6 +794,25 @@ func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
assert.InDelta(t, 0, timeDiff, 1.0, "Token should expire in approximately 1 hour")
})
t.Run("sets authentication method references claim when provided", func(t *testing.T) {
service, _, _ := setupJwtService(t, mockConfig)
user := model.User{
Base: model.Base{ID: "oauth-amr-user"},
}
const clientID = "test-client-amr"
tokenString, err := service.GenerateOAuthAccessToken(user, clientID, AuthenticationMethodPhishingResistant)
require.NoError(t, err, "Failed to generate OAuth access token")
claims, err := service.VerifyOAuthAccessToken(tokenString)
require.NoError(t, err, "Failed to verify generated OAuth access token")
authenticationMethod, err := GetAuthenticationMethod(claims)
_ = assert.NoError(t, err, "Failed to get amr claim") &&
assert.Equal(t, AuthenticationMethodPhishingResistant, authenticationMethod, "amr should match")
})
t.Run("fails verification for expired token", func(t *testing.T) {
service, _, _ := setupJwtService(t, mockConfig)
@@ -805,7 +846,7 @@ func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
user := model.User{Base: model.Base{ID: "user789"}}
const clientID = "test-client-789"
tokenString, err := service1.GenerateOAuthAccessToken(user, clientID)
tokenString, err := service1.GenerateOAuthAccessToken(user, clientID, "")
require.NoError(t, err, "Failed to generate OAuth access token")
_, err = service2.VerifyOAuthAccessToken(tokenString)
@@ -828,7 +869,7 @@ func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
}
const clientID = "eddsa-oauth-client"
tokenString, err := service.GenerateOAuthAccessToken(user, clientID)
tokenString, err := service.GenerateOAuthAccessToken(user, clientID, "")
require.NoError(t, err, "Failed to generate OAuth access token with key")
assert.NotEmpty(t, tokenString, "Token should not be empty")
@@ -865,7 +906,7 @@ func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
}
const clientID = "ecdsa-oauth-client"
tokenString, err := service.GenerateOAuthAccessToken(user, clientID)
tokenString, err := service.GenerateOAuthAccessToken(user, clientID, "")
require.NoError(t, err, "Failed to generate OAuth access token with key")
assert.NotEmpty(t, tokenString, "Token should not be empty")
@@ -902,7 +943,7 @@ func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
}
const clientID = "rsa-oauth-client"
tokenString, err := service.GenerateOAuthAccessToken(user, clientID)
tokenString, err := service.GenerateOAuthAccessToken(user, clientID, "")
require.NoError(t, err, "Failed to generate OAuth access token with key")
assert.NotEmpty(t, tokenString, "Token should not be empty")

View File

@@ -35,6 +35,7 @@ type LdapService struct {
userService *UserService
groupService *UserGroupService
fileStorage storage.FileStorage
clientFactory func() (ldapClient, error)
}
type savePicture struct {
@@ -43,8 +44,33 @@ type savePicture struct {
picture string
}
type ldapDesiredUser struct {
ldapID string
input dto.UserCreateDto
picture string
}
type ldapDesiredGroup struct {
ldapID string
input dto.UserGroupCreateDto
memberUsernames []string
}
type ldapDesiredState struct {
users []ldapDesiredUser
userIDs map[string]struct{}
groups []ldapDesiredGroup
groupIDs map[string]struct{}
}
type ldapClient interface {
Search(searchRequest *ldap.SearchRequest) (*ldap.SearchResult, error)
Bind(username, password string) error
Close() error
}
func NewLdapService(db *gorm.DB, httpClient *http.Client, appConfigService *AppConfigService, userService *UserService, groupService *UserGroupService, fileStorage storage.FileStorage) *LdapService {
return &LdapService{
service := &LdapService{
db: db,
httpClient: httpClient,
appConfigService: appConfigService,
@@ -52,9 +78,12 @@ func NewLdapService(db *gorm.DB, httpClient *http.Client, appConfigService *AppC
groupService: groupService,
fileStorage: fileStorage,
}
service.clientFactory = service.createClient
return service
}
func (s *LdapService) createClient() (*ldap.Conn, error) {
func (s *LdapService) createClient() (ldapClient, error) {
dbConfig := s.appConfigService.GetDbConfig()
if !dbConfig.LdapEnabled.IsTrue() {
@@ -79,24 +108,33 @@ func (s *LdapService) createClient() (*ldap.Conn, error) {
func (s *LdapService) SyncAll(ctx context.Context) error {
// Setup LDAP connection
client, err := s.createClient()
client, err := s.clientFactory()
if err != nil {
return fmt.Errorf("failed to create LDAP client: %w", err)
}
defer client.Close()
// Start a transaction
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
// First, we fetch all users and group from LDAP, which is our "desired state"
desiredState, err := s.fetchDesiredState(ctx, client)
if err != nil {
return fmt.Errorf("failed to fetch LDAP state: %w", err)
}
savePictures, deleteFiles, err := s.SyncUsers(ctx, tx, client)
// Start a transaction
tx := s.db.WithContext(ctx).Begin()
if tx.Error != nil {
return fmt.Errorf("failed to begin database transaction: %w", tx.Error)
}
defer tx.Rollback()
// Reconcile users
savePictures, deleteFiles, err := s.reconcileUsers(ctx, tx, desiredState.users, desiredState.userIDs)
if err != nil {
return fmt.Errorf("failed to sync users: %w", err)
}
err = s.SyncGroups(ctx, tx, client)
// Reconcile groups
err = s.reconcileGroups(ctx, tx, desiredState.groups, desiredState.groupIDs)
if err != nil {
return fmt.Errorf("failed to sync groups: %w", err)
}
@@ -129,10 +167,59 @@ func (s *LdapService) SyncAll(ctx context.Context) error {
return nil
}
//nolint:gocognit
func (s *LdapService) SyncGroups(ctx context.Context, tx *gorm.DB, client *ldap.Conn) error {
func (s *LdapService) fetchDesiredState(ctx context.Context, client ldapClient) (ldapDesiredState, error) {
// Fetch users first so we can use their DNs when resolving group members
users, userIDs, usernamesByDN, err := s.fetchUsersFromLDAP(ctx, client)
if err != nil {
return ldapDesiredState{}, err
}
// Then fetch groups to complete the desired LDAP state snapshot
groups, groupIDs, err := s.fetchGroupsFromLDAP(ctx, client, usernamesByDN)
if err != nil {
return ldapDesiredState{}, err
}
// Apply user admin flags from the desired group membership snapshot.
// This intentionally uses the configured group member attribute rather than
// relying on a user-side reverse-membership attribute such as memberOf.
s.applyAdminGroupMembership(users, groups)
return ldapDesiredState{
users: users,
userIDs: userIDs,
groups: groups,
groupIDs: groupIDs,
}, nil
}
func (s *LdapService) applyAdminGroupMembership(desiredUsers []ldapDesiredUser, desiredGroups []ldapDesiredGroup) {
dbConfig := s.appConfigService.GetDbConfig()
if dbConfig.LdapAdminGroupName.Value == "" {
return
}
adminUsernames := make(map[string]struct{})
for _, group := range desiredGroups {
if group.input.Name != dbConfig.LdapAdminGroupName.Value {
continue
}
for _, username := range group.memberUsernames {
adminUsernames[username] = struct{}{}
}
}
for i := range desiredUsers {
_, isAdmin := adminUsernames[desiredUsers[i].input.Username]
desiredUsers[i].input.IsAdmin = desiredUsers[i].input.IsAdmin || isAdmin
}
}
func (s *LdapService) fetchGroupsFromLDAP(ctx context.Context, client ldapClient, usernamesByDN map[string]string) (desiredGroups []ldapDesiredGroup, ldapGroupIDs map[string]struct{}, err error) {
dbConfig := s.appConfigService.GetDbConfig()
// Query LDAP for all groups we want to manage
searchAttrs := []string{
dbConfig.LdapAttributeGroupName.Value,
dbConfig.LdapAttributeGroupUniqueIdentifier.Value,
@@ -149,90 +236,42 @@ func (s *LdapService) SyncGroups(ctx context.Context, tx *gorm.DB, client *ldap.
)
result, err := client.Search(searchReq)
if err != nil {
return fmt.Errorf("failed to query LDAP: %w", err)
return nil, nil, fmt.Errorf("failed to query LDAP groups: %w", err)
}
// Create a mapping for groups that exist
ldapGroupIDs := make(map[string]struct{}, len(result.Entries))
// Build the in-memory desired state for groups
ldapGroupIDs = make(map[string]struct{}, len(result.Entries))
desiredGroups = make([]ldapDesiredGroup, 0, len(result.Entries))
for _, value := range result.Entries {
ldapId := convertLdapIdToString(value.GetAttributeValue(dbConfig.LdapAttributeGroupUniqueIdentifier.Value))
ldapID := convertLdapIdToString(value.GetAttributeValue(dbConfig.LdapAttributeGroupUniqueIdentifier.Value))
// Skip groups without a valid LDAP ID
if ldapId == "" {
if ldapID == "" {
slog.Warn("Skipping LDAP group without a valid unique identifier", slog.String("attribute", dbConfig.LdapAttributeGroupUniqueIdentifier.Value))
continue
}
ldapGroupIDs[ldapId] = struct{}{}
// Try to find the group in the database
var databaseGroup model.UserGroup
err = tx.
WithContext(ctx).
Where("ldap_id = ?", ldapId).
First(&databaseGroup).
Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
// This could error with ErrRecordNotFound and we want to ignore that here
return fmt.Errorf("failed to query for LDAP group ID '%s': %w", ldapId, err)
}
ldapGroupIDs[ldapID] = struct{}{}
// Get group members and add to the correct Group
groupMembers := value.GetAttributeValues(dbConfig.LdapAttributeGroupMember.Value)
membersUserId := make([]string, 0, len(groupMembers))
memberUsernames := make([]string, 0, len(groupMembers))
for _, member := range groupMembers {
username := getDNProperty(dbConfig.LdapAttributeUserUsername.Value, member)
// If username extraction fails, try to query LDAP directly for the user
username := s.resolveGroupMemberUsername(ctx, client, member, usernamesByDN)
if username == "" {
// Query LDAP to get the user by their DN
userSearchReq := ldap.NewSearchRequest(
member,
ldap.ScopeBaseObject,
0, 0, 0, false,
"(objectClass=*)",
[]string{dbConfig.LdapAttributeUserUsername.Value, dbConfig.LdapAttributeUserUniqueIdentifier.Value},
[]ldap.Control{},
)
userResult, err := client.Search(userSearchReq)
if err != nil || len(userResult.Entries) == 0 {
slog.WarnContext(ctx, "Could not resolve group member DN", slog.String("member", member), slog.Any("error", err))
continue
}
username = userResult.Entries[0].GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value)
if username == "" {
slog.WarnContext(ctx, "Could not extract username from group member DN", slog.String("member", member))
continue
}
}
username = norm.NFC.String(username)
var databaseUser model.User
err = tx.
WithContext(ctx).
Where("username = ? AND ldap_id IS NOT NULL", username).
First(&databaseUser).
Error
if errors.Is(err, gorm.ErrRecordNotFound) {
// The user collides with a non-LDAP user, so we skip it
continue
} else if err != nil {
return fmt.Errorf("failed to query for existing user '%s': %w", username, err)
}
membersUserId = append(membersUserId, databaseUser.ID)
memberUsernames = append(memberUsernames, username)
}
syncGroup := dto.UserGroupCreateDto{
Name: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.Value),
FriendlyName: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.Value),
LdapID: ldapId,
LdapID: ldapID,
}
dto.Normalize(syncGroup)
dto.Normalize(&syncGroup)
err = syncGroup.Validate()
if err != nil {
@@ -240,66 +279,21 @@ func (s *LdapService) SyncGroups(ctx context.Context, tx *gorm.DB, client *ldap.
continue
}
if databaseGroup.ID == "" {
newGroup, err := s.groupService.createInternal(ctx, syncGroup, tx)
if err != nil {
return fmt.Errorf("failed to create group '%s': %w", syncGroup.Name, err)
}
_, err = s.groupService.updateUsersInternal(ctx, newGroup.ID, membersUserId, tx)
if err != nil {
return fmt.Errorf("failed to sync users for group '%s': %w", syncGroup.Name, err)
}
} else {
_, err = s.groupService.updateInternal(ctx, databaseGroup.ID, syncGroup, true, tx)
if err != nil {
return fmt.Errorf("failed to update group '%s': %w", syncGroup.Name, err)
}
_, err = s.groupService.updateUsersInternal(ctx, databaseGroup.ID, membersUserId, tx)
if err != nil {
return fmt.Errorf("failed to sync users for group '%s': %w", syncGroup.Name, err)
}
}
desiredGroups = append(desiredGroups, ldapDesiredGroup{
ldapID: ldapID,
input: syncGroup,
memberUsernames: memberUsernames,
})
}
// Get all LDAP groups from the database
var ldapGroupsInDb []model.UserGroup
err = tx.
WithContext(ctx).
Find(&ldapGroupsInDb, "ldap_id IS NOT NULL").
Select("ldap_id").
Error
if err != nil {
return fmt.Errorf("failed to fetch groups from database: %w", err)
}
// Delete groups that no longer exist in LDAP
for _, group := range ldapGroupsInDb {
if _, exists := ldapGroupIDs[*group.LdapID]; exists {
continue
}
err = tx.
WithContext(ctx).
Delete(&model.UserGroup{}, "ldap_id = ?", group.LdapID).
Error
if err != nil {
return fmt.Errorf("failed to delete group '%s': %w", group.Name, err)
}
slog.Info("Deleted group", slog.String("group", group.Name))
}
return nil
return desiredGroups, ldapGroupIDs, nil
}
//nolint:gocognit
func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.Conn) (savePictures []savePicture, deleteFiles []string, err error) {
func (s *LdapService) fetchUsersFromLDAP(ctx context.Context, client ldapClient) (desiredUsers []ldapDesiredUser, ldapUserIDs map[string]struct{}, usernamesByDN map[string]string, err error) {
dbConfig := s.appConfigService.GetDbConfig()
// Query LDAP for all users we want to manage
searchAttrs := []string{
"memberOf",
"sn",
"cn",
dbConfig.LdapAttributeUserUniqueIdentifier.Value,
@@ -323,59 +317,29 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
result, err := client.Search(searchReq)
if err != nil {
return nil, nil, fmt.Errorf("failed to query LDAP: %w", err)
return nil, nil, nil, fmt.Errorf("failed to query LDAP users: %w", err)
}
// Create a mapping for users that exist
ldapUserIDs := make(map[string]struct{}, len(result.Entries))
savePictures = make([]savePicture, 0, len(result.Entries))
// Build the in-memory desired state for users and a DN lookup for group membership resolution
ldapUserIDs = make(map[string]struct{}, len(result.Entries))
usernamesByDN = make(map[string]string, len(result.Entries))
desiredUsers = make([]ldapDesiredUser, 0, len(result.Entries))
for _, value := range result.Entries {
ldapId := convertLdapIdToString(value.GetAttributeValue(dbConfig.LdapAttributeUserUniqueIdentifier.Value))
username := norm.NFC.String(value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value))
if normalizedDN := normalizeLDAPDN(value.DN); normalizedDN != "" && username != "" {
usernamesByDN[normalizedDN] = username
}
ldapID := convertLdapIdToString(value.GetAttributeValue(dbConfig.LdapAttributeUserUniqueIdentifier.Value))
// Skip users without a valid LDAP ID
if ldapId == "" {
if ldapID == "" {
slog.Warn("Skipping LDAP user without a valid unique identifier", slog.String("attribute", dbConfig.LdapAttributeUserUniqueIdentifier.Value))
continue
}
ldapUserIDs[ldapId] = struct{}{}
// Get the user from the database
var databaseUser model.User
err = tx.
WithContext(ctx).
Where("ldap_id = ?", ldapId).
First(&databaseUser).
Error
// If a user is found (even if disabled), enable them since they're now back in LDAP
if databaseUser.ID != "" && databaseUser.Disabled {
err = tx.
WithContext(ctx).
Model(&model.User{}).
Where("id = ?", databaseUser.ID).
Update("disabled", false).
Error
if err != nil {
return nil, nil, fmt.Errorf("failed to enable user %s: %w", databaseUser.Username, err)
}
}
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
// This could error with ErrRecordNotFound and we want to ignore that here
return nil, nil, fmt.Errorf("failed to query for LDAP user ID '%s': %w", ldapId, err)
}
// Check if user is admin by checking if they are in the admin group
isAdmin := false
for _, group := range value.GetAttributeValues("memberOf") {
if getDNProperty(dbConfig.LdapAttributeGroupName.Value, group) == dbConfig.LdapAdminGroupName.Value {
isAdmin = true
break
}
}
ldapUserIDs[ldapID] = struct{}{}
newUser := dto.UserCreateDto{
Username: value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value),
@@ -384,15 +348,17 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
FirstName: value.GetAttributeValue(dbConfig.LdapAttributeUserFirstName.Value),
LastName: value.GetAttributeValue(dbConfig.LdapAttributeUserLastName.Value),
DisplayName: value.GetAttributeValue(dbConfig.LdapAttributeUserDisplayName.Value),
IsAdmin: isAdmin,
LdapID: ldapId,
// Admin status is computed after groups are loaded so it can use the
// configured group member attribute instead of a hard-coded memberOf.
IsAdmin: false,
LdapID: ldapID,
}
if newUser.DisplayName == "" {
newUser.DisplayName = strings.TrimSpace(newUser.FirstName + " " + newUser.LastName)
}
dto.Normalize(newUser)
dto.Normalize(&newUser)
err = newUser.Validate()
if err != nil {
@@ -400,53 +366,207 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
continue
}
userID := databaseUser.ID
if databaseUser.ID == "" {
createdUser, err := s.userService.createUserInternal(ctx, newUser, true, tx)
if errors.Is(err, &common.AlreadyInUseError{}) {
slog.Warn("Skipping creating LDAP user", slog.String("username", newUser.Username), slog.Any("error", err))
desiredUsers = append(desiredUsers, ldapDesiredUser{
ldapID: ldapID,
input: newUser,
picture: value.GetAttributeValue(dbConfig.LdapAttributeUserProfilePicture.Value),
})
}
return desiredUsers, ldapUserIDs, usernamesByDN, nil
}
func (s *LdapService) resolveGroupMemberUsername(ctx context.Context, client ldapClient, member string, usernamesByDN map[string]string) string {
dbConfig := s.appConfigService.GetDbConfig()
// First try the DN cache we built while loading users
username, exists := usernamesByDN[normalizeLDAPDN(member)]
if exists && username != "" {
return username
}
// Then try to extract the username directly from the DN
username = getDNProperty(dbConfig.LdapAttributeUserUsername.Value, member)
if username != "" {
return norm.NFC.String(username)
}
// posixGroup (and similar) stores bare usernames in memberUid, not DNs. Treat any value
// that is not a valid DN as the username directly — see https://github.com/pocket-id/pocket-id/issues/1408
if _, err := ldap.ParseDN(member); err != nil {
return norm.NFC.String(member)
}
// As a fallback, query LDAP for the referenced entry
userSearchReq := ldap.NewSearchRequest(
member,
ldap.ScopeBaseObject,
0, 0, 0, false,
"(objectClass=*)",
[]string{dbConfig.LdapAttributeUserUsername.Value},
[]ldap.Control{},
)
userResult, err := client.Search(userSearchReq)
if err != nil || len(userResult.Entries) == 0 {
slog.WarnContext(ctx, "Could not resolve group member DN", slog.String("member", member), slog.Any("error", err))
return ""
}
username = userResult.Entries[0].GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value)
if username == "" {
slog.WarnContext(ctx, "Could not extract username from group member DN", slog.String("member", member))
return ""
}
return norm.NFC.String(username)
}
func (s *LdapService) reconcileGroups(ctx context.Context, tx *gorm.DB, desiredGroups []ldapDesiredGroup, ldapGroupIDs map[string]struct{}) error {
// Load the current LDAP-managed state from the database
ldapGroupsInDB, ldapGroupsByID, err := s.loadLDAPGroupsInDB(ctx, tx)
if err != nil {
return fmt.Errorf("failed to fetch groups from database: %w", err)
}
_, _, ldapUsersByUsername, err := s.loadLDAPUsersInDB(ctx, tx)
if err != nil {
return fmt.Errorf("failed to fetch users from database: %w", err)
}
// Apply creates and updates to match the desired LDAP group state
for _, desiredGroup := range desiredGroups {
memberUserIDs := make([]string, 0, len(desiredGroup.memberUsernames))
for _, username := range desiredGroup.memberUsernames {
databaseUser, exists := ldapUsersByUsername[username]
if !exists {
// The user collides with a non-LDAP user or was skipped during user sync, so we ignore it
continue
} else if err != nil {
return nil, nil, fmt.Errorf("error creating user '%s': %w", newUser.Username, err)
}
userID = createdUser.ID
} else {
_, err = s.userService.updateUserInternal(ctx, databaseUser.ID, newUser, false, true, tx)
if errors.Is(err, &common.AlreadyInUseError{}) {
slog.Warn("Skipping updating LDAP user", slog.String("username", newUser.Username), slog.Any("error", err))
continue
} else if err != nil {
return nil, nil, fmt.Errorf("error updating user '%s': %w", newUser.Username, err)
}
memberUserIDs = append(memberUserIDs, databaseUser.ID)
}
// Save profile picture
pictureString := value.GetAttributeValue(dbConfig.LdapAttributeUserProfilePicture.Value)
if pictureString != "" {
// Storage operations must be executed outside of a transaction
savePictures = append(savePictures, savePicture{
userID: databaseUser.ID,
username: userID,
picture: pictureString,
})
databaseGroup := ldapGroupsByID[desiredGroup.ldapID]
if databaseGroup.ID == "" {
newGroup, err := s.groupService.createInternal(ctx, desiredGroup.input, tx)
if err != nil {
return fmt.Errorf("failed to create group '%s': %w", desiredGroup.input.Name, err)
}
ldapGroupsByID[desiredGroup.ldapID] = newGroup
_, err = s.groupService.updateUsersInternal(ctx, newGroup.ID, memberUserIDs, tx)
if err != nil {
return fmt.Errorf("failed to sync users for group '%s': %w", desiredGroup.input.Name, err)
}
continue
}
_, err = s.groupService.updateInternal(ctx, databaseGroup.ID, desiredGroup.input, true, tx)
if err != nil {
return fmt.Errorf("failed to update group '%s': %w", desiredGroup.input.Name, err)
}
_, err = s.groupService.updateUsersInternal(ctx, databaseGroup.ID, memberUserIDs, tx)
if err != nil {
return fmt.Errorf("failed to sync users for group '%s': %w", desiredGroup.input.Name, err)
}
}
// Get all LDAP users from the database
var ldapUsersInDb []model.User
err = tx.
WithContext(ctx).
Find(&ldapUsersInDb, "ldap_id IS NOT NULL").
Select("id, username, ldap_id, disabled").
Error
// Delete groups that are no longer present in LDAP
for _, group := range ldapGroupsInDB {
if group.LdapID == nil {
continue
}
if _, exists := ldapGroupIDs[*group.LdapID]; exists {
continue
}
err = tx.
WithContext(ctx).
Delete(&model.UserGroup{}, "ldap_id = ?", *group.LdapID).
Error
if err != nil {
return fmt.Errorf("failed to delete group '%s': %w", group.Name, err)
}
slog.Info("Deleted group", slog.String("group", group.Name))
}
return nil
}
//nolint:gocognit
func (s *LdapService) reconcileUsers(ctx context.Context, tx *gorm.DB, desiredUsers []ldapDesiredUser, ldapUserIDs map[string]struct{}) (savePictures []savePicture, deleteFiles []string, err error) {
dbConfig := s.appConfigService.GetDbConfig()
// Load the current LDAP-managed state from the database
ldapUsersInDB, ldapUsersByID, _, err := s.loadLDAPUsersInDB(ctx, tx)
if err != nil {
return nil, nil, fmt.Errorf("failed to fetch users from database: %w", err)
}
// Mark users as disabled or delete users that no longer exist in LDAP
deleteFiles = make([]string, 0, len(ldapUserIDs))
for _, user := range ldapUsersInDb {
// Skip if the user ID exists in the fetched LDAP results
// Apply creates and updates to match the desired LDAP user state
savePictures = make([]savePicture, 0, len(desiredUsers))
for _, desiredUser := range desiredUsers {
databaseUser := ldapUsersByID[desiredUser.ldapID]
// If a user is found (even if disabled), enable them since they're now back in LDAP.
if databaseUser.ID != "" && databaseUser.Disabled {
err = tx.
WithContext(ctx).
Model(&model.User{}).
Where("id = ?", databaseUser.ID).
Update("disabled", false).
Error
if err != nil {
return nil, nil, fmt.Errorf("failed to enable user %s: %w", databaseUser.Username, err)
}
databaseUser.Disabled = false
ldapUsersByID[desiredUser.ldapID] = databaseUser
}
userID := databaseUser.ID
if databaseUser.ID == "" {
createdUser, err := s.userService.createUserInternal(ctx, desiredUser.input, true, tx)
if errors.Is(err, &common.AlreadyInUseError{}) {
slog.Warn("Skipping creating LDAP user", slog.String("username", desiredUser.input.Username), slog.Any("error", err))
continue
} else if err != nil {
return nil, nil, fmt.Errorf("error creating user '%s': %w", desiredUser.input.Username, err)
}
userID = createdUser.ID
ldapUsersByID[desiredUser.ldapID] = createdUser
} else {
_, err = s.userService.updateUserInternal(ctx, databaseUser.ID, desiredUser.input, false, true, tx)
if errors.Is(err, &common.AlreadyInUseError{}) {
slog.Warn("Skipping updating LDAP user", slog.String("username", desiredUser.input.Username), slog.Any("error", err))
continue
} else if err != nil {
return nil, nil, fmt.Errorf("error updating user '%s': %w", desiredUser.input.Username, err)
}
}
if desiredUser.picture != "" {
savePictures = append(savePictures, savePicture{
userID: userID,
username: desiredUser.input.Username,
picture: desiredUser.picture,
})
}
}
// Disable or delete users that are no longer present in LDAP
deleteFiles = make([]string, 0, len(ldapUsersInDB))
for _, user := range ldapUsersInDB {
if user.LdapID == nil {
continue
}
if _, exists := ldapUserIDs[*user.LdapID]; exists {
continue
}
@@ -458,29 +578,73 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
}
slog.Info("Disabled user", slog.String("username", user.Username))
} else {
err = s.userService.deleteUserInternal(ctx, tx, user.ID, true)
if err != nil {
target := &common.LdapUserUpdateError{}
if errors.As(err, &target) {
return nil, nil, fmt.Errorf("failed to delete user %s: LDAP user must be disabled before deletion", user.Username)
}
return nil, nil, fmt.Errorf("failed to delete user %s: %w", user.Username, err)
}
slog.Info("Deleted user", slog.String("username", user.Username))
// Storage operations must be executed outside of a transaction
deleteFiles = append(deleteFiles, path.Join("profile-pictures", user.ID+".png"))
continue
}
err = s.userService.deleteUserInternal(ctx, tx, user.ID, true)
if err != nil {
target := &common.LdapUserUpdateError{}
if errors.As(err, &target) {
return nil, nil, fmt.Errorf("failed to delete user %s: LDAP user must be disabled before deletion", user.Username)
}
return nil, nil, fmt.Errorf("failed to delete user %s: %w", user.Username, err)
}
slog.Info("Deleted user", slog.String("username", user.Username))
deleteFiles = append(deleteFiles, path.Join("profile-pictures", user.ID+".png"))
}
return savePictures, deleteFiles, nil
}
func (s *LdapService) loadLDAPUsersInDB(ctx context.Context, tx *gorm.DB) (users []model.User, byLdapID map[string]model.User, byUsername map[string]model.User, err error) {
// Load all LDAP-managed users and index them by LDAP ID and by username
err = tx.
WithContext(ctx).
Select("id, username, ldap_id, disabled").
Where("ldap_id IS NOT NULL").
Find(&users).
Error
if err != nil {
return nil, nil, nil, err
}
byLdapID = make(map[string]model.User, len(users))
byUsername = make(map[string]model.User, len(users))
for _, user := range users {
byLdapID[*user.LdapID] = user
byUsername[user.Username] = user
}
return users, byLdapID, byUsername, nil
}
func (s *LdapService) loadLDAPGroupsInDB(ctx context.Context, tx *gorm.DB) ([]model.UserGroup, map[string]model.UserGroup, error) {
var groups []model.UserGroup
// Load all LDAP-managed groups and index them by LDAP ID
err := tx.
WithContext(ctx).
Select("id, name, ldap_id").
Where("ldap_id IS NOT NULL").
Find(&groups).
Error
if err != nil {
return nil, nil, err
}
groupsByID := make(map[string]model.UserGroup, len(groups))
for _, group := range groups {
groupsByID[*group.LdapID] = group
}
return groups, groupsByID, nil
}
func (s *LdapService) saveProfilePicture(parentCtx context.Context, userId string, pictureString string) error {
var reader io.ReadSeeker
// Accept either a URL, a base64-encoded payload, or raw binary data
_, err := url.ParseRequestURI(pictureString)
if err == nil {
ctx, cancel := context.WithTimeout(parentCtx, 15*time.Second)
@@ -522,6 +686,31 @@ func (s *LdapService) saveProfilePicture(parentCtx context.Context, userId strin
return nil
}
// normalizeLDAPDN returns a canonical lowercase form of a DN for use as a map key.
// Different LDAP servers may format the same DN with varying attribute type casing (e.g. "CN=" vs "cn=") or extra whitespace (e.g. "dc=example, dc=com").
// Without normalization, cache lookups in usernamesByDN would miss when a member attribute value uses a different format than the DN returned in the search entry
//
// ldap.ParseDN is used instead of simple lowercasing because it correctly handles multi-valued RDNs (joined with "+") and strips inter-component whitespace.
// If parsing fails for any reason, we fall back to a simple lowercase+trim.
func normalizeLDAPDN(dn string) string {
parsed, err := ldap.ParseDN(dn)
if err != nil {
return strings.ToLower(strings.TrimSpace(dn))
}
// Reconstruct the DN in a canonical form: lowercase type=lowercase value, with RDN components separated by "," and multi-value attributes by "+"
parts := make([]string, 0, len(parsed.RDNs))
for _, rdn := range parsed.RDNs {
attrs := make([]string, 0, len(rdn.Attributes))
for _, attr := range rdn.Attributes {
attrs = append(attrs, strings.ToLower(attr.Type)+"="+strings.ToLower(attr.Value))
}
parts = append(parts, strings.Join(attrs, "+"))
}
return strings.Join(parts, ",")
}
// getDNProperty returns the value of a property from a LDAP identifier
// See: https://learn.microsoft.com/en-us/previous-versions/windows/desktop/ldap/distinguished-names
func getDNProperty(property string, str string) string {

View File

@@ -1,9 +1,410 @@
package service
import (
"net/http"
"testing"
"github.com/go-ldap/ldap/v3"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/pocket-id/pocket-id/backend/internal/storage"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
type fakeLDAPClient struct {
searchFn func(searchRequest *ldap.SearchRequest) (*ldap.SearchResult, error)
}
func (c *fakeLDAPClient) Search(searchRequest *ldap.SearchRequest) (*ldap.SearchResult, error) {
if c.searchFn == nil {
return nil, nil
}
return c.searchFn(searchRequest)
}
func (c *fakeLDAPClient) Bind(_, _ string) error {
return nil
}
func (c *fakeLDAPClient) Close() error {
return nil
}
func TestLdapServiceSyncAllReconcilesUsersAndGroups(t *testing.T) {
service, db := newTestLdapService(t, newFakeLDAPClient(
ldapSearchResult(
ldapEntry("uid=alice,ou=people,dc=example,dc=com", map[string][]string{
"entryUUID": {"u-alice"},
"uid": {"alice"},
"mail": {"alice@example.com"},
"givenName": {"Alice"},
"sn": {"Jones"},
"displayName": {""},
}),
ldapEntry("uid=bob,ou=people,dc=example,dc=com", map[string][]string{
"entryUUID": {"u-bob"},
"uid": {"bob"},
"mail": {"bob@example.com"},
"givenName": {"Bob"},
"sn": {"Brown"},
"displayName": {""},
}),
),
ldapSearchResult(
ldapEntry("cn=admins,ou=groups,dc=example,dc=com", map[string][]string{
"entryUUID": {"g-admins"},
"cn": {"admins"},
"member": {"uid=alice,ou=people,dc=example,dc=com"},
}),
ldapEntry("cn=team,ou=groups,dc=example,dc=com", map[string][]string{
"entryUUID": {"g-team"},
"cn": {"team"},
"member": {
"UID=Alice, OU=People, DC=example, DC=com",
"uid=bob, ou=people, dc=example, dc=com",
},
}),
),
))
aliceLdapID := "u-alice"
missingLdapID := "u-missing"
teamLdapID := "g-team"
oldGroupLdapID := "g-old"
require.NoError(t, db.Create(&model.User{
Username: "alice-old",
Email: new("alice-old@example.com"),
EmailVerified: true,
FirstName: "Old",
LastName: "Name",
DisplayName: "Old Name",
LdapID: &aliceLdapID,
Disabled: true,
}).Error)
require.NoError(t, db.Create(&model.User{
Username: "missing",
Email: new("missing@example.com"),
EmailVerified: true,
FirstName: "Missing",
LastName: "User",
DisplayName: "Missing User",
LdapID: &missingLdapID,
}).Error)
require.NoError(t, db.Create(&model.UserGroup{
Name: "team-old",
FriendlyName: "team-old",
LdapID: &teamLdapID,
}).Error)
require.NoError(t, db.Create(&model.UserGroup{
Name: "old-group",
FriendlyName: "old-group",
LdapID: &oldGroupLdapID,
}).Error)
require.NoError(t, service.SyncAll(t.Context()))
var alice model.User
require.NoError(t, db.First(&alice, "ldap_id = ?", aliceLdapID).Error)
assert.Equal(t, "alice", alice.Username)
assert.Equal(t, new("alice@example.com"), alice.Email)
assert.Equal(t, "Alice", alice.FirstName)
assert.Equal(t, "Jones", alice.LastName)
assert.Equal(t, "Alice Jones", alice.DisplayName)
assert.True(t, alice.IsAdmin)
assert.False(t, alice.Disabled)
var bob model.User
require.NoError(t, db.First(&bob, "ldap_id = ?", "u-bob").Error)
assert.Equal(t, "bob", bob.Username)
assert.Equal(t, "Bob Brown", bob.DisplayName)
var missing model.User
require.NoError(t, db.First(&missing, "ldap_id = ?", missingLdapID).Error)
assert.True(t, missing.Disabled)
var oldGroupCount int64
require.NoError(t, db.Model(&model.UserGroup{}).Where("ldap_id = ?", oldGroupLdapID).Count(&oldGroupCount).Error)
assert.Zero(t, oldGroupCount)
var team model.UserGroup
require.NoError(t, db.Preload("Users").First(&team, "ldap_id = ?", teamLdapID).Error)
assert.Equal(t, "team", team.Name)
assert.Equal(t, "team", team.FriendlyName)
assert.ElementsMatch(t, []string{"alice", "bob"}, usernames(team.Users))
}
// Regression: posixGroup uses memberUid (bare uid values), not member DNs — issue #1408.
func TestLdapServiceSyncAllMapsPosixGroupMemberUid(t *testing.T) {
appCfg := defaultTestLDAPAppConfig()
appCfg.LdapUserGroupSearchFilter = model.AppConfigVariable{Value: "(objectClass=posixGroup)"}
appCfg.LdapAttributeGroupMember = model.AppConfigVariable{Value: "memberUid"}
service, db := newTestLdapServiceWithAppConfig(t, appCfg, newFakeLDAPClient(
ldapSearchResult(
ldapEntry("uid=alice,ou=users,dc=example,dc=com", map[string][]string{
"entryUUID": {"u-alice"},
"uid": {"alice"},
"mail": {"alice@example.com"},
"givenName": {"Alice"},
"sn": {"Jones"},
"displayName": {""},
}),
ldapEntry("uid=bob,ou=users,dc=example,dc=com", map[string][]string{
"entryUUID": {"u-bob"},
"uid": {"bob"},
"mail": {"bob@example.com"},
"givenName": {"Bob"},
"sn": {"Brown"},
"displayName": {""},
}),
),
ldapSearchResult(
ldapEntry("cn=users,ou=groups,dc=example,dc=com", map[string][]string{
"entryUUID": {"g-users"},
"cn": {"users"},
"memberUid": {"alice", "bob", "unknown"},
}),
),
))
require.NoError(t, service.SyncAll(t.Context()))
var group model.UserGroup
require.NoError(t, db.Preload("Users").First(&group, "ldap_id = ?", "g-users").Error)
assert.Equal(t, "users", group.Name)
assert.ElementsMatch(t, []string{"alice", "bob"}, usernames(group.Users))
}
func TestLdapServiceSyncAllHandlesDuplicateLDAPIDsInSingleRun(t *testing.T) {
service, db := newTestLdapService(t, newFakeLDAPClient(
ldapSearchResult(
ldapEntry("uid=alice,ou=people,dc=example,dc=com", map[string][]string{
"entryUUID": {"u-dup"},
"uid": {"alice"},
"mail": {"alice@example.com"},
"givenName": {"Alice"},
"sn": {"Doe"},
"displayName": {"Alice Doe"},
}),
ldapEntry("uid=alice,ou=people,dc=example,dc=com", map[string][]string{
"entryUUID": {"u-dup"},
"uid": {"alice"},
"mail": {"alice@example.com"},
"givenName": {"Alicia"},
"sn": {"Doe"},
"displayName": {"Alicia Doe"},
}),
),
ldapSearchResult(
ldapEntry("cn=team,ou=groups,dc=example,dc=com", map[string][]string{
"entryUUID": {"g-dup"},
"cn": {"team"},
"member": {"uid=alice,ou=people,dc=example,dc=com"},
}),
ldapEntry("cn=team,ou=groups,dc=example,dc=com", map[string][]string{
"entryUUID": {"g-dup"},
"cn": {"team-renamed"},
"member": {"uid=alice,ou=people,dc=example,dc=com"},
}),
),
))
require.NoError(t, service.SyncAll(t.Context()))
var users []model.User
require.NoError(t, db.Find(&users, "ldap_id = ?", "u-dup").Error)
require.Len(t, users, 1)
assert.Equal(t, "alice", users[0].Username)
assert.Equal(t, "Alicia", users[0].FirstName)
assert.Equal(t, "Alicia Doe", users[0].DisplayName)
var groups []model.UserGroup
require.NoError(t, db.Preload("Users").Find(&groups, "ldap_id = ?", "g-dup").Error)
require.Len(t, groups, 1)
assert.Equal(t, "team-renamed", groups[0].Name)
assert.Equal(t, "team-renamed", groups[0].FriendlyName)
assert.ElementsMatch(t, []string{"alice"}, usernames(groups[0].Users))
}
func TestLdapServiceSyncAllSetsAdminFromGroupMembership(t *testing.T) {
tests := []struct {
name string
appConfig *model.AppConfig
groupEntry *ldap.Entry
groupName string
groupLookup string
}{
{
name: "memberOf missing on user",
appConfig: defaultTestLDAPAppConfig(),
groupEntry: ldapEntry("cn=admins,ou=groups,dc=example,dc=com", map[string][]string{
"entryUUID": {"g-admins"},
"cn": {"admins"},
"member": {"uid=testadmin,ou=people,dc=example,dc=com"},
}),
groupName: "admins",
groupLookup: "g-admins",
},
{
name: "configured group name attribute differs from DN RDN",
appConfig: func() *model.AppConfig {
cfg := defaultTestLDAPAppConfig()
cfg.LdapAttributeGroupName = model.AppConfigVariable{Value: "displayName"}
cfg.LdapAdminGroupName = model.AppConfigVariable{Value: "pocketid.admin"}
return cfg
}(),
groupEntry: ldapEntry("cn=admins,ou=groups,dc=example,dc=com", map[string][]string{
"entryUUID": {"g-display-admins"},
"cn": {"admins"},
"displayName": {"pocketid.admin"},
"member": {"uid=testadmin,ou=people,dc=example,dc=com"},
}),
groupName: "pocketid.admin",
groupLookup: "g-display-admins",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
service, db := newTestLdapServiceWithAppConfig(t, tt.appConfig, newFakeLDAPClient(
ldapSearchResult(
ldapEntry("uid=testadmin,ou=people,dc=example,dc=com", map[string][]string{
"entryUUID": {"u-testadmin"},
"uid": {"testadmin"},
"mail": {"testadmin@example.com"},
"givenName": {"Test"},
"sn": {"Admin"},
"displayName": {""},
}),
),
ldapSearchResult(tt.groupEntry),
))
require.NoError(t, service.SyncAll(t.Context()))
var user model.User
require.NoError(t, db.First(&user, "ldap_id = ?", "u-testadmin").Error)
assert.True(t, user.IsAdmin)
var group model.UserGroup
require.NoError(t, db.Preload("Users").First(&group, "ldap_id = ?", tt.groupLookup).Error)
assert.Equal(t, tt.groupName, group.Name)
assert.ElementsMatch(t, []string{"testadmin"}, usernames(group.Users))
})
}
}
func newTestLdapService(t *testing.T, client ldapClient) (*LdapService, *gorm.DB) {
t.Helper()
return newTestLdapServiceWithAppConfig(t, defaultTestLDAPAppConfig(), client)
}
func newTestLdapServiceWithAppConfig(t *testing.T, appConfigModel *model.AppConfig, client ldapClient) (*LdapService, *gorm.DB) {
t.Helper()
db := testutils.NewDatabaseForTest(t)
fileStorage, err := storage.NewDatabaseStorage(db)
require.NoError(t, err)
appConfig := NewTestAppConfigService(appConfigModel)
groupService := NewUserGroupService(db, appConfig, nil)
userService := NewUserService(
db,
nil,
nil,
nil,
appConfig,
NewCustomClaimService(db),
NewAppImagesService(map[string]string{}, fileStorage),
nil,
fileStorage,
)
service := NewLdapService(db, &http.Client{}, appConfig, userService, groupService, fileStorage)
service.clientFactory = func() (ldapClient, error) {
return client, nil
}
return service, db
}
func defaultTestLDAPAppConfig() *model.AppConfig {
return &model.AppConfig{
RequireUserEmail: model.AppConfigVariable{Value: "false"},
LdapEnabled: model.AppConfigVariable{Value: "true"},
LdapBase: model.AppConfigVariable{Value: "dc=example,dc=com"},
LdapUserSearchFilter: model.AppConfigVariable{Value: "(objectClass=person)"},
LdapUserGroupSearchFilter: model.AppConfigVariable{Value: "(objectClass=groupOfNames)"},
LdapAttributeUserUniqueIdentifier: model.AppConfigVariable{Value: "entryUUID"},
LdapAttributeUserUsername: model.AppConfigVariable{Value: "uid"},
LdapAttributeUserEmail: model.AppConfigVariable{Value: "mail"},
LdapAttributeUserFirstName: model.AppConfigVariable{Value: "givenName"},
LdapAttributeUserLastName: model.AppConfigVariable{Value: "sn"},
LdapAttributeUserDisplayName: model.AppConfigVariable{Value: "displayName"},
LdapAttributeUserProfilePicture: model.AppConfigVariable{Value: "jpegPhoto"},
LdapAttributeGroupMember: model.AppConfigVariable{Value: "member"},
LdapAttributeGroupUniqueIdentifier: model.AppConfigVariable{Value: "entryUUID"},
LdapAttributeGroupName: model.AppConfigVariable{Value: "cn"},
LdapAdminGroupName: model.AppConfigVariable{Value: "admins"},
LdapSoftDeleteUsers: model.AppConfigVariable{Value: "true"},
}
}
func newFakeLDAPClient(userResult, groupResult *ldap.SearchResult) ldapClient {
return &fakeLDAPClient{
searchFn: func(searchRequest *ldap.SearchRequest) (*ldap.SearchResult, error) {
switch searchRequest.Filter {
case "(objectClass=person)":
return userResult, nil
case "(objectClass=groupOfNames)", "(objectClass=posixGroup)":
return groupResult, nil
default:
return &ldap.SearchResult{}, nil
}
},
}
}
func ldapSearchResult(entries ...*ldap.Entry) *ldap.SearchResult {
return &ldap.SearchResult{Entries: entries}
}
func ldapEntry(dn string, attrs map[string][]string) *ldap.Entry {
entry := &ldap.Entry{
DN: dn,
Attributes: make([]*ldap.EntryAttribute, 0, len(attrs)),
}
for name, values := range attrs {
entry.Attributes = append(entry.Attributes, &ldap.EntryAttribute{
Name: name,
Values: values,
})
}
return entry
}
func usernames(users []model.User) []string {
result := make([]string, 0, len(users))
for _, user := range users {
result = append(result, user.Username)
}
return result
}
func TestGetDNProperty(t *testing.T) {
tests := []struct {
name string
@@ -64,10 +465,58 @@ func TestGetDNProperty(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := getDNProperty(tt.property, tt.dn)
if result != tt.expectedResult {
t.Errorf("getDNProperty(%q, %q) = %q, want %q",
tt.property, tt.dn, result, tt.expectedResult)
}
assert.Equalf(t, tt.expectedResult, result, "getDNProperty(%q, %q)", tt.property, tt.dn)
})
}
}
func TestNormalizeLDAPDN(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "already normalized",
input: "cn=alice,dc=example,dc=com",
expected: "cn=alice,dc=example,dc=com",
},
{
name: "uppercase attribute types",
input: "CN=Alice,DC=example,DC=com",
expected: "cn=alice,dc=example,dc=com",
},
{
name: "spaces after commas",
input: "cn=alice, dc=example, dc=com",
expected: "cn=alice,dc=example,dc=com",
},
{
name: "uppercase types and spaces",
input: "CN=Alice, DC=example, DC=com",
expected: "cn=alice,dc=example,dc=com",
},
{
name: "multi-valued RDN",
input: "cn=alice+uid=a123,dc=example,dc=com",
expected: "cn=alice+uid=a123,dc=example,dc=com",
},
{
name: "invalid DN falls back to lowercase+trim",
input: " NOT A VALID DN ",
expected: "not a valid dn",
},
{
name: "empty string",
input: "",
expected: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := normalizeLDAPDN(tt.input)
assert.Equalf(t, tt.expected, result, "normalizeLDAPDN(%q)", tt.input)
})
}
}
@@ -98,9 +547,7 @@ func TestConvertLdapIdToString(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := convertLdapIdToString(tt.input)
if got != tt.expected {
t.Errorf("Expected %q, got %q", tt.expected, got)
}
assert.Equal(t, tt.expected, got)
})
}
}

View File

@@ -123,11 +123,9 @@ func (s *OidcService) getJWKCache(ctx context.Context) (*jwk.Cache, error) {
)
}
func (s *OidcService) Authorize(ctx context.Context, input dto.AuthorizeOidcClientRequestDto, userID, ipAddress, userAgent string) (string, string, error) {
func (s *OidcService) Authorize(ctx context.Context, input dto.AuthorizeOidcClientRequestDto, userID string, authenticationMethod string, ipAddress, userAgent string) (string, string, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
defer tx.Rollback()
var client model.OidcClient
err := tx.
@@ -139,27 +137,49 @@ func (s *OidcService) Authorize(ctx context.Context, input dto.AuthorizeOidcClie
return "", "", err
}
if client.RequiresReauthentication {
if input.ReauthenticationToken == "" {
return "", "", &common.ReauthenticationRequiredError{}
}
err = s.webAuthnService.ConsumeReauthenticationToken(ctx, tx, input.ReauthenticationToken, userID)
if err != nil {
return "", "", err
}
}
// If the client is not public, the code challenge must be provided
if client.IsPublic && input.CodeChallenge == "" {
return "", "", &common.OidcMissingCodeChallengeError{}
}
// Get the callback URL of the client. Return an error if the provided callback URL is not allowed
// Validate the callback URL before any prompt checks, so that prompt-related
// error responses never contain an unvalidated redirect target
callbackURL, err := s.getCallbackURL(&client, input.CallbackURL, tx, ctx)
if err != nil {
return "", "", err
}
// Parse prompt parameter (space-delimited list per OIDC spec)
promptValues := parsePromptParameter(input.Prompt)
hasPromptNone := contains(promptValues, "none")
hasPromptLogin := contains(promptValues, "login")
hasPromptConsent := contains(promptValues, "consent")
hasPromptSelectAccount := contains(promptValues, "select_account")
// Validate prompt parameter conflicts early.
// Per OIDC Core §3.1.2.6, prompt=none must not be combined with any
// value that requires user interaction.
if hasPromptNone && (hasPromptConsent || hasPromptLogin || hasPromptSelectAccount) {
return "", "", &common.OidcInteractionRequiredError{}
}
// Handle prompt=select_account early (not supported)
if hasPromptSelectAccount {
return "", "", &common.OidcInteractionRequiredError{}
}
// If prompt=login is specified or the client requires reauthentication, check the reauthentication token
if hasPromptLogin || client.RequiresReauthentication {
if input.ReauthenticationToken == "" {
return "", "", &common.ReauthenticationRequiredError{}
}
err = s.webAuthnService.ConsumeReauthenticationToken(ctx, tx, input.ReauthenticationToken, userID)
if err != nil {
return "", "", err
}
}
// Check if the user group is allowed to authorize the client
var user model.User
err = tx.
@@ -175,13 +195,24 @@ func (s *OidcService) Authorize(ctx context.Context, input dto.AuthorizeOidcClie
return "", "", &common.OidcAccessDeniedError{}
}
// Handle prompt=none - if consent would be required, we can't show UI
if hasPromptNone {
hasAlreadyAuthorized, err := s.hasAuthorizedClientInternal(ctx, input.ClientID, userID, input.Scope, tx)
if err != nil {
return "", "", err
}
if !hasAlreadyAuthorized {
return "", "", &common.OidcConsentRequiredError{}
}
}
hasAlreadyAuthorizedClient, err := s.createAuthorizedClientInternal(ctx, userID, input.ClientID, input.Scope, tx)
if err != nil {
return "", "", err
}
// Create the authorization code
code, err := s.createAuthorizationCode(ctx, input.ClientID, userID, input.Scope, input.Nonce, input.CodeChallenge, input.CodeChallengeMethod, tx)
code, err := s.createAuthorizationCode(ctx, input.ClientID, userID, input.Scope, authenticationMethod, input.Nonce, input.CodeChallenge, input.CodeChallengeMethod, tx)
if err != nil {
return "", "", err
}
@@ -314,17 +345,17 @@ func (s *OidcService) createTokenFromDeviceCode(ctx context.Context, input dto.O
}
// Explicitly use the input clientID for the audience claim to ensure consistency
idToken, err := s.jwtService.GenerateIDToken(userClaims, input.ClientID, deviceAuth.Nonce)
idToken, err := s.jwtService.GenerateIDToken(userClaims, input.ClientID, deviceAuth.Nonce, deviceAuth.AuthenticationMethod)
if err != nil {
return CreatedTokens{}, err
}
refreshToken, err := s.createRefreshToken(ctx, input.ClientID, *deviceAuth.UserID, deviceAuth.Scope, tx)
refreshToken, err := s.createRefreshToken(ctx, input.ClientID, *deviceAuth.UserID, deviceAuth.Scope, deviceAuth.AuthenticationMethod, tx)
if err != nil {
return CreatedTokens{}, err
}
accessToken, err := s.jwtService.GenerateOAuthAccessToken(deviceAuth.User, input.ClientID)
accessToken, err := s.jwtService.GenerateOAuthAccessToken(deviceAuth.User, input.ClientID, deviceAuth.AuthenticationMethod)
if err != nil {
return CreatedTokens{}, err
}
@@ -365,7 +396,7 @@ func (s *OidcService) createTokenFromClientCredentials(ctx context.Context, inpu
audClaim = input.Resource
}
accessToken, err := s.jwtService.GenerateOAuthAccessToken(dummyUser, audClaim)
accessToken, err := s.jwtService.GenerateOAuthAccessToken(dummyUser, audClaim, "")
if err != nil {
return CreatedTokens{}, err
}
@@ -404,7 +435,7 @@ func (s *OidcService) createTokenFromAuthorizationCode(ctx context.Context, inpu
}
}
if authorizationCodeMetaData.ClientID != input.ClientID && authorizationCodeMetaData.ExpiresAt.ToTime().Before(time.Now()) {
if authorizationCodeMetaData.ClientID != input.ClientID || authorizationCodeMetaData.ExpiresAt.ToTime().Before(time.Now()) {
return CreatedTokens{}, &common.OidcInvalidAuthorizationCodeError{}
}
@@ -413,18 +444,20 @@ func (s *OidcService) createTokenFromAuthorizationCode(ctx context.Context, inpu
return CreatedTokens{}, err
}
idToken, err := s.jwtService.GenerateIDToken(userClaims, input.ClientID, authorizationCodeMetaData.Nonce)
authenticationMethod := authorizationCodeMetaData.AuthenticationMethod
idToken, err := s.jwtService.GenerateIDToken(userClaims, input.ClientID, authorizationCodeMetaData.Nonce, authenticationMethod)
if err != nil {
return CreatedTokens{}, err
}
// Generate a refresh token
refreshToken, err := s.createRefreshToken(ctx, input.ClientID, authorizationCodeMetaData.UserID, authorizationCodeMetaData.Scope, tx)
refreshToken, err := s.createRefreshToken(ctx, input.ClientID, authorizationCodeMetaData.UserID, authorizationCodeMetaData.Scope, authenticationMethod, tx)
if err != nil {
return CreatedTokens{}, err
}
accessToken, err := s.jwtService.GenerateOAuthAccessToken(authorizationCodeMetaData.User, input.ClientID)
accessToken, err := s.jwtService.GenerateOAuthAccessToken(authorizationCodeMetaData.User, input.ClientID, authenticationMethod)
if err != nil {
return CreatedTokens{}, err
}
@@ -501,8 +534,46 @@ func (s *OidcService) createTokenFromRefreshToken(ctx context.Context, input dto
return CreatedTokens{}, &common.OidcInvalidRefreshTokenError{}
}
if storedRefreshToken.User.Disabled {
return CreatedTokens{}, &common.OidcInvalidRefreshTokenError{}
}
var authorizedClient model.UserAuthorizedOidcClient
err = tx.
WithContext(ctx).
Where("user_id = ? AND client_id = ?", storedRefreshToken.UserID, input.ClientID).
First(&authorizedClient).
Error
if errors.Is(err, gorm.ErrRecordNotFound) {
err = tx.WithContext(ctx).Delete(&storedRefreshToken).Error
if err != nil {
return CreatedTokens{}, err
}
err = tx.Commit().Error
if err != nil {
return CreatedTokens{}, err
}
return CreatedTokens{}, &common.OidcInvalidRefreshTokenError{}
} else if err != nil {
return CreatedTokens{}, err
}
if client.IsGroupRestricted {
err = tx.WithContext(ctx).Model(client).Association("AllowedUserGroups").Find(&client.AllowedUserGroups)
if err != nil {
return CreatedTokens{}, err
}
}
if !IsUserGroupAllowedToAuthorize(storedRefreshToken.User, *client) {
return CreatedTokens{}, &common.OidcAccessDeniedError{}
}
// Generate a new access token
accessToken, err := s.jwtService.GenerateOAuthAccessToken(storedRefreshToken.User, input.ClientID)
authenticationMethods := storedRefreshToken.AuthenticationMethod
accessToken, err := s.jwtService.GenerateOAuthAccessToken(storedRefreshToken.User, input.ClientID, authenticationMethods)
if err != nil {
return CreatedTokens{}, err
}
@@ -515,13 +586,13 @@ func (s *OidcService) createTokenFromRefreshToken(ctx context.Context, input dto
// Generate a new ID token
// There's no nonce here because we don't have one with the refresh token, but that's not required
idToken, err := s.jwtService.GenerateIDToken(userClaims, input.ClientID, "")
idToken, err := s.jwtService.GenerateIDToken(userClaims, input.ClientID, "", authenticationMethods)
if err != nil {
return CreatedTokens{}, err
}
// Generate a new refresh token and invalidate the old one
newRefreshToken, err := s.createRefreshToken(ctx, input.ClientID, storedRefreshToken.UserID, storedRefreshToken.Scope, tx)
newRefreshToken, err := s.createRefreshToken(ctx, input.ClientID, storedRefreshToken.UserID, storedRefreshToken.Scope, authenticationMethods, tx)
if err != nil {
return CreatedTokens{}, err
}
@@ -1142,7 +1213,7 @@ func (s *OidcService) ValidateEndSession(ctx context.Context, input dto.OidcLogo
return callbackURL, nil
}
func (s *OidcService) createAuthorizationCode(ctx context.Context, clientID string, userID string, scope string, nonce string, codeChallenge string, codeChallengeMethod string, tx *gorm.DB) (string, error) {
func (s *OidcService) createAuthorizationCode(ctx context.Context, clientID string, userID string, scope string, authenticationMethod string, nonce string, codeChallenge string, codeChallengeMethod string, tx *gorm.DB) (string, error) {
randomString, err := utils.GenerateRandomAlphanumericString(32)
if err != nil {
return "", err
@@ -1156,6 +1227,7 @@ func (s *OidcService) createAuthorizationCode(ctx context.Context, clientID stri
ClientID: clientID,
UserID: userID,
Scope: scope,
AuthenticationMethod: authenticationMethod,
Nonce: nonce,
CodeChallenge: &codeChallenge,
CodeChallengeMethodSha256: &codeChallengeMethodSha256,
@@ -1299,7 +1371,7 @@ func (s *OidcService) CreateDeviceAuthorization(ctx context.Context, input dto.O
}, nil
}
func (s *OidcService) VerifyDeviceCode(ctx context.Context, userCode string, userID string, ipAddress string, userAgent string) error {
func (s *OidcService) VerifyDeviceCode(ctx context.Context, userCode string, userID string, authenticationMethod string, ipAddress string, userAgent string) error {
tx := s.db.Begin()
defer func() {
tx.Rollback()
@@ -1348,6 +1420,7 @@ func (s *OidcService) VerifyDeviceCode(ctx context.Context, userCode string, use
}
deviceAuth.UserID = &userID
deviceAuth.AuthenticationMethod = authenticationMethod
deviceAuth.IsAuthorized = true
err = tx.
@@ -1464,6 +1537,15 @@ func (s *OidcService) RevokeAuthorizedClient(ctx context.Context, userID string,
return err
}
err = tx.
WithContext(ctx).
Where("user_id = ? AND client_id = ?", userID, clientID).
Delete(&model.OidcRefreshToken{}).
Error
if err != nil {
return err
}
err = tx.Commit().Error
if err != nil {
return err
@@ -1551,7 +1633,7 @@ func (s *OidcService) ListAccessibleOidcClients(ctx context.Context, userID stri
return dtos, response, err
}
func (s *OidcService) createRefreshToken(ctx context.Context, clientID string, userID string, scope string, tx *gorm.DB) (string, error) {
func (s *OidcService) createRefreshToken(ctx context.Context, clientID string, userID string, scope string, authenticationMethod string, tx *gorm.DB) (string, error) {
refreshToken, err := utils.GenerateRandomAlphanumericString(40)
if err != nil {
return "", err
@@ -1562,11 +1644,12 @@ func (s *OidcService) createRefreshToken(ctx context.Context, clientID string, u
refreshTokenHash := utils.CreateSha256Hash(refreshToken)
m := model.OidcRefreshToken{
ExpiresAt: datatype.DateTime(time.Now().Add(RefreshTokenDuration)),
Token: refreshTokenHash,
ClientID: clientID,
UserID: userID,
Scope: scope,
ExpiresAt: datatype.DateTime(time.Now().Add(RefreshTokenDuration)),
Token: refreshTokenHash,
ClientID: clientID,
UserID: userID,
Scope: scope,
AuthenticationMethod: authenticationMethod,
}
err = tx.
@@ -1644,34 +1727,19 @@ func clientAuthCredentialsFromCreateTokensDto(d *dto.OidcCreateTokensDto) Client
}
func (s *OidcService) verifyClientCredentialsInternal(ctx context.Context, tx *gorm.DB, input ClientAuthCredentials, allowPublicClientsWithoutAuth bool) (client *model.OidcClient, err error) {
isClientAssertion := input.ClientAssertionType == ClientAssertionTypeJWTBearer && input.ClientAssertion != ""
// Determine the client ID based on the authentication method
var clientID string
switch {
case isClientAssertion:
// Extract client ID from the JWT assertion's 'sub' claim
clientID, err = s.extractClientIDFromAssertion(input.ClientAssertion)
if err != nil {
slog.Error("Failed to extract client ID from assertion", "error", err)
return nil, &common.OidcClientAssertionInvalidError{}
}
case input.ClientID != "":
// Use the provided client ID for other authentication methods
clientID = input.ClientID
default:
if input.ClientID == "" {
return nil, &common.OidcMissingClientCredentialsError{}
}
// Load the OIDC client's configuration
err = tx.
WithContext(ctx).
First(&client, "id = ?", clientID).
First(&client, "id = ?", input.ClientID).
Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) && isClientAssertion {
return nil, &common.OidcClientAssertionInvalidError{}
}
if errors.Is(err, gorm.ErrRecordNotFound) {
slog.WarnContext(ctx, "Client not found", slog.String("client", input.ClientID))
return nil, &common.OidcClientNotFoundError{}
} else if err != nil {
return nil, err
}
@@ -1686,7 +1754,7 @@ func (s *OidcService) verifyClientCredentialsInternal(ctx context.Context, tx *g
return client, nil
// Next, check if we want to use client assertions from federated identities
case isClientAssertion:
case input.ClientAssertionType == ClientAssertionTypeJWTBearer && input.ClientAssertion != "":
err = s.verifyClientAssertionFromFederatedIdentities(ctx, client, input)
if err != nil {
slog.WarnContext(ctx, "Invalid assertion for client", slog.String("client", client.ID), slog.Any("error", err))
@@ -1783,37 +1851,21 @@ func (s *OidcService) verifyClientAssertionFromFederatedIdentities(ctx context.C
// (Note: we don't use jwt.WithIssuer() because that would be redundant)
_, err = jwt.Parse(assertion,
jwt.WithValidate(true),
jwt.WithAcceptableSkew(clockSkew),
jwt.WithKeySet(jwks, jws.WithInferAlgorithmFromKey(true), jws.WithUseDefault(true)),
jwt.WithAudience(audience),
jwt.WithSubject(subject),
)
if err != nil {
return fmt.Errorf("client assertion is not valid: %w", err)
return fmt.Errorf("client assertion could not be verified: %w", err)
}
// If we're here, the assertion is valid
return nil
}
// extractClientIDFromAssertion extracts the client_id from the JWT assertion's 'sub' claim
func (s *OidcService) extractClientIDFromAssertion(assertion string) (string, error) {
// Parse the JWT without verification first to get the claims
insecureToken, err := jwt.ParseInsecure([]byte(assertion))
if err != nil {
return "", fmt.Errorf("failed to parse JWT assertion: %w", err)
}
// Extract the subject claim which must be the client_id according to RFC 7523
sub, ok := insecureToken.Subject()
if !ok || sub == "" {
return "", fmt.Errorf("missing or invalid 'sub' claim in JWT assertion")
}
return sub, nil
}
func (s *OidcService) GetClientPreview(ctx context.Context, clientID string, userID string, scopes []string) (*dto.OidcClientPreviewDto, error) {
func (s *OidcService) GetClientPreview(ctx context.Context, clientID string, userID string, scopes []string, authenticationMethod string) (*dto.OidcClientPreviewDto, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
@@ -1849,12 +1901,12 @@ func (s *OidcService) GetClientPreview(ctx context.Context, clientID string, use
return nil, err
}
idToken, err := s.jwtService.BuildIDToken(userClaims, clientID, "")
idToken, err := s.jwtService.BuildIDToken(userClaims, clientID, "", authenticationMethod)
if err != nil {
return nil, err
}
accessToken, err := s.jwtService.BuildOAuthAccessToken(user, clientID)
accessToken, err := s.jwtService.BuildOAuthAccessToken(user, clientID, authenticationMethod)
if err != nil {
return nil, err
}
@@ -2134,3 +2186,21 @@ func (s *OidcService) GetClientScimServiceProvider(ctx context.Context, clientID
return provider, nil
}
// parsePromptParameter parses the OIDC prompt parameter which is a space-delimited list of values
func parsePromptParameter(prompt string) []string {
if prompt == "" {
return []string{}
}
return strings.Fields(prompt)
}
// contains checks if a string slice contains a specific value
func contains(slice []string, value string) bool {
for _, item := range slice {
if item == value {
return true
}
}
return false
}

View File

@@ -20,6 +20,7 @@ import (
"github.com/lestrrat-go/jwx/v3/jwt"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
@@ -229,6 +230,12 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
Subject: federatedClient.ID,
JWKS: federatedClientIssuer + "/jwks.json",
},
{
Issuer: "federated-issuer-2",
Audience: federatedClientAudience,
Subject: "my-federated-client",
JWKS: federatedClientIssuer + "/jwks.json",
},
{Issuer: federatedClientIssuerDefaults},
},
},
@@ -461,6 +468,43 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
// Generate a token
input := dto.OidcCreateTokensDto{
ClientID: federatedClient.ID,
ClientAssertion: string(signedToken),
ClientAssertionType: ClientAssertionTypeJWTBearer,
}
createdToken, err := s.createTokenFromClientCredentials(t.Context(), input)
require.NoError(t, err)
require.NotNil(t, token)
// Verify the token
claims, err := s.jwtService.VerifyOAuthAccessToken(createdToken.AccessToken)
require.NoError(t, err, "Failed to verify generated token")
// Check the claims
subject, ok := claims.Subject()
_ = assert.True(t, ok, "User ID not found in token") &&
assert.Equal(t, "client-"+federatedClient.ID, subject, "Token subject should match federated client ID with prefix")
audience, ok := claims.Audience()
_ = assert.True(t, ok, "Audience not found in token") &&
assert.Equal(t, []string{federatedClient.ID}, audience, "Audience should contain the federated client ID")
})
t.Run("Succeeds with valid assertion and custom subject", func(t *testing.T) {
// Create JWT for federated identity
token, err := jwt.NewBuilder().
Issuer("federated-issuer-2").
Audience([]string{federatedClientAudience}).
Subject("my-federated-client").
IssuedAt(time.Now()).
Expiration(time.Now().Add(10 * time.Minute)).
Build()
require.NoError(t, err)
signedToken, err := jwt.Sign(token, jwt.WithKey(jwa.ES256(), privateJWK))
require.NoError(t, err)
// Generate a token
input := dto.OidcCreateTokensDto{
ClientID: federatedClient.ID,
ClientAssertion: string(signedToken),
ClientAssertionType: ClientAssertionTypeJWTBearer,
}
@@ -483,6 +527,7 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
t.Run("Fails with invalid assertion", func(t *testing.T) {
input := dto.OidcCreateTokensDto{
ClientID: confidentialClient.ID,
ClientAssertion: "invalid.jwt.token",
ClientAssertionType: ClientAssertionTypeJWTBearer,
}
@@ -518,6 +563,180 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
})
}
func TestOidcServiceRefreshTokenAuthorizationState(t *testing.T) {
newFixture := func(t *testing.T, isGroupRestricted bool) (*OidcService, *gorm.DB, model.User, model.OidcClient, string, string, *model.UserGroup) {
t.Helper()
db := testutils.NewDatabaseForTest(t)
common.EnvConfig.EncryptionKey = []byte("0123456789abcdef0123456789abcdef")
mockConfig := NewTestAppConfigService(&model.AppConfig{
SessionDuration: model.AppConfigVariable{Value: "60"},
})
jwtService, err := NewJwtService(t.Context(), db, mockConfig)
require.NoError(t, err)
service := &OidcService{
db: db,
jwtService: jwtService,
appConfigService: mockConfig,
}
email := "refresh-token-user@example.com"
user := model.User{
Username: "refresh-token-user",
Email: &email,
EmailVerified: true,
FirstName: "Refresh",
LastName: "User",
}
require.NoError(t, db.Create(&user).Error)
client, err := service.CreateClient(t.Context(), dto.OidcClientCreateDto{
OidcClientUpdateDto: dto.OidcClientUpdateDto{
Name: "Refresh Token Client",
CallbackURLs: []string{"https://example.com/callback"},
IsGroupRestricted: isGroupRestricted,
},
}, user.ID)
require.NoError(t, err)
clientSecret, err := service.CreateClientSecret(t.Context(), client.ID)
require.NoError(t, err)
var userGroup *model.UserGroup
if isGroupRestricted {
group := model.UserGroup{
FriendlyName: "Allowed Group",
Name: "allowed-group",
}
require.NoError(t, db.Create(&group).Error)
require.NoError(t, db.Model(&user).Association("UserGroups").Append(&group))
require.NoError(t, db.Model(&client).Association("AllowedUserGroups").Append(&group))
userGroup = &group
}
scope := "openid profile email groups"
require.NoError(t, db.Create(&model.UserAuthorizedOidcClient{
UserID: user.ID,
ClientID: client.ID,
Scope: scope,
}).Error)
refreshToken, err := service.createRefreshToken(t.Context(), client.ID, user.ID, scope, AuthenticationMethodPhishingResistant, db)
require.NoError(t, err)
return service, db, user, client, clientSecret, refreshToken, userGroup
}
refreshInput := func(client model.OidcClient, clientSecret string, refreshToken string) dto.OidcCreateTokensDto {
return dto.OidcCreateTokensDto{
GrantType: GrantTypeRefreshToken,
RefreshToken: refreshToken,
ClientID: client.ID,
ClientSecret: clientSecret,
}
}
t.Run("rejects refresh token after authorization revocation", func(t *testing.T) {
service, db, user, client, clientSecret, refreshToken, _ := newFixture(t, false)
err := service.RevokeAuthorizedClient(t.Context(), user.ID, client.ID)
require.NoError(t, err)
var refreshTokenCount int64
require.NoError(t, db.Model(&model.OidcRefreshToken{}).
Where("user_id = ? AND client_id = ?", user.ID, client.ID).
Count(&refreshTokenCount).Error)
assert.Zero(t, refreshTokenCount)
_, err = service.createTokenFromRefreshToken(t.Context(), refreshInput(client, clientSecret, refreshToken))
require.Error(t, err)
require.ErrorIs(t, err, &common.OidcInvalidRefreshTokenError{})
})
t.Run("rejects and deletes stale refresh token without authorization record", func(t *testing.T) {
service, db, user, client, clientSecret, refreshToken, _ := newFixture(t, false)
require.NoError(t, db.
Where("user_id = ? AND client_id = ?", user.ID, client.ID).
Delete(&model.UserAuthorizedOidcClient{}).Error)
_, err := service.createTokenFromRefreshToken(t.Context(), refreshInput(client, clientSecret, refreshToken))
require.Error(t, err)
require.ErrorIs(t, err, &common.OidcInvalidRefreshTokenError{})
var refreshTokenCount int64
require.NoError(t, db.Model(&model.OidcRefreshToken{}).
Where("user_id = ? AND client_id = ?", user.ID, client.ID).
Count(&refreshTokenCount).Error)
assert.Zero(t, refreshTokenCount)
})
t.Run("rejects refresh token for disabled user", func(t *testing.T) {
service, db, user, client, clientSecret, refreshToken, _ := newFixture(t, false)
require.NoError(t, db.Model(&model.User{}).
Where("id = ?", user.ID).
Update("disabled", true).Error)
_, err := service.createTokenFromRefreshToken(t.Context(), refreshInput(client, clientSecret, refreshToken))
require.Error(t, err)
require.ErrorIs(t, err, &common.OidcInvalidRefreshTokenError{})
})
t.Run("rejects refresh token after user leaves allowed group", func(t *testing.T) {
service, db, user, client, clientSecret, refreshToken, userGroup := newFixture(t, true)
require.NotNil(t, userGroup)
require.NoError(t, db.Model(&user).Association("UserGroups").Delete(userGroup))
_, err := service.createTokenFromRefreshToken(t.Context(), refreshInput(client, clientSecret, refreshToken))
require.Error(t, err)
require.ErrorIs(t, err, &common.OidcAccessDeniedError{})
})
}
func TestOidcServiceAuthenticationMethodsPersistence(t *testing.T) {
mockConfig := NewTestAppConfigService(&model.AppConfig{
SessionDuration: model.AppConfigVariable{Value: "60"},
})
jwtService, db, _ := setupJwtService(t, mockConfig)
service := &OidcService{
db: db,
jwtService: jwtService,
}
authenticationMethod := AuthenticationMethodPhishingResistant
t.Run("stores authentication method on authorization codes", func(t *testing.T) {
code, err := service.createAuthorizationCode(
t.Context(),
"amr-client",
"amr-user",
"openid profile",
authenticationMethod,
"",
"",
"",
db,
)
require.NoError(t, err)
var authorizationCode model.OidcAuthorizationCode
require.NoError(t, db.First(&authorizationCode, "code = ?", code).Error)
assert.Equal(t, authenticationMethod, authorizationCode.AuthenticationMethod)
})
t.Run("stores authentication methods on refresh tokens", func(t *testing.T) {
_, err := service.createRefreshToken(t.Context(), "amr-client", "amr-user", "openid profile", authenticationMethod, db)
require.NoError(t, err)
var refreshToken model.OidcRefreshToken
require.NoError(t, db.First(&refreshToken, "client_id = ? AND user_id = ?", "amr-client", "amr-user").Error)
assert.Equal(t, authenticationMethod, refreshToken.AuthenticationMethod)
})
}
func TestValidateCodeVerifier_Plain(t *testing.T) {
require.False(t, validateCodeVerifier("", "", false))
require.False(t, validateCodeVerifier("", "", true))
@@ -974,3 +1193,73 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) {
require.ErrorContains(t, err, "failed to look up client")
})
}
// Tests for prompt parameter parsing and handling
func TestParsePromptParameter(t *testing.T) {
t.Run("empty prompt returns empty slice", func(t *testing.T) {
result := parsePromptParameter("")
assert.Equal(t, []string{}, result)
})
t.Run("single prompt value", func(t *testing.T) {
result := parsePromptParameter("none")
assert.Equal(t, []string{"none"}, result)
})
t.Run("multiple prompt values space-delimited", func(t *testing.T) {
result := parsePromptParameter("login consent")
assert.Equal(t, []string{"login", "consent"}, result)
})
t.Run("multiple prompt values with extra spaces", func(t *testing.T) {
result := parsePromptParameter(" none login ")
assert.Equal(t, []string{"none", "login"}, result)
})
}
func TestPromptParameterConflicts(t *testing.T) {
tests := []struct {
name string
prompt string
expectConflict bool
}{
{"none alone is valid", "none", false},
{"login alone is valid", "login", false},
{"consent alone is valid", "consent", false},
{"login consent is valid", "login consent", false},
{"none consent conflicts", "none consent", true},
{"none login conflicts", "none login", true},
{"none select_account conflicts", "none select_account", true},
{"none consent login conflicts", "none consent login", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
values := parsePromptParameter(tt.prompt)
hasNone := contains(values, "none")
hasConsent := contains(values, "consent")
hasLogin := contains(values, "login")
hasSelectAccount := contains(values, "select_account")
conflict := hasNone && (hasConsent || hasLogin || hasSelectAccount)
assert.Equal(t, tt.expectConflict, conflict)
})
}
}
func TestContains(t *testing.T) {
t.Run("finds value in slice", func(t *testing.T) {
slice := []string{"none", "login", "consent"}
assert.True(t, contains(slice, "login"))
})
t.Run("returns false for missing value", func(t *testing.T) {
slice := []string{"none", "login"}
assert.False(t, contains(slice, "consent"))
})
t.Run("returns false for empty slice", func(t *testing.T) {
slice := []string{}
assert.False(t, contains(slice, "none"))
})
}

View File

@@ -79,7 +79,7 @@ func (s *OneTimeAccessService) requestOneTimeAccessEmailInternal(ctx context.Con
tx.Rollback()
}()
user, err := s.userService.GetUser(ctx, userID)
user, err := s.userService.getUserInternal(ctx, userID, tx)
if err != nil {
return nil, err
}
@@ -97,7 +97,7 @@ func (s *OneTimeAccessService) requestOneTimeAccessEmailInternal(ctx context.Con
return nil, err
}
// We use a background context here as this is running in a goroutine
// #nosec G118 - We use a background context here as this is running in a goroutine
//nolint:contextcheck
go func() {
span := trace.SpanFromContext(ctx)
@@ -131,8 +131,32 @@ func (s *OneTimeAccessService) requestOneTimeAccessEmailInternal(ctx context.Con
}
func (s *OneTimeAccessService) CreateOneTimeAccessToken(ctx context.Context, userID string, ttl time.Duration) (token string, err error) {
token, _, err = s.createOneTimeAccessTokenInternal(ctx, userID, ttl, false, s.db)
return token, err
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
// Load the user to ensure it exists
_, err = s.userService.getUserInternal(ctx, userID, tx)
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", &common.UserNotFoundError{}
} else if err != nil {
return "", err
}
// Create the one-time access token
token, _, err = s.createOneTimeAccessTokenInternal(ctx, userID, ttl, false, tx)
if err != nil {
return "", err
}
// Commit
err = tx.Commit().Error
if err != nil {
return "", err
}
return token, nil
}
func (s *OneTimeAccessService) createOneTimeAccessTokenInternal(ctx context.Context, userID string, ttl time.Duration, withDeviceToken bool, tx *gorm.DB) (token string, deviceToken *string, err error) {
@@ -173,7 +197,7 @@ func (s *OneTimeAccessService) ExchangeOneTimeAccessToken(ctx context.Context, t
return model.User{}, "", &common.DeviceCodeInvalid{}
}
accessToken, err := s.jwtService.GenerateAccessToken(oneTimeAccessToken.User)
accessToken, err := s.jwtService.GenerateAccessToken(oneTimeAccessToken.User, AuthenticationMethodOneTimePassword)
if err != nil {
return model.User{}, "", err
}

View File

@@ -0,0 +1,25 @@
package service
import (
"context"
backoff "github.com/cenkalti/backoff/v5"
"github.com/go-co-op/gocron/v2"
)
// RegisterJobOpts holds optional configuration for registering a scheduled job.
type RegisterJobOpts struct {
// RunImmediately runs the job immediately after registration.
RunImmediately bool
// ExtraOptions are additional gocron job options.
ExtraOptions []gocron.JobOption
// BackOff is an optional backoff strategy. If non-nil, the job will be wrapped
// with automatic retry logic using the provided backoff on transient failures.
BackOff backoff.BackOff
}
// Scheduler is an interface for registering and managing background jobs.
type Scheduler interface {
RegisterJob(ctx context.Context, name string, def gocron.JobDefinition, job func(ctx context.Context) error, opts RegisterJobOpts) error
RemoveJob(name string) error
}

View File

@@ -34,11 +34,6 @@ const scimErrorBodyLimit = 4096
type scimSyncAction int
type Scheduler interface {
RegisterJob(ctx context.Context, name string, def gocron.JobDefinition, job func(ctx context.Context) error, runImmediately bool, extraOptions ...gocron.JobOption) error
RemoveJob(name string) error
}
const (
scimActionNone scimSyncAction = iota
scimActionCreated
@@ -149,7 +144,7 @@ func (s *ScimService) ScheduleSync() {
err := s.scheduler.RegisterJob(
context.Background(), jobName,
gocron.OneTimeJob(gocron.OneTimeJobStartDateTime(start)), s.SyncAll, false)
gocron.OneTimeJob(gocron.OneTimeJobStartDateTime(start)), s.SyncAll, RegisterJobOpts{})
if err != nil {
slog.Error("Failed to schedule SCIM sync", slog.Any("error", err))
@@ -168,7 +163,8 @@ func (s *ScimService) SyncAll(ctx context.Context) error {
errs = append(errs, ctx.Err())
break
}
if err := s.SyncServiceProvider(ctx, provider.ID); err != nil {
err = s.SyncServiceProvider(ctx, provider.ID)
if err != nil {
errs = append(errs, fmt.Errorf("failed to sync SCIM provider %s: %w", provider.ID, err))
}
}
@@ -210,26 +206,20 @@ func (s *ScimService) SyncServiceProvider(ctx context.Context, serviceProviderID
}
var errs []error
var userStats scimSyncStats
var groupStats scimSyncStats
// Sync users first, so that groups can reference them
if stats, err := s.syncUsers(ctx, provider, users, &userResources); err != nil {
errs = append(errs, err)
userStats = stats
} else {
userStats = stats
}
stats, err := s.syncGroups(ctx, provider, groups, groupResources.Resources, userResources.Resources)
userStats, err := s.syncUsers(ctx, provider, users, &userResources)
if err != nil {
errs = append(errs, err)
}
groupStats, err := s.syncGroups(ctx, provider, groups, groupResources.Resources, userResources.Resources)
if err != nil {
errs = append(errs, err)
groupStats = stats
} else {
groupStats = stats
}
if len(errs) > 0 {
err = errors.Join(errs...)
slog.WarnContext(ctx, "SCIM sync completed with errors",
slog.String("provider_id", provider.ID),
slog.Int("error_count", len(errs)),
@@ -240,12 +230,14 @@ func (s *ScimService) SyncServiceProvider(ctx context.Context, serviceProviderID
slog.Int("groups_updated", groupStats.Updated),
slog.Int("groups_deleted", groupStats.Deleted),
slog.Duration("duration", time.Since(start)),
slog.Any("error", err),
)
return errors.Join(errs...)
return err
}
provider.LastSyncedAt = new(datatype.DateTime(time.Now()))
if err := s.db.WithContext(ctx).Save(&provider).Error; err != nil {
err = s.db.WithContext(ctx).Save(&provider).Error
if err != nil {
return err
}
@@ -273,7 +265,7 @@ func (s *ScimService) syncUsers(
// Update or create users
for _, u := range users {
existing := getResourceByExternalID[dto.ScimUser](u.ID, resourceList.Resources)
existing := getResourceByExternalID(u.ID, resourceList.Resources)
action, created, err := s.syncUser(ctx, provider, u, existing)
if created != nil && existing == nil {
@@ -434,7 +426,7 @@ func (s *ScimService) syncGroup(
// Prepare group members
members := make([]dto.ScimGroupMember, len(group.Users))
for i, user := range group.Users {
userResource := getResourceByExternalID[dto.ScimUser](user.ID, userResources)
userResource := getResourceByExternalID(user.ID, userResources)
if userResource == nil {
// Groups depend on user IDs already being provisioned
return scimActionNone, fmt.Errorf("cannot sync group %s: user %s is not provisioned in SCIM provider", group.ID, user.ID)

View File

@@ -96,7 +96,10 @@ func (s *UserGroupService) Delete(ctx context.Context, id string) error {
return err
}
s.scimService.ScheduleSync()
if s.scimService != nil {
s.scimService.ScheduleSync()
}
return nil
}
@@ -126,7 +129,10 @@ func (s *UserGroupService) createInternal(ctx context.Context, input dto.UserGro
return model.UserGroup{}, err
}
s.scimService.ScheduleSync()
if s.scimService != nil {
s.scimService.ScheduleSync()
}
return group, nil
}
@@ -175,7 +181,10 @@ func (s *UserGroupService) updateInternal(ctx context.Context, id string, input
return model.UserGroup{}, err
}
s.scimService.ScheduleSync()
if s.scimService != nil {
s.scimService.ScheduleSync()
}
return group, nil
}
@@ -238,7 +247,10 @@ func (s *UserGroupService) updateUsersInternal(ctx context.Context, id string, u
return model.UserGroup{}, err
}
s.scimService.ScheduleSync()
if s.scimService != nil {
s.scimService.ScheduleSync()
}
return group, nil
}
@@ -315,6 +327,9 @@ func (s *UserGroupService) UpdateAllowedOidcClient(ctx context.Context, id strin
return model.UserGroup{}, err
}
s.scimService.ScheduleSync()
if s.scimService != nil {
s.scimService.ScheduleSync()
}
return group, nil
}

View File

@@ -136,7 +136,8 @@ func (s *UserService) GetProfilePicture(ctx context.Context, userID string) (io.
// Save the default picture for future use (in a goroutine to avoid blocking)
defaultPictureBytes := defaultPicture.Bytes()
//nolint:contextcheck
//#nosec G118 - We use a background context as this is running in background
// nolint:contextcheck
go func() {
// Use bytes.NewReader because we need an io.ReadSeeker
rErr := s.fileStorage.Save(context.Background(), defaultPicturePath, bytes.NewReader(defaultPictureBytes))
@@ -225,7 +226,10 @@ func (s *UserService) deleteUserInternal(ctx context.Context, tx *gorm.DB, userI
return fmt.Errorf("failed to delete user: %w", err)
}
s.scimService.ScheduleSync()
if s.scimService != nil {
s.scimService.ScheduleSync()
}
return nil
}
@@ -310,7 +314,10 @@ func (s *UserService) createUserInternal(ctx context.Context, input dto.UserCrea
}
}
s.scimService.ScheduleSync()
if s.scimService != nil {
s.scimService.ScheduleSync()
}
return user, nil
}
@@ -456,7 +463,10 @@ func (s *UserService) updateUserInternal(ctx context.Context, userID string, upd
return user, err
}
s.scimService.ScheduleSync()
if s.scimService != nil {
s.scimService.ScheduleSync()
}
return user, nil
}
@@ -515,7 +525,10 @@ func (s *UserService) UpdateUserGroups(ctx context.Context, id string, userGroup
return model.User{}, err
}
s.scimService.ScheduleSync()
if s.scimService != nil {
s.scimService.ScheduleSync()
}
return user, nil
}
@@ -576,7 +589,10 @@ func (s *UserService) disableUserInternal(ctx context.Context, tx *gorm.DB, user
return err
}
s.scimService.ScheduleSync()
if s.scimService != nil {
s.scimService.ScheduleSync()
}
return nil
}

View File

@@ -87,7 +87,7 @@ func (s *UserSignUpService) SignUp(ctx context.Context, signupData dto.SignUpDto
return model.User{}, "", err
}
accessToken, err := s.jwtService.GenerateAccessToken(user)
accessToken, err := s.jwtService.GenerateAccessToken(user, "")
if err != nil {
return model.User{}, "", err
}
@@ -124,14 +124,12 @@ func (s *UserSignUpService) SignUpInitialAdmin(ctx context.Context, signUpData d
tx.Rollback()
}()
var userCount int64
if err := tx.WithContext(ctx).Model(&model.User{}).
Where("id != ?", staticApiKeyUserID).
Count(&userCount).Error; err != nil {
setupCompleted, err := s.isInitialAdminSetupCompleted(ctx, tx)
if err != nil {
return model.User{}, "", err
}
if userCount != 0 {
return model.User{}, "", &common.SetupAlreadyCompletedError{}
if setupCompleted {
return model.User{}, "", &common.SetupNotAvailableError{}
}
userToCreate := dto.UserCreateDto{
@@ -148,7 +146,7 @@ func (s *UserSignUpService) SignUpInitialAdmin(ctx context.Context, signUpData d
return model.User{}, "", err
}
token, err := s.jwtService.GenerateAccessToken(user)
token, err := s.jwtService.GenerateAccessToken(user, AuthenticationMethodOneTimePassword)
if err != nil {
return model.User{}, "", err
}
@@ -161,6 +159,21 @@ func (s *UserSignUpService) SignUpInitialAdmin(ctx context.Context, signUpData d
return user, token, nil
}
func (s *UserSignUpService) IsInitialAdminSetupCompleted(ctx context.Context) (bool, error) {
return s.isInitialAdminSetupCompleted(ctx, s.db)
}
func (s *UserSignUpService) isInitialAdminSetupCompleted(ctx context.Context, db *gorm.DB) (bool, error) {
var userCount int64
if err := db.WithContext(ctx).Model(&model.User{}).
Where("id != ?", staticApiKeyUserID).
Count(&userCount).Error; err != nil {
return false, err
}
return userCount != 0, nil
}
func (s *UserSignUpService) ListSignupTokens(ctx context.Context, listRequestOptions utils.ListRequestOptions) ([]model.SignupToken, utils.PaginationResponse, error) {
var tokens []model.SignupToken
query := s.db.WithContext(ctx).Preload("UserGroups").Model(&model.SignupToken{})

View File

@@ -266,7 +266,7 @@ func (s *WebAuthnService) VerifyLogin(ctx context.Context, sessionID string, cre
return model.User{}, "", &common.UserDisabledError{}
}
token, err := s.jwtService.GenerateAccessToken(*user)
token, err := s.jwtService.GenerateAccessToken(*user, AuthenticationMethodPhishingResistant)
if err != nil {
return model.User{}, "", err
}
@@ -293,26 +293,40 @@ func (s *WebAuthnService) ListCredentials(ctx context.Context, userID string) ([
return credentials, nil
}
func (s *WebAuthnService) DeleteCredential(ctx context.Context, userID string, credentialID string, ipAddress string, userAgent string) error {
func (s *WebAuthnService) DeleteCredential(ctx context.Context, userID string, credentialID string, ipAddress string, userAgent string, actorUserID string) error {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
credential := &model.WebauthnCredential{}
err := tx.
result := tx.
WithContext(ctx).
Clauses(clause.Returning{}).
Delete(credential, "id = ? AND user_id = ?", credentialID, userID).
Error
if err != nil {
return fmt.Errorf("failed to delete record: %w", err)
Delete(credential, "id = ? AND user_id = ?", credentialID, userID)
if result.Error != nil {
return fmt.Errorf("failed to delete record: %w", result.Error)
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
auditLogData := model.AuditLogData{"credentialID": hex.EncodeToString(credential.CredentialID), "passkeyName": credential.Name}
if actorUserID != "" && actorUserID != userID {
var actor model.User
err := tx.
WithContext(ctx).
First(&actor, "id = ?", actorUserID).
Error
if err != nil {
return fmt.Errorf("failed to load actor user: %w", err)
}
auditLogData["actorUserID"] = actorUserID
auditLogData["actorUsername"] = actor.Username
}
s.auditLogService.Create(ctx, model.AuditLogEventPasskeyRemoved, ipAddress, userAgent, userID, auditLogData, tx)
err = tx.Commit().Error
err := tx.Commit().Error
if err != nil {
return fmt.Errorf("failed to commit transaction: %w", err)
}
@@ -375,6 +389,14 @@ func (s *WebAuthnService) CreateReauthenticationTokenWithAccessToken(ctx context
return "", errors.New("access token does not contain user ID")
}
authenticationMethod, err := GetAuthenticationMethod(token)
if err != nil {
return "", err
}
if authenticationMethod != AuthenticationMethodPhishingResistant {
return "", &common.ReauthenticationRequiredError{}
}
// Check if token is issued less than a minute ago
tokenExpiration, ok := token.IssuedAt()
if !ok || time.Since(tokenExpiration) > time.Minute {

View File

@@ -0,0 +1,68 @@
package service
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/model"
)
func TestCreateReauthenticationTokenWithAccessToken(t *testing.T) {
mockConfig := NewTestAppConfigService(&model.AppConfig{
SessionDuration: model.AppConfigVariable{Value: "60"},
})
setupService := func(t *testing.T) (*WebAuthnService, model.User) {
t.Helper()
jwtService, db, _ := setupJwtService(t, mockConfig)
user := model.User{
Base: model.Base{ID: "reauth-user"},
Username: "reauth-user",
}
require.NoError(t, db.Create(&user).Error)
return &WebAuthnService{
db: db,
jwtService: jwtService,
}, user
}
t.Run("accepts a fresh access token from WebAuthn login", func(t *testing.T) {
service, user := setupService(t)
accessToken, err := service.jwtService.GenerateAccessToken(user, AuthenticationMethodPhishingResistant)
require.NoError(t, err)
reauthenticationToken, err := service.CreateReauthenticationTokenWithAccessToken(t.Context(), accessToken)
require.NoError(t, err)
assert.NotEmpty(t, reauthenticationToken)
})
t.Run("rejects a fresh access token from one-time access login", func(t *testing.T) {
service, user := setupService(t)
accessToken, err := service.jwtService.GenerateAccessToken(user, AuthenticationMethodOneTimePassword)
require.NoError(t, err)
reauthenticationToken, err := service.CreateReauthenticationTokenWithAccessToken(t.Context(), accessToken)
assert.Empty(t, reauthenticationToken)
require.Error(t, err)
assert.ErrorAs(t, err, new(*common.ReauthenticationRequiredError))
})
t.Run("rejects a fresh access token without an authentication method", func(t *testing.T) {
service, user := setupService(t)
accessToken, err := service.jwtService.GenerateAccessToken(user, "")
require.NoError(t, err)
reauthenticationToken, err := service.CreateReauthenticationTokenWithAccessToken(t.Context(), accessToken)
assert.Empty(t, reauthenticationToken)
require.Error(t, err)
assert.ErrorAs(t, err, new(*common.ReauthenticationRequiredError))
})
}

View File

@@ -138,7 +138,7 @@ func (s *s3Storage) List(ctx context.Context, path string) ([]ObjectInfo, error)
continue
}
objects = append(objects, ObjectInfo{
Path: aws.ToString(obj.Key),
Path: s.pathFromKey(aws.ToString(obj.Key)),
Size: aws.ToInt64(obj.Size),
ModTime: aws.ToTime(obj.LastModified),
})
@@ -147,6 +147,13 @@ func (s *s3Storage) List(ctx context.Context, path string) ([]ObjectInfo, error)
return objects, nil
}
func (s *s3Storage) pathFromKey(key string) string {
if s.prefix == "" {
return key
}
return strings.TrimPrefix(key, s.prefix+"/")
}
func (s *s3Storage) Walk(ctx context.Context, root string, fn func(ObjectInfo) error) error {
objects, err := s.List(ctx, root)
if err != nil {

View File

@@ -35,6 +35,50 @@ func TestS3Helpers(t *testing.T) {
}
})
t.Run("pathFromKey strips prefix to honor relative-path contract", func(t *testing.T) {
tests := []struct {
name string
prefix string
key string
expected string
}{
{name: "no prefix returns key unchanged", prefix: "", key: "images/logo.png", expected: "images/logo.png"},
{name: "no prefix empty key", prefix: "", key: "", expected: ""},
{name: "prefix matches and is stripped", prefix: "data/uploads", key: "data/uploads/application-images/logo.svg", expected: "application-images/logo.svg"},
{name: "single-segment prefix stripped", prefix: "root", key: "root/foo/bar.txt", expected: "foo/bar.txt"},
{name: "prefix equal to key without trailing slash is unchanged", prefix: "root", key: "root", expected: "root"},
{name: "key without expected prefix returned unchanged", prefix: "data/uploads", key: "other/path.txt", expected: "other/path.txt"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
s := &s3Storage{
bucket: "bucket",
prefix: tc.prefix,
}
assert.Equal(t, tc.expected, s.pathFromKey(tc.key))
})
}
})
t.Run("pathFromKey is the inverse of buildObjectKey for clean paths", func(t *testing.T) {
paths := []string{
"images/logo.png",
"application-images/logo.svg",
"oidc-client-images/abc.png",
"deeply/nested/file.bin",
}
prefixes := []string{"", "root", "data/uploads"}
for _, prefix := range prefixes {
for _, p := range paths {
s := &s3Storage{bucket: "bucket", prefix: prefix}
assert.Equal(t, p, s.pathFromKey(s.buildObjectKey(p)),
"round-trip failed for prefix=%q path=%q", prefix, p)
}
}
})
t.Run("isS3NotFound detects expected errors", func(t *testing.T) {
assert.True(t, isS3NotFound(&smithy.GenericAPIError{Code: "NoSuchKey"}))
assert.True(t, isS3NotFound(&smithy.GenericAPIError{Code: "NotFound"}))

View File

@@ -1,14 +1,31 @@
package utils
import (
"log/slog"
"net"
"net/url"
"path"
"regexp"
"strconv"
"strings"
"github.com/dunglas/go-urlpattern"
)
// GetCallbackURLFromList returns the first callback URL that matches the input callback URL
// ValidateCallbackURLPattern checks if the given callback URL pattern
// is valid according to the rules defined in this package.
func ValidateCallbackURLPattern(pattern string) error {
if pattern == "*" {
return nil
}
pattern, _, _ = strings.Cut(pattern, "#")
pattern = normalizeToURLPatternStandard(pattern)
_, err := urlpattern.New(pattern, "", nil)
return err
}
// GetCallbackURLFromList returns the first callback URL that matches the input callback URL.
func GetCallbackURLFromList(urls []string, inputCallbackURL string) (callbackURL string, err error) {
// Special case for Loopback Interface Redirection. Quoting from RFC 8252 section 7.3:
// https://datatracker.ietf.org/doc/html/rfc8252#section-7.3
@@ -17,17 +34,7 @@ func GetCallbackURLFromList(urls []string, inputCallbackURL string) (callbackURL
// time of the request for loopback IP redirect URIs, to accommodate
// clients that obtain an available ephemeral port from the operating
// system at the time of the request.
loopbackCallbackURLWithoutPort := ""
u, _ := url.Parse(inputCallbackURL)
if u != nil && u.Scheme == "http" {
host := u.Hostname()
ip := net.ParseIP(host)
if host == "localhost" || (ip != nil && ip.IsLoopback()) {
u.Host = host
loopbackCallbackURLWithoutPort = u.String()
}
}
loopbackCallbackURLWithoutPort := loopbackURLWithWildcardPort(inputCallbackURL)
for _, pattern := range urls {
// Try the original callback first
@@ -54,6 +61,28 @@ func GetCallbackURLFromList(urls []string, inputCallbackURL string) (callbackURL
return "", nil
}
func loopbackURLWithWildcardPort(input string) string {
u, _ := url.Parse(input)
if u == nil || u.Scheme != "http" {
return ""
}
host := u.Hostname()
ip := net.ParseIP(host)
if host != "localhost" && (ip == nil || !ip.IsLoopback()) {
return ""
}
// For IPv6 loopback hosts, brackets are required when serializing without a port.
if strings.Contains(host, ":") {
u.Host = "[" + host + "]"
} else {
u.Host = host
}
return u.String()
}
// matchCallbackURL checks if the input callback URL matches the given pattern.
// It supports wildcard matching for paths and query parameters.
//
@@ -64,143 +93,176 @@ func matchCallbackURL(pattern string, inputCallbackURL string) (matches bool, er
return true, nil
}
// Strip fragment part
// Strip fragment part.
// The endpoint URI MUST NOT include a fragment component.
// https://datatracker.ietf.org/doc/html/rfc6749#section-3.1.2
pattern, _, _ = strings.Cut(pattern, "#")
inputCallbackURL, _, _ = strings.Cut(inputCallbackURL, "#")
// Store and strip query part
var patternQuery url.Values
if i := strings.Index(pattern, "?"); i >= 0 {
patternQuery, err = url.ParseQuery(pattern[i+1:])
if err != nil {
return false, err
}
pattern = pattern[:i]
}
var inputQuery url.Values
if i := strings.Index(inputCallbackURL, "?"); i >= 0 {
inputQuery, err = url.ParseQuery(inputCallbackURL[i+1:])
if err != nil {
return false, err
}
inputCallbackURL = inputCallbackURL[:i]
}
// Split both pattern and input parts
patternParts, patternPath := splitParts(pattern)
inputParts, inputPath := splitParts(inputCallbackURL)
// Verify everything except the path and query parameters
if len(patternParts) != len(inputParts) {
return false, nil
}
for i, patternPart := range patternParts {
matched, err := path.Match(patternPart, inputParts[i])
if err != nil || !matched {
return false, err
}
}
// Verify path with wildcard support
matched, err := matchPath(patternPath, inputPath)
if err != nil || !matched {
pattern, patternQuery, err := extractQueryParams(pattern)
if err != nil {
return false, err
}
// Verify query parameters
if len(patternQuery) != len(inputQuery) {
inputCallbackURL, inputQuery, err := extractQueryParams(inputCallbackURL)
if err != nil {
return false, err
}
pattern = normalizeToURLPatternStandard(pattern)
// Validate query params
v := validateQueryParams(patternQuery, inputQuery)
if !v {
return false, nil
}
// Validate the rest of the URL using urlpattern
p, err := urlpattern.New(pattern, "", nil)
if err != nil {
//nolint:nilerr
slog.Warn("invalid callback URL pattern, skipping", "pattern", pattern, "error", err)
return false, nil
}
return p.Test(inputCallbackURL, ""), nil
}
// normalizeToURLPatternStandard converts patterns with single asterisk wildcards and globstar wildcards
// into a format that can be parsed by the urlpattern package, which uses :param for single segment wildcards
// and ** for multi-segment wildcards.
// Additionally, it escapes ":" with a backslash inside IPv6 addresses
func normalizeToURLPatternStandard(pattern string) string {
patternBase, patternPath := extractPath(pattern)
var result strings.Builder
result.Grow(len(pattern) + 5) // Add 5 for some extra capacity, hoping to avoid many re-allocations
// First, process the base
// 0 = scheme
// 1 = hostname (optionally with username/password) - before IPv6 start (no `[` found)
// 2 = is matching IPv6 (until `]`)
// 3 = after hostname
var step int
for i := 0; i < len(patternBase); i++ {
switch step {
case 0:
if i > 3 && patternBase[i] == '/' && patternBase[i-1] == '/' && patternBase[i-2] == ':' {
// We just passed the scheme
step = 1
}
case 1:
switch patternBase[i] {
case '/', ']':
// No IPv6, skip to end of this logic
step = 3
case '[':
// Start of IPv6 match
step = 2
}
case 2:
if patternBase[i] == '/' || patternBase[i] == ']' || patternBase[i] == '[' {
// End of IPv6 match
step = 3
}
switch patternBase[i] {
case ':':
// We are matching an IPv6 block and there's a colon, so escape that
result.WriteByte('\\')
case '/', ']', '[':
// End of IPv6 match
step = 3
}
}
// Write the byte
result.WriteByte(patternBase[i])
}
// Next, process the path
for i := 0; i < len(patternPath); i++ {
if patternPath[i] == '*' {
// Replace globstar with a single asterisk
if i+1 < len(patternPath) && patternPath[i+1] == '*' {
result.WriteString("*")
i++ // skip next *
} else {
// Replace single asterisk with :p{index}
result.WriteString(":p")
result.WriteString(strconv.Itoa(i))
}
} else {
// Add the byte
result.WriteByte(patternPath[i])
}
}
return result.String()
}
func extractPath(url string) (base string, path string) {
pathStart := -1
// Look for scheme:// first
i := strings.Index(url, "://")
if i >= 0 {
// Look for the next slash after scheme://
rest := url[i+3:]
if j := strings.IndexByte(rest, '/'); j >= 0 {
pathStart = i + 3 + j
}
} else {
// Otherwise, first slash is path start
pathStart = strings.IndexByte(url, '/')
}
if pathStart >= 0 {
path = url[pathStart:]
base = url[:pathStart]
} else {
path = ""
base = url
}
return base, path
}
func extractQueryParams(rawUrl string) (base string, query url.Values, err error) {
if i := strings.IndexByte(rawUrl, '?'); i >= 0 {
query, err = url.ParseQuery(rawUrl[i+1:])
if err != nil {
return "", nil, err
}
rawUrl = rawUrl[:i]
}
return rawUrl, query, nil
}
func validateQueryParams(patternQuery, inputQuery url.Values) bool {
if len(patternQuery) != len(inputQuery) {
return false
}
for patternKey, patternValues := range patternQuery {
inputValues, exists := inputQuery[patternKey]
if !exists {
return false, nil
return false
}
if len(patternValues) != len(inputValues) {
return false, nil
return false
}
for i := range patternValues {
matched, err := path.Match(patternValues[i], inputValues[i])
if err != nil || !matched {
return false, err
return false
}
}
}
return true, nil
}
// matchPath matches the input path against the pattern with wildcard support
// Supported wildcards:
//
// '*' matches any sequence of characters except '/'
// '**' matches any sequence of characters including '/'
func matchPath(pattern string, input string) (matches bool, err error) {
var regexPattern strings.Builder
regexPattern.WriteString("^")
runes := []rune(pattern)
n := len(runes)
for i := 0; i < n; {
switch runes[i] {
case '*':
// Check if it's a ** (globstar)
if i+1 < n && runes[i+1] == '*' {
// globstar = .* (match slashes too)
regexPattern.WriteString(".*")
i += 2
} else {
// single * = [^/]* (no slash)
regexPattern.WriteString(`[^/]*`)
i++
}
default:
regexPattern.WriteString(regexp.QuoteMeta(string(runes[i])))
i++
}
}
regexPattern.WriteString("$")
matched, err := regexp.MatchString(regexPattern.String(), input)
return matched, err
}
// splitParts splits the URL into parts by special characters and returns the path separately
func splitParts(s string) (parts []string, path string) {
split := func(r rune) bool {
return r == ':' || r == '/' || r == '[' || r == ']' || r == '@' || r == '.'
}
pathStart := -1
// Look for scheme:// first
if i := strings.Index(s, "://"); i >= 0 {
// Look for the next slash after scheme://
rest := s[i+3:]
if j := strings.IndexRune(rest, '/'); j >= 0 {
pathStart = i + 3 + j
}
} else {
// Otherwise, first slash is path start
pathStart = strings.IndexRune(s, '/')
}
if pathStart >= 0 {
path = s[pathStart:]
base := s[:pathStart]
parts = strings.FieldsFunc(base, split)
} else {
parts = strings.FieldsFunc(s, split)
path = ""
}
return parts, path
return true
}

View File

@@ -7,6 +7,142 @@ import (
"github.com/stretchr/testify/require"
)
func TestValidateCallbackURLPattern(t *testing.T) {
tests := []struct {
name string
pattern string
shouldError bool
}{
{
name: "exact URL",
pattern: "https://example.com/callback",
shouldError: false,
},
{
name: "wildcard scheme",
pattern: "*://example.com/callback",
shouldError: false,
},
{
name: "wildcard port",
pattern: "https://example.com:*/callback",
shouldError: false,
},
{
name: "partial wildcard port",
pattern: "https://example.com:80*/callback",
shouldError: false,
},
{
name: "wildcard userinfo",
pattern: "https://user:*@example.com/callback", // #nosec G101 - Test credential
shouldError: false,
},
{
name: "glob wildcard",
pattern: "*",
shouldError: false,
},
{
name: "relative URL",
pattern: "/callback",
shouldError: true,
},
{
name: "missing scheme separator",
pattern: "https//example.com/callback",
shouldError: true,
},
{
name: "malformed wildcard host glob",
pattern: "https://exa[mple.com/callback",
shouldError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateCallbackURLPattern(tt.pattern)
if tt.shouldError {
require.Error(t, err)
return
}
require.NoError(t, err)
})
}
}
func TestNormalizeToURLPatternStandard(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "exact URL unchanged",
input: "https://example.com/callback",
expected: "https://example.com/callback",
},
{
name: "single wildcard path segment converted to named parameter",
input: "https://example.com/api/*/callback",
expected: "https://example.com/api/:p5/callback",
},
{
name: "single wildcard in path suffix converted to named parameter",
input: "https://example.com/test*",
expected: "https://example.com/test:p5",
},
{
name: "globstar converted to single asterisk",
input: "https://example.com/**/callback",
expected: "https://example.com/*/callback",
},
{
name: "mixed globstar and single wildcard conversion",
input: "https://example.com/**/v1/**/callback/*",
expected: "https://example.com/*/v1/*/callback/:p19",
},
{
name: "URL without path unchanged",
input: "https://example.com",
expected: "https://example.com",
},
{
name: "relative path conversion",
input: "/foo/*/bar",
expected: "/foo/:p5/bar",
},
{
name: "wildcard in hostname is not normalized by this function",
input: "https://*.example.com/callback",
expected: "https://*.example.com/callback",
},
{
name: "IPv6 hostname escapes all colons inside address",
input: "https://[2001:db8:1:1::a:1]/callback",
expected: "https://[2001\\:db8\\:1\\:1\\:\\:a\\:1]/callback",
},
{
name: "IPv6 hostname with port escapes only address colons",
input: "https://[::1]:8080/callback",
expected: "https://[\\:\\:1]:8080/callback",
},
{
name: "wildcard in query is converted when query is part of input",
input: "https://example.com/callback?code=*",
expected: "https://example.com/callback?code=:p15",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expected, normalizeToURLPatternStandard(tt.input))
})
}
}
func TestMatchCallbackURL(t *testing.T) {
tests := []struct {
name string
@@ -27,6 +163,18 @@ func TestMatchCallbackURL(t *testing.T) {
"https://example.com/callback",
false,
},
{
"exact match - IPv4",
"https://10.1.0.1/callback",
"https://10.1.0.1/callback",
true,
},
{
"exact match - IPv6",
"https://[2001:db8:1:1::a:1]/callback",
"https://[2001:db8:1:1::a:1]/callback",
true,
},
// Scheme
{
@@ -111,6 +259,30 @@ func TestMatchCallbackURL(t *testing.T) {
"https://example.com:8080/callback",
true,
},
{
"wildcard port - IPv4",
"https://10.1.0.1:*/callback",
"https://10.1.0.1:8080/callback",
true,
},
{
"partial wildcard in port prefix - IPv4",
"https://10.1.0.1:80*/callback",
"https://10.1.0.1:8080/callback",
true,
},
{
"wildcard port - IPv6",
"https://[2001:db8:1:1::a:1]:*/callback",
"https://[2001:db8:1:1::a:1]:8080/callback",
true,
},
{
"partial wildcard in port prefix - IPv6",
"https://[2001:db8:1:1::a:1]:80*/callback",
"https://[2001:db8:1:1::a:1]:8080/callback",
true,
},
// Path
{
@@ -131,6 +303,18 @@ func TestMatchCallbackURL(t *testing.T) {
"https://example.com/callback",
true,
},
{
"wildcard entire path - IPv4",
"https://10.1.0.1/*",
"https://10.1.0.1/callback",
true,
},
{
"wildcard entire path - IPv6",
"https://[2001:db8:1:1::a:1]/*",
"https://[2001:db8:1:1::a:1]/callback",
true,
},
{
"partial wildcard in path prefix",
"https://example.com/test*",
@@ -187,12 +371,6 @@ func TestMatchCallbackURL(t *testing.T) {
"https://example.com/callback",
false,
},
{
"unexpected credentials",
"https://example.com/callback",
"https://user:pass@example.com/callback",
false,
},
{
"wildcard password",
"https://user:*@example.com/callback",
@@ -347,7 +525,7 @@ func TestMatchCallbackURL(t *testing.T) {
"backslash instead of forward slash",
"https://example.com/callback",
"https://example.com\\callback",
false,
true,
},
{
"double slash in hostname (protocol smuggling)",
@@ -370,10 +548,11 @@ func TestMatchCallbackURL(t *testing.T) {
}
for _, tt := range tests {
matches, err := matchCallbackURL(tt.pattern, tt.input)
require.NoError(t, err, tt.name)
assert.Equal(t, tt.shouldMatch, matches, tt.name)
t.Run(tt.name, func(t *testing.T) {
matches, err := matchCallbackURL(tt.pattern, tt.input)
require.NoError(t, err)
assert.Equal(t, tt.shouldMatch, matches)
})
}
}
@@ -407,14 +586,21 @@ func TestGetCallbackURLFromList_LoopbackSpecialHandling(t *testing.T) {
expectMatch: true,
},
{
name: "IPv6 loopback with dynamic port",
name: "IPv6 loopback with dynamic port - exact match",
urls: []string{"http://[::1]/callback"},
inputCallbackURL: "http://[::1]:8080/callback",
expectedURL: "http://[::1]:8080/callback",
expectMatch: true,
},
{
name: "IPv6 loopback with wildcard path",
name: "IPv6 loopback with same port - exact match",
urls: []string{"http://[::1]:8080/callback"},
inputCallbackURL: "http://[::1]:8080/callback",
expectedURL: "http://[::1]:8080/callback",
expectMatch: true,
},
{
name: "IPv6 loopback with path match",
urls: []string{"http://[::1]/auth/*"},
inputCallbackURL: "http://[::1]:8080/auth/callback",
expectedURL: "http://[::1]:8080/auth/callback",
@@ -441,6 +627,20 @@ func TestGetCallbackURLFromList_LoopbackSpecialHandling(t *testing.T) {
expectedURL: "http://127.0.0.1:3000/auth/callback",
expectMatch: true,
},
{
name: "loopback with path port",
urls: []string{"http://127.0.0.1:*/auth/callback"},
inputCallbackURL: "http://127.0.0.1:3000/auth/callback",
expectedURL: "http://127.0.0.1:3000/auth/callback",
expectMatch: true,
},
{
name: "IPv6 loopback with path port",
urls: []string{"http://[::1]:*/auth/callback"},
inputCallbackURL: "http://[::1]:3000/auth/callback",
expectedURL: "http://[::1]:3000/auth/callback",
expectMatch: true,
},
{
name: "loopback with path mismatch",
urls: []string{"http://127.0.0.1/callback"},
@@ -484,6 +684,76 @@ func TestGetCallbackURLFromList_LoopbackSpecialHandling(t *testing.T) {
}
}
func TestLoopbackURLWithWildcardPort(t *testing.T) {
tests := []struct {
name string
input string
output string
}{
{
name: "localhost http with port strips port",
input: "http://localhost:3000/callback",
output: "http://localhost/callback",
},
{
name: "localhost http without port stays same",
input: "http://localhost/callback",
output: "http://localhost/callback",
},
{
name: "IPv4 loopback with port strips port",
input: "http://127.0.0.1:8080/callback",
output: "http://127.0.0.1/callback",
},
{
name: "IPv4 loopback without port stays same",
input: "http://127.0.0.1/callback",
output: "http://127.0.0.1/callback",
},
{
name: "IPv6 loopback with port strips port and keeps brackets",
input: "http://[::1]:8080/callback",
output: "http://[::1]/callback",
},
{
name: "IPv6 loopback preserves path query and fragment",
input: "http://[::1]:8080/auth/callback?code=123#state",
output: "http://[::1]/auth/callback?code=123#state",
},
{
name: "https loopback returns empty",
input: "https://127.0.0.1:8080/callback",
output: "",
},
{
name: "non loopback host returns empty",
input: "http://example.com:8080/callback",
output: "",
},
{
name: "non loopback IP returns empty",
input: "http://192.168.1.10:8080/callback",
output: "",
},
{
name: "malformed URL returns empty",
input: "http://[::1:8080/callback",
output: "",
},
{
name: "relative URL returns empty",
input: "/callback",
output: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.output, loopbackURLWithWildcardPort(tt.input))
})
}
}
func TestGetCallbackURLFromList_MultiplePatterns(t *testing.T) {
tests := []struct {
name string
@@ -553,246 +823,3 @@ func TestGetCallbackURLFromList_MultiplePatterns(t *testing.T) {
})
}
}
func TestMatchPath(t *testing.T) {
tests := []struct {
name string
pattern string
input string
shouldMatch bool
}{
// Exact matches
{
name: "exact match",
pattern: "/callback",
input: "/callback",
shouldMatch: true,
},
{
name: "exact mismatch",
pattern: "/callback",
input: "/other",
shouldMatch: false,
},
{
name: "empty paths",
pattern: "",
input: "",
shouldMatch: true,
},
// Single wildcard (*)
{
name: "single wildcard matches segment",
pattern: "/api/*/callback",
input: "/api/v1/callback",
shouldMatch: true,
},
{
name: "single wildcard doesn't match multiple segments",
pattern: "/api/*/callback",
input: "/api/v1/v2/callback",
shouldMatch: false,
},
{
name: "single wildcard at end",
pattern: "/callback/*",
input: "/callback/test",
shouldMatch: true,
},
{
name: "single wildcard at start",
pattern: "/*/callback",
input: "/api/callback",
shouldMatch: true,
},
{
name: "multiple single wildcards",
pattern: "/*/test/*",
input: "/api/test/callback",
shouldMatch: true,
},
{
name: "partial wildcard prefix",
pattern: "/test*",
input: "/testing",
shouldMatch: true,
},
{
name: "partial wildcard suffix",
pattern: "/*-callback",
input: "/oauth-callback",
shouldMatch: true,
},
{
name: "partial wildcard middle",
pattern: "/api-*-v1",
input: "/api-internal-v1",
shouldMatch: true,
},
// Double wildcard (**)
{
name: "double wildcard matches multiple segments",
pattern: "/api/**/callback",
input: "/api/v1/v2/v3/callback",
shouldMatch: true,
},
{
name: "double wildcard matches single segment",
pattern: "/api/**/callback",
input: "/api/v1/callback",
shouldMatch: true,
},
{
name: "double wildcard doesn't match when pattern has extra slashes",
pattern: "/api/**/callback",
input: "/api/callback",
shouldMatch: false,
},
{
name: "double wildcard at end",
pattern: "/api/**",
input: "/api/v1/v2/callback",
shouldMatch: true,
},
{
name: "double wildcard in middle",
pattern: "/api/**/v2/**/callback",
input: "/api/v1/v2/v3/v4/callback",
shouldMatch: true,
},
// Complex patterns
{
name: "mix of single and double wildcards",
pattern: "/*/api/**/callback",
input: "/app/api/v1/v2/callback",
shouldMatch: true,
},
{
name: "wildcard with special characters",
pattern: "/callback-*",
input: "/callback-123",
shouldMatch: true,
},
{
name: "path with query-like string (no special handling)",
pattern: "/callback?code=*",
input: "/callback?code=abc",
shouldMatch: true,
},
// Edge cases
{
name: "single wildcard matches empty segment",
pattern: "/api/*/callback",
input: "/api//callback",
shouldMatch: true,
},
{
name: "pattern longer than input",
pattern: "/api/v1/callback",
input: "/api",
shouldMatch: false,
},
{
name: "input longer than pattern",
pattern: "/api",
input: "/api/v1/callback",
shouldMatch: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
matches, err := matchPath(tt.pattern, tt.input)
require.NoError(t, err)
assert.Equal(t, tt.shouldMatch, matches)
})
}
}
func TestSplitParts(t *testing.T) {
tests := []struct {
name string
input string
expectedParts []string
expectedPath string
}{
{
name: "simple https URL",
input: "https://example.com/callback",
expectedParts: []string{"https", "example", "com"},
expectedPath: "/callback",
},
{
name: "URL with port",
input: "https://example.com:8080/callback",
expectedParts: []string{"https", "example", "com", "8080"},
expectedPath: "/callback",
},
{
name: "URL with subdomain",
input: "https://api.example.com/callback",
expectedParts: []string{"https", "api", "example", "com"},
expectedPath: "/callback",
},
{
name: "URL with credentials",
input: "https://user:pass@example.com/callback",
expectedParts: []string{"https", "user", "pass", "example", "com"},
expectedPath: "/callback",
},
{
name: "URL without path",
input: "https://example.com",
expectedParts: []string{"https", "example", "com"},
expectedPath: "",
},
{
name: "URL with deep path",
input: "https://example.com/api/v1/callback",
expectedParts: []string{"https", "example", "com"},
expectedPath: "/api/v1/callback",
},
{
name: "URL with path and query",
input: "https://example.com/callback?code=123",
expectedParts: []string{"https", "example", "com"},
expectedPath: "/callback?code=123",
},
{
name: "URL with trailing slash",
input: "https://example.com/",
expectedParts: []string{"https", "example", "com"},
expectedPath: "/",
},
{
name: "URL with multiple subdomains",
input: "https://api.v1.staging.example.com/callback",
expectedParts: []string{"https", "api", "v1", "staging", "example", "com"},
expectedPath: "/callback",
},
{
name: "URL with port and credentials",
input: "https://user:pass@example.com:8080/callback",
expectedParts: []string{"https", "user", "pass", "example", "com", "8080"},
expectedPath: "/callback",
},
{
name: "scheme with authority separator but no slash",
input: "http://example.com",
expectedParts: []string{"http", "example", "com"},
expectedPath: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parts, path := splitParts(tt.input)
assert.Equal(t, tt.expectedParts, parts, "parts mismatch")
assert.Equal(t, tt.expectedPath, path, "path mismatch")
})
}
}

View File

@@ -64,6 +64,7 @@ func TestBearerAuth(t *testing.T) {
}
}
// #nosec G101 - Test credentials
func TestOAuthClientBasicAuth(t *testing.T) {
tests := []struct {
name string

View File

@@ -0,0 +1,35 @@
//go:build linux
package utils
import (
"fmt"
"syscall"
)
// Filesystem magic values from Linux's include/uapi/linux/magic.h, used by statfs(2).
const (
nfsSuperMagic = 0x6969
smbSuperMagic = 0x517b
cifsSuperMagic = 0xff534d42
fuseSuperMagic = 0x65735546
)
// IsNetworkedFileSystem reports whether path is on a filesystem that is known to be unsafe for SQLite, specifically NFS, SMB/CIFS, or FUSE mounts.
func IsNetworkedFileSystem(path string) (bool, error) {
var statfs syscall.Statfs_t
err := syscall.Statfs(path, &statfs)
if err != nil {
return false, fmt.Errorf("error executing statfs syscall: %w", err)
}
// Statfs_t.Type is arch-dependent (for example, int32 on some systems and int64 on others).
// Normalize through uint32 first so signed values still preserve the Linux bit pattern for magic numbers such as CIFS (0xff534d42), then compare in a wide unsigned form.
//nolint:gosec
switch uint64(uint32(statfs.Type)) {
case nfsSuperMagic, smbSuperMagic, cifsSuperMagic, fuseSuperMagic:
return true, nil
default:
return false, nil
}
}

View File

@@ -0,0 +1,8 @@
//go:build !linux
package utils
// IsNetworkedFileSystem returns false on non-Linux systems because this detection is only used for Linux-specific statfs(2) filesystem magic values.
func IsNetworkedFileSystem(string) (bool, error) {
return false, nil
}

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
{{define "root"}}<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir="ltr" lang="en"><head><link rel="preload" as="image" href="{{.LogoURL}}"/><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/></head><body style="background-color:#FBFBFB"><!--$--><!--html--><!--head--><!--body--><table border="0" width="100%" cellPadding="0" cellSpacing="0" role="presentation" align="center"><tbody><tr><td style="padding:50px;background-color:#FBFBFB;font-family:Arial, sans-serif"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="max-width:37.5em;width:500px;margin:0 auto"><tbody><tr style="width:100%"><td><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><table align="left" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="margin-bottom:16px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column" style="width:50px"><img alt="{{.AppName}}" height="32" src="{{.LogoURL}}" style="display:block;outline:none;border:none;text-decoration:none;width:32px;height:32px;vertical-align:middle" width="32"/></td><td data-id="__react-email-column"><p style="font-size:23px;line-height:24px;font-weight:bold;margin:0;padding:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.AppName}}</p></td></tr></tbody></table></td></tr></tbody></table><div style="background-color:white;padding:24px;border-radius:10px;box-shadow:0 1px 4px 0px rgba(0, 0, 0, 0.1)"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column"><h1 style="font-size:20px;font-weight:bold;margin:0">API Key Expiring Soon</h1></td><td align="right" data-id="__react-email-column"><p style="font-size:12px;line-height:24px;background-color:#ffd966;color:#7f6000;padding:1px 12px;border-radius:50px;display:inline-block;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">Warning</p></td></tr></tbody></table><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">Hello <!-- -->{{.Data.Name}}<!-- -->, <br/>This is a reminder that your API key <strong>{{.Data.APIKeyName}}</strong> <!-- -->will expire on <strong>{{.Data.ExpiresAt.Format "2006-01-02 15:04:05 MST"}}</strong>.</p><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">Please generate a new API key if you need continued access.</p></div></td></tr></tbody></table></td></tr></tbody></table><!--/$--></body></html>{{end}}
{{define "root"}}<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir="ltr" lang="en"><head><link rel="preload" as="image" href="{{.LogoURL}}"/><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/></head><body style="background-color:#FBFBFB"><!--$--><!--html--><!--head--><!--body--><table border="0" width="100%" cellPadding="0" cellSpacing="0" role="presentation" align="center"><tbody><tr><td style="padding:50px;background-color:#FBFBFB;font-family:Arial, sans-serif"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="max-width:37.5em;width:500px;margin:0 auto"><tbody><tr style="width:100%"><td><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><table align="left" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="margin-bottom:16px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column" style="width:50px"><img alt="{{.AppName}}" height="32" src="{{.LogoURL}}" style="display:block;outline:none;border:none;text-decoration:none;width:32px;height:32px;vertical-align:middle" width="32"/></td><td data-id="__react-email-column"><p style="font-size:23px;line-height:24px;font-weight:bold;margin:0;padding:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.AppName}}</p></td></tr></tbody></table></td></tr></tbody></table><div style="background-color:white;padding:24px;border-radius:10px;box-shadow:0 1px 4px 0px rgba(0, 0, 0, 0.1)"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column"><h1 style="font-size:20px;font-weight:bold;margin:0">API Key Expiring Soon</h1></td><td align="right" data-id="__react-email-column"><p style="font-size:12px;line-height:24px;background-color:#ffd966;color:#7f6000;padding:1px 12px;border-radius:50px;display:inline-block;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">Warning</p></td></tr></tbody></table><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">Hello <!-- -->{{.Data.Name}}<!-- -->, <br/>This is a reminder that your API key <strong>{{.Data.ApiKeyName}}</strong> <!-- -->will expire on <strong>{{.Data.ExpiresAt.Format "2006-01-02 15:04:05 MST"}}</strong>.</p><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">Please generate a new API key if you need continued access.</p></div></td></tr></tbody></table></td></tr></tbody></table><!--/$--></body></html>{{end}}

View File

@@ -6,6 +6,6 @@ API KEY EXPIRING SOON
Warning
Hello {{.Data.Name}},
This is a reminder that your API key {{.Data.APIKeyName}} will expire on {{.Data.ExpiresAt.Format "2006-01-02 15:04:05 MST"}}.
This is a reminder that your API key {{.Data.ApiKeyName}} will expire on {{.Data.ExpiresAt.Format "2006-01-02 15:04:05 MST"}}.
Please generate a new API key if you need continued access.{{end}}

View File

@@ -0,0 +1 @@
-- No-op

View File

@@ -0,0 +1,6 @@
CREATE INDEX IF NOT EXISTS idx_webauthn_sessions_expires_at ON webauthn_sessions (expires_at);
CREATE INDEX IF NOT EXISTS idx_one_time_access_tokens_expires_at ON one_time_access_tokens (expires_at);
CREATE INDEX IF NOT EXISTS idx_oidc_authorization_codes_expires_at ON oidc_authorization_codes (expires_at);
CREATE INDEX IF NOT EXISTS idx_oidc_refresh_tokens_expires_at ON oidc_refresh_tokens (expires_at);
CREATE INDEX IF NOT EXISTS idx_reauthentication_tokens_expires_at ON reauthentication_tokens (expires_at);
CREATE INDEX IF NOT EXISTS idx_email_verification_tokens_expires_at ON email_verification_tokens (expires_at);

View File

@@ -0,0 +1,3 @@
ALTER TABLE oidc_authorization_codes DROP COLUMN authentication_method;
ALTER TABLE oidc_refresh_tokens DROP COLUMN authentication_method;
ALTER TABLE oidc_device_codes DROP COLUMN authentication_method;

View File

@@ -0,0 +1,6 @@
ALTER TABLE oidc_authorization_codes
ADD COLUMN authentication_method TEXT NOT NULL DEFAULT '';
ALTER TABLE oidc_refresh_tokens
ADD COLUMN authentication_method TEXT NOT NULL DEFAULT '';
ALTER TABLE oidc_device_codes
ADD COLUMN authentication_method TEXT NOT NULL DEFAULT '';

View File

@@ -0,0 +1 @@
-- No-op

View File

@@ -0,0 +1,12 @@
PRAGMA foreign_keys= OFF;
BEGIN;
CREATE INDEX IF NOT EXISTS idx_webauthn_sessions_expires_at ON webauthn_sessions (expires_at);
CREATE INDEX IF NOT EXISTS idx_one_time_access_tokens_expires_at ON one_time_access_tokens (expires_at);
CREATE INDEX IF NOT EXISTS idx_oidc_authorization_codes_expires_at ON oidc_authorization_codes (expires_at);
CREATE INDEX IF NOT EXISTS idx_oidc_refresh_tokens_expires_at ON oidc_refresh_tokens (expires_at);
CREATE INDEX IF NOT EXISTS idx_reauthentication_tokens_expires_at ON reauthentication_tokens (expires_at);
CREATE INDEX IF NOT EXISTS idx_email_verification_tokens_expires_at ON email_verification_tokens (expires_at);
COMMIT;
PRAGMA foreign_keys=ON;

View File

@@ -0,0 +1,3 @@
ALTER TABLE oidc_authorization_codes DROP COLUMN authentication_method;
ALTER TABLE oidc_refresh_tokens DROP COLUMN authentication_method;
ALTER TABLE oidc_device_codes DROP COLUMN authentication_method;

View File

@@ -0,0 +1,6 @@
ALTER TABLE oidc_authorization_codes
ADD COLUMN authentication_method TEXT NOT NULL DEFAULT '';
ALTER TABLE oidc_refresh_tokens
ADD COLUMN authentication_method TEXT NOT NULL DEFAULT '';
ALTER TABLE oidc_device_codes
ADD COLUMN authentication_method TEXT NOT NULL DEFAULT '';

View File

@@ -40,7 +40,7 @@ RUN VERSION=$(cat /build/.version) \
.
# Stage 3: Production Image
FROM alpine
FROM alpine:3.23.4
WORKDIR /app
RUN apk add --no-cache curl su-exec

View File

@@ -1,7 +1,7 @@
# This Dockerfile embeds a pre-built binary for the given Linux architecture
# Binaries must be built using "./scripts/development/build-binaries.sh --docker-only"
FROM alpine
FROM alpine:3.23.4
# TARGETARCH can be "amd64" or "arm64"
ARG TARGETARCH

View File

@@ -40,7 +40,7 @@ ApiKeyExpiringEmail.TemplateProps = {
...sharedTemplateProps,
data: {
name: "{{.Data.Name}}",
apiKeyName: "{{.Data.APIKeyName}}",
apiKeyName: "{{.Data.ApiKeyName}}",
expiresAt: '{{.Data.ExpiresAt.Format "2006-01-02 15:04:05 MST"}}',
},
};

View File

@@ -10,14 +10,14 @@
},
"dependencies": {
"@react-email/components": "1.0.1",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"tailwind-merge": "^3.4.0"
"react": "^19.2.5",
"react-dom": "^19.2.5",
"tailwind-merge": "^3.5.0"
},
"devDependencies": {
"@react-email/preview-server": "5.0.7",
"@types/node": "^24.10.4",
"@types/react": "^19.2.7",
"@types/node": "^24.12.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"react-email": "5.0.7",
"tsx": "^4.21.0"

View File

@@ -5,4 +5,5 @@ yarn.lock
# Compiled files
.svelte-kit/
build/
build/
src/lib/paraglide/messages

528
frontend/messages/ca.json Normal file
View File

@@ -0,0 +1,528 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"my_account": "El meu compte",
"logout": "Tancar la sessió",
"confirm": "Confirma",
"docs": "Documentació",
"key": "Clau",
"value": "Valor",
"remove_custom_claim": "Elimina l'atribut personalitzat",
"add_custom_claim": "Afegeix un atribut personalitzat",
"add_another": "Afegeix-ne una altra",
"select_a_date": "Selecciona una data",
"select_file": "Selecciona un fitxer",
"profile_picture": "Imatge de perfil",
"profile_picture_is_managed_by_ldap_server": "La imatge de perfil és gestionada pel servidor LDAP i no es pot canviar aquí.",
"click_profile_picture_to_upload_custom": "Fes clic a la imatge de perfil per pujar-ne una des dels teus fitxers.",
"image_should_be_in_format": "La imatge ha d'estar en format PNG, JPEG o WEBP.",
"items_per_page": "Elements per pàgina",
"no_items_found": "No s'han trobat elements",
"select_items": "Selecciona elements...",
"search": "Cerca...",
"expand_card": "Expandeix la targeta",
"copied": "Copiat",
"click_to_copy": "Fes clic per copiar",
"something_went_wrong": "Alguna cosa ha fallat",
"go_back_to_home": "Torna a la pàgina principal",
"alternative_sign_in_methods": "Mètodes alternatius d'inici de sessió",
"login_background": "Fons d'inici de sessió",
"logo": "Logotip",
"login_code": "Codi d'inici de sessió",
"create_a_login_code_to_sign_in_without_a_passkey_once": "Crea un codi d'inici de sessió d'un sol ús que l'usuari pot utilitzar per accedir sense clau d'accés.",
"one_hour": "1 hora",
"twelve_hours": "12 hores",
"one_day": "1 dia",
"one_week": "1 setmana",
"one_month": "1 mes",
"expiration": "Expiració",
"generate_code": "Genera Codi",
"name": "Nom",
"browser_unsupported": "Navegador no compatible",
"this_browser_does_not_support_passkeys": "Aquest navegador no admet claus d'accés. Utilitza un mètode alternatiu per iniciar la sessió.",
"an_unknown_error_occurred": "S'ha produït un error desconegut",
"authentication_process_was_aborted": "El procés d'autenticació s'ha cancel·lat",
"error_occurred_with_authenticator": "S'ha produït un error amb l'autenticador",
"authenticator_does_not_support_discoverable_credentials": "L'autenticador no admet credencials descobribles",
"authenticator_does_not_support_resident_keys": "L'autenticador no admet claus residents",
"passkey_was_previously_registered": "Aquesta clau d'accés ja estava registrada",
"authenticator_does_not_support_any_of_the_requested_algorithms": "L'autenticador no admet cap dels algoritmes sol·licitats",
"webauthn_error_invalid_rp_id": "L'identificador de relying party configurat no és vàlid.",
"webauthn_error_invalid_domain": "El domini configurat no és vàlid.",
"contact_administrator_to_fix": "Contacta amb l'administrador per solucionar-ho.",
"webauthn_operation_not_allowed_or_timed_out": "L'operació no està permesa o ha expirat",
"webauthn_not_supported_by_browser": "Les claus d'accés no són compatibles amb aquest navegador. Utilitza un mètode alternatiu per iniciar la sessió.",
"critical_error_occurred_contact_administrator": "S'ha produït un error crític. Contacta amb l'administrador.",
"sign_in_to": "Inicia la sessió a {name}",
"client_not_found": "Client no trobat",
"client_wants_to_access_the_following_information": "<b>{client}</b> vol accedir a la informació següent:",
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "Vols iniciar la sessió a <b>{client}</b> amb el teu compte de {appName}?",
"email": "Correu electrònic",
"view_your_email_address": "Mostra la teva adreça de correu",
"profile": "Perfil",
"view_your_profile_information": "Mostra la informació del teu perfil",
"groups": "Grups",
"view_the_groups_you_are_a_member_of": "Mostra els grups dels quals ets membre",
"cancel": "Cancel·la",
"sign_in": "Inicia la sessió",
"try_again": "Torna-ho a intentar",
"client_logo": "Logotip del client",
"sign_out": "Tanca sessió",
"do_you_want_to_sign_out_of_pocketid_with_the_account": "Vols tancar la sessió de {appName} amb el compte <b>{username}</b>?",
"sign_in_to_appname": "Inicia la sessió a {appName}",
"please_try_to_sign_in_again": "Si us plau, torna a iniciar la sessió.",
"authenticate_with_passkey_to_access_account": "Autentica't amb la teva clau d'accés per accedir al teu compte.",
"authenticate": "Autentica't",
"please_try_again": "Si us plau, torna-ho a intentar.",
"continue": "Continua",
"alternative_sign_in": "Inici de sessió alternatiu",
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "Si no tens accés a la teva clau d'accés, pots iniciar la sessió amb un dels mètodes següents.",
"use_your_passkey_instead": "Altrament, vols fer servir la teva clau d'accés?",
"email_login": "Inici de sessió per correu",
"enter_a_login_code_to_sign_in": "Introdueix un codi d'inici de sessió per iniciar la sessió.",
"sign_in_with_login_code": "Inicia la sessió amb un codi",
"request_a_login_code_via_email": "Sol·licita un codi d'inici de sessió per correu.",
"go_back": "Torna enrere",
"an_email_has_been_sent_to_the_provided_email_if_it_exists_in_the_system": "S'ha enviat un correu a l'adreça proporcionada, si existeix al sistema.",
"enter_code": "Introdueix el codi",
"enter_your_email_address_to_receive_an_email_with_a_login_code": "Introdueix la teva adreça de correu per rebre un codi d'inici de sessió.",
"your_email": "El teu correu",
"submit": "Envia",
"enter_the_code_you_received_to_sign_in": "Introdueix el codi que has rebut per iniciar la sessió.",
"code": "Codi",
"invalid_redirect_url": "URL de redirecció invàlida",
"audit_log": "Registre d'auditoria",
"users": "Usuaris",
"user_groups": "Grups d'usuaris",
"oidc_clients": "Clients OIDC",
"api_keys": "Claus API",
"application_configuration": "Configuració de l'aplicació",
"settings": "Configuració",
"update_pocket_id": "Actualitza Pocket ID",
"powered_by": "Impulsat per",
"see_your_recent_account_activities": "Veuràs les activitats del teu compte dins del període de retenció configurat.",
"time": "Hora",
"event": "Esdeveniment",
"approximate_location": "Ubicació aproximada",
"ip_address": "Adreça IP",
"device": "Dispositiu",
"client": "Client",
"actor": "Actor",
"unknown": "Desconegut",
"account_details_updated_successfully": "Els detalls del compte s'han actualitzat correctament",
"profile_picture_updated_successfully": "La imatge de perfil s'ha actualitzat correctament. Pot trigar uns minuts a actualitzar-se.",
"account_settings": "Configuració del compte",
"passkey_missing": "Clau d'accés absent",
"please_provide_a_passkey_to_prevent_losing_access_to_your_account": "Si us plau, afegeix una clau d'accés per evitar perdre l'accés al teu compte.",
"single_passkey_configured": "Clau d'accés única configurada",
"it_is_recommended_to_add_more_than_one_passkey": "Es recomana afegir més d'una clau d'accés per evitar quedar sense accés al compte.",
"account_details": "Detalls del compte",
"passkeys": "Clau d'accéss",
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Gestiona les claus d'accés que pots utilitzar per autenticar-te.",
"manage_this_users_passkeys": "Gestiona les claus d'accés d'aquest usuari.",
"add_passkey": "Afegeix clau d'accés",
"create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey": "Crea un codi d'un sol ús per iniciar la sessió des d'un altre dispositiu sense clau d'accés.",
"create": "Crea",
"first_name": "Nom",
"last_name": "Cognom",
"username": "Nom d'usuari",
"save": "Desa",
"username_can_only_contain": "El nom d'usuari només pot contenir lletres minúscules, números, guions baixos, punts, guions i el símbol '@'",
"username_must_start_with": "El nom d'usuari ha de començar per un caràcter alfanumèric",
"username_must_end_with": "El nom d'usuari ha de acabar amb un caràcter alfanumèric",
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Inicia la sessió amb el codi següent. El codi caducarà en 15 minuts.",
"or_visit": "o visita",
"added_on": "Afegit el",
"rename": "Reanomena",
"delete": "Elimina",
"are_you_sure_you_want_to_delete_this_passkey": "Segur que vols eliminar aquesta clau d'accés?",
"passkey_deleted_successfully": "Clau d'accés eliminada correctament",
"delete_passkey_name": "Elimina {passkeyName}",
"passkey_name_updated_successfully": "S'ha actualitzat correctament el nom de la clau d'accés",
"name_passkey": "Anomena la clau d'accés",
"name_your_passkey_to_easily_identify_it_later": "Dóna un nom a la teva clau d'accés per identificar-la fàcilment més endavant.",
"create_api_key": "Crea una clau API",
"add_a_new_api_key_for_programmatic_access": "Afegeix una nova clau API per a l'accés programàtic a la <link href='https://pocket-id.org/docs/api'>API de Pocket ID</link>.",
"add_api_key": "Afegeix una clau API",
"manage_api_keys": "Gestiona les claus API",
"api_key_created": "S'ha creat una Clau API",
"for_security_reasons_this_key_will_only_be_shown_once": "Per motius de seguretat aquesta clau només es mostrarà una vegada. Desa-la de manera segura.",
"description": "Descripció",
"api_key": "Clau API",
"close": "Tanca",
"name_to_identify_this_api_key": "Nom per identificar aquesta clau API.",
"expires_at": "Caduca el",
"when_this_api_key_will_expire": "Quan caducarà aquesta clau API.",
"optional_description_to_help_identify_this_keys_purpose": "Descripció opcional per ajudar a identificar l'ús d'aquesta clau.",
"expiration_date_must_be_in_the_future": "La data d'expiració ha de ser en el futur",
"revoke_api_key": "Revoca la clau API",
"never": "Mai",
"revoke": "Revoca",
"api_key_revoked_successfully": "La clau API ha estat revocada correctament",
"are_you_sure_you_want_to_revoke_the_api_key_apikeyname": "Segur que vols revocar la clau API \"{apiKeyName}\"? Això trencarà qualsevol integració que utilitzi aquesta clau.",
"last_used": "Últim ús",
"actions": "Accions",
"images_updated_successfully": "Imatges actualitzades correctament. Pot trigar uns minuts a actualitzar-se.",
"general": "General",
"configure_smtp_to_send_emails": "Configura SMTP per enviar notificacions per correu quan hi hagi un inici de sessió des d'un dispositiu o ubicació nova.",
"ldap": "LDAP",
"configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server": "Configura LDAP per sincronitzar usuaris i grups des d'un servidor LDAP.",
"images": "Imatges",
"update": "Actualitza",
"email_configuration_updated_successfully": "Configuració de correu actualitzada correctament",
"save_changes_question": "Vols desar els canvis?",
"you_have_to_save_the_changes_before_sending_a_test_email_do_you_want_to_save_now": "Has de desar els canvis abans d'enviar un correu de prova. Vols desar-los ara?",
"save_and_send": "Desa i envia",
"test_email_sent_successfully": "El correu de prova ha estat enviat correctament a la teva adreça.",
"failed_to_send_test_email": "No s'ha pogut enviar el correu de prova. Consulta els registres d'activitat del servidor per més informació.",
"smtp_configuration": "Configuració SMTP",
"smtp_host": "Servidor SMTP",
"smtp_port": "Port SMTP",
"smtp_user": "Usuari SMTP",
"smtp_password": "Contrasenya SMTP",
"smtp_from": "Remitent SMTP",
"smtp_tls_option": "Opció TLS SMTP",
"email_tls_option": "Opció TLS del correu",
"skip_certificate_verification": "Omet la verificació del certificat",
"this_can_be_useful_for_selfsigned_certificates": "Això pot ser útil per certificats autosignats.",
"enabled_emails": "Correus habilitats",
"email_login_notification": "Notificació d'inici de sessió per correu",
"send_an_email_to_the_user_when_they_log_in_from_a_new_device": "Envia un correu a l'usuari quan iniciï la sessió des d'un dispositiu nou.",
"emai_login_code_requested_by_user": "Codi d'inici de sessió sol·licitat per correu",
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Permet als usuaris iniciar la sessió amb un codi enviat al seu correu. Això redueix significativament la seguretat, ja que qualsevol amb accés al correu de l'usuari podria accedir.",
"email_login_code_from_admin": "Codi d'inici de sessió per correu des d'administrador",
"allows_an_admin_to_send_a_login_code_to_the_user": "Permet que un administrador enviï un codi d'inici de sessió a l'usuari per correu.",
"send_test_email": "Envia un correu de prova",
"application_configuration_updated_successfully": "Configuració de l'aplicació actualitzada correctament",
"application_name": "Nom de l'aplicació",
"session_duration": "Durada de la sessió",
"the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "Durada d'una sessió en minuts abans que l'usuari hagi d'iniciar la sessió de nou.",
"enable_self_account_editing": "Permet editar el propi compte",
"whether_the_users_should_be_able_to_edit_their_own_account_details": "Permet als usuaris editar els seus propis detalls del compte.",
"ldap_configuration_updated_successfully": "Configuració LDAP actualitzada correctament",
"ldap_disabled_successfully": "LDAP deshabilitat correctament",
"ldap_sync_finished": "Sincronització LDAP finalitzada",
"client_configuration": "Configuració del client",
"ldap_url": "URL LDAP",
"ldap_bind_dn": "DN de vinculació LDAP",
"ldap_bind_password": "Contrasenya de vinculació LDAP",
"ldap_base_dn": "Base DN LDAP",
"user_search_filter": "Filtre de cerca d'usuaris",
"the_search_filter_to_use_to_search_or_sync_users": "El filtre de cerca per utilitzar per buscar/sincronitzar usuaris.",
"groups_search_filter": "Filtre de cerca de grups",
"the_search_filter_to_use_to_search_or_sync_groups": "El filtre de cerca per utilitzar per buscar/sincronitzar grups.",
"attribute_mapping": "Mapeig d'atributs",
"user_unique_identifier_attribute": "Atribut identificador únic d'usuari",
"the_value_of_this_attribute_should_never_change": "El valor d'aquest atribut no hauria de canviar mai.",
"username_attribute": "Atribut de nom d'usuari",
"user_mail_attribute": "Atribut de correu de l'usuari",
"user_first_name_attribute": "Atribut de nom de l'usuari",
"user_last_name_attribute": "Atribut de cognom de l'usuari",
"user_profile_picture_attribute": "Atribut de la imatge de perfil de l'usuari",
"the_value_of_this_attribute_can_either_be_a_url_binary_or_base64_encoded_image": "El valor d'aquest atribut pot ser una URL, un binari o una imatge codificada en base64.",
"group_members_attribute": "Atribut de membres del grup",
"the_attribute_to_use_for_querying_members_of_a_group": "L'atribut a utilitzar per consultar els membres d'un grup.",
"group_unique_identifier_attribute": "Atribut identificador únic del grup",
"group_rdn_attribute": "Atribut RDN del grup (en DN)",
"admin_group_name": "Nom del grup d'administradors",
"members_of_this_group_will_have_admin_privileges_in_pocketid": "Els membres d'aquest grup tindran privilegis d'administrador a Pocket ID.",
"disable": "Desactiva",
"sync_now": "Sincronitza ara",
"enable": "Activa",
"user_created_successfully": "Usuari creat correctament",
"create_user": "Crea usuari",
"add_a_new_user_to_appname": "Afegeix un nou usuari a {appName}",
"add_user": "Afegeix usuari",
"manage_users": "Gestiona usuaris",
"admin_privileges": "Privilegis d'administrador",
"admins_have_full_access_to_the_admin_panel": "Els administradors tenen accés complet al tauler d'administració.",
"delete_firstname_lastname": "Elimina {firstName} {lastName}",
"are_you_sure_you_want_to_delete_this_user": "Segur que vols eliminar aquest usuari?",
"user_deleted_successfully": "Usuari eliminat correctament",
"role": "Rol",
"source": "Origen",
"admin": "Administrador",
"user": "Usuari",
"local": "Local",
"toggle_menu": "Mostra/Amaga menú",
"edit": "Edita",
"user_groups_updated_successfully": "Grups d'usuaris actualitzats correctament",
"user_updated_successfully": "Usuari actualitzat correctament",
"custom_claims_updated_successfully": "Atributs personalitzats actualitzades correctament",
"back": "Enrere",
"user_details_firstname_lastname": "Detalls de l'usuari {firstName} {lastName}",
"manage_which_groups_this_user_belongs_to": "Gestiona a quins grups pertany aquest usuari.",
"custom_claims": "Atributs personalitzats",
"custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user": "Els atributs personalitzats són parells clau-valor que permeten emmagatzemar informació addicional sobre un usuari. Aquestes afirmacions s'inclouran en el token ID si es sol·licita l'abast 'profile'.",
"user_group_created_successfully": "Grup d'usuaris creat correctament",
"create_user_group": "Crea grup d'usuaris",
"create_a_new_group_that_can_be_assigned_to_users": "Crea un nou grup que es pot assignar als usuaris.",
"add_group": "Afegeix grup",
"manage_user_groups": "Gestiona grups d'usuaris",
"friendly_name": "Nom amigable",
"name_that_will_be_displayed_in_the_ui": "Nom que es mostrarà a la interfície",
"name_that_will_be_in_the_groups_claim": "Nom que apareixerà a l'atribut \"groups\"",
"delete_name": "Elimina {name}",
"are_you_sure_you_want_to_delete_this_user_group": "Segur que vols eliminar aquest grup d'usuaris?",
"user_group_deleted_successfully": "Grup d'usuaris eliminat correctament",
"user_count": "Nombre d'usuaris",
"user_group_updated_successfully": "Grup d'usuaris actualitzat correctament",
"users_updated_successfully": "Usuaris actualitzats correctament",
"user_group_details_name": "Detalls del grup d'usuaris {name}",
"assign_users_to_this_group": "Assigna usuaris a aquest grup.",
"custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user_prioritized": "Els atributs personalitzats són parells clau-valor que permeten emmagatzemar informació addicional sobre un usuari. Aquestes afirmacions s'inclouran en el token ID si es sol·licita l'abast 'profile'. Les afirmacions definides a l'usuari tindran prioritat en cas de conflicte.",
"oidc_client_created_successfully": "Client OIDC creat correctament",
"create_oidc_client": "Crea client OIDC",
"add_a_new_oidc_client_to_appname": "Afegeix un nou client OIDC a {appName}.",
"add_oidc_client": "Afegeix client OIDC",
"manage_oidc_clients": "Gestiona clients OIDC",
"one_time_link": "Enllaç d'una sola vegada",
"use_this_link_to_sign_in_once": "Utilitza aquest enllaç per iniciar la sessió una sola vegada. És necessari per a usuaris que no han afegit una clau d'accés o l'han perduda.",
"add": "Afegeix",
"callback_urls": "URLs de callback",
"logout_callback_urls": "URLs de tancar la sessió",
"public_client": "Client públic",
"public_clients_description": "Els clients públics no tenen secret de client. Estan dissenyats per a aplicacions mòbils, web i natives on els secrets no es poden emmagatzemar de forma segura.",
"pkce": "PKCE",
"public_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks": "Public Key Code Exchange és una característica de seguretat per prevenir atacs CSRF i la interceptació del codi d'autorització.",
"requires_reauthentication": "Requereix re-autenticació",
"requires_users_to_authenticate_again_on_each_authorization": "Requereix que els usuaris s'autentiquin de nou en cada autorització, encara que ja estiguin connectats",
"name_logo": "Logotip de {name}",
"change_logo": "Canvia el logotip",
"upload_logo": "Puja logotip",
"remove_logo": "Elimina logotip",
"are_you_sure_you_want_to_delete_this_oidc_client": "Estàs segur que vols esborrar aquest client OIDC?",
"oidc_client_deleted_successfully": "Client OIDC eliminat correctament",
"authorization_url": "URL d'autorització",
"oidc_discovery_url": "URL de descobriment OIDC",
"token_url": "URL del token",
"userinfo_url": "URL d'informació de l'usuari",
"logout_url": "URL de tancar la sessió",
"certificate_url": "URL del certificat",
"enabled": "Activat",
"disabled": "Desactivat",
"oidc_client_updated_successfully": "Client OIDC actualitzat correctament",
"create_new_client_secret": "Crea un nou secret de client",
"are_you_sure_you_want_to_create_a_new_client_secret": "Segur que vols crear un nou secret de client? L'anterior quedarà invalidat.",
"generate": "Genera",
"new_client_secret_created_successfully": "Nou secret de client creat correctament",
"oidc_client_name": "Client OIDC {name}",
"client_id": "ID del client",
"client_secret": "Secret del client",
"show_more_details": "Mostra més detalls",
"allowed_user_groups": "Grups d'usuaris permesos",
"allowed_user_groups_description": "Selecciona els grups d'usuaris els membres dels quals poden iniciar la sessió a aquest client.",
"allowed_user_groups_status_unrestricted_description": "No s'apliquen restriccions de grup. Qualsevol usuari pot iniciar la sessió a aquest client.",
"unrestrict": "Elimina restriccions",
"restrict": "Restringeix",
"user_groups_restriction_updated_successfully": "Restricció de grups d'usuaris actualitzada correctament",
"allowed_user_groups_updated_successfully": "Grups d'usuaris permesos actualitzats correctament",
"favicon": "Icona de la web",
"light_mode_logo": "Logotip per mode clar",
"dark_mode_logo": "Logotip per mode fosc",
"email_logo": "Logotip per correus",
"background_image": "Imatge de fons",
"language": "Idioma",
"reset_profile_picture_question": "Restablir la imatge de perfil?",
"this_will_remove_the_uploaded_image_and_reset_the_profile_picture_to_default": "Això eliminarà la imatge pujada i restablirà la imatge de perfil per defecte. Vols continuar?",
"reset": "Restableix",
"reset_to_default": "Restableix per defecte",
"profile_picture_has_been_reset": "La imatge de perfil s'ha restablert. Pot trigar uns minuts a actualitzar-se.",
"select_the_language_you_want_to_use": "Selecciona l'idioma que vols utilitzar. Tingues en compte que algun text podria haver estat traduït automàticament i ser inexacte.",
"contribute_to_translation": "Si trobes un error, ets benvingut a contribuir a la traducció a <link href='https://crowdin.com/project/pocket-id'>Crowdin</link>.",
"personal": "Personal",
"global": "Global",
"all_users": "Tots els usuaris",
"all_events": "Tots els esdeveniments",
"all_clients": "Tots els clients",
"all_locations": "Totes les ubicacions",
"global_audit_log": "Registre d'auditoria global",
"see_all_recent_account_activities": "Mostra les activitats dels comptes de tots els usuaris durant el període de retenció establert.",
"token_sign_in": "Inici de sessió amb token",
"client_authorization": "Autorizació del client",
"new_client_authorization": "Nova autorització de client",
"device_code_authorization": "Autorizació per codi de dispositiu",
"new_device_code_authorization": "Nova autorització per codi de dispositiu",
"passkey_added": "Clau d'accés afegida",
"passkey_removed": "Clau d'accés eliminada",
"disable_animations": "Desactiva animacions",
"turn_off_ui_animations": "Desactiva les animacions de la interfície d'usuari.",
"user_disabled": "Compte desactivat",
"disabled_users_cannot_log_in_or_use_services": "Els usuaris desactivats no poden iniciar la sessió ni utilitzar serveis.",
"user_disabled_successfully": "Usuari desactivat correctament.",
"user_enabled_successfully": "Usuari activat correctament.",
"status": "Estat",
"disable_firstname_lastname": "Desactiva {firstName} {lastName}",
"are_you_sure_you_want_to_disable_this_user": "Segur que vols desactivar aquest usuari? No podrà iniciar la sessió ni accedir als serveis.",
"ldap_soft_delete_users": "Mantenir usuaris desactivats des de LDAP.",
"ldap_soft_delete_users_description": "Quan està habilitat, els usuaris eliminats de LDAP seran desactivats en lloc d'eliminats del sistema.",
"login_code_email_success": "El codi d'inici de sessió s'ha enviat a l'usuari.",
"send_email": "Envia correu",
"show_code": "Mostra codi",
"callback_url_description": "URL(s) proporcionada(s) pel teu client. S'afegiran automàticament si es deixa en blanc. S'admeten <link href='https://pocket-id.org/docs/advanced/callback-url-wildcards'>comodins</link>.",
"logout_callback_url_description": "URL(s) proporcionada(s) pel teu client per tancar la sessió. S'admeten <link href='https://pocket-id.org/docs/advanced/callback-url-wildcards'>comodins</link>.",
"api_key_expiration": "Caducitat de la clau API",
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Envia un correu a l'usuari quan la seva clau API estigui a punt de caducar.",
"authorize_device": "Autoritza dispositiu",
"the_device_has_been_authorized": "El dispositiu ha estat autoritzat.",
"enter_code_displayed_in_previous_step": "Introdueix el codi que es va mostrar al pas anterior.",
"authorize": "Autoritza",
"federated_client_credentials": "Credencials de client federat",
"federated_client_credentials_description": "Les credencials de client federat permeten autenticar clients OIDC sense gestionar secrets de llarga durada. Fan servir tokens JWT emesos per autoritats de tercers per a assertions de client, p. ex. tokens d'identitat de càrrega de treball.",
"add_federated_client_credential": "Afegeix credencial de client federat",
"add_another_federated_client_credential": "Afegeix una altra credencial de client federat",
"oidc_allowed_group_count": "Nombre de grups permesos",
"unrestricted": "Sense restriccions",
"show_advanced_options": "Mostra opcions avançades",
"hide_advanced_options": "Oculta opcions avançades",
"oidc_data_preview": "Previsualització de dades OIDC",
"preview_the_oidc_data_that_would_be_sent_for_different_users": "Previsualitza les dades OIDC que s'enviarien per a diferents usuaris",
"id_token": "Token ID",
"access_token": "Token d'accés",
"userinfo": "Informació d'usuari",
"id_token_payload": "Contingut del token ID",
"access_token_payload": "Contingut del token d'accés",
"userinfo_endpoint_response": "Resposta de l'endpoint userinfo",
"copy": "Copia",
"no_preview_data_available": "No hi ha dades de previsualització disponibles",
"copy_all": "Copia-ho tot",
"preview": "Previsualitza",
"preview_for_user": "Previsualitza per a {name}",
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Previsualitza les dades OIDC que s'enviarien per a aquest usuari",
"show": "Mostra",
"select_an_option": "Selecciona una opció",
"select_user": "Selecciona usuari",
"error": "Error",
"select_an_accent_color_to_customize_the_appearance_of_pocket_id": "Selecciona un color d'èmfasi per personalitzar l'aparença de Pocket ID.",
"accent_color": "Color d'èmfasi",
"custom_accent_color": "Color d'èmfasi personalitzat",
"custom_accent_color_description": "Introdueix un color personalitzat utilitzant formats CSS vàlids (p.ex., hex, rgb, hsl).",
"color_value": "Valor del color",
"apply": "Aplica",
"signup_token": "Token d'alta",
"create_a_signup_token_to_allow_new_user_registration": "Crea un token d'alta per permetre l'alta de nous usuaris.",
"usage_limit": "Límit d'ús",
"number_of_times_token_can_be_used": "Nombre de vegades que es pot utilitzar el token d'alta.",
"expires": "Caduca",
"signup": "Dona't d'alta",
"user_creation": "Creació d'usuari",
"configure_user_creation": "Gestiona la creació d'usuaris, incloent mètodes de d'alta i permisos per defecte.",
"user_creation_groups_description": "Assigna aquests grups automàticament quan es donin d'alta nous usuaris.",
"user_creation_claims_description": "Assigna aquests atributs personalitzats automàticament quan es donin d'alta nous usuaris.",
"user_creation_updated_successfully": "Configuració de creació d'usuaris actualitzada correctament.",
"signup_disabled_description": "L'alta d'usuaris està deshabilitat. Només els administradors poden crear comptes nous.",
"signup_requires_valid_token": "Es requereix un token d'alta vàlid per crear un compte",
"validating_signup_token": "Validant el token d'alta",
"go_to_login": "Vés a iniciar la sessió",
"signup_to_appname": "Dona't d'alta a {appName}",
"create_your_account_to_get_started": "Crea el teu compte per començar.",
"initial_account_creation_description": "Si us plau, crea el teu compte per començar. Podràs configurar una clau d'accés més endavant.",
"setup_your_passkey": "Configura la teva clau d'accés",
"create_a_passkey_to_securely_access_your_account": "Crea una clau d'accés per accedir de manera segura al teu compte. Aquesta serà la teva manera principal d'iniciar la sessió.",
"skip_for_now": "Ignora-ho per ara",
"account_created": "Compte creat",
"enable_user_signups": "Activa l'alta d'usuaris",
"enable_user_signups_description": "Decideix com poden donar-se d'alta els usuaris per crear comptes nous a Pocket ID.",
"user_signups_are_disabled": "L'alta d'usuaris està deshabilitada actualment",
"create_signup_token": "Crea token d'alta",
"view_active_signup_tokens": "Mostra tokens d'alta actius",
"manage_signup_tokens": "Gestiona tokens d'alta",
"view_and_manage_active_signup_tokens": "Mostra i gestiona tokens d'alta actius.",
"signup_token_deleted_successfully": "Token d'alta eliminat correctament.",
"expired": "Caducat",
"used_up": "Utilitzat",
"active": "Actiu",
"usage": "Ús",
"created": "Creat",
"token": "Token",
"loading": "Carregant",
"delete_signup_token": "Elimina token d'alta",
"are_you_sure_you_want_to_delete_this_signup_token": "Segur que vols eliminar aquest token d'alta? Aquesta acció no es pot desfer.",
"signup_with_token": "Dona't d'alta amb token",
"signup_with_token_description": "Els usuaris només poden donar-se d'alta amb un token d'alta vàlid creat per un administrador.",
"signup_open": "Procés d'alta obert",
"signup_open_description": "Qualsevol pot crear-se un compte nou sense restriccions.",
"of": "de",
"skip_passkey_setup": "Omet la configuració de clau d'accés",
"skip_passkey_setup_description": "Es recomana configurar una clau d'accés; sense ella, podries quedar-te fora quan caduqui la sessió.",
"my_apps": "Les meves aplicacions",
"no_apps_available": "No hi ha aplicacions disponibles",
"contact_your_administrator_for_app_access": "Contacta amb l'administrador per obtenir accés a les aplicacions.",
"launch": "Ves-hi",
"client_launch_url": "URL d'accés del client",
"client_launch_url_description": "L'URL que s'obrirà quan un usuari accedeix-hi a l'aplicació des de la pàgina Les meves aplicacions.",
"client_name_description": "El nom del client que es mostra a la interfície de Pocket ID.",
"revoke_access": "Revoca l'accés",
"revoke_access_description": "Revoca l'accés a <b>{clientName}</b>. <b>{clientName}</b> ja no podrà accedir a la informació del teu compte.",
"revoke_access_successful": "S'ha revocat correctament l'accés a {clientName}.",
"last_signed_in_ago": "Últim inici de sessió fa {time}",
"invalid_client_id": "L'ID del client només pot contenir lletres, números, guions baixos i guions",
"custom_client_id_description": "Estableix un ID de client personalitzat si la teva aplicació ho requereix. Si no, deixa-ho en blanc per generar-ne un aleatori.",
"generated": "Generat",
"administration": "Administració",
"group_rdn_attribute_description": "L'atribut utilitzat en el nom distingit (DN) dels grups.",
"display_name_attribute": "Atribut del nom a mostrar",
"display_name": "Nom a mostrar",
"configure_application_images": "Configura les imatges de l'aplicació",
"ui_config_disabled_info_title": "Configuració de la interfície d'usuari deshabilitada",
"ui_config_disabled_info_description": "La configuració de la interfície d'usuari està deshabilitada perquè la configuració de l'aplicació es gestiona mitjançant variables d'entorn. Algunes opcions poden no ser editables.",
"logo_from_url_description": "Enganxa directament una URL d'imatge (svg, png, webp). Troba icones a <link href=\"https://selfh.st/icons\">Selfh.st Icons</link> o <link href=\"https://dashboardicons.com\">Dashboard Icons</link>.",
"invalid_url": "URL invàlida",
"require_user_email": "Requerir adreça de correu",
"require_user_email_description": "Requereix que els usuaris tinguin una adreça de correu. Si està deshabilitat, els usuaris sense correu no podran utilitzar funcions que ho requereixen.",
"view": "Veure",
"toggle_columns": "Mostra/Amaga columnes",
"locale": "Localització",
"ldap_id": "ID LDAP",
"reauthentication": "Reautenticació",
"clear_filters": "Neteja filtres",
"default_profile_picture": "Imatge de perfil per defecte",
"light": "Clar",
"dark": "Fosc",
"system": "Sistema",
"signup_token_user_groups_description": "Assigna automàticament aquests grups als usuaris que es donin d'alta amb aquest token.",
"allowed_oidc_clients": "Clients OIDC permesos",
"allowed_oidc_clients_description": "Selecciona els clients OIDC als quals els membres d'aquest grup poden iniciar la sessió.",
"unrestrict_oidc_client": "Elimina restriccions a {clientName}",
"confirm_unrestrict_oidc_client_description": "Segur que vols eliminar les restriccions del client OIDC <b>{clientName}</b>? Això eliminarà totes les assignacions de grup per aquest client i qualsevol usuari podrà iniciar la sessió.",
"allowed_oidc_clients_updated_successfully": "Clients OIDC permesos actualitzats correctament",
"yes": "Sí",
"no": "No",
"restricted": "Restrigit",
"scim_provisioning": "Provisionament SCIM",
"scim_provisioning_description": "El provisionament SCIM permet provisionar i desaprovisionar automàticament usuaris i grups des del teu client OIDC. Més informació als <link href='https://pocket-id.org/docs/configuration/scim'>docs</link>.",
"scim_endpoint": "Endpoint SCIM",
"scim_token": "Token SCIM",
"last_successful_sync_at": "Última sincronització correcta: {time}",
"scim_configuration_updated_successfully": "Configuració SCIM actualitzada correctament.",
"scim_enabled_successfully": "SCIM habilitat correctament.",
"scim_disabled_successfully": "SCIM deshabilitat correctament.",
"disable_scim_provisioning": "Desactiva proveïment SCIM",
"disable_scim_provisioning_confirm_description": "Segur que vols desactivar el proveïment SCIM per a <b>{clientName}</b>? Això aturarà la provisió automàtica d'usuaris i grups.",
"scim_sync_failed": "La sincronització SCIM ha fallat. Consulta els registres del servidor per més informació.",
"scim_sync_successful": "La sincronització SCIM s'ha completat correctament.",
"save_and_sync": "Desa i sincronitza",
"scim_save_changes_description": "Has de desar els canvis abans d'iniciar una sincronització SCIM. Vols desar-los ara?",
"scopes": "Àmbits",
"issuer_url": "URL de l'emissor",
"smtp_field_required_when_other_provided": "Requerit quan s'ha proporcionat qualsevol configuració SMTP",
"smtp_field_required_when_email_enabled": "Requerit quan les notificacions per correu estan habilitades",
"renew": "Renova-ho",
"renew_api_key": "Renova clau API",
"renew_api_key_description": "Renovar la clau API generarà una nova clau. Assegura't d'actualitzar qualsevol integració que l'utilitzi.",
"api_key_renewed": "Clau API renovada",
"app_config_home_page": "Pàgina d'inici",
"app_config_home_page_description": "La pàgina a la qual es redirigeix l'usuari després d'iniciar la sessió.",
"email_verification_warning": "Verifica la teva adreça de correu",
"email_verification_warning_description": "La teva adreça de correu encara no està verificada. Verifica-la tan aviat com sigui possible.",
"email_verification": "Verificació de correu",
"email_verification_description": "Envia un correu de verificació als usuaris quan es donguin d'alta o canvien la seva adreça de correu.",
"email_verification_success_title": "Correu verificat correctament",
"email_verification_success_description": "La teva adreça de correu ha estat verificada correctament.",
"email_verification_error_title": "Error en la verificació de correu",
"mark_as_unverified": "Marca com no verificat",
"mark_as_verified": "Marca com verificat",
"email_verification_sent": "Correu de verificació enviat correctament.",
"emails_verified_by_default": "Correus verificats per defecte",
"emails_verified_by_default_description": "Quan està habilitat, les adreces de correu dels usuaris seran marcades com verificades per defecte durant l'alta o en canviar la seva adreça.",
"user_has_no_passkeys_yet": "Aquest usuari encara no té claus d'accés."
}

View File

@@ -106,6 +106,7 @@
"ip_address": "IP adresa",
"device": "Zařízení",
"client": "Klient",
"actor": "Herec",
"unknown": "Neznámé",
"account_details_updated_successfully": "Účet byl úspěšně aktualizován",
"profile_picture_updated_successfully": "Profilový obrázek byl úspěšně aktualizován. Aktualizace může trvat několik minut.",
@@ -117,6 +118,7 @@
"account_details": "Podrobnosti účtu",
"passkeys": "Přístupové klíče",
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Spravujte své přístupové klíče, které můžete použít pro ověření.",
"manage_this_users_passkeys": "Přehled a správa přístupových klíčů tohoto uživatele.",
"add_passkey": "Přidat přístupový klíč",
"create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey": "Vytvořte jednorázový přihlašovací kód pro přihlášení z jiného zařízení bez přístupového klíče.",
"create": "Vytvořit",
@@ -365,7 +367,7 @@
"enter_code_displayed_in_previous_step": "Zadejte kód, který byl zobrazen v předchozím kroku.",
"authorize": "Autorizovat",
"federated_client_credentials": "Údaje o klientovi ve federaci",
"federated_client_credentials_description": "Pomocí federovaných přihlašovacích údajů klienta můžete ověřit klienty OIDC pomocí JWT tokenů vydaných třetí stranou.",
"federated_client_credentials_description": "Federované klientské přihlašovací údaje umožňují ověřování klientů OIDC bez správy dlouhodobých tajných klíčů. Využívají tokeny JWT vydané třetími stranami pro klientská tvrzení, např. tokeny identity pracovního zatížení.",
"add_federated_client_credential": "Přidat údaje federovaného klienta",
"add_another_federated_client_credential": "Přidat dalšího federovaného klienta",
"oidc_allowed_group_count": "Počet povolených skupin",
@@ -521,5 +523,6 @@
"mark_as_verified": "Označit jako ověřené",
"email_verification_sent": "Ověřovací e-mail byl úspěšně odeslán.",
"emails_verified_by_default": "E-maily ověřené ve výchozím nastavení",
"emails_verified_by_default_description": "Pokud je tato funkce povolena, budou e-mailové adresy uživatelů při registraci nebo při změně e-mailové adresy automaticky označeny jako ověřené."
"emails_verified_by_default_description": "Pokud je tato funkce povolena, budou e-mailové adresy uživatelů při registraci nebo při změně e-mailové adresy automaticky označeny jako ověřené.",
"user_has_no_passkeys_yet": "Tento uživatel zatím nemá žádné přístupové klíče."
}

View File

@@ -106,6 +106,7 @@
"ip_address": "IP-adresse",
"device": "Enhed",
"client": "Klient",
"actor": "Skuespiller",
"unknown": "Ukendt",
"account_details_updated_successfully": "Kontodetaljer blev opdateret",
"profile_picture_updated_successfully": "Profilbillede opdateret. Det kan tage et par minutter før ændringen vises.",
@@ -117,6 +118,7 @@
"account_details": "Kontooplysninger",
"passkeys": "Adgangsnøgler",
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Administrér dine adgangsnøgler, som du kan bruge til at godkende dig selv.",
"manage_this_users_passkeys": "Administrer denne brugers adgangskoder.",
"add_passkey": "Tilføj adgangsnøgle",
"create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey": "Opret en engangskode for at logge ind fra en anden enhed uden en adgangsnøgle.",
"create": "Opret",
@@ -356,7 +358,7 @@
"login_code_email_success": "Loginkoden er sendt til brugeren.",
"send_email": "Send e-mail",
"show_code": "Vis kode",
"callback_url_description": "URL(er) angivet af din klient. Tilføjes automatisk, hvis feltet efterlades tomt. <link href='https://pocket-id.org/docs/advanced/callback-url-wildcards'>Jokertegn</link> understøttes.",
"callback_url_description": "URL(er) angivet af din klient. Tilføjes automatisk, hvis feltet efterlades tomt. <link href='https://pocket-id.org/docs/advanced/callback-url-wildcards'>Wildcards</link> understøttes.",
"logout_callback_url_description": "URL(er) angivet af din klient til logout. <link href='https://pocket-id.org/docs/advanced/callback-url-wildcards'>Wildcards</link> understøttes.",
"api_key_expiration": "Udløb af API-nøgle",
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Send en e-mail til brugeren, når deres API-nøgle er ved at udløbe.",
@@ -365,7 +367,7 @@
"enter_code_displayed_in_previous_step": "Indtast koden, der blev vist i det forrige trin.",
"authorize": "Godkend",
"federated_client_credentials": "Federated klientlegitimationsoplysninger",
"federated_client_credentials_description": "Ved hjælp af federated klientlegitimationsoplysninger kan du godkende OIDC-klienter med JWT-tokens udstedt af tredjepartsudbydere.",
"federated_client_credentials_description": "Federerede klientlegitimationsoplysninger gør det muligt at autentificere OIDC-klienter uden at skulle administrere langvarige hemmeligheder. De udnytter JWT-tokens udstedt af tredjepartsmyndigheder til klientpåstande, f.eks. identitetstokens for arbejdsbelastning.",
"add_federated_client_credential": "Tilføj federated klientlegitimation",
"add_another_federated_client_credential": "Tilføj endnu en federated klientlegitimation",
"oidc_allowed_group_count": "Tilladt antal grupper",
@@ -446,7 +448,7 @@
"no_apps_available": "Ingen apps tilgængelige",
"contact_your_administrator_for_app_access": "Kontakt din administrator for at få adgang til applikationer.",
"launch": "Start",
"client_launch_url": "Kundens lancerings-URL",
"client_launch_url": "Start-URL til klient",
"client_launch_url_description": "Den URL, der åbnes, når en bruger starter appen fra siden Mine apps.",
"client_name_description": "Navnet på den klient, der vises i Pocket ID-brugergrænsefladen.",
"revoke_access": "Tilbagekald adgang",
@@ -521,5 +523,6 @@
"mark_as_verified": "Marker som verificeret",
"email_verification_sent": "Bekræftelses-e-mail sendt med succes.",
"emails_verified_by_default": "E-mails verificeret som standard",
"emails_verified_by_default_description": "Når denne funktion er aktiveret, vil brugernes e-mailadresser som standard blive markeret som verificerede ved tilmelding eller når deres e-mailadresse ændres."
"emails_verified_by_default_description": "Når denne funktion er aktiveret, vil brugernes e-mailadresser som standard blive markeret som verificerede ved tilmelding eller når deres e-mailadresse ændres.",
"user_has_no_passkeys_yet": "Denne bruger har endnu ingen adgangskoder."
}

View File

@@ -106,6 +106,7 @@
"ip_address": "IP-Adresse",
"device": "Gerät",
"client": "Client",
"actor": "Schauspieler",
"unknown": "unbekannt",
"account_details_updated_successfully": "Kontodetails erfolgreich aktualisiert",
"profile_picture_updated_successfully": "Profilbild erfolgreich aktualisiert. Die Aktualisierung kann einige Minuten dauern.",
@@ -117,6 +118,7 @@
"account_details": "Kontodetails",
"passkeys": "Passkeys",
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Verwalte deine Passkeys, mit denen du dich authentifizieren kannst.",
"manage_this_users_passkeys": "Verwalte die Passwörter dieses Benutzers.",
"add_passkey": "Passkey hinzufügen",
"create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey": "Erzeuge einen einmaligen Anmeldecode, um dich ohne Passkey von einem anderen Gerät aus anzumelden.",
"create": "Erzeugen",
@@ -365,7 +367,7 @@
"enter_code_displayed_in_previous_step": "Gib den Code ein, der im vorherigen Schritt angezeigt wurde.",
"authorize": "Autorisieren",
"federated_client_credentials": "Federated Client Credentials",
"federated_client_credentials_description": "Mit Hilfe von Verbund-Client-Anmeldeinformationen kannst du OIDC-Clients mit JWT-Tokens authentifizieren, die von Drittanbietern ausgestellt wurden.",
"federated_client_credentials_description": "Mit föderierten Client-Anmeldeinfos kann man OIDC-Clients authentifizieren, ohne sich um langlebige Geheimnisse kümmern zu müssen. Sie nutzen JWT-Token, die von Drittanbietern für Client-Assertions ausgestellt werden, z. B. Workload-Identitätstoken.",
"add_federated_client_credential": "Föderierte Client-Anmeldeinfos hinzufügen",
"add_another_federated_client_credential": "Weitere Anmeldeinformationen für einen Verbundclient hinzufügen",
"oidc_allowed_group_count": "Erlaubte Gruppenanzahl",
@@ -500,7 +502,7 @@
"scim_sync_successful": "Die SCIM-Synchronisierung ist erfolgreich abgeschlossen worden.",
"save_and_sync": "Speichern und synchronisieren",
"scim_save_changes_description": "Du musst die Änderungen speichern, bevor du eine SCIM-Synchronisierung startest. Willst du jetzt speichern?",
"scopes": "Kopfsuchgeräte",
"scopes": "Scopes",
"issuer_url": "Aussteller-URL",
"smtp_field_required_when_other_provided": "Muss angegeben werden, wenn SMTP-Einstellungen gemacht werden",
"smtp_field_required_when_email_enabled": "Muss aktiviert sein, wenn du E-Mail-Benachrichtigungen nutzen willst.",
@@ -521,5 +523,6 @@
"mark_as_verified": "Als verifiziert markieren",
"email_verification_sent": "Bestätigungs-E-Mail erfolgreich verschickt.",
"emails_verified_by_default": "E-Mails sind standardmäßig verifiziert",
"emails_verified_by_default_description": "Wenn diese Option aktiviert ist, werden die E-Mail-Adressen der Nutzer bei der Anmeldung oder bei einer Änderung ihrer E-Mail-Adresse standardmäßig als verifiziert markiert."
"emails_verified_by_default_description": "Wenn diese Option aktiviert ist, werden die E-Mail-Adressen der Nutzer bei der Anmeldung oder bei einer Änderung ihrer E-Mail-Adresse standardmäßig als verifiziert markiert.",
"user_has_no_passkeys_yet": "Dieser Benutzer hat noch keine Passwörter."
}

View File

@@ -106,6 +106,7 @@
"ip_address": "IP Address",
"device": "Device",
"client": "Client",
"actor": "Actor",
"unknown": "Unknown",
"account_details_updated_successfully": "Account details updated successfully",
"profile_picture_updated_successfully": "Profile picture updated successfully. It may take a few minutes to update.",
@@ -117,6 +118,7 @@
"account_details": "Account Details",
"passkeys": "Passkeys",
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Manage your passkeys that you can use to authenticate yourself.",
"manage_this_users_passkeys": "Manage this user's passkeys.",
"add_passkey": "Add Passkey",
"create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey": "Create a one-time login code to sign in from a different device without a passkey.",
"create": "Create",
@@ -365,7 +367,7 @@
"enter_code_displayed_in_previous_step": "Enter the code that was displayed in the previous step.",
"authorize": "Authorize",
"federated_client_credentials": "Federated Client Credentials",
"federated_client_credentials_description": "Using federated client credentials, you can authenticate OIDC clients using JWT tokens issued by third-party authorities.",
"federated_client_credentials_description": "Federated client credentials allow authenticating OIDC clients without managing long-lived secrets. They leverage JWT tokens issued by third-party authorities for client assertions, e.g. workload identity tokens.",
"add_federated_client_credential": "Add Federated Client Credential",
"add_another_federated_client_credential": "Add another federated client credential",
"oidc_allowed_group_count": "Allowed Group Count",
@@ -521,5 +523,6 @@
"mark_as_verified": "Mark as verified",
"email_verification_sent": "Verification email sent successfully.",
"emails_verified_by_default": "Emails verified by default",
"emails_verified_by_default_description": "When enabled, users' email addresses will be marked as verified by default upon signup or when their email address is changed."
"emails_verified_by_default_description": "When enabled, users' email addresses will be marked as verified by default upon signup or when their email address is changed.",
"user_has_no_passkeys_yet": "This user has no passkeys yet."
}

View File

@@ -106,6 +106,7 @@
"ip_address": "Dirección IP",
"device": "Dispositivo",
"client": "Cliente",
"actor": "Actor",
"unknown": "Desconocido",
"account_details_updated_successfully": "Detalles de la cuenta actualizados exitosamente",
"profile_picture_updated_successfully": "Imagen de perfil actualizada correctamente. Puede tardar unos minutos en actualizarse.",
@@ -117,6 +118,7 @@
"account_details": "Detalles de la cuenta",
"passkeys": "Claves de acceso",
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Administra las claves de acceso que te permiten autenticarte.",
"manage_this_users_passkeys": "Gestiona las claves de acceso de este usuario.",
"add_passkey": "Añade una clave de acceso",
"create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey": "Crea un código de inicio de sesión único para iniciar sesión desde un dispositivo diferente sin una clave.",
"create": "Crear",
@@ -365,7 +367,7 @@
"enter_code_displayed_in_previous_step": "Introduce el código que se mostró en el paso anterior.",
"authorize": "Autorizar",
"federated_client_credentials": "Credenciales de cliente federadas",
"federated_client_credentials_description": "Mediante credenciales de cliente federadas, puedes autenticar clientes OIDC utilizando tokens JWT emitidos por autoridades de terceros.",
"federated_client_credentials_description": "Las credenciales de cliente federadas permiten autenticar clientes OIDC sin gestionar secretos de larga duración. Aprovechan los tokens JWT emitidos por autoridades externas para las afirmaciones de los clientes, por ejemplo, tokens de identidad de carga de trabajo.",
"add_federated_client_credential": "Añadir credenciales de cliente federado",
"add_another_federated_client_credential": "Añadir otra credencial de cliente federado",
"oidc_allowed_group_count": "Recuento de grupos permitidos",
@@ -521,5 +523,6 @@
"mark_as_verified": "Marcar como verificado",
"email_verification_sent": "El correo electrónico de verificación se ha enviado correctamente.",
"emails_verified_by_default": "Correos electrónicos verificados de forma predeterminada",
"emails_verified_by_default_description": "Cuando esta opción está activada, las direcciones de correo electrónico de los usuarios se marcarán como verificadas de forma predeterminada al registrarse o cuando se modifique su dirección de correo electrónico."
"emails_verified_by_default_description": "Cuando esta opción está activada, las direcciones de correo electrónico de los usuarios se marcarán como verificadas de forma predeterminada al registrarse o cuando se modifique su dirección de correo electrónico.",
"user_has_no_passkeys_yet": "Este usuario aún no tiene claves de acceso."
}

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"my_account": "My Account",
"my_account": "Minu konto",
"logout": "Logout",
"confirm": "Confirm",
"docs": "Docs",
@@ -106,6 +106,7 @@
"ip_address": "IP Address",
"device": "Device",
"client": "Client",
"actor": "Näitleja",
"unknown": "Unknown",
"account_details_updated_successfully": "Account details updated successfully",
"profile_picture_updated_successfully": "Profile picture updated successfully. It may take a few minutes to update.",
@@ -117,6 +118,7 @@
"account_details": "Account Details",
"passkeys": "Passkeys",
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Manage your passkeys that you can use to authenticate yourself.",
"manage_this_users_passkeys": "Haldage selle kasutaja paroolivõtmeid.",
"add_passkey": "Add Passkey",
"create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey": "Create a one-time login code to sign in from a different device without a passkey.",
"create": "Create",
@@ -365,7 +367,7 @@
"enter_code_displayed_in_previous_step": "Enter the code that was displayed in the previous step.",
"authorize": "Authorize",
"federated_client_credentials": "Federated Client Credentials",
"federated_client_credentials_description": "Using federated client credentials, you can authenticate OIDC clients using JWT tokens issued by third-party authorities.",
"federated_client_credentials_description": "Föderatiivsed kliendi autentimisandmed võimaldavad OIDC-kliente autentida ilma pikaajalisi salajasi andmeid haldamata. Need kasutavad kolmandate osapoolte poolt väljastatud JWT-tokeneid kliendi kinnituste jaoks, nt töökoormuse identiteeditokeneid.",
"add_federated_client_credential": "Add Federated Client Credential",
"add_another_federated_client_credential": "Add another federated client credential",
"oidc_allowed_group_count": "Allowed Group Count",
@@ -521,5 +523,6 @@
"mark_as_verified": "Mark as verified",
"email_verification_sent": "Verification email sent successfully.",
"emails_verified_by_default": "Emails verified by default",
"emails_verified_by_default_description": "When enabled, users' email addresses will be marked as verified by default upon signup or when their email address is changed."
"emails_verified_by_default_description": "When enabled, users' email addresses will be marked as verified by default upon signup or when their email address is changed.",
"user_has_no_passkeys_yet": "Sellel kasutajal pole veel paroolivõtmeid."
}

View File

@@ -106,6 +106,7 @@
"ip_address": "IP-osoite",
"device": "Laite",
"client": "Asiakas",
"actor": "Näyttelijä",
"unknown": "Tuntematon",
"account_details_updated_successfully": "Tilin tiedot päivitetty onnistuneesti",
"profile_picture_updated_successfully": "Profiilikuva päivitetty onnistuneesti. Päivitys voi kestää muutaman minuutin.",
@@ -117,6 +118,7 @@
"account_details": "Tilitiedot",
"passkeys": "Pääsyavaimet",
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Hallitse pääsyavaimiasi, joita voit käyttää tunnistautumiseen.",
"manage_this_users_passkeys": "Hallitse tämän käyttäjän salasanat.",
"add_passkey": "Lisää pääsyavain",
"create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey": "Luo kertakäyttöinen kirjautumiskoodi, jotta voit kirjautua sisään toisella laitteella ilman pääsyavainta.",
"create": "Luo",
@@ -365,7 +367,7 @@
"enter_code_displayed_in_previous_step": "Syötä edellisessä vaiheessa näkynyt koodi.",
"authorize": "Salli",
"federated_client_credentials": "Federoidut asiakastunnukset",
"federated_client_credentials_description": "Yhdistettyjen asiakastunnistetietojen avulla voit todentaa OIDC-asiakkaat kolmannen osapuolen myöntämillä JWT-tunnuksilla.",
"federated_client_credentials_description": "Yhdistetyt asiakastunnistetiedot mahdollistavat OIDC-asiakkaiden todentamisen ilman pitkäaikaisten salaisuuksien hallintaa. Ne hyödyntävät kolmansien osapuolten viranomaisten myöntämiä JWT-tunnuksia asiakastodistuksiin, esimerkiksi työkuorman tunnistetunnuksiin.",
"add_federated_client_credential": "Lisää federoitu asiakastunnus",
"add_another_federated_client_credential": "Lisää toinen federoitu asiakastunnus",
"oidc_allowed_group_count": "Sallittujen ryhmien määrä",
@@ -521,5 +523,6 @@
"mark_as_verified": "Merkitse vahvistetuksi",
"email_verification_sent": "Vahvistussähköposti lähetetty onnistuneesti.",
"emails_verified_by_default": "Sähköpostit vahvistettu oletuksena",
"emails_verified_by_default_description": "Kun tämä toiminto on käytössä, käyttäjien sähköpostiosoitteet merkitään oletusarvoisesti vahvistetuiksi rekisteröitymisen yhteydessä tai kun heidän sähköpostiosoitteensa muuttuu."
"emails_verified_by_default_description": "Kun tämä toiminto on käytössä, käyttäjien sähköpostiosoitteet merkitään oletusarvoisesti vahvistetuiksi rekisteröitymisen yhteydessä tai kun heidän sähköpostiosoitteensa muuttuu.",
"user_has_no_passkeys_yet": "Tällä käyttäjällä ei ole vielä salasanakoodeja."
}

View File

@@ -106,6 +106,7 @@
"ip_address": "Adresse IP",
"device": "Périphérique",
"client": "Application",
"actor": "Acteur",
"unknown": "Indisponible",
"account_details_updated_successfully": "Les informations du compte ont été mises à jour avec succès",
"profile_picture_updated_successfully": "La photo de profil a été mise à jour avec succès. La mise à jour peut prendre quelques minutes.",
@@ -117,6 +118,7 @@
"account_details": "Paramètres du compte",
"passkeys": "Clés d'accès",
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Gérez vos clés d'accès que vous pouvez utiliser pour vous authentifier.",
"manage_this_users_passkeys": "Gérer les mots de passe de cet utilisateur.",
"add_passkey": "Ajouter une clé d'accès",
"create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey": "Créez un code de connexion unique pour vous connecter depuis un autre appareil sans mot de passe.",
"create": "Créer",
@@ -365,7 +367,7 @@
"enter_code_displayed_in_previous_step": "Entrez le code affiché à l'étape précédente.",
"authorize": "Autoriser",
"federated_client_credentials": "Identifiants client fédérés",
"federated_client_credentials_description": "Avec des identifiants clients fédérés, vous pouvez authentifier des clients OIDC avec des tokens JWT émis par des autorités tierces.",
"federated_client_credentials_description": "Les informations d'identification client fédérées permettent d'authentifier les clients OIDC sans avoir à gérer des secrets à long terme. Elles utilisent des jetons JWT émis par des autorités tierces pour les assertions client, par exemple des jetons d'identité de charge de travail.",
"add_federated_client_credential": "Ajouter un identifiant client fédéré",
"add_another_federated_client_credential": "Ajouter un autre identifiant client fédéré",
"oidc_allowed_group_count": "Nombre de groupes autorisés",
@@ -521,5 +523,6 @@
"mark_as_verified": "Marquer comme vérifié",
"email_verification_sent": "L'e-mail de vérification a été envoyé sans problème.",
"emails_verified_by_default": "E-mails vérifiés par défaut",
"emails_verified_by_default_description": "Quand cette option est activée, les adresses e-mail des utilisateurs seront marquées comme vérifiées par défaut lors de leur inscription ou quand ils changent d'adresse e-mail."
"emails_verified_by_default_description": "Quand cette option est activée, les adresses e-mail des utilisateurs seront marquées comme vérifiées par défaut lors de leur inscription ou quand ils changent d'adresse e-mail.",
"user_has_no_passkeys_yet": "Cet utilisateur n'a pas encore de mots de passe."
}

Some files were not shown because too many files have changed in this diff Show More