Compare commits

...

361 Commits

Author SHA1 Message Date
Elias Schneider
20254eea7c release: 2.10.0 2026-07-10 17:08:11 +02:00
Elias Schneider
f62d476c56 chore: upgrade go version 2026-07-10 16:58:27 +02:00
Elias Schneider
119eecefc3 chore(translations): update translations via Crowdin (#1573) 2026-07-10 16:25:05 +02:00
Elias Schneider
59a6868ecd refactor: run formatter 2026-07-10 15:57:17 +02:00
Elias Schneider
2910c517bb fix: mobile layout improvements 2026-07-10 15:56:47 +02:00
Elias Schneider
da3677f33d docs: fix wrong OpenAPI documentation 2026-07-10 15:44:28 +02:00
Elias Schneider
968d07229e docs: add link to API docs 2026-07-10 15:38:29 +02:00
Elias Schneider
28a553f63b feat: add tab bar navigation for crowded pages 2026-07-10 15:33:02 +02:00
Alessandro (Ale) Segala
b2711ced99 fix: /authorize endpoint crashes when list of scopes is empty (#1575) 2026-07-08 17:48:32 -07:00
Sean McKenzie
6734585712 feat: add description field to oidc clients (#1547)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2026-07-08 13:41:51 +02:00
Elias Schneider
c85a4e63da fix: disable one time access token exchange for disabled users 2026-07-08 12:04:16 +02:00
Elias Schneider
ce7d3a7e1d tests(unit): fix wrong migration name 2026-07-08 11:51:43 +02:00
Elias Schneider
a5f2192d67 tests(e2e): fix migration version in database.json 2026-07-08 11:19:09 +02:00
Elias Schneider
190914fd72 refactor: remove duplicate fosite config properties 2026-07-08 11:01:50 +02:00
Elias Schneider
25dcad757a feat: add support for unencrypted OIDC request parameter 2026-07-08 10:42:05 +02:00
Elias Schneider
c72da58eaf refactor: rename migrations 2026-07-07 17:16:10 +02:00
Elias Schneider
f3b6ceb876 fix: add missing burst to rate limit 2026-07-07 14:01:26 +02:00
Elias Schneider
fa2d08cb6d refactor: remove redundant dtos 2026-07-07 11:51:37 +02:00
Elias Schneider
2f55b7cbc3 fix: device authorization resolve resource creating device_code 2026-07-07 11:51:02 +02:00
Elias Schneider
8035a4c8d5 perf: include permissions in api list response 2026-07-07 11:38:59 +02:00
Elias Schneider
7667377c98 refactor: pass transaction to resolveResource 2026-07-07 11:14:19 +02:00
Elias Schneider
5e2cc6f40e fix: merge requested scopes instead of replacing them 2026-07-07 11:03:06 +02:00
Elias Schneider
e8cb0c831c fix: re-check api permissions on access token refresh 2026-07-07 10:58:39 +02:00
Elias Schneider
337fc6fd1e chore: update Fosite version 2026-07-06 23:43:09 +02:00
Elias Schneider
590feadab9 chore(translations): update translations via Crowdin (#1569) 2026-07-06 23:35:03 +02:00
Elias Schneider
9a94aa0694 refactor: move Pocket ID specific logic from Fosite into Pocket ID repo 2026-07-06 23:34:33 +02:00
Elias Schneider
34e9a6d198 fix: restore behavior that unknown scopes get ignored 2026-07-06 22:42:38 +02:00
Elias Schneider
6120bb4dd8 chore(translations): update translations via Crowdin (#1568) 2026-07-06 22:24:03 +02:00
Elias Schneider
09d196f7c5 feat: add OAuth APIs with scoped permissions (#1542)
Co-authored-by: Alessandro (Ale) Segala <43508+ItalyPaleAle@users.noreply.github.com>
2026-07-06 12:25:02 -07:00
github-actions[bot]
0b2706a488 chore: update AAGUIDs (#1567)
Co-authored-by: stonith404 <58886915+stonith404@users.noreply.github.com>
2026-07-06 08:58:44 +02:00
Alessandro (Ale) Segala
eefd51cc4d fix: various bugs in observability / OTel (#1564) 2026-07-05 14:14:39 -07:00
Alessandro (Ale) Segala
f68ad42618 chore: use go-kit for sending emails (#1565) 2026-07-05 13:10:11 -07:00
dependabot[bot]
0d2624db9c chore(deps): Bump the "all-dependencies" group with 3 updates across multiple ecosystems (#1563)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Kyle Mendell <ksm@ofkm.us>
2026-07-05 22:06:07 +02:00
Elias Schneider
5c988a921a chore(translations): update translations via Crowdin (#1559)
Co-authored-by: Alessandro (Ale) Segala <43508+ItalyPaleAle@users.noreply.github.com>
2026-07-03 08:37:08 +02:00
Alessandro (Ale) Segala
9607495ab4 refactor: integrate Francis actor framework for background jobs, cron scheduling, and rate limiting (#1556)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-07-03 08:36:45 +02:00
Alessandro (Ale) Segala
d359438814 tests(e2e): make API key renewal date picker navigation date-independent (#1560) 2026-07-02 21:16:47 -05:00
Elias Schneider
931a6c2adb feat: drop TOFU support for callback URL 2026-07-02 14:05:37 +02:00
James18232
8564d35682 feat: login code input boxes (#1545)
Co-authored-by: james <james@goldfish.net>
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2026-07-02 07:56:18 +00:00
Elias Schneider
ecad31cae2 refactor: migrate signup functionality to single usersignup module 2026-06-30 20:53:31 +02:00
Elias Schneider
58fcf7cbe6 refactor: migrate Webauthn functionality to single webauthn module 2026-06-29 14:05:07 +02:00
Elias Schneider
96ea55b9bc chore(translations): update translations via Crowdin (#1550) 2026-06-29 12:12:51 +02:00
Elias Schneider
9fce987106 refactor: migrate API key functionality to single apikey module 2026-06-29 12:11:18 +02:00
James18232
97bd466f38 feat: prompt admin with PKCE client support hint (#1499)
Co-authored-by: james <james@goldfish.net>
Co-authored-by: Alessandro (Ale) Segala <43508+ItalyPaleAle@users.noreply.github.com>
Co-authored-by: Elias Schneider <login@eliasschneider.com>
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
2026-06-28 11:10:30 -07:00
Elias Schneider
1b3ba3f6c2 chore(translations): update translations via Crowdin (#1548) 2026-06-28 12:01:44 -05:00
Elias Schneider
254ae88cda ci/cd: add exempt user to PR quality checks 2026-06-27 14:41:14 +02:00
Elias Schneider
cb665681c4 chore(translations): update translations via Crowdin (#1543) 2026-06-27 13:01:19 +02:00
Elias Schneider
d467855870 feat: add ability to skip consent for client 2026-06-26 23:35:26 +02:00
Elias Schneider
16b5c16a66 fix: CSP error with response_mode=form_post 2026-06-26 14:51:56 +02:00
Elias Schneider
2ed703540d fix: don't reject offline_accessscope 2026-06-26 14:43:22 +02:00
Elias Schneider
cf54786cc3 ci/cd: don't tag next image with current version 2026-06-26 13:50:52 +02:00
Elias Schneider
cdc23744ae chore: change Depot project 2026-06-25 22:41:40 +02:00
Elias Schneider
4b957a0106 ci/cd: don't build unnecessary binaries for next image 2026-06-25 22:23:41 +02:00
Elias Schneider
49305ed1f9 ci/cd: remove static next image tag 2026-06-24 22:29:27 +02:00
Elias Schneider
99ab38dc79 chore: add support for Docker Hub 2026-06-24 22:00:49 +02:00
Kyle Mendell
35dcf58ee9 chore: use goreleaser for binaries and static image builds (#1478)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2026-06-24 21:49:27 +02:00
Elias Schneider
d0243fe396 chore: add AGENTS.md 2026-06-23 09:09:34 +02:00
Elias Schneider
59fd6b3726 chore(translations): update translations via Crowdin (#1524) 2026-06-23 08:38:59 +02:00
Elias Schneider
7eaaea717b tests(e2e): fix flaky test 2026-06-22 23:00:10 +02:00
Elias Schneider
e538ea0bf1 chore(deps): update vulnerable dependencies 2026-06-22 22:37:51 +02:00
Elias Schneider
8689ddd72b feat: improve error handling on authorize page 2026-06-22 22:12:14 +02:00
Elias Schneider
519cda0eef refactor: remove dead code 2026-06-22 21:58:55 +02:00
Elias Schneider
cf9a31986f chore: wrap last two migrations into transaction 2026-06-22 18:44:48 +02:00
Elias Schneider
8158452b37 refactor: use fosite for OAuth 2.0 logic (#1520) 2026-06-22 18:42:02 +02:00
Elias Schneider
dbbe2a403a tests(e2e): fix locators after shadcn upgrade 2026-06-21 20:52:42 +02:00
Elias Schneider
80509c83ee feat: upgrade shadcn components 2026-06-20 23:57:34 +02:00
James18232
2e77d57282 fix: login code null submission and login code length check (#1512)
Co-authored-by: james <james@goldfish.net>
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
2026-06-20 20:42:50 +02:00
dependabot[bot]
f673a91a67 chore(deps): Bump the "all-dependencies" group with 2 updates across multiple ecosystems (#1528)
Signed-off-by: dependabot[bot] <support@github.com>
2026-06-20 09:58:24 -05:00
Elias Schneider
35987b9c8d ci/cd: close low-demand feature requests 2026-06-16 14:16:24 +02:00
Elias Schneider
49cb131b7d release: 2.9.0 2026-06-16 12:28:24 +02:00
Elias Schneider
84678c3a7b fix: passkey card not rounded 2026-06-16 12:25:15 +02:00
dependabot[bot]
24c85029b4 chore(deps): Bump the "all-dependencies" group with 4 updates across multiple ecosystems (#1523)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-16 10:17:14 +00:00
Elias Schneider
effd2a146f tests(e2e): fix wrong assertion 2026-06-16 12:08:38 +02:00
Elias Schneider
c0d96a0b13 ci/cd: npm dependabot not run from root 2026-06-16 12:03:54 +02:00
Elias Schneider
8a75774971 fix: callback URL validation not validated if prompt=none 2026-06-16 12:02:57 +02:00
Elias Schneider
73bda4580a chore(translations): update translations via Crowdin (#1522) 2026-06-16 11:23:19 +02:00
dependabot[bot]
104ddb1120 chore(deps-dev): bump vite from 8.0.13 to 8.0.16 (#1521)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-16 11:23:03 +02:00
Max Wassiljew
d198ded1dd fix: update terminology from public key code exchange to proof key co… (#1515)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2026-06-16 09:55:56 +02:00
Elias Schneider
2726ddd0c2 refactor: run formatter 2026-06-16 09:47:57 +02:00
Elias Schneider
89b4abb8b5 fix: load Gloock font from static path instead of inline 2026-06-16 09:47:31 +02:00
James18232
9d4b621caa fix: add responsive css for api/user/group/client page add buttons (#1508)
Co-authored-by: james <james@goldfish.net>
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2026-06-16 09:35:30 +02:00
Elias Schneider
6c1d45e021 chore(translations): update translations via Crowdin (#1503) 2026-06-16 09:31:49 +02:00
Elias Schneider
83b45f682d ci/cd: run svelte check and unit tests in breaking branches 2026-06-16 09:30:04 +02:00
Elias Schneider
3d9d4de619 fix: PAR parameters not respected by authorize page 2026-06-16 09:29:34 +02:00
github-actions[bot]
212c574a68 chore: update AAGUIDs (#1511)
Co-authored-by: stonith404 <58886915+stonith404@users.noreply.github.com>
2026-06-08 09:22:50 +02:00
Elias Schneider
4f97cd4188 refactor: fix linter issues 2026-06-02 14:08:33 +02:00
Thibault NORMAND
68a5abdcca feat(oauth): add support for Pushed Authorization Requests (RFC9126) (#1404)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2026-06-02 14:02:12 +02:00
wollew
2eada149af docs: fix link to watchtower repo (#1500) 2026-06-01 11:42:00 +02:00
Elias Schneider
81e5aa2168 chore(translations): update translations via Crowdin (#1498) 2026-06-01 11:17:12 +02:00
Melvin Snijders
e88ff03ea2 fix: issues with loading new font (#1496) 2026-06-01 11:16:55 +02:00
Elias Schneider
fc42f6211d docs: add "needs more upvotes" label as default 2026-05-31 21:08:08 +02:00
Elias Schneider
bf9f76bbd5 docs: add PR template 2026-05-31 21:04:33 +02:00
Elias Schneider
fea933b62d docs: add AI Usage Policy to CONTRIBUTING.md 2026-05-31 20:55:05 +02:00
Elias Schneider
cdc77062f0 release: 2.8.0 2026-05-31 20:04:59 +02:00
Elias Schneider
7027296632 fix: restore cross-platform binary builds 2026-05-31 20:04:30 +02:00
Elias Schneider
e046a03364 chore: use fixed minor version of Go 2026-05-31 19:09:06 +02:00
Elias Schneider
0616abaf7f refactor: run formatter 2026-05-31 16:11:21 +02:00
Elias Schneider
9ad2bfc7b3 tests(e2e): don't compare hashes of profile pictures 2026-05-29 13:43:21 +02:00
Elias Schneider
bc4f75c44f refactor: fix linter issues 2026-05-29 12:25:06 +02:00
Elias Schneider
a71219637a fix: add support for unix socket mode in healthcheck 2026-05-29 12:11:26 +02:00
Elias Schneider
b3d40a476b feat: improve design trough the whole application 2026-05-29 11:37:34 +02:00
Elias Schneider
272d1479bd fix: scope confirmation wasn't shown if account selection was prompted 2026-05-29 10:04:56 +02:00
Elias Schneider
f13424720b tests(e2e): use custom Playwright route for callback URL checks 2026-05-29 09:44:37 +02:00
Elias Schneider
6aefe6c8e1 chore(translations): update translations via Crowdin (#1490) 2026-05-29 09:18:59 +02:00
V
dd77bb0f32 feat: add support for systemd socket activation (#1479) 2026-05-29 09:18:29 +02:00
Elias Schneider
0c95b7c3cc feat: add support for response_mode=fragment 2026-05-29 09:14:44 +02:00
github-actions[bot]
f4a20e3ef9 chore: update AAGUIDs (#1487)
Co-authored-by: stonith404 <58886915+stonith404@users.noreply.github.com>
2026-05-25 09:20:32 -05:00
Elias Schneider
5db570bf66 feat: remove EXIF/XMP metadata from uploaded images (#1477) 2026-05-19 20:56:14 -07:00
Elias Schneider
e8c398ffbd ci/cd(dependabot): fix invalid schema 2026-05-19 17:09:14 +02:00
Elias Schneider
9dd3d319cf feat: delete OAuth refresh token on RP initiated logout (#1480) 2026-05-19 17:05:44 +02:00
Elias Schneider
b9fdd530c0 ci/cd: use dependabot for automatic dependency upgrades 2026-05-19 17:02:05 +02:00
Elias Schneider
cc9163f577 fix: make stream of downloaded logos seekable for S3 checksum calculation 2026-05-19 16:28:30 +02:00
Elias Schneider
b27a52a591 revert: delete refresh tokens on end-session to prevent reuse after logout (#1458)
This reverts commit 7aacbd0245.
2026-05-18 23:36:39 +02:00
Elias Schneider
bb5a111e3d docs: update SECURITY.md 2026-05-18 23:15:12 +02:00
Elias Schneider
8ad95b8af1 refactor: apply go 1.26.0 syntax updated 2026-05-18 23:06:54 +02:00
Elias Schneider
e56dc124ce refactor: remove deprecated http2 package 2026-05-18 23:04:08 +02:00
Elias Schneider
91c2ea2a66 chore: upgrade dependencies 2026-05-18 22:55:00 +02:00
Elias Schneider
ce6bdb9d7e fix: reject unknown PKCE code challenge methods 2026-05-18 22:07:20 +02:00
Elias Schneider
8b22fcaaa4 fix: add email_verified to reserved claims list 2026-05-18 21:08:51 +02:00
github-actions[bot]
4abd1a5124 chore: update AAGUIDs (#1476)
Co-authored-by: stonith404 <58886915+stonith404@users.noreply.github.com>
2026-05-18 18:16:48 +02:00
Elias Schneider
714b5b3307 fix: return 404 status code for .well-known routes if not found 2026-05-18 18:15:54 +02:00
wucm667
7aacbd0245 fix(oidc): delete refresh tokens on end-session to prevent reuse after logout (#1458)
Co-authored-by: Alessandro (Ale) Segala <43508+ItalyPaleAle@users.noreply.github.com>
Signed-off-by: wucm667 <stevenwucongmin@gmail.com>
2026-05-15 21:25:40 +00:00
Elias Schneider
532a7a1d14 chore(translations): update translations via Crowdin (#1472) 2026-05-15 16:19:53 -05:00
Kyle Mendell
9399cba775 release: 2.7.0 2026-05-11 11:06:09 -05:00
Kyle Mendell
5c7e5f680b chore: fix incorrect container name variable 2026-05-11 11:04:36 -05:00
Elias Schneider
4e8995e04e chore(translations): update translations via Crowdin (#1470) 2026-05-10 18:16:13 -05:00
mgabor
da3489cfa5 fix(oidc): fall back to Basic auth when PKCE puts client_id in body (#1466)
Co-authored-by: mgabor3141 <@mgabor3141>
Co-authored-by: Gabor Mezo <git@mgabor.hu>
Co-authored-by: Alessandro (Ale) Segala <43508+ItalyPaleAle@users.noreply.github.com>
2026-05-07 23:01:51 -05:00
Elias Schneider
12ac7973a2 chore(translations): update translations via Crowdin (#1460) 2026-05-05 11:16:06 -05:00
GameTec-live
27945f5695 fix: invalidate cache when changing image (#1462) 2026-05-05 11:10:38 -05:00
Kyle Mendell
2340bb0f1d chore: fix caching of ldap-cli e2e tests docker build (#1457) 2026-04-28 15:14:22 -05:00
Elias Schneider
d860ef43ec chore(translations): update translations via Crowdin (#1456)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
2026-04-28 12:12:41 -05:00
Kyle Mendell
d5cf60689e ci/cd: migrate github actions runners to depot runners (#1329) 2026-04-26 19:48:19 -05:00
Alessandro (Ale) Segala
f4706cd6cc feat: add support for "select_account" prompt (#1453)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2026-04-26 17:26:21 +00:00
Elias Schneider
e33a9b8c88 chore: post dependency upgrade fixes 2026-04-26 15:46:35 +02:00
Elias Schneider
20df033c1f chore: upgrade dependencies 2026-04-26 14:41:41 +02:00
Elias Schneider
f9f93f0ef1 chore: add script to update deps 2026-04-26 14:41:27 +02:00
John
64d4ac7919 feat: add support for response_mode=form_post (#1360)
Co-authored-by: Alessandro (Ale) Segala <43508+ItalyPaleAle@users.noreply.github.com>
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2026-04-26 14:11:35 +02:00
Anthony Clerici
0ed2c48591 docs: add missing /api prefix to app config swagger routes (#1454) 2026-04-25 17:58:41 +02:00
Alessandro (Ale) Segala
5559077ab4 fix: add _FILE support for S3_SECRET_ACCESS_KEY_FILE env var (#1452) 2026-04-22 14:22:42 -07:00
Kyle Mendell
c8ff6b1cca release: 2.6.2 2026-04-21 16:47:45 -05:00
Elias Schneider
ea5a0fcf1e chore(translations): update translations via Crowdin (#1441) 2026-04-21 15:23:07 -05:00
Björn F.
a9fdab10f1 fix: improve keyboard navigation and screen-reader labels (#1445) 2026-04-21 15:21:23 -05:00
Kyle Mendell
605c8b2ba4 chore(deps): upgrade to vite 8.0 and pnpm 10.33.0 (#1446) 2026-04-21 15:21:08 -05:00
Amit
c96d591484 fix: return correct byte count in HEAD request writer (#1443)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
2026-04-21 15:03:08 -05:00
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
Elias Schneider
375f0a0c34 release: 2.3.0 2026-02-23 20:36:19 +01:00
Elias Schneider
522a4eee00 chore(translations): update translations via Crowdin (#1335) 2026-02-23 20:36:00 +01:00
Elias Schneider
0c41872cd4 fix: disallow API key renewal and creation with API key authentication (#1334) 2026-02-23 20:34:25 +01:00
Elias Schneider
b3fe143136 fix: left align input error messages 2026-02-23 19:54:32 +01:00
Kyle Mendell
a90c8abe51 chore(deps): upgrade to node 24 and go 1.26.0 (#1328)
Co-authored-by: ItalyPaleAle <43508+ItalyPaleAle@users.noreply.github.com>
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2026-02-23 19:50:44 +01:00
Kyle Mendell
ae269371da feat: current version api endpoint (#1310) 2026-02-22 10:39:19 -08:00
James Ward
27caaf2cac feat: add JWT ID for generated tokens (#1322) 2026-02-22 16:23:14 +00:00
Elias Schneider
0678699d0c chore: upgrade dependencies 2026-02-22 17:14:57 +01:00
dependabot[bot]
4f82957e13 chore(deps-dev): bump @sveltejs/kit from 2.49.5 to 2.52.2 in the npm_and_yarn group across 1 directory (#1327)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-22 14:54:01 +01:00
dependabot[bot]
5e2534bd6b chore(deps-dev): bump svelte from 5.46.4 to 5.51.5 in the npm_and_yarn group across 1 directory (#1324) 2026-02-20 14:23:55 -06:00
Alessandro (Ale) Segala
eb0456a395 fix: token endpoint must not accept params as query string args (#1321) 2026-02-17 22:31:09 -06:00
Elias Schneider
f0249377ac chore(translations): update translations via Crowdin (#1312) 2026-02-17 15:55:22 -06:00
github-actions[bot]
97f2e4eec2 chore: update AAGUIDs (#1316)
Co-authored-by: stonith404 <58886915+stonith404@users.noreply.github.com>
2026-02-16 09:54:02 -06:00
Elias Schneider
adbdfcf9ff chore(translations): update translations via Crowdin (#1307) 2026-02-10 15:29:08 -06:00
Kyle Mendell
94a48977ba chore(deps): update dependenicies 2026-02-10 15:26:25 -06:00
dependabot[bot]
5ab0996475 chore(deps): bump axios from 1.13.2 to 1.13.5 in the npm_and_yarn group across 1 directory (#1309)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-10 15:25:21 -06:00
Kyle Mendell
60825c5743 chore: run formatter 2026-02-10 15:21:09 -06:00
Cheng Gu
310b81c277 feat: manageability of uncompressed geolite db file (#1234) 2026-02-10 21:17:06 +00:00
Elias Schneider
549b487663 chore(translations): update translations via Crowdin (#1271) 2026-02-04 02:21:13 -06:00
Yegor Pomortsev
6eebecd85a fix: decode URL-encoded client ID and secret in Basic auth (#1263) 2026-01-24 20:52:17 +00:00
Elias Schneider
1de231f1ff chore(translations): update translations via Crowdin (#1270) 2026-01-24 21:46:48 +01:00
Elias Schneider
aab7e364e8 fix: increase rate limit for frontend and api requests 2026-01-24 20:29:50 +01:00
Elias Schneider
56afebc242 feat: add support for HTTP/2 2026-01-24 18:24:34 +01:00
Elias Schneider
bb7b0d5608 fix: add type="url" to url inputs 2026-01-24 17:37:54 +01:00
Elias Schneider
80558c5625 chore(translations): add Norwegian language files 2026-01-24 17:33:58 +01:00
Moritz
a5629e63d2 fix: prevent deletion of OIDC provider logo for non admin/anonymous users (#1267) 2026-01-24 17:23:21 +01:00
github-actions[bot]
317879bb37 chore: update AAGUIDs (#1257)
Co-authored-by: stonith404 <58886915+stonith404@users.noreply.github.com>
2026-01-20 19:26:25 -06:00
Kyle Mendell
c62533d388 fix: ENCRYPTION_KEY needed for version and help commands (#1256) 2026-01-18 18:04:53 -06:00
Jasper Bernhardt
0978a89fcc feat: add VERSION_CHECK_DISABLED environment variable (#1254) 2026-01-18 17:28:24 -06:00
Kyle Mendell
53ef61a3e5 chore(translations): add Estonian files 2026-01-17 19:42:28 -06:00
Kyle Mendell
4811625cdd chore: upgrade deps 2026-01-15 18:15:41 -06:00
Kyle Mendell
9dbc02e568 chore(deps): bump devalue to 5.6.2 2026-01-15 18:14:00 -06:00
dependabot[bot]
43a1e4a25b chore(deps-dev): bump svelte from 5.46.1 to 5.46.4 in the npm_and_yarn group across 1 directory (#1242)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-15 20:32:39 +00:00
dependabot[bot]
e78b16d0c6 chore(deps-dev): bump @sveltejs/kit from 2.49.2 to 2.49.5 in the npm_and_yarn group across 1 directory (#1240)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-15 14:23:39 -06:00
Elias Schneider
1967de6828 chore(translations): update translations via Crowdin (#1233) 2026-01-14 01:17:08 -06:00
Elias Schneider
2c64bebf6a release: 2.2.0 2026-01-11 15:46:36 +01:00
Elias Schneider
2a11c3e609 fix: use user specific email verified claim instead of global one 2026-01-11 15:46:14 +01:00
Elias Schneider
a0ced2443c chore(translations): update translations via Crowdin (#1230) 2026-01-11 15:43:21 +01:00
Elias Schneider
746aa71d67 feat: add static api key env variable (#1229) 2026-01-11 15:36:27 +01:00
Elias Schneider
9ca3d33c88 feat: add environment variable to disable built-in rate limiting 2026-01-11 14:26:30 +01:00
Elias Schneider
4df4bcb645 fix: db version downgrades don't downgrade db schema 2026-01-11 14:14:44 +01:00
Elias Schneider
875c5b94a6 chore(translations): update translations via Crowdin (#1226) 2026-01-11 13:01:12 +01:00
Elias Schneider
0e2cdc393e fix: allow exchanging logic code if already authenticated 2026-01-11 12:59:31 +01:00
Elias Schneider
1e7442f5df feat: add support for email verification (#1223) 2026-01-11 12:31:26 +01:00
Elias Schneider
e955118a6f chore(translations): update translations via Crowdin (#1213) 2026-01-10 23:19:26 +01:00
Elias Schneider
811e8772b6 feat: add option to renew API key (#1214) 2026-01-09 12:08:58 +01:00
Elias Schneider
0a94f0fd64 feat: make home page URL configurable (#1215) 2026-01-07 22:01:51 +01:00
Elias Schneider
03f9be0d12 fix: login codes sent by an admin incorrectly requires a device token 2026-01-07 16:13:18 +01:00
Elias Schneider
2f25861d15 feat: improve passkey error messages 2026-01-07 11:30:37 +01:00
Elias Schneider
2af70d9b4d feat: add CLI command for encryption key rotation (#1209) 2026-01-07 09:34:23 +01:00
Elias Schneider
5828fa5779 fix: user can't update account if email is empty 2026-01-06 17:35:47 +01:00
Elias Schneider
1a032a812e fix: data import from sqlite to postgres fails because of wrong datatype 2026-01-06 16:08:49 +01:00
Elias Schneider
8c68b08c12 fix: allow changing "require email address" if no SMTP credentials present 2026-01-06 14:28:08 +01:00
Elias Schneider
646f849441 release: 2.1.0 2026-01-04 21:18:24 +01:00
Elias Schneider
20bbd4a06f chore(translations): update translations via Crowdin (#1189) 2026-01-04 21:17:49 +01:00
Justin Moy
2d7e2ec8df feat: process nonce within device authorization flow (#1185)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2026-01-04 18:18:17 +00:00
Kyle Mendell
72009ced67 feat: add issuer url to oidc client details list (#1197) 2026-01-04 19:04:16 +01:00
Elias Schneider
4881130ead refactor: run SCIM jobs in context of gocron instead of custom implementation 2026-01-04 19:00:18 +01:00
Elias Schneider
d6a7b503ff fix: invalid cookie name for email login code device token 2026-01-03 23:46:44 +01:00
Elias Schneider
3c3916536e release: 2.0.2 2026-01-03 15:16:46 +01:00
Elias Schneider
a24b2afb7b chore: add no-op migration to postgres 2026-01-03 15:12:14 +01:00
Elias Schneider
7c34501055 fix: localhost callback URLs with port don't match correctly 2026-01-03 15:07:56 +01:00
Elias Schneider
ba00f40bd4 fix: allow version downgrade database is dirty 2026-01-03 15:06:39 +01:00
Elias Schneider
2f651adf3b fix: migration fails if users exist with no email address 2026-01-03 15:06:34 +01:00
Elias Schneider
f42ba3bbef release: 2.0.1 2026-01-02 23:50:35 +01:00
Elias Schneider
2341da99e9 fix: restore old input input field size 2026-01-02 23:49:41 +01:00
Elias Schneider
2cce200892 fix: admins imported from LDAP lose admin privileges 2026-01-02 23:42:25 +01:00
Elias Schneider
cd2e9f3a2a chore(docker): bump image tag to v2 2026-01-02 19:21:58 +01:00
Elias Schneider
f5e2c68ba3 release: 2.0.0 2026-01-02 19:07:32 +01:00
Elias Schneider
651b58aee6 chore(translations): update translations via Crowdin (#1184) 2026-01-02 18:55:58 +01:00
Elias Schneider
ffb2ef91bd tests: change translation string in e2e tests 2026-01-02 18:46:57 +01:00
Elias Schneider
4776b70d96 chore: upgrade dependencies 2026-01-02 17:55:24 +01:00
Elias Schneider
579cfdc678 feat: add support for SCIM provisioning (#1182) 2026-01-02 17:54:20 +01:00
Elias Schneider
e4a8ca476c refactor: run formatter 2026-01-02 17:45:53 +01:00
Kyle Mendell
386add08c4 refactor: update forms and other areas to use new shadcn components (#1115)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
Co-authored-by: ItalyPaleAle <43508+ItalyPaleAle@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-02 17:45:08 +01:00
Elias Schneider
894eaf3cff fix(translations): add missing translations to date picker 2026-01-02 15:57:50 +01:00
Elias Schneider
d9e7bf9eef fix: remove ambiguous characters from login code 2026-01-02 15:48:46 +01:00
Elias Schneider
b19d901618 chore(translations): update translations via Crowdin (#1181) 2026-01-01 16:56:11 +01:00
Kyle Mendell
0b625a9707 chore(deps): bump pnpm to version 10.27.0 (#1183) 2026-01-01 16:55:54 +01:00
Elias Schneider
e60b80632f chore: preparation for merge into main branch 2025-12-30 17:01:22 +01:00
Elias Schneider
078152d4db fix!: make wildcard matching in callback URLs more stricter (#1161) 2025-12-30 17:01:22 +01:00
Kyle Mendell
ba2f0f18f4 feat: remove DbProvider env variable and calculate it dynamically (#1114) 2025-12-30 17:01:22 +01:00
Elias Schneider
3420a00073 feat: add CLI command for importing and exporting Pocket ID data (#998)
Co-authored-by: Alessandro (Ale) Segala <43508+ItalyPaleAle@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-30 17:01:22 +01:00
Elias Schneider
f0144584af feat!: drop support for storing JWK on the filesystem (#1088) 2025-12-30 17:01:22 +01:00
Elias Schneider
e1c5021eee fix!: rename LDAP_ATTRIBUTE_ADMIN_GROUP env variable to LDAP_ADMIN_GROUP_NAME (#1089) 2025-12-30 17:01:22 +01:00
Elias Schneider
c0e490c28f chore(translations): update translations via Crowdin (#1167) 2025-12-29 09:03:44 -06:00
github-actions[bot]
3c98c98fe3 chore: update AAGUIDs (#1177)
Co-authored-by: stonith404 <58886915+stonith404@users.noreply.github.com>
2025-12-29 09:03:25 -06:00
Elias Schneider
1bc9f5f7e7 feat: add "restricted" column to oidc client table 2025-12-24 14:05:37 +01:00
Elias Schneider
461293ba1d ci/cd: remove breaking/** push trigger from actions 2025-12-24 11:45:15 +01:00
Elias Schneider
7c5ffbf9a5 chore(translations): update translations via Crowdin (#1134) 2025-12-24 11:42:35 +01:00
Elias Schneider
f75cef83d5 feat: restrict oidc clients by user groups per default (#1164) 2025-12-24 09:09:25 +01:00
Jenic Rycr
e358c433f0 feat: allow audit log retention to be controlled by env variable (#1158) 2025-12-23 13:50:00 +01:00
Elias Schneider
08e4ffeb60 feat: minor redesign of auth pages 2025-12-22 21:36:23 +01:00
Elias Schneider
59ca6b26ac feat: add ability define user groups for sign up tokens (#1155) 2025-12-21 18:26:52 +01:00
Melvin Snijders
f5da11b99b feat: add email logo customization (#1150) 2025-12-17 16:20:22 +01:00
Elias Schneider
3eaf36aae7 fix: restrict email one time sign in token to same browser (#1144) 2025-12-12 14:51:07 +01:00
Masahiro Ono
0a6ff6f84b fix(translations): add Japanese locale to inlang settings (#1142) 2025-12-10 16:43:31 +01:00
Elias Schneider
edb32d82b2 chore: fix type error after version bump 2025-12-10 16:41:59 +01:00
Elias Schneider
90f555f7c1 chore: upgrade dependencies 2025-12-10 16:13:24 +01:00
github-actions[bot]
177ada10ba chore: update AAGUIDs (#1140)
Co-authored-by: stonith404 <58886915+stonith404@users.noreply.github.com>
2025-12-07 19:41:51 -06:00
Elias Schneider
91b0d74c43 feat: add HTTP HEAD method support (#1135) 2025-12-05 11:17:13 +01:00
Sebastian
3a1dd3168e fix(translations): update image format message to include WEBP (#1133) 2025-12-04 07:58:03 +00:00
Elias Schneider
25f67bd25a tests: fix api key e2e test 2025-12-03 10:51:19 +01:00
Elias Schneider
e3483a9c78 chore(translations): update translations via Crowdin (#1129) 2025-12-02 15:17:58 -06:00
github-actions[bot]
95d49256f6 chore: update AAGUIDs (#1128)
Co-authored-by: stonith404 <58886915+stonith404@users.noreply.github.com>
2025-11-30 19:00:53 +01:00
Elias Schneider
8cddcb88e8 release: 1.16.0 2025-11-30 18:30:29 +01:00
Elias Schneider
a25d6ef56c feat: add Cache-Control: private, no-store to all API routes per default (#1126) 2025-11-30 18:29:35 +01:00
Elias Schneider
14c7471b52 refactor: run formatter 2025-11-30 18:17:22 +01:00
Elias Schneider
5d6a7fdb58 fix: hide theme switcher on auth pages because of dynamic background 2025-11-30 18:17:11 +01:00
Elias Schneider
a1cd3251cd fix: theme mode not correctly applied if selected manually 2025-11-30 18:05:01 +01:00
Elias Schneider
4eeb06f29d docs: add ENCRYPTION_KEY to .env.example for breaking change preparation 2025-11-30 13:14:15 +01:00
Elias Schneider
b2c718d13d ci/cd: fix wrong storage value 2025-11-30 13:12:57 +01:00
Elias Schneider
8d30346f64 refactor: rename file backend value fs to filesystem 2025-11-30 12:56:15 +01:00
Elias Schneider
714b7744f0 chore(translations): update translations via Crowdin (#1123) 2025-11-30 12:20:35 +01:00
Elias Schneider
d98c0a391a fix: global audit log user filter not working 2025-11-29 23:15:50 +01:00
Mike Nestor
4fe56a8d5c chore: update vscode launch.json (#1117)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-11-29 21:16:25 +01:00
Elias Schneider
cfc9e464d9 fix: automatically create parent directory of Sqlite db 2025-11-29 21:14:23 +01:00
Elias Schneider
3d46badb3c chore: fix package vulnerabilities 2025-11-27 11:58:44 +01:00
Elias Schneider
f523f39483 tests: fix Dutch validation message 2025-11-25 22:51:20 +01:00
Elias Schneider
4bde271b47 chore: upgrade dependencies 2025-11-25 22:30:28 +01:00
Elias Schneider
a3c968758a feat: add option to disable S3 integrity check 2025-11-25 22:14:44 +01:00
Elias Schneider
ca888b3dd2 chore(translations): add Finish files 2025-11-25 20:46:48 +01:00
Elias Schneider
ce88686c5f chore(translations): update translations via Crowdin (#1111) 2025-11-25 20:43:47 +01:00
Elias Schneider
a9b6635126 chore(translations): update translations via Crowdin (#1101) 2025-11-23 17:10:24 +01:00
dependabot[bot]
e817f042ec chore(deps): bump golang.org/x/crypto from 0.43.0 to 0.45.0 in /backend in the go_modules group across 1 directory (#1107)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-23 17:10:10 +01:00
Alessandro (Ale) Segala
c56afe016e feat: adding/removing passkeys creates an entry in audit logs (#1099)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-11-16 14:51:38 -08:00
Alessandro (Ale) Segala
a54b867105 refactor: use constants for AppEnv values (#1098) 2025-11-16 18:25:06 +01:00
Alessandro (Ale) Segala
29a1d3b778 feat: add database storage backend (#1091)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-11-16 18:23:46 +01:00
Elias Schneider
12125713a2 feat: add support for WEBP profile pictures (#1090) 2025-11-11 10:56:20 -06:00
Elias Schneider
ab9c0f9ac0 ci/cd: run checks on PR to breaking/** branches 2025-11-11 11:21:39 +01:00
Elias Schneider
42b872d6b2 chore(translations): update translations via Crowdin (#1085) 2025-11-10 14:46:48 +01:00
Elias Schneider
bfd71d090c feat: add support for S3 storage backend (#1080)
Co-authored-by: Alessandro (Ale) Segala <43508+ItalyPaleAle@users.noreply.github.com>
2025-11-10 09:02:25 +00:00
Kyle Mendell
d5e0cfd4a6 feat: light/dark/system mode switcher (#1081) 2025-11-09 13:28:58 -06:00
Kyle Mendell
9981304b4b chore(deps): update pnpm to 10.20 (#1082) 2025-11-08 13:09:05 -06:00
Elias Schneider
5cf73e9309 fix: use quoted-printable encoding for mails to prevent line limitation 2025-11-08 17:34:43 +01:00
Elias Schneider
f125cf0dad release: 1.15.0 2025-11-06 15:49:39 +01:00
Elias Schneider
6a038fcf9a fix: remove redundant indexes in Postgres 2025-11-06 15:02:39 +01:00
Elias Schneider
76e0192cee fix: disabled property gets ignored when creating an user 2025-11-06 12:28:07 +01:00
Elias Schneider
3ebf94dd84 chore(translations): update translations via Crowdin (#1059) 2025-11-05 11:28:14 +01:00
dai
7ec57437ac fix: replace %lang% placeholder in html lang (#1071)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-11-04 13:01:12 +00:00
Elias Schneider
ed2c7b2303 feat: add ability to set default profile picture (#1061)
Co-authored-by: Alessandro (Ale) Segala <43508+ItalyPaleAle@users.noreply.github.com>
2025-11-04 13:40:00 +01:00
Elias Schneider
e03270eb9d fix: sorting by PKCE and re-auth of OIDC clients 2025-11-04 13:27:05 +01:00
Elias Schneider
d683d18d91 chore: add support for OpenBSD binaries 2025-11-01 16:26:43 +01:00
Elias Schneider
f184120890 feat: open edit page on table row click 2025-10-29 10:46:03 +01:00
Elias Schneider
04d8500910 release: 1.14.2 2025-10-29 09:25:28 +01:00
Mufeed Ali
93639dddb2 fix: dark oidc client icons not saved on client creation (#1057) 2025-10-28 12:35:56 +01:00
Elias Schneider
a190529117 chore(translations): add Turkish language files 2025-10-28 09:31:32 +01:00
798 changed files with 47912 additions and 18062 deletions

View File

@@ -2,7 +2,9 @@
"name": "pocket-id",
"image": "mcr.microsoft.com/devcontainers/typescript-node:1-22-bookworm",
"features": {
"ghcr.io/devcontainers/features/go:1": {}
"ghcr.io/devcontainers/features/go:1": {
"version": "1.26"
}
},
"customizations": {
"vscode": {

View File

@@ -1,6 +1,18 @@
# See the documentation for more information: https://pocket-id.org/docs/configuration/environment-variables
# These variables must be configured for your deployment:
APP_URL=https://your-pocket-id-domain.com
# Encryption key (choose one method):
# Method 1: Direct key (simple but less secure)
# Generate with: openssl rand -base64 32
ENCRYPTION_KEY=
# Method 2: File-based key (recommended)
# Put the base64 key in a file and point to it here.
# ENCRYPTION_KEY_FILE=/path/to/encryption_key
# These variables are optional but recommended to review:
TRUST_PROXY=false
MAXMIND_LICENSE_KEY=
PUID=1000
PGID=1000
PGID=1000

View File

@@ -1,7 +1,8 @@
name: 🚀 Feature
description: "Submit a proposal for a new feature"
title: "🚀 Feature: "
type: 'Feature'
type: "Feature"
labels: ["needs more upvotes"]
body:
- type: textarea
id: feature-description

View File

@@ -1,12 +1,83 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for more information:
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
# https://containers.dev/guide/dependabot
version: 2
multi-ecosystem-groups:
all-dependencies:
schedule:
interval: "weekly"
day: "friday"
time: "17:00"
timezone: "Europe/Zurich"
labels:
- "dependencies"
open-pull-requests-limit: 2
pull-request-branch-name:
separator: "-"
commit-message:
prefix: "chore"
include: "scope"
updates:
- package-ecosystem: "devcontainers"
directory: "/"
schedule:
interval: weekly
- package-ecosystem: "github-actions"
directory: "/"
patterns: ["*"]
multi-ecosystem-group: "all-dependencies"
ignore:
- dependency-name: "*"
update-types: ["version-update:semver-major"]
cooldown:
default-days: 7
- package-ecosystem: "gomod"
directory: "/backend"
patterns: ["*"]
multi-ecosystem-group: "all-dependencies"
ignore:
- dependency-name: "*"
update-types: ["version-update:semver-major"]
cooldown:
default-days: 7
- package-ecosystem: "npm"
directory: "/"
patterns: ["*"]
multi-ecosystem-group: "all-dependencies"
versioning-strategy: "increase-if-necessary"
ignore:
- dependency-name: "*"
update-types: ["version-update:semver-major"]
cooldown:
default-days: 7
- package-ecosystem: "docker"
directories:
- "/docker"
- "/tests/setup"
patterns: ["*"]
multi-ecosystem-group: "all-dependencies"
ignore:
- dependency-name: "*"
update-types: ["version-update:semver-major"]
cooldown:
default-days: 7
- package-ecosystem: "docker-compose"
directories:
- "/"
- "/tests/setup"
patterns: ["*"]
multi-ecosystem-group: "all-dependencies"
ignore:
- dependency-name: "*"
update-types: ["version-update:semver-major"]
cooldown:
default-days: 7
- package-ecosystem: "devcontainers"
directory: "/"
patterns: ["*"]
multi-ecosystem-group: "all-dependencies"
ignore:
- dependency-name: "*"
update-types: ["version-update:semver-major"]
cooldown:
default-days: 7

12
.github/pull_request_template.md vendored Normal file
View File

@@ -0,0 +1,12 @@
## What does this PR do?
## Screenshots
## AI Usage
## Contributing Guidelines
Have you read the [Contributing Guidelines](https://github.com/pocket-id/pocket-id/blob/main/CONTRIBUTING.md)?
- [ ] If I'm implementing a new feature, I have opened an issue first, or commented on an existing one, to discuss the implementation details.
- [ ] I have read the [AI Usage Guidelines](https://github.com/pocket-id/pocket-id/blob/main/CONTRIBUTING.md#ai-usage-policy).

View File

@@ -6,7 +6,7 @@ on:
paths:
- "backend/**"
pull_request:
branches: [main]
branches: [main, breaking/**]
paths:
- "backend/**"
@@ -17,14 +17,15 @@ permissions:
pull-requests: read
# Optional: allow write access to checks to allow the action to annotate code in the PR.
checks: write
id-token: write
jobs:
golangci-lint:
name: Run Golangci-lint
runs-on: ubuntu-latest
runs-on: depot-ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
@@ -32,9 +33,9 @@ jobs:
go-version-file: backend/go.mod
- name: Run Golangci-lint
uses: golangci/golangci-lint-action@v8.0.0
uses: golangci/golangci-lint-action@v9.2.1
with:
version: v2.4.0
version: v2.11.4
args: --build-tags=exclude_frontend
working-directory: backend
only-new-issues: ${{ github.event_name == 'pull_request' }}

View File

@@ -4,93 +4,89 @@ on:
push:
branches:
- main
workflow_dispatch:
concurrency:
group: build-next-image
cancel-in-progress: true
permissions:
contents: read
packages: write
id-token: write
attestations: write
artifact-metadata: write
jobs:
build-next:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
attestations: write
runs-on: depot-ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v5
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v5
uses: actions/setup-node@v6
with:
node-version: 22
node-version: 24
cache: pnpm
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version-file: "backend/go.mod"
go-version-file: backend/go.mod
cache-dependency-path: backend/go.sum
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Depot CLI
uses: depot/setup-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Set DOCKER_IMAGE_NAME
run: |
# Lowercase REPO_OWNER which is required for containers
REPO_OWNER=${{ github.repository_owner }}
DOCKER_IMAGE_NAME="ghcr.io/${REPO_OWNER,,}/pocket-id"
echo "DOCKER_IMAGE_NAME=${DOCKER_IMAGE_NAME}" >>${GITHUB_ENV}
- name: Setup depot buildx driver
run: depot configure-docker
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
uses: docker/login-action@v4.2.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
- name: Build frontend
working-directory: frontend
run: pnpm run build
- name: Build binaries
run: sh scripts/development/build-binaries.sh --docker-only
- name: Build and push container image
id: build-push-image
uses: docker/build-push-action@v6
- name: Login to Docker Hub
uses: docker/login-action@v4.2.0
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ env.DOCKER_IMAGE_NAME }}:next
file: docker/Dockerfile-prebuilt
- name: Build and push container image (distroless)
uses: docker/build-push-action@v6
id: container-build-push-distroless
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v7.2.2
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ env.DOCKER_IMAGE_NAME }}:next-distroless
file: docker/Dockerfile-distroless
distribution: goreleaser-pro
version: "~> v2"
args: release --clean --skip=announce,validate --parallelism=4
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BUILD_NEXT: "true"
SKIP_RELEASE: "true"
SKIP_CHANGELOG: "true"
GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }}
MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }}
MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }}
MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }}
MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }}
MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }}
- name: Binary attestation
uses: actions/attest@v4
with:
subject-checksums: ./dist/checksums.txt
- name: Container image attestation
uses: actions/attest-build-provenance@v2
uses: actions/attest@v4
with:
subject-name: "${{ env.DOCKER_IMAGE_NAME }}"
subject-digest: ${{ steps.build-push-image.outputs.digest }}
push-to-registry: true
- name: Container image attestation (distroless)
uses: actions/attest-build-provenance@v2
with:
subject-name: "${{ env.DOCKER_IMAGE_NAME }}"
subject-digest: ${{ steps.container-build-push-distroless.outputs.digest }}
push-to-registry: true
subject-checksums: ./dist/digests.txt

View File

@@ -0,0 +1,24 @@
name: Close Stale Feature Requests
on:
schedule:
- cron: "0 0 * * 1" # Runs every Monday at midnight
workflow_dispatch:
inputs:
dry-run:
description: "Log matching issues without closing them"
required: false
default: false
type: boolean
permissions:
contents: read
issues: write
jobs:
close-stale-feature-requests:
runs-on: ubuntu-latest
steps:
- uses: pocket-id/low-demand-closer@v1
with:
dry-run: ${{ inputs['dry-run'] || false }}

View File

@@ -1,121 +1,113 @@
name: E2E Tests
on:
push:
branches: [main]
branches: [ main ]
paths-ignore:
- "docs/**"
- "**.md"
- ".github/**"
pull_request:
branches: [main]
branches: [ main, breaking/** ]
paths-ignore:
- "docs/**"
- "**.md"
- ".github/**"
permissions:
contents: read
actions: write
id-token: write
jobs:
build:
if: github.event.pull_request.head.ref != 'i18n_crowdin'
timeout-minutes: 20
permissions:
contents: read
actions: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and export
uses: docker/build-push-action@v6
with:
context: .
file: docker/Dockerfile
push: false
load: false
tags: pocket-id:test
outputs: type=docker,dest=/tmp/docker-image.tar
build-args: BUILD_TAGS=e2etest
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Upload Docker image artifact
uses: actions/upload-artifact@v4
with:
name: docker-image
path: /tmp/docker-image.tar
retention-days: 1
test:
if: github.event.pull_request.head.ref != 'i18n_crowdin'
permissions:
contents: read
actions: write
runs-on: ubuntu-latest
needs: build
runs-on: depot-ubuntu-24.04-32
strategy:
fail-fast: false
matrix:
db: [sqlite, postgres]
include:
- db: sqlite
storage: filesystem
- db: postgres
storage: filesystem
- db: sqlite
storage: s3
- db: sqlite
storage: database
- db: postgres
storage: database
steps:
- uses: actions/checkout@v5
- name: Checkout code
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v5
- name: Setup Node.js
uses: actions/setup-node@v5
uses: actions/setup-node@v6
with:
node-version: 22
node-version: 24
cache: "pnpm"
- name: Set up Depot CLI
uses: depot/setup-action@v1
- name: Set up Depot Docker builder
run: depot configure-docker
- name: Cache Playwright Browsers
uses: actions/cache@v3
uses: actions/cache@v5
id: playwright-cache
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('pnpm-lock.yaml') }}
- name: Cache PostgreSQL Docker image
if: matrix.db == 'postgres'
uses: actions/cache@v3
uses: actions/cache@v5
id: postgres-cache
with:
path: /tmp/postgres-image.tar
key: postgres-17-${{ runner.os }}
- name: Pull and save PostgreSQL image
if: matrix.db == 'postgres' && steps.postgres-cache.outputs.cache-hit != 'true'
run: |
docker pull postgres:17
docker save postgres:17 > /tmp/postgres-image.tar
- name: Load PostgreSQL image from cache
- name: Load PostgreSQL image
if: matrix.db == 'postgres' && steps.postgres-cache.outputs.cache-hit == 'true'
run: docker load < /tmp/postgres-image.tar
- name: Cache LLDAP Docker image
uses: actions/cache@v3
id: lldap-cache
with:
path: /tmp/lldap-image.tar
key: lldap-stable-${{ runner.os }}
- name: Pull and save LLDAP image
if: steps.lldap-cache.outputs.cache-hit != 'true'
- name: Cache SCIM Test Server Docker image
uses: actions/cache@v5
id: scim-cache
with:
path: /tmp/scim-test-server-image.tar
key: scim-test-server-${{ runner.os }}
- name: Pull and save SCIM Test Server image
if: steps.scim-cache.outputs.cache-hit != 'true'
run: |
docker pull nitnelave/lldap:stable
docker save nitnelave/lldap:stable > /tmp/lldap-image.tar
docker pull ghcr.io/pocket-id/scim-test-server
docker save ghcr.io/pocket-id/scim-test-server > /tmp/scim-test-server-image.tar
- name: Load SCIM Test Server image
if: steps.scim-cache.outputs.cache-hit == 'true'
run: docker load < /tmp/scim-test-server-image.tar
- name: Load LLDAP image from cache
if: steps.lldap-cache.outputs.cache-hit == 'true'
run: docker load < /tmp/lldap-image.tar
- name: Download Docker image artifact
uses: actions/download-artifact@v4
- name: Cache Localstack S3 Docker image
if: matrix.storage == 's3'
uses: actions/cache@v5
id: s3-cache
with:
name: docker-image
path: /tmp
- name: Load Docker image
run: docker load -i /tmp/docker-image.tar
path: /tmp/localstack-s3-image.tar
key: localstack-4.14.0-${{ runner.os }}
- name: Pull and save Localstack S3 image
if: matrix.storage == 's3' && steps.s3-cache.outputs.cache-hit != 'true'
run: |
docker pull localstack/localstack:4.14.0
docker save localstack/localstack:4.14.0 > /tmp/localstack-s3-image.tar
- name: Load Localstack S3 image
if: matrix.storage == 's3' && steps.s3-cache.outputs.cache-hit == 'true'
run: docker load < /tmp/localstack-s3-image.tar
- name: Install test dependencies
run: pnpm --filter pocket-id-tests install --frozen-lockfile
@@ -124,38 +116,58 @@ jobs:
working-directory: ./tests
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: pnpm exec playwright install --with-deps chromium
- name: Run Docker Container (sqlite) with LDAP
if: matrix.db == 'sqlite'
working-directory: ./tests/setup
run: |
docker compose up -d
docker compose logs -f pocket-id &> /tmp/backend.log &
- name: Run Docker Container (postgres) with LDAP
if: matrix.db == 'postgres'
- name: Run Docker containers
working-directory: ./tests/setup
run: |
docker compose -f docker-compose-postgres.yml up -d
docker compose -f docker-compose-postgres.yml logs -f pocket-id &> /tmp/backend.log &
DOCKER_COMPOSE_FILE=docker-compose.yml
cat > .env <<EOF
FILE_BACKEND=${{ matrix.storage }}
SCIM_SERVICE_PROVIDER_URL=http://localhost:18123/v2
SCIM_SERVICE_PROVIDER_URL_INTERNAL=http://scim-test-server:8080/v2
EOF
if [ "${{ matrix.db }}" = "postgres" ]; then
DOCKER_COMPOSE_FILE=docker-compose-postgres.yml
elif [ "${{ matrix.storage }}" = "s3" ]; then
DOCKER_COMPOSE_FILE=docker-compose-s3.yml
fi
docker compose -f "$DOCKER_COMPOSE_FILE" up -d --build
{
LOG_FILE="/tmp/backend.log"
while true; do
CID=$(docker compose -f "$DOCKER_COMPOSE_FILE" ps -q pocket-id)
if [ -n "$CID" ]; then
echo "[$(date)] Attaching logs for $CID" >> "$LOG_FILE"
docker logs -f --since=0 "$CID" >> "$LOG_FILE" 2>&1
else
echo "[$(date)] Container not yet running…" >> "$LOG_FILE"
fi
sleep 1
done
} &
- name: Run Playwright tests
working-directory: ./tests
run: pnpm exec playwright test
- name: Upload Test Report
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
if: always() && github.event.pull_request.head.ref != 'i18n_crowdin'
with:
name: playwright-report-${{ matrix.db }}
name: playwright-report-${{ matrix.db }}-${{ matrix.storage }}
path: tests/.report
include-hidden-files: true
retention-days: 15
- name: Upload Backend Test Report
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
if: always() && github.event.pull_request.head.ref != 'i18n_crowdin'
with:
name: backend-${{ matrix.db }}
name: backend-${{ matrix.db }}-${{ matrix.storage }}
path: /tmp/backend.log
include-hidden-files: true
retention-days: 15

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: "Contributing Guidelines"
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: "James18232"
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

@@ -5,121 +5,87 @@ on:
tags:
- "v*.*.*"
permissions:
contents: write
packages: write
id-token: write
attestations: write
artifact-metadata: write
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: write
packages: write
attestations: write
id-token: write
release:
runs-on: depot-ubuntu-24.04-16
steps:
- name: Checkout code
uses: actions/checkout@v3
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v5
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v5
uses: actions/setup-node@v6
with:
node-version: 22
- uses: actions/setup-go@v6
node-version: 24
cache: pnpm
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version-file: "backend/go.mod"
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Set DOCKER_IMAGE_NAME
run: |
# Lowercase REPO_OWNER which is required for containers
REPO_OWNER=${{ github.repository_owner }}
DOCKER_IMAGE_NAME="ghcr.io/${REPO_OWNER,,}/pocket-id"
echo "DOCKER_IMAGE_NAME=${DOCKER_IMAGE_NAME}" >>${GITHUB_ENV}
go-version-file: backend/go.mod
cache-dependency-path: backend/go.sum
- name: Set up Depot CLI
uses: depot/setup-action@v1
- name: Setup depot buildx driver
run: depot configure-docker
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
uses: docker/login-action@v4.2.0
with:
registry: ghcr.io
username: ${{github.repository_owner}}
password: ${{secrets.GITHUB_TOKEN}}
- name: Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.DOCKER_IMAGE_NAME }}
tags: |
type=semver,pattern={{version}},prefix=v
type=semver,pattern={{major}}.{{minor}},prefix=v
type=semver,pattern={{major}},prefix=v
- name: Docker metadata (distroless)
id: meta-distroless
uses: docker/metadata-action@v5
with:
images: |
${{ env.DOCKER_IMAGE_NAME }}
flavor: |
suffix=-distroless,onlatest=true
tags: |
type=semver,pattern={{version}},prefix=v
type=semver,pattern={{major}}.{{minor}},prefix=v
type=semver,pattern={{major}},prefix=v
- name: Install frontend dependencies
run: pnpm --filter pocket-id-frontend install --frozen-lockfile
- name: Build frontend
run: pnpm --filter pocket-id-frontend build
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build binaries
run: sh scripts/development/build-binaries.sh
- name: Build and push container image
uses: docker/build-push-action@v6
id: container-build-push
- name: Login to Docker Hub
uses: docker/login-action@v4.2.0
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
file: docker/Dockerfile-prebuilt
- name: Build and push container image (distroless)
uses: docker/build-push-action@v6
id: container-build-push-distroless
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v7.2.2
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta-distroless.outputs.tags }}
labels: ${{ steps.meta-distroless.outputs.labels }}
file: docker/Dockerfile-distroless
- name: Binary attestation
uses: actions/attest-build-provenance@v2
with:
subject-path: "backend/.bin/pocket-id-**"
- name: Container image attestation
uses: actions/attest-build-provenance@v2
with:
subject-name: "${{ env.DOCKER_IMAGE_NAME }}"
subject-digest: ${{ steps.container-build-push.outputs.digest }}
push-to-registry: true
- name: Container image attestation (distroless)
uses: actions/attest-build-provenance@v2
with:
subject-name: "${{ env.DOCKER_IMAGE_NAME }}"
subject-digest: ${{ steps.container-build-push-distroless.outputs.digest }}
push-to-registry: true
- name: Upload binaries to release
distribution: goreleaser-pro
version: "~> v2"
args: release --clean --skip=validate --parallelism=4
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release upload ${{ github.ref_name }} backend/.bin/*
GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }}
MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }}
MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }}
MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }}
MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }}
MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }}
DISCORD_WEBHOOK_ID: ${{ secrets.DISCORD_WEBHOOK_ID }}
DISCORD_WEBHOOK_TOKEN: ${{ secrets.DISCORD_WEBHOOK_TOKEN }}
publish-release:
runs-on: ubuntu-latest
needs: [build]
permissions:
contents: write
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Mark release as published
- name: Binary attestation
uses: actions/attest@v4
with:
subject-checksums: ./dist/checksums.txt
- name: Container image attestation
uses: actions/attest@v4
with:
subject-checksums: ./dist/digests.txt
- name: Publish release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release edit ${{ github.ref_name }} --draft=false

View File

@@ -11,7 +11,7 @@ on:
- "frontend/tsconfig.json"
- "frontend/svelte.config.js"
pull_request:
branches: [main]
branches: [main, breaking/**]
paths:
- "frontend/src/**"
- ".github/svelte-check-matcher.json"
@@ -21,28 +21,31 @@ on:
- "frontend/svelte.config.js"
workflow_dispatch:
permissions:
contents: read
checks: write
pull-requests: write
id-token: write
jobs:
type-check:
name: Run Svelte Check
# Don't run on dependabot branches
if: github.actor != 'dependabot[bot]'
runs-on: ubuntu-latest
permissions:
contents: read
checks: write
pull-requests: write
runs-on: depot-ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v4
uses: pnpm/action-setup@v5
- name: Setup Node.js
uses: actions/setup-node@v5
uses: actions/setup-node@v6
with:
node-version: 22
node-version: 24
cache: "pnpm"
- name: Install dependencies
run: pnpm --filter pocket-id-frontend install --frozen-lockfile

View File

@@ -5,18 +5,20 @@ on:
paths:
- "backend/**"
pull_request:
branches: [main]
branches: [main, breaking/**]
paths:
- "backend/**"
permissions:
contents: read
id-token: write
actions: write
jobs:
test-backend:
permissions:
contents: read
actions: write
runs-on: ubuntu-latest
runs-on: depot-ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: "backend/go.mod"
@@ -30,7 +32,7 @@ jobs:
run: |
set -e -o pipefail
go test -tags=exclude_frontend -v ./... | tee /tmp/TestResults.log
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v7
if: always()
with:
name: backend-unit-tests

View File

@@ -8,14 +8,15 @@ on:
permissions:
contents: write
pull-requests: write
id-token: write
jobs:
update-aaguids:
runs-on: ubuntu-latest
runs-on: depot-ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Fetch JSON data
run: |

3
.gitignore vendored
View File

@@ -15,6 +15,7 @@ node_modules
/backend/bin
pocket-id
/tests/test-results/*.json
.tmp/
# OS
.DS_Store
@@ -59,3 +60,5 @@ yarn-error.log*
#Debug
backend/cmd/__debug_*
# Added by goreleaser init:
dist/

247
.goreleaser.yaml Normal file
View File

@@ -0,0 +1,247 @@
# yaml-language-server: $schema=https://goreleaser.com/static/schema-pro.json
# vim: set ts=2 sw=2 tw=0 fo=jcroql
version: 2
pro: true
project_name: pocket-id
before:
hooks:
- pnpm install --frozen-lockfile
- pnpm -C frontend build
builds:
- id: pocket-id
main: ./cmd
dir: backend
binary: pocket-id
env:
- CGO_ENABLED=0
- GOMAXPROCS=2
goos:
- linux
- darwin
- windows
- freebsd
- openbsd
goarch:
- amd64
- arm64
- "386"
- arm
goarm:
- "7"
goamd64:
- v1
go386:
- sse2
ignore:
- goos: darwin
goarch: "386"
- goos: darwin
goarch: arm
- goos: windows
goarch: "386"
- goos: windows
goarch: arm
- goos: freebsd
goarch: "386"
- goos: freebsd
goarch: arm
- goos: openbsd
goarch: "386"
- goos: openbsd
goarch: arm
ldflags:
- -w -s
- -buildid={{ .Version }}
- -X github.com/pocket-id/pocket-id/backend/internal/common.Version={{ .Version }}
flags:
- -trimpath
- -p=2
mod_timestamp: "{{ .CommitTimestamp }}"
skip: '{{ if index .Env "BUILD_NEXT" }}true{{ end }}'
- id: pocket-id-next
main: ./cmd
dir: backend
binary: pocket-id
env:
- CGO_ENABLED=0
- GOMAXPROCS=2
targets:
- linux_amd64_v1
- linux_arm64_v8.0
ldflags:
- -w -s
- -buildid={{ .Version }}
- -X github.com/pocket-id/pocket-id/backend/internal/common.Version={{ .Version }}
flags:
- -trimpath
- -p=2
mod_timestamp: "{{ .CommitTimestamp }}"
skip: '{{ if not (index .Env "BUILD_NEXT") }}true{{ end }}'
archives:
- id: default
formats:
- binary
name_template: "{{ .Binary }}_{{ .Os }}_{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}"
checksum:
name_template: checksums.txt
docker_digest:
name_template: digests.txt
dockers_v2:
- id: pocket-id
ids:
- pocket-id
dockerfile: docker/Dockerfile-prebuilt
images:
- "pocketid/pocket-id"
- "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/pocket-id"
tags:
- "{{ .Tag }}"
- "v{{ .Major }}"
- "v{{ .Major }}.{{ .Minor }}"
- "latest"
platforms:
- linux/amd64
- linux/arm64
labels:
"org.opencontainers.image.authors": "Pocket ID"
"org.opencontainers.image.url": "https://github.com/pocket-id/pocket-id"
"org.opencontainers.image.documentation": "https://github.com/pocket-id/pocket-id/blob/main/README.md"
"org.opencontainers.image.source": "https://github.com/pocket-id/pocket-id"
"org.opencontainers.image.version": "{{ .Version }}"
"org.opencontainers.image.revision": "{{ .FullCommit }}"
"org.opencontainers.image.licenses": "BSD-2-Clause"
"org.opencontainers.image.ref.name": "pocket-id"
"org.opencontainers.image.title": "Pocket ID"
disable: '{{ if index .Env "BUILD_NEXT" }}true{{ end }}'
sbom: true
extra_files:
- scripts/docker
- id: pocket-id-distroless
ids:
- pocket-id
dockerfile: docker/Dockerfile-distroless
images:
- "pocketid/pocket-id"
- "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/pocket-id"
tags:
- "{{ .Tag }}-distroless"
- "v{{ .Major }}-distroless"
- "v{{ .Major }}.{{ .Minor }}-distroless"
- "latest-distroless"
platforms:
- linux/amd64
- linux/arm64
labels:
"org.opencontainers.image.authors": "Pocket ID"
"org.opencontainers.image.url": "https://github.com/pocket-id/pocket-id"
"org.opencontainers.image.documentation": "https://github.com/pocket-id/pocket-id/blob/main/README.md"
"org.opencontainers.image.source": "https://github.com/pocket-id/pocket-id"
"org.opencontainers.image.version": "{{ .Version }}"
"org.opencontainers.image.revision": "{{ .FullCommit }}"
"org.opencontainers.image.licenses": "BSD-2-Clause"
"org.opencontainers.image.ref.name": "pocket-id"
"org.opencontainers.image.title": "Pocket ID"
disable: '{{ if index .Env "BUILD_NEXT" }}true{{ end }}'
sbom: true
- id: pocket-id-next
ids:
- pocket-id-next
dockerfile: docker/Dockerfile-prebuilt
images:
- "pocketid/pocket-id"
- "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/pocket-id"
tags:
- "next"
platforms:
- linux/amd64
- linux/arm64
labels:
"org.opencontainers.image.authors": "Pocket ID"
"org.opencontainers.image.url": "https://github.com/pocket-id/pocket-id"
"org.opencontainers.image.documentation": "https://github.com/pocket-id/pocket-id/blob/main/README.md"
"org.opencontainers.image.source": "https://github.com/pocket-id/pocket-id"
"org.opencontainers.image.version": "{{ .Version }}"
"org.opencontainers.image.revision": "{{ .FullCommit }}"
"org.opencontainers.image.licenses": "BSD-2-Clause"
"org.opencontainers.image.ref.name": "pocket-id"
"org.opencontainers.image.title": "Pocket ID"
disable: '{{ if not (index .Env "BUILD_NEXT") }}true{{ end }}'
sbom: true
extra_files:
- scripts/docker
- id: pocket-id-next-distroless
ids:
- pocket-id-next
dockerfile: docker/Dockerfile-distroless
images:
- "pocketid/pocket-id"
- "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/pocket-id"
tags:
- "next-distroless"
platforms:
- linux/amd64
- linux/arm64
labels:
"org.opencontainers.image.authors": "Pocket ID"
"org.opencontainers.image.url": "https://github.com/pocket-id/pocket-id"
"org.opencontainers.image.documentation": "https://github.com/pocket-id/pocket-id/blob/main/README.md"
"org.opencontainers.image.source": "https://github.com/pocket-id/pocket-id"
"org.opencontainers.image.version": "{{ .Version }}"
"org.opencontainers.image.revision": "{{ .FullCommit }}"
"org.opencontainers.image.licenses": "BSD-2-Clause"
"org.opencontainers.image.ref.name": "pocket-id"
"org.opencontainers.image.title": "Pocket ID"
disable: '{{ if not (index .Env "BUILD_NEXT") }}true{{ end }}'
sbom: true
notarize:
macos:
- enabled: '{{ if and (isEnvSet "MACOS_SIGN_P12") (not (index .Env "BUILD_NEXT")) }}true{{ end }}'
ids:
- pocket-id
sign:
certificate: "{{ .Env.MACOS_SIGN_P12 }}"
password: "{{ .Env.MACOS_SIGN_PASSWORD }}"
notarize:
issuer_id: "{{ .Env.MACOS_NOTARY_ISSUER_ID }}"
key_id: "{{ .Env.MACOS_NOTARY_KEY_ID }}"
key: "{{ .Env.MACOS_NOTARY_KEY }}"
wait: true
timeout: 20m
changelog:
disable: true
announce:
discord:
enabled: true
message_template: "Pocket ID version {{ .Tag }} has been released! Checkout the changelog at {{ .ReleaseURL }}"
author: "GitHub Actions"
# Color code of the embed. You have to use decimal numeral system, not hexadecimal.
# color: "9320959"
icon_url: "https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png"
release:
github:
owner: pocket-id
name: pocket-id
disable: '{{ if index .Env "SKIP_RELEASE" }}true{{ end }}'
draft: true
replace_existing_draft: false
use_existing_draft: true
replace_existing_artifacts: true
prerelease: auto
make_latest: true
mode: keep-existing
name_template: "{{ .Tag }}"

View File

@@ -1 +1 @@
1.14.1
2.10.0

4
.vscode/launch.json vendored
View File

@@ -5,12 +5,14 @@
"name": "Backend",
"type": "go",
"request": "launch",
"envFile": "${workspaceFolder}/backend/cmd/.env",
"envFile": "${workspaceFolder}/backend/.env",
"env": {
"APP_ENV": "development"
},
"mode": "debug",
"program": "${workspaceFolder}/backend/cmd/main.go",
"buildFlags": "-tags=exclude_frontend",
"cwd": "${workspaceFolder}/backend",
},
{
"name": "Frontend",

View File

@@ -1,4 +1,4 @@
{
"go.buildTags": "e2etest",
"prettier.documentSelectors": ["**/*.svelte"],
}
"go.buildTags": "e2etest",
"prettier.documentSelectors": ["**/*.svelte"]
}

103
AGENTS.md Normal file
View File

@@ -0,0 +1,103 @@
# AGENTS.md
Pocket ID — a passkey-only OIDC provider. Go backend serves a SvelteKit SPA (embedded in
the binary for production). This file lists what isn't obvious from reading the code.
## Layout
- `backend/` — Go module (gin, GORM, [ory/fosite fork](#backend-go)). Its own toolchain, not part of the repository.
- `frontend/` — SvelteKit 5 SPA. Builds into `backend/frontend/dist` and is embedded via `go:embed`.
- `tests/` — Playwright end-to-end tests (drives a Dockerized full stack).
## Build / test / lint
```sh
# backend/ — the exclude_frontend tag is mandatory locally; CI uses it too
go test -tags=exclude_frontend ./... # unit/integration tests
go test -tags=exclude_frontend -run TestName ./internal/... # a single test
golangci-lint run --build-tags=exclude_frontend # lint (config: backend/.golangci.yml)
# frontend/ (or root)
pnpm check # svelte-check — the ONLY frontend type gate (no unit tests exist)
pnpm lint # prettier --check && eslint (note: not enforced by CI)
pnpm format # prettier --write — REQUIRED before opening a PR
```
End-to-end (needs Docker; **stop any local backend on `:1411` first** — see gotchas):
```sh
cd tests/setup && docker compose up -d --build # rebuild after ANY code change, or you test stale code
cd ../.. && pnpm test # = playwright test in tests/
```
## Critical gotchas
- **`exclude_frontend` build tag.** `backend/frontend/` embeds `dist/` via `go:embed`. Without
a built frontend present, plain `go run`/`go test`/`golangci-lint` fail to compile. Always pass
`-tags exclude_frontend` for backend dev/test/lint. Production builds omit it to embed the SPA.
- **Never edit generated files:** `frontend/src/lib/paraglide/**` (Paraglide i18n output) and
`backend/frontend/dist/**`. For i18n, only edit `frontend/messages/en.json`; other locales come
from Crowdin.
- **Migrations are split by DB.** Raw SQL via golang-migrate in
`backend/resources/migrations/{sqlite,postgres}/` — separate files _and_ separate version
timelines. Add a matching up/down pair to **both**. Not GORM AutoMigrate. SQLite migrations
are not auto-wrapped in a transaction (`NoTxWrap`); wrap multi-statement ones manually
(`PRAGMA foreign_keys=OFF; BEGIN; … COMMIT; PRAGMA foreign_keys=ON;`).
## Backend (Go)
- **Config:** global `common.EnvConfig` (caarlos0/env); any secret var supports a `*_FILE` variant.
- **Logging:** stdlib `log/slog` only (bridged to OpenTelemetry). No zerolog/logrus in app code.
- `go.mod` pins a **fork** of fosite (`replace github.com/ory/fosite => github.com/pocket-id/fosite`).
## Frontend (SvelteKit)
- **Svelte 5 runes only:** `$state`, `$derived`, `$props`, `$bindable`. No `export let`. Event
modifiers are gone — use `preventDefault` from `$lib/utils/event-util` (`onsubmit={preventDefault(fn)}`).
- **Forms:** use the custom `createForm(schema, initial)` from `$lib/utils/form-util.ts` with
`form-input.svelte`. The vendored shadcn formsnap/superforms wrappers exist but app forms don't
use them — match the surrounding file. Import zod as `import { z } from 'zod/v4'`.
## Coding Style Guidelines
### Comments
- Exactly one sentence per line
- There is NO maximum line width: never wrap a single sentence across multiple comment lines, no matter how long that sentence is
- A new line in a comment means a new sentence; a wrapped line does not exist
- No trailing period on single-line comments
- Prefer comments that explain intent, invariants, or why a branch exists
- Avoid comments that simply restate the next line of code
- For multi-step logic, use short section comments to separate the steps and explain why each step exists
- Inside a function, put a one-sentence comment above each major action; the comments double as visual separators between sections and should say what the step does and why, not how
- Favor a few well-placed section comments over a wall of code; a reader should be able to skim the comments and understand the method's flow
```go
// Wrong — one sentence wrapped across multiple lines
// This function performs the main validation logic. It checks
// the input against the schema and returns an error if the
// input is invalid.
// Wrong — trailing period on single-line comment
// Validate the input.
// Right — one sentence per line, each line as long as it needs to be
// This function performs the main validation logic
// It checks the input against the schema and returns an error if the input is invalid
// Right
// Validate the input
// Right
// Normalize the request host so callers can pass either Host or X-Forwarded-Host values
// Right
// Browsers do not accept a cookie Domain attribute set to an IP address
// Returning an empty domain tells the caller to set a host-only cookie instead
// Wrong — restates the code
// Trim whitespace and lowercase the host
host = strings.TrimSpace(strings.ToLower(host))
```
Section comments inside a function — one sentence per major action, describing what and why, acting as visual separators:

View File

@@ -1,3 +1,537 @@
## v2.10.0
### Bug Fixes
- login code null submission and login code length check ([#1512](https://github.com/pocket-id/pocket-id/pull/1512) by @James18232)
- don't reject `offline_access`scope ([2ed7035](https://github.com/pocket-id/pocket-id/commit/2ed703540d901eaf58427d0cf54fb0a5e68bc6a0) by @stonith404)
- CSP error with `response_mode=form_post` ([16b5c16](https://github.com/pocket-id/pocket-id/commit/16b5c16a664a2f1b20ad824d907294de9cb60c9e) by @stonith404)
- various bugs in observability / OTel ([#1564](https://github.com/pocket-id/pocket-id/pull/1564) by @ItalyPaleAle)
- restore behavior that unknown scopes get ignored ([34e9a6d](https://github.com/pocket-id/pocket-id/commit/34e9a6d1982a3d50bb3874aff3316c3154113725) by @stonith404)
- re-check api permissions on access token refresh ([e8cb0c8](https://github.com/pocket-id/pocket-id/commit/e8cb0c831ca914522babce101cf8533acbf94136) by @stonith404)
- merge requested scopes instead of replacing them ([5e2cc6f](https://github.com/pocket-id/pocket-id/commit/5e2cc6f40e5c7139b122d8a59ca121af1fdbdceb) by @stonith404)
- device authorization resolve resource creating device_code ([2f55b7c](https://github.com/pocket-id/pocket-id/commit/2f55b7cbc3d601128061dbc70014e77f5b1ec211) by @stonith404)
- add missing burst to rate limit ([f3b6ceb](https://github.com/pocket-id/pocket-id/commit/f3b6ceb8768616fb155455cceb7a539c13c3da9f) by @stonith404)
- disable one time access token exchange for disabled users ([c85a4e6](https://github.com/pocket-id/pocket-id/commit/c85a4e63dabcb2c71bbf35e03482969cb07a6df8) by @stonith404)
- /authorize endpoint crashes when list of scopes is empty ([#1575](https://github.com/pocket-id/pocket-id/pull/1575) by @ItalyPaleAle)
- mobile layout improvements ([2910c51](https://github.com/pocket-id/pocket-id/commit/2910c517bb7545f932c34321907299064b08bb10) by @stonith404)
### Documentation
- add link to API docs ([968d072](https://github.com/pocket-id/pocket-id/commit/968d07229eeefa763fe981e5c7e4970d5640c783) by @stonith404)
- fix wrong OpenAPI documentation ([da3677f](https://github.com/pocket-id/pocket-id/commit/da3677f33d50c96f06ee1d8f65d9f802f67af270) by @stonith404)
### Features
- upgrade shadcn components ([80509c8](https://github.com/pocket-id/pocket-id/commit/80509c83ee0ce92b416286217c11f116d5b9fe2a) by @stonith404)
- improve error handling on authorize page ([8689ddd](https://github.com/pocket-id/pocket-id/commit/8689ddd72b143ceac29adb63d624cc18585f580e) by @stonith404)
- add ability to skip consent for client ([d467855](https://github.com/pocket-id/pocket-id/commit/d4678558708542719c5d3cc48c48d3053f37800c) by @stonith404)
- prompt admin with PKCE client support hint ([#1499](https://github.com/pocket-id/pocket-id/pull/1499) by @James18232)
- login code input boxes ([#1545](https://github.com/pocket-id/pocket-id/pull/1545) by @James18232)
- drop TOFU support for callback URL ([931a6c2](https://github.com/pocket-id/pocket-id/commit/931a6c2adb39fb8cdfe18487f4206f8848b3d026) by @stonith404)
- add OAuth APIs with scoped permissions ([#1542](https://github.com/pocket-id/pocket-id/pull/1542) by @stonith404)
- add support for unencrypted OIDC request parameter ([25dcad7](https://github.com/pocket-id/pocket-id/commit/25dcad757a7d7670bfd8d4d4ac209e26dc919596) by @stonith404)
- add description field to oidc clients ([#1547](https://github.com/pocket-id/pocket-id/pull/1547) by @seanmckenzie428)
- add tab bar navigation for crowded pages ([28a553f](https://github.com/pocket-id/pocket-id/commit/28a553f63b85ecccaa764279af5f65ea1bf2d013) by @stonith404)
### Other
- close low-demand feature requests ([35987b9](https://github.com/pocket-id/pocket-id/commit/35987b9c8d44efb08928d031ab9f1e84eda9882f) by @stonith404)
- Bump the "all-dependencies" group with 2 updates across multiple ecosystems ([#1528](https://github.com/pocket-id/pocket-id/pull/1528) by @dependabot[bot])
- fix locators after shadcn upgrade ([dbbe2a4](https://github.com/pocket-id/pocket-id/commit/dbbe2a403aa6afc39e14cf559bd074c6640e3b0c) by @stonith404)
- use fosite for OAuth 2.0 logic ([#1520](https://github.com/pocket-id/pocket-id/pull/1520) by @stonith404)
- wrap last two migrations into transaction ([cf9a319](https://github.com/pocket-id/pocket-id/commit/cf9a31986f521451bc9e1c2754a09ee2d141cd57) by @stonith404)
- remove dead code ([519cda0](https://github.com/pocket-id/pocket-id/commit/519cda0eef1083274deed9bf6c501b815986241a) by @stonith404)
- update vulnerable dependencies ([e538ea0](https://github.com/pocket-id/pocket-id/commit/e538ea0bf1ba4d5f70f977c7021bfa4f4543070e) by @stonith404)
- fix flaky test ([7eaaea7](https://github.com/pocket-id/pocket-id/commit/7eaaea717b418c167b733723eeb000073c6915fe) by @stonith404)
- add AGENTS.md ([d0243fe](https://github.com/pocket-id/pocket-id/commit/d0243fe396fb4ae2ec8d66ab95c80c8a283a631b) by @stonith404)
- use goreleaser for binaries and static image builds ([#1478](https://github.com/pocket-id/pocket-id/pull/1478) by @kmendell)
- add support for Docker Hub ([99ab38d](https://github.com/pocket-id/pocket-id/commit/99ab38dc799d07d4d91863199b1350fb7d06e03e) by @stonith404)
- remove static next image tag ([49305ed](https://github.com/pocket-id/pocket-id/commit/49305ed1f98d838167703d3dc2f6e3e5786fb9fc) by @stonith404)
- don't build unnecessary binaries for `next` image ([4b957a0](https://github.com/pocket-id/pocket-id/commit/4b957a0106899666b55420b61d2bb12d08d82022) by @stonith404)
- change Depot project ([cdc2374](https://github.com/pocket-id/pocket-id/commit/cdc23744ae1cb731184828afe326fc2a1712c02f) by @stonith404)
- don't tag `next` image with current version ([cf54786](https://github.com/pocket-id/pocket-id/commit/cf54786cc3bfe59bd01e8d5cbb4a3ccabb7d6466) by @stonith404)
- add exempt user to PR quality checks ([254ae88](https://github.com/pocket-id/pocket-id/commit/254ae88cda67ecc1095b0b738630388c93d90c44) by @stonith404)
- migrate API key functionality to single `apikey` module ([9fce987](https://github.com/pocket-id/pocket-id/commit/9fce98710679e522233c3b7af8d3b6cd2ce5594c) by @stonith404)
- migrate Webauthn functionality to single `webauthn` module ([58fcf7c](https://github.com/pocket-id/pocket-id/commit/58fcf7cbe665be8d7670170097735fe8efe31c6f) by @stonith404)
- migrate signup functionality to single `usersignup` module ([ecad31c](https://github.com/pocket-id/pocket-id/commit/ecad31cae2dbbc51d15a7007601829c57accff75) by @stonith404)
- make API key renewal date picker navigation date-independent ([#1560](https://github.com/pocket-id/pocket-id/pull/1560) by @ItalyPaleAle)
- integrate Francis actor framework for background jobs, cron scheduling, and rate limiting ([#1556](https://github.com/pocket-id/pocket-id/pull/1556) by @ItalyPaleAle)
- Bump the "all-dependencies" group with 3 updates across multiple ecosystems ([#1563](https://github.com/pocket-id/pocket-id/pull/1563) by @dependabot[bot])
- use go-kit for sending emails ([#1565](https://github.com/pocket-id/pocket-id/pull/1565) by @ItalyPaleAle)
- update AAGUIDs ([#1567](https://github.com/pocket-id/pocket-id/pull/1567) by @github-actions[bot])
- move Pocket ID specific logic from Fosite into Pocket ID repo ([9a94aa0](https://github.com/pocket-id/pocket-id/commit/9a94aa0694abda5bdd7c9380cbadc1f080a8027e) by @stonith404)
- update Fosite version ([337fc6f](https://github.com/pocket-id/pocket-id/commit/337fc6fd1ec53aa65275f5ad964869462664a194) by @stonith404)
- pass transaction to `resolveResource` ([7667377](https://github.com/pocket-id/pocket-id/commit/7667377c98c01f45dc5e959c999c60143efca1ef) by @stonith404)
- remove redundant dtos ([fa2d08c](https://github.com/pocket-id/pocket-id/commit/fa2d08cb6d60bc3c9d8865c36c07ef8868e8ed9e) by @stonith404)
- rename migrations ([c72da58](https://github.com/pocket-id/pocket-id/commit/c72da58eaf45eafff3232bcc050fd87e84ae050f) by @stonith404)
- remove duplicate fosite config properties ([190914f](https://github.com/pocket-id/pocket-id/commit/190914fd72481c2da7372215f369a5b96feed3f0) by @stonith404)
- fix migration version in `database.json` ([a5f2192](https://github.com/pocket-id/pocket-id/commit/a5f2192d67045cca63a4e0038e118986754d4184) by @stonith404)
- fix wrong migration name ([ce7d3a7](https://github.com/pocket-id/pocket-id/commit/ce7d3a7e1d6ab08696a149fa8805fcb99337afa9) by @stonith404)
- run formatter ([59a6868](https://github.com/pocket-id/pocket-id/commit/59a6868ecdb017354d4dfd8d70c4ba0710f02f08) by @stonith404)
- upgrade go version ([f62d476](https://github.com/pocket-id/pocket-id/commit/f62d476c56d39f7f20c443eb430462e9141d31fa) by @stonith404)
### Performance Improvements
- include permissions in api list response ([8035a4c](https://github.com/pocket-id/pocket-id/commit/8035a4c8d589109e767a031dae3c473ea3e77d75) by @stonith404)
**Full Changelog**: https://github.com/pocket-id/pocket-id/compare/v2.9.0...v2.10.0
## v2.9.0
### Bug Fixes
- issues with loading new font ([#1496](https://github.com/pocket-id/pocket-id/pull/1496) by @MelvinSnijders)
- PAR parameters not respected by authorize page ([3d9d4de](https://github.com/pocket-id/pocket-id/commit/3d9d4de61940747c64f4a5a4ba24010be2a2fbb6) by @stonith404)
- add responsive css for api/user/group/client page add buttons ([#1508](https://github.com/pocket-id/pocket-id/pull/1508) by @James18232)
- load Gloock font from static path instead of inline ([89b4abb](https://github.com/pocket-id/pocket-id/commit/89b4abb8b5cc71b62f458f966f2778259b167838) by @stonith404)
- update terminology from public key code exchange to proof key co… ([#1515](https://github.com/pocket-id/pocket-id/pull/1515) by @maxwassiljew)
- callback URL validation not validated if prompt=none ([8a75774](https://github.com/pocket-id/pocket-id/commit/8a7577497131229badb35cb4b3a4227b1300afff) by @stonith404)
- passkey card not rounded ([84678c3](https://github.com/pocket-id/pocket-id/commit/84678c3a7bfa1f601261943f03aa935626fe54a2) by @stonith404)
### Documentation
- add AI Usage Policy to CONTRIBUTING.md ([fea933b](https://github.com/pocket-id/pocket-id/commit/fea933b62d6a2899bf1e46fc75dab8b6bca81a0f) by @stonith404)
- add PR template ([bf9f76b](https://github.com/pocket-id/pocket-id/commit/bf9f76bbd5b8d49dabda74f1c3de5120ea5db698) by @stonith404)
- add "needs more upvotes" label as default ([fc42f62](https://github.com/pocket-id/pocket-id/commit/fc42f6211d410ea8db75018292df8f311078a7fe) by @stonith404)
- fix link to watchtower repo ([#1500](https://github.com/pocket-id/pocket-id/pull/1500) by @wollew)
### Features
- add support for Pushed Authorization Requests (RFC9126) ([#1404](https://github.com/pocket-id/pocket-id/pull/1404) by @Zenithar)
### Other
- fix linter issues ([4f97cd4](https://github.com/pocket-id/pocket-id/commit/4f97cd4188b6028e955c8dc62e122ec409b32db3) by @stonith404)
- update AAGUIDs ([#1511](https://github.com/pocket-id/pocket-id/pull/1511) by @github-actions[bot])
- run svelte check and unit tests in breaking branches ([83b45f6](https://github.com/pocket-id/pocket-id/commit/83b45f682dc89b2019b1812157157f431dfd3ad7) by @stonith404)
- run formatter ([2726ddd](https://github.com/pocket-id/pocket-id/commit/2726ddd0c23ab6ae22f307bf8d377fdfd73e1c14) by @stonith404)
- bump vite from 8.0.13 to 8.0.16 ([#1521](https://github.com/pocket-id/pocket-id/pull/1521) by @dependabot[bot])
- npm dependabot not run from root ([c0d96a0](https://github.com/pocket-id/pocket-id/commit/c0d96a0b1349d7dd0dec65c78dcdc2e043713ff8) by @stonith404)
- fix wrong assertion ([effd2a1](https://github.com/pocket-id/pocket-id/commit/effd2a146f9b85d8bf1df203b4970aafc7ae24f8) by @stonith404)
- Bump the "all-dependencies" group with 4 updates across multiple ecosystems ([#1523](https://github.com/pocket-id/pocket-id/pull/1523) by @dependabot[bot])
**Full Changelog**: https://github.com/pocket-id/pocket-id/compare/v2.8.0...v2.9.0
## v2.8.0
### Bug Fixes
- delete refresh tokens on end-session to prevent reuse after logout ([#1458](https://github.com/pocket-id/pocket-id/pull/1458) by @wucm667)
- return 404 status code for `.well-known` routes if not found ([714b5b3](https://github.com/pocket-id/pocket-id/commit/714b5b3307bb012b94e66e8dcb1de93a2d62eceb) by @stonith404)
- add `email_verified` to reserved claims list ([8b22fca](https://github.com/pocket-id/pocket-id/commit/8b22fcaaa475984bb4c42306487035073d7c47e3) by @stonith404)
- reject unknown PKCE code challenge methods ([ce6bdb9](https://github.com/pocket-id/pocket-id/commit/ce6bdb9d7e6c0f1eaaa21ddb6d808e41088a38b8) by @stonith404)
- make stream of downloaded logos seekable for S3 checksum calculation ([cc9163f](https://github.com/pocket-id/pocket-id/commit/cc9163f577280d7c278dc744e20d0505dbe83ede) by @stonith404)
- scope confirmation wasn't shown if account selection was prompted ([272d147](https://github.com/pocket-id/pocket-id/commit/272d1479bd64b4a068bfe4dfa5ba4cd3c5e90505) by @stonith404)
- add support for unix socket mode in healthcheck ([a712196](https://github.com/pocket-id/pocket-id/commit/a71219637af1746e9faba5274b6095da676ec555) by @stonith404)
- restore cross-platform binary builds ([7027296](https://github.com/pocket-id/pocket-id/commit/7027296632ab82cd9930f2fbe60054c2421688c4) by @stonith404)
### Documentation
- update SECURITY.md ([bb5a111](https://github.com/pocket-id/pocket-id/commit/bb5a111e3d51ad56fc074df7083022e3d4e25369) by @stonith404)
### Features
- delete OAuth refresh token on RP initiated logout ([#1480](https://github.com/pocket-id/pocket-id/pull/1480) by @stonith404)
- remove EXIF/XMP metadata from uploaded images ([#1477](https://github.com/pocket-id/pocket-id/pull/1477) by @stonith404)
- add support for `response_mode=fragment` ([0c95b7c](https://github.com/pocket-id/pocket-id/commit/0c95b7c3cc171ed77b51f236009b5ff659da0bec) by @stonith404)
- add support for systemd socket activation ([#1479](https://github.com/pocket-id/pocket-id/pull/1479) by @deviant)
- improve design trough the whole application ([b3d40a4](https://github.com/pocket-id/pocket-id/commit/b3d40a476b35cfa2451749e9a3d307b7babaeb6b) by @stonith404)
### Other
- update AAGUIDs ([#1476](https://github.com/pocket-id/pocket-id/pull/1476) by @github-actions[bot])
- upgrade dependencies ([91c2ea2](https://github.com/pocket-id/pocket-id/commit/91c2ea2a6616a500922365d1789f1fa1ba66c112) by @stonith404)
- remove deprecated http2 package ([e56dc12](https://github.com/pocket-id/pocket-id/commit/e56dc124cee4d3c176c89e7d574ddfde089bcbd1) by @stonith404)
- apply go 1.26.0 syntax updated ([8ad95b8](https://github.com/pocket-id/pocket-id/commit/8ad95b8af17ba8cbb62168ed0bb54c87e3df379c) by @stonith404)
- delete refresh tokens on end-session to prevent reuse after logout ([b27a52a](https://github.com/pocket-id/pocket-id/commit/b27a52a5915228383dbdbf0a2c353452cd351d0f) by @stonith404)
- use dependabot for automatic dependency upgrades ([b9fdd53](https://github.com/pocket-id/pocket-id/commit/b9fdd530c0f370f2fccb6251d700aa6f9bdf4124) by @stonith404)
- fix invalid schema ([e8c398f](https://github.com/pocket-id/pocket-id/commit/e8c398ffbd6545f76c983f01556bb3b73d40a728) by @stonith404)
- update AAGUIDs ([#1487](https://github.com/pocket-id/pocket-id/pull/1487) by @github-actions[bot])
- use custom Playwright route for callback URL checks ([f134247](https://github.com/pocket-id/pocket-id/commit/f13424720b7f5da3978988f0f9169cf303971a13) by @stonith404)
- fix linter issues ([bc4f75c](https://github.com/pocket-id/pocket-id/commit/bc4f75c44f12a71380042ad4c140d41f4dddc7dd) by @stonith404)
- don't compare hashes of profile pictures ([9ad2bfc](https://github.com/pocket-id/pocket-id/commit/9ad2bfc7b30e0eb2d7ae1406874b8caba5a1f121) by @stonith404)
- run formatter ([0616aba](https://github.com/pocket-id/pocket-id/commit/0616abaf7f079d0a35543258e13aa5b4acb4adc0) by @stonith404)
- use fixed minor version of Go ([e046a03](https://github.com/pocket-id/pocket-id/commit/e046a03364f00001f8699aaa7e902b33c2661118) by @stonith404)
**Full Changelog**: https://github.com/pocket-id/pocket-id/compare/v2.7.0...v2.8.0
## v2.7.0
### Bug Fixes
- add `_FILE` support for `S3_SECRET_ACCESS_KEY_FILE` env var ([#1452](https://github.com/pocket-id/pocket-id/pull/1452) by @ItalyPaleAle)
- invalidate cache when changing image ([#1462](https://github.com/pocket-id/pocket-id/pull/1462) by @GameTec-live)
- fall back to Basic auth when PKCE puts client_id in body ([#1466](https://github.com/pocket-id/pocket-id/pull/1466) by @mgabor3141)
### Documentation
- add missing /api prefix to app config swagger routes ([#1454](https://github.com/pocket-id/pocket-id/pull/1454) by @aclerici38)
### Features
- add support for response_mode=form_post ([#1360](https://github.com/pocket-id/pocket-id/pull/1360) by @Johnwulp)
- add support for "select_account" prompt ([#1453](https://github.com/pocket-id/pocket-id/pull/1453) by @ItalyPaleAle)
### Other
- add script to update deps ([f9f93f0](https://github.com/pocket-id/pocket-id/commit/f9f93f0ef164426d4d4cfb58fcf59ce30b686130) by @stonith404)
- upgrade dependencies ([20df033](https://github.com/pocket-id/pocket-id/commit/20df033c1fc3d737927447206b4f5e65d3ab4a40) by @stonith404)
- post dependency upgrade fixes ([e33a9b8](https://github.com/pocket-id/pocket-id/commit/e33a9b8c888988f7b2e6c500e72d53b57db01f34) by @stonith404)
- migrate github actions runners to depot runners ([#1329](https://github.com/pocket-id/pocket-id/pull/1329) by @kmendell)
- fix caching of ldap-cli e2e tests docker build ([#1457](https://github.com/pocket-id/pocket-id/pull/1457) by @kmendell)
- fix incorrect container name variable ([5c7e5f6](https://github.com/pocket-id/pocket-id/commit/5c7e5f680bb70d3056b11f1a33669cc4bd9a1e17) by @kmendell)
**Full Changelog**: https://github.com/pocket-id/pocket-id/compare/v2.6.2...v2.7.0
## v2.6.2
### Bug Fixes
- return correct byte count in HEAD request writer ([#1443](https://github.com/pocket-id/pocket-id/pull/1443) by @ahampal)
- improve keyboard navigation and screen-reader labels ([#1445](https://github.com/pocket-id/pocket-id/pull/1445) by @bjoernch)
### Other
- upgrade to vite 8.0 and pnpm 10.33.0 ([#1446](https://github.com/pocket-id/pocket-id/pull/1446) by @kmendell)
**Full Changelog**: https://github.com/pocket-id/pocket-id/compare/v2.6.1...v2.6.2
## 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
- ENCRYPTION_KEY needed for version and help commands ([#1256](https://github.com/pocket-id/pocket-id/pull/1256) by @kmendell)
- prevent deletion of OIDC provider logo for non admin/anonymous users ([#1267](https://github.com/pocket-id/pocket-id/pull/1267) by @HiMoritz)
- add `type="url"` to url inputs ([bb7b0d5](https://github.com/pocket-id/pocket-id/commit/bb7b0d56084df49b6a003cc3eaf076884e2cbf60) by @stonith404)
- increase rate limit for frontend and api requests ([aab7e36](https://github.com/pocket-id/pocket-id/commit/aab7e364e85f1ce13950da93cc50324328cdd96d) by @stonith404)
- decode URL-encoded client ID and secret in Basic auth ([#1263](https://github.com/pocket-id/pocket-id/pull/1263) by @ypomortsev)
- token endpoint must not accept params as query string args ([#1321](https://github.com/pocket-id/pocket-id/pull/1321) by @ItalyPaleAle)
- left align input error messages ([b3fe143](https://github.com/pocket-id/pocket-id/commit/b3fe14313684f9d8c389ed93ea8e479e3681b5c6) by @stonith404)
- disallow API key renewal and creation with API key authentication ([#1334](https://github.com/pocket-id/pocket-id/pull/1334) by @stonith404)
### Features
- add VERSION_CHECK_DISABLED environment variable ([#1254](https://github.com/pocket-id/pocket-id/pull/1254) by @dihmandrake)
- add support for HTTP/2 ([56afebc](https://github.com/pocket-id/pocket-id/commit/56afebc242be7ed14b58185425d6445bf18f640a) by @stonith404)
- manageability of uncompressed geolite db file ([#1234](https://github.com/pocket-id/pocket-id/pull/1234) by @gucheen)
- add JWT ID for generated tokens ([#1322](https://github.com/pocket-id/pocket-id/pull/1322) by @imnotjames)
- current version api endpoint ([#1310](https://github.com/pocket-id/pocket-id/pull/1310) by @kmendell)
### Other
- bump @sveltejs/kit from 2.49.2 to 2.49.5 in the npm_and_yarn group across 1 directory ([#1240](https://github.com/pocket-id/pocket-id/pull/1240) by @dependabot[bot])
- bump svelte from 5.46.1 to 5.46.4 in the npm_and_yarn group across 1 directory ([#1242](https://github.com/pocket-id/pocket-id/pull/1242) by @dependabot[bot])
- bump devalue to 5.6.2 ([9dbc02e](https://github.com/pocket-id/pocket-id/commit/9dbc02e56871b2de6a39c443e1455efc26a949f7) by @kmendell)
- upgrade deps ([4811625](https://github.com/pocket-id/pocket-id/commit/4811625cdd64b47ea67b7a9b03396e455896ccd6) by @kmendell)
- add Estonian files ([53ef61a](https://github.com/pocket-id/pocket-id/commit/53ef61a3e5c4b77edec49d41ab94302bfec84269) by @kmendell)
- update AAGUIDs ([#1257](https://github.com/pocket-id/pocket-id/pull/1257) by @github-actions[bot])
- add Norwegian language files ([80558c5](https://github.com/pocket-id/pocket-id/commit/80558c562533e7b4d658d5baa4221d8cd209b47d) by @stonith404)
- run formatter ([60825c5](https://github.com/pocket-id/pocket-id/commit/60825c5743b0e233ab622fd4d0ea04eb7ab59529) by @kmendell)
- bump axios from 1.13.2 to 1.13.5 in the npm_and_yarn group across 1 directory ([#1309](https://github.com/pocket-id/pocket-id/pull/1309) by @dependabot[bot])
- update dependenicies ([94a4897](https://github.com/pocket-id/pocket-id/commit/94a48977ba24e099b6221838d620c365eb1d4bf4) by @kmendell)
- update AAGUIDs ([#1316](https://github.com/pocket-id/pocket-id/pull/1316) by @github-actions[bot])
- bump svelte from 5.46.4 to 5.51.5 in the npm_and_yarn group across 1 directory ([#1324](https://github.com/pocket-id/pocket-id/pull/1324) by @dependabot[bot])
- bump @sveltejs/kit from 2.49.5 to 2.52.2 in the npm_and_yarn group across 1 directory ([#1327](https://github.com/pocket-id/pocket-id/pull/1327) by @dependabot[bot])
- upgrade dependencies ([0678699](https://github.com/pocket-id/pocket-id/commit/0678699d0cce5448c425b2c16bedab5fc242cbf0) by @stonith404)
- upgrade to node 24 and go 1.26.0 ([#1328](https://github.com/pocket-id/pocket-id/pull/1328) by @kmendell)
**Full Changelog**: https://github.com/pocket-id/pocket-id/compare/v2.2.0...v2.3.0
## v2.2.0
### Bug Fixes
- allow changing "require email address" if no SMTP credentials present ([8c68b08](https://github.com/pocket-id/pocket-id/commit/8c68b08c12ba371deda61662e3d048d63d07c56f) by @stonith404)
- data import from sqlite to postgres fails because of wrong datatype ([1a032a8](https://github.com/pocket-id/pocket-id/commit/1a032a812ef78b250a898d14bec73a8ef7a7859a) by @stonith404)
- user can't update account if email is empty ([5828fa5](https://github.com/pocket-id/pocket-id/commit/5828fa57791314594625d52475733dce23cc2fcc) by @stonith404)
- login codes sent by an admin incorrectly requires a device token ([03f9be0](https://github.com/pocket-id/pocket-id/commit/03f9be0d125732e02a8e2c5390d9e6d0c74ce957) by @stonith404)
- allow exchanging logic code if already authenticated ([0e2cdc3](https://github.com/pocket-id/pocket-id/commit/0e2cdc393e34276bb3b8ea318cdc7261de3f2dec) by @stonith404)
- db version downgrades don't downgrade db schema ([4df4bcb](https://github.com/pocket-id/pocket-id/commit/4df4bcb6451b4bf88093e04f3222c8737f2c7be3) by @stonith404)
- use user specific email verified claim instead of global one ([2a11c3e](https://github.com/pocket-id/pocket-id/commit/2a11c3e60942d45c2e5b422d99945bce65a622a2) by @stonith404)
### Features
- add CLI command for encryption key rotation ([#1209](https://github.com/pocket-id/pocket-id/pull/1209) by @stonith404)
- improve passkey error messages ([2f25861](https://github.com/pocket-id/pocket-id/commit/2f25861d15aefa868042e70d3e21b7b38a6ae679) by @stonith404)
- make home page URL configurable ([#1215](https://github.com/pocket-id/pocket-id/pull/1215) by @stonith404)
- add option to renew API key ([#1214](https://github.com/pocket-id/pocket-id/pull/1214) by @stonith404)
- add support for email verification ([#1223](https://github.com/pocket-id/pocket-id/pull/1223) by @stonith404)
- add environment variable to disable built-in rate limiting ([9ca3d33](https://github.com/pocket-id/pocket-id/commit/9ca3d33c8897cf49a871783058205bb180529cd2) by @stonith404)
- add static api key env variable ([#1229](https://github.com/pocket-id/pocket-id/pull/1229) by @stonith404)
**Full Changelog**: https://github.com/pocket-id/pocket-id/compare/v2.1.0...v2.2.0
## v2.1.0
### Bug Fixes
- invalid cookie name for email login code device token ([d6a7b50](https://github.com/pocket-id/pocket-id/commit/d6a7b503ff4571b1291a55a569add3374f5e2d5b) by @stonith404)
### Features
- add issuer url to oidc client details list ([#1197](https://github.com/pocket-id/pocket-id/pull/1197) by @kmendell)
- process nonce within device authorization flow ([#1185](https://github.com/pocket-id/pocket-id/pull/1185) by @justincmoy)
### Other
- run SCIM jobs in context of gocron instead of custom implementation ([4881130](https://github.com/pocket-id/pocket-id/commit/4881130eadcef0642f8a87650b7c36fda453b51b) by @stonith404)
**Full Changelog**: https://github.com/pocket-id/pocket-id/compare/v2.0.2...v2.1.0
## v2.0.2
### Bug Fixes
- migration fails if users exist with no email address ([2f651ad](https://github.com/pocket-id/pocket-id/commit/2f651adf3b4e8d689461da2083c3afcb1eb1d477) by @stonith404)
- allow version downgrade database is dirty ([ba00f40](https://github.com/pocket-id/pocket-id/commit/ba00f40bd4b06f31d251599fcb1db63e902a6987) by @stonith404)
- localhost callback URLs with port don't match correctly ([7c34501](https://github.com/pocket-id/pocket-id/commit/7c345010556f11a593948b2a1ae558b7a8003696) by @stonith404)
### Other
- add no-op migration to postgres ([a24b2af](https://github.com/pocket-id/pocket-id/commit/a24b2afb7b8165bed05976058a8ae797adc245df) by @stonith404)
**Full Changelog**: https://github.com/pocket-id/pocket-id/compare/v2.0.1...v2.0.2
## v2.0.1
### Bug Fixes
- admins imported from LDAP lose admin privileges ([2cce200](https://github.com/pocket-id/pocket-id/commit/2cce2008928081b5e0f0e6bcbc3f43816f082de9) by @stonith404)
- restore old input input field size ([2341da9](https://github.com/pocket-id/pocket-id/commit/2341da99e9716686cf28dd0680d751ae9da0fadc) by @stonith404)
### Other
- bump image tag to `v2` ([cd2e9f3](https://github.com/pocket-id/pocket-id/commit/cd2e9f3a2ad753815ef8da998f9b54853d953a2a) by @stonith404)
**Full Changelog**: https://github.com/pocket-id/pocket-id/compare/v2.0.0...v2.0.1
## v2.0.0
### Bug Fixes
- update image format message to include WEBP ([#1133](https://github.com/pocket-id/pocket-id/pull/1133) by @sebdanielsson)
- add Japanese locale to inlang settings ([#1142](https://github.com/pocket-id/pocket-id/pull/1142) by @tai-ga)
- restrict email one time sign in token to same browser ([#1144](https://github.com/pocket-id/pocket-id/pull/1144) by @stonith404)
- rename `LDAP_ATTRIBUTE_ADMIN_GROUP` env variable to `LDAP_ADMIN_GROUP_NAME` ([e1c5021](https://github.com/pocket-id/pocket-id/commit/e1c5021eeedcbc54bad0eccd72d7ae760be61934) by @stonith404)
- make wildcard matching in callback URLs more stricter ([078152d](https://github.com/pocket-id/pocket-id/commit/078152d4dbb05dd027ff323f39d090ecb67927c7) by @stonith404)
- remove ambiguous characters from login code ([d9e7bf9](https://github.com/pocket-id/pocket-id/commit/d9e7bf9eef522d8c081fac2000bace6f95518039) by @stonith404)
- add missing translations to date picker ([894eaf3](https://github.com/pocket-id/pocket-id/commit/894eaf3cffdd9182b9c29e28b4dcb7e8bcbda26b) by @stonith404)
### Features
- add HTTP `HEAD` method support ([#1135](https://github.com/pocket-id/pocket-id/pull/1135) by @stonith404)
- add email logo customization ([#1150](https://github.com/pocket-id/pocket-id/pull/1150) by @MelvinSnijders)
- add ability define user groups for sign up tokens ([#1155](https://github.com/pocket-id/pocket-id/pull/1155) by @stonith404)
- minor redesign of auth pages ([08e4ffe](https://github.com/pocket-id/pocket-id/commit/08e4ffeb600a4a6644d91b1600b0205997ed1685) by @stonith404)
- allow audit log retention to be controlled by env variable ([#1158](https://github.com/pocket-id/pocket-id/pull/1158) by @jenic)
- restrict oidc clients by user groups per default ([#1164](https://github.com/pocket-id/pocket-id/pull/1164) by @stonith404)
- add "restricted" column to oidc client table ([1bc9f5f](https://github.com/pocket-id/pocket-id/commit/1bc9f5f7e780310d81608381544ba530df7f433b) by @stonith404)
- drop support for storing JWK on the filesystem ([f014458](https://github.com/pocket-id/pocket-id/commit/f0144584af90b918a3157a298f1bb95928a117b8) by @stonith404)
- add CLI command for importing and exporting Pocket ID data ([3420a00](https://github.com/pocket-id/pocket-id/commit/3420a000737d89a5c3c6c250d171d96126553beb) by @stonith404)
- remove DbProvider env variable and calculate it dynamically ([ba2f0f1](https://github.com/pocket-id/pocket-id/commit/ba2f0f18f4bacc5a86217dec0b0dcb6030c40cb9) by @kmendell)
- add support for SCIM provisioning ([#1182](https://github.com/pocket-id/pocket-id/pull/1182) by @stonith404)
### Other
- update AAGUIDs ([#1128](https://github.com/pocket-id/pocket-id/pull/1128) by @github-actions[bot])
- fix api key e2e test ([25f67bd](https://github.com/pocket-id/pocket-id/commit/25f67bd25a0ee0cab48d72107722e8c8428fa547) by @stonith404)
- update AAGUIDs ([#1140](https://github.com/pocket-id/pocket-id/pull/1140) by @github-actions[bot])
- upgrade dependencies ([90f555f](https://github.com/pocket-id/pocket-id/commit/90f555f7c12ff07545f7cd1a1754a8c19f5a4978) by @stonith404)
- fix type error after version bump ([edb32d8](https://github.com/pocket-id/pocket-id/commit/edb32d82b2c138433d8eb17d5a6a19f4728ae2d4) by @stonith404)
- remove `breaking/**` push trigger from actions ([461293b](https://github.com/pocket-id/pocket-id/commit/461293ba1da4ddbff2c77f23a42487b63964e474) by @stonith404)
- update AAGUIDs ([#1177](https://github.com/pocket-id/pocket-id/pull/1177) by @github-actions[bot])
- preparation for merge into main branch ([#1113](https://github.com/pocket-id/pocket-id/pull/1113) by @stonith404)
- bump pnpm to version 10.27.0 ([#1183](https://github.com/pocket-id/pocket-id/pull/1183) by @kmendell)
- update forms and other areas to use new shadcn components ([#1115](https://github.com/pocket-id/pocket-id/pull/1115) by @kmendell)
- run formatter ([e4a8ca4](https://github.com/pocket-id/pocket-id/commit/e4a8ca476cc3c7e8d8cdc8de21b5d7d99d07f7a0) by @stonith404)
- upgrade dependencies ([4776b70](https://github.com/pocket-id/pocket-id/commit/4776b70d96f3dc291394dc79c941738bbe48199a) by @stonith404)
- change translation string in e2e tests ([ffb2ef9](https://github.com/pocket-id/pocket-id/commit/ffb2ef91bd7bbe78eb29e86cd3675b695e821498) by @stonith404)
**Full Changelog**: https://github.com/pocket-id/pocket-id/compare/v1.16.0...v2.0.0
## v1.16.0
### Bug Fixes
- use `quoted-printable` encoding for mails to prevent line limitation ([5cf73e9](https://github.com/pocket-id/pocket-id/commit/5cf73e9309640d097ba94d97851cf502b7b2e063) by @stonith404)
- automatically create parent directory of Sqlite db ([cfc9e46](https://github.com/pocket-id/pocket-id/commit/cfc9e464d983b051e7ed4da1620fae61dc73cff2) by @stonith404)
- global audit log user filter not working ([d98c0a3](https://github.com/pocket-id/pocket-id/commit/d98c0a391a747f9eea70ea01c3f984264a4a7a19) by @stonith404)
- theme mode not correctly applied if selected manually ([a1cd325](https://github.com/pocket-id/pocket-id/commit/a1cd3251cd2b7d7aca610696ef338c5d01fdce2e) by @stonith404)
- hide theme switcher on auth pages because of dynamic background ([5d6a7fd](https://github.com/pocket-id/pocket-id/commit/5d6a7fdb58b6b82894dcb9be3b9fe6ca3e53f5fa) by @stonith404)
### Documentation
- add `ENCRYPTION_KEY` to `.env.example` for breaking change preparation ([4eeb06f](https://github.com/pocket-id/pocket-id/commit/4eeb06f29d984164939bf66299075efead87ee19) by @stonith404)
### Features
- light/dark/system mode switcher ([#1081](https://github.com/pocket-id/pocket-id/pull/1081) by @kmendell)
- add support for S3 storage backend ([#1080](https://github.com/pocket-id/pocket-id/pull/1080) by @stonith404)
- add support for WEBP profile pictures ([#1090](https://github.com/pocket-id/pocket-id/pull/1090) by @stonith404)
- add database storage backend ([#1091](https://github.com/pocket-id/pocket-id/pull/1091) by @ItalyPaleAle)
- adding/removing passkeys creates an entry in audit logs ([#1099](https://github.com/pocket-id/pocket-id/pull/1099) by @ItalyPaleAle)
- add option to disable S3 integrity check ([a3c9687](https://github.com/pocket-id/pocket-id/commit/a3c968758a17e95b2e55ae179d6601d8ec2cf052) by @stonith404)
- add `Cache-Control: private, no-store` to all API routes per default ([#1126](https://github.com/pocket-id/pocket-id/pull/1126) by @stonith404)
### Other
- update pnpm to 10.20 ([#1082](https://github.com/pocket-id/pocket-id/pull/1082) by @kmendell)
- run checks on PR to `breaking/**` branches ([ab9c0f9](https://github.com/pocket-id/pocket-id/commit/ab9c0f9ac092725c70ec3a963f57bc739f425d4f) by @stonith404)
- use constants for AppEnv values ([#1098](https://github.com/pocket-id/pocket-id/pull/1098) by @ItalyPaleAle)
- bump golang.org/x/crypto from 0.43.0 to 0.45.0 in /backend in the go_modules group across 1 directory ([#1107](https://github.com/pocket-id/pocket-id/pull/1107) by @dependabot[bot])
- add Finish files ([ca888b3](https://github.com/pocket-id/pocket-id/commit/ca888b3dd221a209df5e7beb749156f7ea21e1c0) by @stonith404)
- upgrade dependencies ([4bde271](https://github.com/pocket-id/pocket-id/commit/4bde271b4715f59bd2ed1f7c18a867daf0f26b8b) by @stonith404)
- fix Dutch validation message ([f523f39](https://github.com/pocket-id/pocket-id/commit/f523f39483a06256892d17dc02528ea009c87a9f) by @stonith404)
- fix package vulnerabilities ([3d46bad](https://github.com/pocket-id/pocket-id/commit/3d46badb3cecc1ee8eb8bfc9b377108be32d4ffc) by @stonith404)
- update vscode launch.json ([#1117](https://github.com/pocket-id/pocket-id/pull/1117) by @mnestor)
- rename file backend value `fs` to `filesystem` ([8d30346](https://github.com/pocket-id/pocket-id/commit/8d30346f642b483653f7a3dec006cb0273927afb) by @stonith404)
- fix wrong storage value ([b2c718d](https://github.com/pocket-id/pocket-id/commit/b2c718d13d12b6c152e19974d3490c2ed7f5d51d) by @stonith404)
- run formatter ([14c7471](https://github.com/pocket-id/pocket-id/commit/14c7471b5272cdaf42751701d842348d0d60cd0e) by @stonith404)
**Full Changelog**: https://github.com/pocket-id/pocket-id/compare/v1.15.0...v1.16.0
## v1.15.0
### Bug Fixes
- sorting by PKCE and re-auth of OIDC clients ([e03270e](https://github.com/pocket-id/pocket-id/commit/e03270eb9d474735ff4a1b4d8c90f1857b8cd52b) by @stonith404)
- replace %lang% placeholder in html lang ([#1071](https://github.com/pocket-id/pocket-id/pull/1071) by @daimond113)
- disabled property gets ignored when creating an user ([76e0192](https://github.com/pocket-id/pocket-id/commit/76e0192ceec339b6ddb4ad3424057d2bb48fae8f) by @stonith404)
- remove redundant indexes in Postgres ([6a038fc](https://github.com/pocket-id/pocket-id/commit/6a038fcf9afabbf00c45e42071e9bbe62ecab403) by @stonith404)
### Features
- open edit page on table row click ([f184120](https://github.com/pocket-id/pocket-id/commit/f184120890c32f1e75a918c171084878a10e8b42) by @stonith404)
- add ability to set default profile picture ([#1061](https://github.com/pocket-id/pocket-id/pull/1061) by @stonith404)
### Other
- add support for OpenBSD binaries ([d683d18](https://github.com/pocket-id/pocket-id/commit/d683d18d9109ca2850e278b78f7bf3e5aca1d34d) by @stonith404)
**Full Changelog**: https://github.com/pocket-id/pocket-id/compare/v1.14.2...v1.15.0
## v1.14.2
### Bug Fixes
- dark oidc client icons not saved on client creation ([#1057](https://github.com/pocket-id/pocket-id/pull/1057) by @mufeedali)
### Other
- add Turkish language files ([a190529](https://github.com/pocket-id/pocket-id/commit/a190529117fe20b5b836d452b382da69abba9458) by @stonith404)
**Full Changelog**: https://github.com/pocket-id/pocket-id/compare/v1.14.1...v1.14.2
## v1.14.1
### Bug Fixes

View File

@@ -1,12 +1,35 @@
# Contributing
I am happy that you want to contribute to Pocket ID and help to make it better! All contributions are welcome, including issues, suggestions, pull requests and more.
We are happy that you want to contribute to Pocket ID and help to make it better!
## Before you start
Before starting to work on a new feature, please open an issue first, or comment on an existing one, to discuss the implementation details. This saves you time and avoids the disappointment of having a PR closed later because the change doesn't fit the project's direction.
### AI Usage Policy
We have nothing against using AI tools to assist your development. But we've seen a growing number of pull requests that appear to be fully or largely AI-generated and submitted without genuine human review. These are difficult and time-consuming for maintainers to review, and they often waste everyone's time.
To keep contributions reviewable and high-quality, please follow these guidelines when using AI tools.
#### Guidelines for Using AI Tools
1. **Understand every line.** You must be able to explain what your code does and why, in your own words. "The AI wrote it" is not an acceptable answer to a reviewer's question.
2. **Test before submitting.** Review and test all code manually, as a human, before opening a PR. Don't trust the AI's claim that it works.
3. **Write your own words.** Don't paste AI-generated text into issues, comments, or PR descriptions. Walls of generated text make discussions harder, not easier.
4. **Disclose your usage.** Note in the PR description how you used AI (see below).
PRs that appear to be low effort AI output may be closed without a detailed review.
#### Example disclosure
> Used GitHub Copilot for autocomplete, and asked an LLM to help draft the test cases in `parser_test.go`. I reviewed, edited, and tested everything myself and understand how it works.
> Used an LLM to generate the initial implementation of the new API endpoint, but I manually reviewed and tested it before submitting.
## Getting started
You've found a bug, have suggestion or something else, just create an issue on GitHub and we can get in touch.
## Submit a Pull Request
### Submit a Pull Request
Before you submit the pull request for review please ensure that
@@ -21,7 +44,6 @@ Before you submit the pull request for review please ensure that
```
Where `TYPE` can be:
- **feat** - is a new feature
- **doc** - documentation only changes
- **fix** - a bug fix
@@ -30,15 +52,15 @@ Before you submit the pull request for review please ensure that
- Your pull request has a detailed description
- You run `pnpm format` to format the code
## Development Environment
### Development Environment
Pocket ID consists of a frontend and backend. In production the frontend gets statically served by the backend, but in development they run as separate processes to enable hot reloading.
There are two ways to get the development environment setup:
### 1. Install required tools
#### 1. Install required tools
#### With Dev Containers
##### With Dev Containers
If you use [Dev Containers](https://code.visualstudio.com/docs/remote/containers) in VS Code, you don't need to install anything manually, just follow the steps below.
@@ -47,17 +69,17 @@ If you use [Dev Containers](https://code.visualstudio.com/docs/remote/containers
3. VS Code will detect .devcontainer and will prompt you to open the folder in devcontainer
4. If the auto prompt does not work, hit `F1` and select `Dev Containers: Open Folder in Container.`, then select the pocket-id repo root folder and it'll open in container.
#### Without Dev Containers
##### Without Dev Containers
If you don't use Dev Containers, you need to install the following tools manually:
- [Node.js](https://nodejs.org/en/download/) >= 22
- [Go](https://golang.org/doc/install) >= 1.25
- [Node.js](https://nodejs.org/en/download/) >= 24
- [Go](https://golang.org/doc/install) >= 1.26
- [Git](https://git-scm.com/downloads)
### 2. Setup
#### 2. Setup
#### Backend
##### Backend
The backend is built with [Gin](https://gin-gonic.com) and written in Go. To set it up, follow these steps:
@@ -65,7 +87,7 @@ The backend is built with [Gin](https://gin-gonic.com) and written in Go. To set
2. Copy the `.env.development-example` file to `.env` and edit the variables as needed
3. Start the backend with `go run -tags exclude_frontend ./cmd`
### Frontend
##### Frontend
The frontend is built with [SvelteKit](https://kit.svelte.dev) and written in TypeScript. To set it up, follow these steps:
@@ -78,9 +100,13 @@ You're all set! The application is now listening on `localhost:3000`. The backen
### Testing
If you are contributing to a new feature please ensure that you add tests for it.
#### End-to-end tests
We are using [Playwright](https://playwright.dev) for end-to-end testing.
If you are contributing to a new feature please ensure that you add tests for it. The tests are located in the `tests` folder at the root of the project.
The tests are located in the `tests` folder at the root of the project.
The tests can be run like this:
@@ -94,3 +120,9 @@ The tests can be run like this:
5. Run the tests with `pnpm dlx playwright test` or from the root project folder `pnpm test`
If you make any changes to the application, you have to rebuild the test environment by running `docker compose up -d --build` again.
#### Unit tests
In the backend we are using unit tests with the built-in Go testing framework. The tests are located in the same folder as the code they are testing and have the `_test.go` suffix.
To run the tests, simply run `go test ./...` from the root of the `backend` folder.

View File

@@ -4,7 +4,7 @@ Pocket ID is a simple OIDC provider that allows users to authenticate with their
→ Try out the [Demo](https://demo.pocket-id.org)
<img src="https://github.com/user-attachments/assets/96ac549d-b897-404a-8811-f42b16ea58e2" width="1200"/>
<img src="https://github.com/user-attachments/assets/1e99ba44-76da-4b47-9b8a-dbe9b7f84512" width="1200"/>
The goal of Pocket ID is to be a simple and easy-to-use. There are other self-hosted OIDC providers like [Keycloak](https://www.keycloak.org/) or [ORY Hydra](https://www.ory.sh/hydra/) but they are often too complex for simple use cases.

View File

@@ -2,8 +2,11 @@
## Supported Versions
As Pocket ID is in its early stages, older versions don't get security updates. Please consider to update Pocket ID regularly. Updates can be automated with e.g [Watchtower](https://github.com/containrrr/watchtower).
It's recommended to always use the latest version of Pocket ID. We will provide security updates for the latest version.
> [!NOTE]
> Updates can be automated with e.g [Watchtower](https://github.com/nicholas-fedor/watchtower/). Upgrading between non major versions is safe but you shouldn't upgrade between major versions before checking the release notes.
## Reporting a Vulnerability
Thank you for taking the time to report a vulnerability. Please DO NOT create an issue on GitHub because the vulnerability could get exploited. Instead please write an email to [elias@eliasschneider.com](mailto:elias@eliasschneider.com).
Thank you for taking the time to report a vulnerability. Please DO NOT create an issue on GitHub because the vulnerability could get exploited. Instead please create a Security Advisory [here](https://github.com/pocket-id/pocket-id/security/advisories/new). You can learn more about how to report a vulnerability in the [GitHub Security Advisories documentation](https://docs.github.com/en/code-security/security-advisories/creating-a-security-advisory).

View File

@@ -6,4 +6,7 @@ APP_ENV=development
# In the development environment the backend gets proxied by the frontend
APP_URL=http://localhost:3000
PORT=1411
MAXMIND_LICENSE_KEY=your_license_key
MAXMIND_LICENSE_KEY=your_license_key
# Set the ENCRYPTION_KEY to a random sequence of characters, for example generated with `openssl rand -base64 32`
ENCRYPTION_KEY=

View File

@@ -1,9 +1,12 @@
package main
import (
"fmt"
"os"
_ "time/tzdata"
"github.com/pocket-id/pocket-id/backend/internal/cmds"
"github.com/pocket-id/pocket-id/backend/internal/common"
)
// @title Pocket ID API
@@ -11,5 +14,9 @@ import (
// @description.markdown
func main() {
if err := common.ValidateEnvConfig(&common.EnvConfig); err != nil {
fmt.Fprintf(os.Stderr, "config error: %v\n", err)
os.Exit(1)
}
cmds.Execute()
}

View File

@@ -2,7 +2,9 @@
package frontend
import "github.com/gin-gonic/gin"
import (
"github.com/gin-gonic/gin"
)
func RegisterFrontend(router *gin.Engine) error {
return ErrFrontendNotIncluded

View File

@@ -8,8 +8,9 @@ import (
"fmt"
"io"
"io/fs"
"mime"
"net/http"
"os"
"path"
"strings"
"time"
@@ -58,10 +59,17 @@ func RegisterFrontend(router *gin.Engine) error {
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)
}
router.NoRoute(func(c *gin.Context) {
// 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, "/")
if strings.HasSuffix(path, "/") {
@@ -69,19 +77,12 @@ func RegisterFrontend(router *gin.Engine) error {
return
}
if strings.HasPrefix(path, "api/") {
if strings.HasPrefix(path, "api/") || strings.HasPrefix(path, ".well-known/") {
c.JSON(http.StatusNotFound, gin.H{"error": "API endpoint not found"})
return
}
// If path is / or does not exist, serve index.html
if path == "" {
path = "index.html"
} else if _, err := fs.Stat(distFS, path); os.IsNotExist(err) {
path = "index.html"
}
if path == "index.html" {
if isSPARequest(path, distFS) {
nonce := middleware.GetCSPNonce(c)
// Do not cache the HTML shell, as it embeds a per-request nonce
@@ -97,43 +98,161 @@ func RegisterFrontend(router *gin.Engine) error {
// Serve other static assets with caching
c.Request.URL.Path = "/" + path
fileServer.ServeHTTP(c.Writer, c.Request)
})
}
router.NoRoute(handler)
return nil
}
func isSPARequest(path string, distFS fs.FS) bool {
if path == "" {
return true
}
if _, err := fs.Stat(distFS, path); err != nil {
return true
}
return false
}
// FileServerWithCaching wraps http.FileServer to add caching headers
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

@@ -0,0 +1,28 @@
//go:build !exclude_frontend
package frontend
import (
"testing"
"testing/fstest"
"github.com/stretchr/testify/assert"
)
func TestIsSPARequest(t *testing.T) {
distFS := fstest.MapFS{
"assets/app.js": &fstest.MapFile{Data: []byte("console.log('test')")},
}
t.Run("root path is spa request", func(t *testing.T) {
assert.True(t, isSPARequest("", distFS))
})
t.Run("existing bundled asset is not spa request", func(t *testing.T) {
assert.False(t, isSPARequest("assets/app.js", distFS))
})
t.Run("unknown path is spa request", func(t *testing.T) {
assert.True(t, isSPARequest("authorize", distFS))
})
}

View File

@@ -1,161 +1,251 @@
module github.com/pocket-id/pocket-id/backend
go 1.25
go 1.26.5
require (
github.com/caarlos0/env/v11 v11.3.1
github.com/aws/aws-sdk-go-v2 v1.42.1
github.com/aws/aws-sdk-go-v2/config v1.32.25
github.com/aws/aws-sdk-go-v2/credentials v1.19.24
github.com/aws/aws-sdk-go-v2/service/s3 v1.104.0
github.com/aws/smithy-go v1.27.3
github.com/caarlos0/env/v11 v11.4.1
github.com/cenkalti/backoff/v5 v5.0.3
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
github.com/disintegration/imaging v1.6.2
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6
github.com/emersion/go-smtp v0.24.0
github.com/fxamacker/cbor/v2 v2.9.0
github.com/gin-contrib/slog v1.1.0
github.com/gin-gonic/gin v1.11.0
github.com/glebarez/go-sqlite v1.22.0
github.com/glebarez/sqlite v1.11.0
github.com/go-co-op/gocron/v2 v2.17.0
github.com/go-ldap/ldap/v3 v3.4.12
github.com/go-playground/validator/v10 v10.28.0
github.com/go-webauthn/webauthn v0.14.0
github.com/golang-migrate/migrate/v4 v4.19.0
github.com/dunglas/go-urlpattern v0.0.0-20241020164140-716dfa1c80b1
github.com/fsnotify/fsnotify v1.10.1
github.com/gin-contrib/slog v1.2.1
github.com/gin-gonic/gin v1.12.0
github.com/go-co-op/gocron/v2 v2.21.2
github.com/go-jose/go-jose/v4 v4.1.4
github.com/go-ldap/ldap/v3 v3.4.13
github.com/go-playground/validator/v10 v10.30.3
github.com/go-webauthn/webauthn v0.17.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/italypaleale/francis v0.1.0-beta.6
github.com/italypaleale/go-kit v0.0.0-20260705144331-cc6661f9a8cf
github.com/italypaleale/go-sql-utils v0.2.4
github.com/jackc/pgx/v5 v5.10.0
github.com/jinzhu/copier v0.4.0
github.com/joho/godotenv v1.5.1
github.com/lestrrat-go/httprc/v3 v3.0.1
github.com/lestrrat-go/jwx/v3 v3.0.12
github.com/lmittmann/tint v1.1.2
github.com/mattn/go-isatty v0.0.20
github.com/lestrrat-go/httprc/v3 v3.0.6
github.com/lestrrat-go/jwx/v3 v3.1.1
github.com/libtnb/sqlite v1.1.2
github.com/lmittmann/tint v1.1.3
github.com/mattn/go-isatty v0.0.22
github.com/mileusna/useragent v1.3.5
github.com/orandin/slog-gorm v1.4.0
github.com/oschwald/maxminddb-golang/v2 v2.0.0
github.com/spf13/cobra v1.10.1
github.com/ory/fosite v0.49.1-0.20250703093431-a5f0b09bf31c
github.com/oschwald/maxminddb-golang/v2 v2.4.0
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
go.opentelemetry.io/contrib/bridges/otelslog v0.13.0
go.opentelemetry.io/contrib/exporters/autoexport v0.63.0
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.63.0
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0
go.opentelemetry.io/otel v1.38.0
go.opentelemetry.io/otel/log v0.14.0
go.opentelemetry.io/otel/metric v1.38.0
go.opentelemetry.io/otel/sdk v1.38.0
go.opentelemetry.io/otel/sdk/log v0.14.0
go.opentelemetry.io/otel/sdk/metric v1.38.0
go.opentelemetry.io/otel/trace v1.38.0
golang.org/x/crypto v0.43.0
golang.org/x/image v0.32.0
golang.org/x/sync v0.17.0
golang.org/x/text v0.30.0
golang.org/x/time v0.14.0
github.com/zitadel/exifremove v0.1.0
go.opentelemetry.io/contrib/bridges/otelslog v0.19.0
go.opentelemetry.io/contrib/exporters/autoexport v0.69.0
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.69.0
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0
go.opentelemetry.io/otel v1.44.0
go.opentelemetry.io/otel/log v0.20.0
go.opentelemetry.io/otel/sdk v1.44.0
go.opentelemetry.io/otel/sdk/log v0.20.0
go.opentelemetry.io/otel/sdk/metric v1.44.0
go.opentelemetry.io/otel/trace v1.44.0
golang.org/x/crypto v0.53.0
golang.org/x/image v0.43.0
golang.org/x/sync v0.21.0
golang.org/x/text v0.38.0
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.0
gorm.io/gorm v1.31.1
gorm.io/plugin/opentelemetry v0.1.16
modernc.org/sqlite v1.53.0
)
require (
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
filippo.io/edwards25519 v1.2.0 // indirect
github.com/Azure/go-ntlmssp v0.1.1 // indirect
github.com/ClickHouse/ch-go v0.61.5 // indirect
github.com/ClickHouse/clickhouse-go/v2 v2.30.0 // indirect
github.com/alphadose/haxmap v1.4.1 // indirect
github.com/andybalholm/brotli v1.1.1 // indirect
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.4.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.32.0 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.44.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.14.1 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // 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.1 // 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.1 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
github.com/cloudwego/base64x v0.1.7 // indirect
github.com/cristalhq/jwt/v5 v5.4.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect
github.com/dgraph-io/ristretto/v2 v2.4.0 // indirect
github.com/disintegration/gift v1.2.1 // indirect
github.com/dsoprea/go-exif v0.0.0-20230826092837-6579e82b732d // indirect
github.com/dsoprea/go-exif/v2 v2.0.0-20230826092837-6579e82b732d // indirect
github.com/dsoprea/go-iptc v0.0.0-20200610044640-bc9ca208b413 // indirect
github.com/dsoprea/go-jpeg-image-structure v0.0.0-20221012074422-4f3f7e934102 // indirect
github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd // indirect
github.com/dsoprea/go-photoshop-info-format v0.0.0-20200610045659-121dd752914d // indirect
github.com/dsoprea/go-png-image-structure v0.0.0-20210512210324-29b889a6093d // indirect
github.com/dsoprea/go-utility v0.0.0-20221003172846-a3e1774ef349 // indirect
github.com/dunglas/httpsfv v1.1.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/gabriel-vasile/mimetype v1.4.10 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/felixge/httpsnoop v1.1.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // 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-errors/errors v1.5.1 // indirect
github.com/go-faster/city v1.0.1 // indirect
github.com/go-faster/errors v0.7.1 // 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-webauthn/x v0.1.25 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/go-sql-driver/mysql v1.9.3 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-webauthn/x v0.2.6 // indirect
github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect
github.com/goccy/go-json v0.10.6 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/gogo/googleapis v1.4.1 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/golang/geo v0.0.0-20250319145452-ed1c8b99c3d7 // indirect
github.com/google/go-github/v39 v39.2.0 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/go-tpm v0.9.6 // indirect
github.com/grafana/regexp v0.0.0-20250905093917-f7b3be9d1853 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/google/go-querystring v1.2.0 // indirect
github.com/google/go-tpm v0.9.8 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
github.com/h2non/filetype v1.1.3 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
github.com/hashicorp/go-version v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 // 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.7.6 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jaegertracing/jaeger-idl v0.9.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/jonboulle/clockwork v0.5.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.18.5 // indirect
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 v1.0.1 // indirect
github.com/lestrrat-go/option/v2 v2.0.0 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/mattn/go-sqlite3 v1.14.32 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/lib/pq v1.12.3 // indirect
github.com/mattn/go-sqlite3 v1.14.42 // indirect
github.com/mattn/goveralls v0.0.12 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // 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/pmezard/go-difflib v1.0.0 // indirect
github.com/nlnwa/whatwg-url v0.6.2 // indirect
github.com/openzipkin/zipkin-go v0.4.3 // indirect
github.com/ory/go-acc v0.2.9-0.20230103102148-6b1c9a70dbbe // indirect
github.com/ory/go-convenience v0.1.0 // indirect
github.com/ory/pop/v6 v6.4.1 // indirect
github.com/ory/x v0.0.729 // indirect
github.com/paulmach/orb v0.11.1 // indirect
github.com/pelletier/go-toml/v2 v2.3.1 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/pierrec/lz4/v4 v4.1.21 // indirect
github.com/pkg/errors v0.9.1 // 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.1 // indirect
github.com/prometheus/common v0.69.0 // indirect
github.com/prometheus/otlptranslator v1.0.0 // indirect
github.com/prometheus/procfs v0.18.0 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.55.0 // indirect
github.com/prometheus/procfs v0.21.1 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.60.0 // indirect
github.com/quic-go/webtransport-go v0.10.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/seatgeek/logrus-gelf-formatter v0.0.0-20210414080842-5b05eb8ff761 // indirect
github.com/segmentio/asm v1.2.1 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/spf13/viper v1.21.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/tinylib/msgp v1.6.4 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
github.com/valyala/fastjson v1.6.4 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/valyala/fastjson v1.6.10 // indirect
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.mongodb.org/mongo-driver/v2 v2.6.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/bridges/prometheus v0.63.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.14.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.14.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.38.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 // indirect
go.opentelemetry.io/otel/exporters/prometheus v0.60.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.14.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.38.0 // indirect
go.opentelemetry.io/proto/otlp v1.8.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.opentelemetry.io/contrib/bridges/prometheus v0.69.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.69.0 // indirect
go.opentelemetry.io/contrib/propagators/b3 v1.44.0 // indirect
go.opentelemetry.io/contrib/propagators/jaeger v1.44.0 // indirect
go.opentelemetry.io/contrib/samplers/jaegerremote v0.37.1 // indirect
go.opentelemetry.io/otel/exporters/jaeger v1.17.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 // indirect
go.opentelemetry.io/otel/exporters/prometheus v0.66.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.20.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0 // indirect
go.opentelemetry.io/otel/exporters/zipkin v1.44.0 // indirect
go.opentelemetry.io/otel/metric v1.44.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
go.uber.org/mock v0.6.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/mod v0.29.0 // indirect
golang.org/x/net v0.46.0 // indirect
golang.org/x/oauth2 v0.32.0 // indirect
golang.org/x/sys v0.37.0 // indirect
golang.org/x/tools v0.38.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 // indirect
google.golang.org/grpc v1.76.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/arch v0.27.0 // indirect
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 // indirect
golang.org/x/mod v0.37.0 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/time v0.15.0 // indirect
golang.org/x/tools v0.46.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7 // indirect
google.golang.org/grpc v1.82.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.66.10 // indirect
gorm.io/driver/clickhouse v0.7.0 // indirect
gorm.io/driver/mysql v1.5.7 // indirect
k8s.io/utils v0.0.0-20260617174310-a95e086a2553 // indirect
modernc.org/libc v1.73.5 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.39.1 // indirect
)
replace github.com/ory/fosite => github.com/pocket-id/fosite v0.0.0-20260708083902-56a3c0f378d6

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,57 @@
package api
import (
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
)
// apiResponseDto is the full representation of an API including its permissions
type apiResponseDto struct {
ID string `json:"id"`
Name string `json:"name"`
Resource string `json:"resource"`
CreatedAt datatype.DateTime `json:"createdAt"`
Permissions []apiPermissionResponseDto `json:"permissions"`
}
type apiPermissionResponseDto struct {
ID string `json:"id"`
Key string `json:"key"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
}
// apiCreateDto is the payload for creating an API
// The resource identifier is only accepted here because changing it later would invalidate every token already minted for the API
type apiCreateDto struct {
Name string `json:"name" binding:"required,min=1,max=50" unorm:"nfc"`
Resource string `json:"resource" binding:"required,resource_uri,max=350" unorm:"nfc"`
}
// apiUpdateDto is the payload for updating an API
// The resource identifier is intentionally not updatable
type apiUpdateDto struct {
Name string `json:"name" binding:"required,min=1,max=50" unorm:"nfc"`
}
type apiPermissionInputDto struct {
Key string `json:"key" binding:"required,min=1,max=128" unorm:"nfc"`
Name string `json:"name" binding:"required,min=1,max=50" unorm:"nfc"`
Description *string `json:"description" binding:"omitempty,max=200"`
}
// apiPermissionsUpdateDto replaces the full permission set of an API
type apiPermissionsUpdateDto struct {
Permissions []apiPermissionInputDto `json:"permissions" binding:"omitempty,dive"`
}
// clientApiAccessDto is the set of API permissions a client is allowed to request, split by subject type
// User-delegated permissions may be requested on behalf of a signed-in user, client permissions may be obtained by the client itself through the client credentials grant
type clientApiAccessDto struct {
UserDelegatedPermissionIDs []string `json:"userDelegatedPermissionIds"`
ClientPermissionIDs []string `json:"clientPermissionIds"`
}
type clientApiAccessUpdateDto struct {
UserDelegatedPermissionIDs []string `json:"userDelegatedPermissionIds" binding:"omitempty,dive,required"`
ClientPermissionIDs []string `json:"clientPermissionIds" binding:"omitempty,dive,required"`
}

View File

@@ -0,0 +1,235 @@
package api
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
type handler struct {
service *Service
}
func newHandler(service *Service) *handler {
return &handler{service: service}
}
// list godoc
// @Summary List APIs
// @Description Get a paginated list of APIs with optional search and sorting
// @Tags APIs
// @Produce json
// @Param search query string false "Search term to filter APIs by name or resource"
// @Param pagination[page] query int false "Page number for pagination" default(1)
// @Param pagination[limit] query int false "Number of items per page" default(20)
// @Param sort[column] query string false "Column to sort by"
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Success 200 {object} dto.Paginated[apiResponseDto]
// @Router /api/apis [get]
func (h *handler) list(c *gin.Context) {
search := c.Query("search")
listRequestOptions := utils.ParseListRequestOptions(c)
apis, pagination, err := h.service.List(c.Request.Context(), search, listRequestOptions)
if err != nil {
_ = c.Error(err)
return
}
items := make([]apiResponseDto, len(apis))
for i, api := range apis {
var item apiResponseDto
if err := dto.MapStruct(api, &item); err != nil {
_ = c.Error(err)
return
}
item.Resource = api.Audience
items[i] = item
}
c.JSON(http.StatusOK, dto.Paginated[apiResponseDto]{
Data: items,
Pagination: pagination,
})
}
// get godoc
// @Summary Get API by ID
// @Description Retrieve a single API including its permissions
// @Tags APIs
// @Produce json
// @Param id path string true "API ID"
// @Success 200 {object} apiResponseDto
// @Router /api/apis/{id} [get]
func (h *handler) get(c *gin.Context) {
api, err := h.service.Get(c.Request.Context(), nil, c.Param("id"))
if err != nil {
_ = c.Error(err)
return
}
h.respond(c, http.StatusOK, api)
}
// create godoc
// @Summary Create API
// @Description Create a new API resource server
// @Tags APIs
// @Accept json
// @Produce json
// @Param api body apiCreateDto true "API information"
// @Success 201 {object} apiResponseDto "Created API"
// @Router /api/apis [post]
func (h *handler) create(c *gin.Context) {
var input apiCreateDto
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
}
api, err := h.service.Create(c.Request.Context(), input)
if err != nil {
_ = c.Error(err)
return
}
h.respond(c, http.StatusCreated, api)
}
// update godoc
// @Summary Update API
// @Description Update an existing API by ID
// @Tags APIs
// @Accept json
// @Produce json
// @Param id path string true "API ID"
// @Param api body apiUpdateDto true "API information"
// @Success 200 {object} apiResponseDto "Updated API"
// @Router /api/apis/{id} [put]
func (h *handler) update(c *gin.Context) {
var input apiUpdateDto
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
}
api, err := h.service.Update(c.Request.Context(), c.Param("id"), input)
if err != nil {
_ = c.Error(err)
return
}
h.respond(c, http.StatusOK, api)
}
// delete godoc
// @Summary Delete API
// @Description Delete an API by ID
// @Tags APIs
// @Param id path string true "API ID"
// @Success 204 "No Content"
// @Router /api/apis/{id} [delete]
func (h *handler) delete(c *gin.Context) {
if err := h.service.Delete(c.Request.Context(), c.Param("id")); err != nil {
_ = c.Error(err)
return
}
c.Status(http.StatusNoContent)
}
// updatePermissions godoc
// @Summary Update API permissions
// @Description Replace the full set of permissions for an API
// @Tags APIs
// @Accept json
// @Produce json
// @Param id path string true "API ID"
// @Param permissions body apiPermissionsUpdateDto true "Permissions to set"
// @Success 200 {object} apiResponseDto "Updated API"
// @Router /api/apis/{id}/permissions [put]
func (h *handler) updatePermissions(c *gin.Context) {
var input apiPermissionsUpdateDto
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
}
api, err := h.service.UpdatePermissions(c.Request.Context(), c.Param("id"), input)
if err != nil {
_ = c.Error(err)
return
}
h.respond(c, http.StatusOK, api)
}
// getClientAccess godoc
// @Summary Get client API access
// @Description Get the API permissions an OIDC client is allowed to request, split into user-delegated and client (machine-to-machine) access
// @Tags APIs
// @Produce json
// @Param clientId path string true "OIDC Client ID"
// @Success 200 {object} clientApiAccessDto
// @Router /api/api-access/{clientId} [get]
func (h *handler) getClientAccess(c *gin.Context) {
access, err := h.service.GetClientAPIAccess(c.Request.Context(), c.Param("clientId"))
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, newClientApiAccessDto(access))
}
// updateClientAccess godoc
// @Summary Update client API access
// @Description Replace the API permissions an OIDC client is allowed to request, split into user-delegated and client (machine-to-machine) access
// @Tags APIs
// @Accept json
// @Produce json
// @Param clientId path string true "OIDC Client ID"
// @Param access body clientApiAccessUpdateDto true "Allowed permission IDs per subject type"
// @Success 200 {object} clientApiAccessDto
// @Router /api/api-access/{clientId} [put]
func (h *handler) updateClientAccess(c *gin.Context) {
var input clientApiAccessUpdateDto
err := c.ShouldBindJSON(&input)
if err != nil {
_ = c.Error(err)
return
}
applied, err := h.service.SetClientAPIAccess(c.Request.Context(), c.Param("clientId"), ClientAPIAccess(input))
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, newClientApiAccessDto(applied))
}
// newClientApiAccessDto always serializes both permission lists as arrays rather than null
func newClientApiAccessDto(access ClientAPIAccess) clientApiAccessDto {
dto := clientApiAccessDto(access)
if dto.UserDelegatedPermissionIDs == nil {
dto.UserDelegatedPermissionIDs = []string{}
}
if dto.ClientPermissionIDs == nil {
dto.ClientPermissionIDs = []string{}
}
return dto
}
func (h *handler) respond(c *gin.Context, status int, api API) {
var responseDto apiResponseDto
if err := dto.MapStruct(api, &responseDto); err != nil {
_ = c.Error(err)
return
}
responseDto.Resource = api.Audience
c.JSON(status, responseDto)
}

View File

@@ -0,0 +1,38 @@
package api
import (
"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/oidc"
)
type API struct {
model.Base
Name string `sortable:"true"`
Audience string `sortable:"true"`
UpdatedAt *datatype.DateTime
Permissions []Permission `gorm:"foreignKey:APIID;references:ID;constraint:OnDelete:CASCADE"`
}
type Permission struct {
model.Base
APIID string `gorm:"column:api_id"`
Key string `sortable:"true"`
Name string
Description *string
}
func (Permission) TableName() string { return "api_permissions" }
type OidcClientAllowedAPIPermission struct {
OidcClientID string
APIPermissionID string
SubjectType oidc.SubjectType
}
func (OidcClientAllowedAPIPermission) TableName() string {
return "oidc_clients_allowed_api_permissions"
}

View File

@@ -0,0 +1,78 @@
package api
import (
"context"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/oidc"
)
type Dependencies struct {
DB *gorm.DB
// Issuer is the OpenID Provider issuer URL, reserved so a custom API cannot claim it as its audience
Issuer string
}
type Module struct {
service *Service
handler *handler
}
func New(deps Dependencies) *Module {
service := newService(deps.DB, deps.Issuer)
return &Module{
service: service,
handler: newHandler(service),
}
}
// ClientAPIScopes implements the OIDC module's APIAccessProvider interface
func (m *Module) ClientAPIScopes(ctx context.Context, tx *gorm.DB, clientID string) (scopes []string, audiences []string, err error) {
return m.service.ClientAPIScopesAndAudiences(ctx, tx, clientID)
}
// AllowedScopesForAudience implements the OIDC module's APIAccessProvider interface
func (m *Module) AllowedScopesForAudience(ctx context.Context, tx *gorm.DB, clientID, audience string, subjectType oidc.SubjectType) (scopes []string, apiExists bool, err error) {
return m.service.AllowedScopesForAudience(ctx, tx, clientID, audience, subjectType)
}
// DescribePermissions implements the OIDC module's APIAccessProvider interface
func (m *Module) DescribePermissions(ctx context.Context, audience string, keys []string) ([]dto.ScopeInfoDto, error) {
permissions, err := m.service.DescribePermissions(ctx, audience, keys)
if err != nil {
return nil, err
}
infos := make([]dto.ScopeInfoDto, len(permissions))
for i, permission := range permissions {
description := ""
if permission.Description != nil {
description = *permission.Description
}
infos[i] = dto.ScopeInfoDto{Key: permission.Key, Name: permission.Name, Description: description}
}
return infos, nil
}
// RegisterRoutes mounts the admin CRUD endpoints
// adminAuth is passed in as a gin handler so the module does not import internal/middleware
func (m *Module) RegisterRoutes(apiGroup *gin.RouterGroup, adminAuth gin.HandlerFunc) {
apis := apiGroup.Group("/apis")
apis.Use(adminAuth)
apis.GET("", m.handler.list)
apis.POST("", m.handler.create)
apis.GET("/:id", m.handler.get)
apis.PUT("/:id", m.handler.update)
apis.DELETE("/:id", m.handler.delete)
apis.PUT("/:id/permissions", m.handler.updatePermissions)
// The per-client API-access allow-list lives on a separate path so it does not collide with the /apis/:id wildcard
access := apiGroup.Group("/api-access")
access.Use(adminAuth)
access.GET("/:clientId", m.handler.getClientAccess)
access.PUT("/:clientId", m.handler.updateClientAccess)
}

View File

@@ -0,0 +1,484 @@
package api
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/ory/fosite"
"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/oidc"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// isPermissionKeyReserved reports whether the key is a scope or claim name owned by Pocket ID's built-in identity layer
// A custom API permission must not reuse one, otherwise its scope string would collide with a standard OIDC scope or claim
func isPermissionKeyReserved(key string) bool {
switch strings.ToLower(key) {
case "openid", "profile", "email", "email_verified", "groups", "offline_access":
return true
default:
return false
}
}
// isValidPermissionKey reports whether the key consists only of RFC 6749 scope-token characters, which are printable ASCII without space, double-quote or backslash
// This keeps a key safe as a space-delimited value in the token scope claim and free of the control character used to qualify consent records
func isValidPermissionKey(key string) bool {
return fosite.IsValidScopeToken(key)
}
// Service holds the business logic for managing APIs and their permissions
type Service struct {
db *gorm.DB
issuer string
}
func newService(db *gorm.DB, issuer string) *Service {
return &Service{db: db, issuer: issuer}
}
// isIssuerAudience reports whether the audience refers to Pocket ID itself (the issuer)
// A custom API must not claim the issuer as its audience, otherwise its tokens would be indistinguishable from Pocket ID's own identity tokens
func isIssuerAudience(audience, issuer string) bool {
return issuer != "" && strings.ToLower(strings.TrimRight(audience, "/")) == issuer
}
func (s *Service) List(ctx context.Context, search string, listRequestOptions utils.ListRequestOptions) (apis []API, response utils.PaginationResponse, err error) {
query := s.db.
WithContext(ctx).
Preload("Permissions").
Model(&API{})
if listRequestOptions.Sort.Column == "resource" {
listRequestOptions.Sort.Column = "audience"
}
if search != "" {
like := "%" + search + "%"
query = query.Where("name LIKE ? OR audience LIKE ?", like, like)
}
response, err = utils.PaginateFilterAndSort(listRequestOptions, query, &apis)
return apis, response, err
}
// Get loads an API and its permissions
func (s *Service) Get(ctx context.Context, tx *gorm.DB, id string) (api API, err error) {
query := s.db.WithContext(ctx)
if tx != nil {
query = tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"})
}
err = query.
Preload("Permissions").
Where("id = ?", id).
First(&api).
Error
return api, err
}
func (s *Service) Create(ctx context.Context, input apiCreateDto) (api API, err error) {
// Reject the issuer as an audience so a custom API cannot impersonate Pocket ID's own identity tokens
if isIssuerAudience(input.Resource, s.issuer) {
return API{}, &common.ValidationError{Message: "the resource is reserved by Pocket ID and cannot be used for a custom API"}
}
api = API{
Name: input.Name,
Audience: input.Resource,
}
err = s.db.WithContext(ctx).Create(&api).Error
if err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
return API{}, &common.AlreadyInUseError{Property: "resource"}
}
return API{}, err
}
return api, nil
}
func (s *Service) Update(ctx context.Context, id string, input apiUpdateDto) (api API, err error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
api, err = s.Get(ctx, tx, id)
if err != nil {
return API{}, err
}
api.Name = input.Name
api.UpdatedAt = new(datatype.DateTime(time.Now()))
err = tx.WithContext(ctx).Save(&api).Error
if err != nil {
return API{}, err
}
if err = tx.Commit().Error; err != nil {
return API{}, err
}
return api, nil
}
func (s *Service) Delete(ctx context.Context, id string) error {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
api, err := s.Get(ctx, tx, id)
if err != nil {
return err
}
if err = s.deletePermissions(ctx, tx, collectIDs(api.Permissions)); err != nil {
return err
}
if err = tx.WithContext(ctx).Delete(&API{}, "id = ?", id).Error; err != nil {
return err
}
return tx.Commit().Error
}
// UpdatePermissions replaces the full permission set of an API, matching existing permissions by key
// Unchanged keys keep their grants, removed keys and their client grants are deleted, and new keys are inserted
func (s *Service) UpdatePermissions(ctx context.Context, id string, input apiPermissionsUpdateDto) (api API, err error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
api, err = s.Get(ctx, tx, id)
if err != nil {
return API{}, err
}
// Reject keys with invalid characters, that collide with Pocket ID's reserved scopes and claims, or that repeat within the request before persisting anything
// A duplicate key would otherwise be silently coalesced last-wins into the map below, dropping a row behind a 200
seen := make(map[string]struct{}, len(input.Permissions))
for _, permission := range input.Permissions {
if !isValidPermissionKey(permission.Key) {
return API{}, &common.ValidationError{Message: fmt.Sprintf("the permission key %q contains invalid characters", permission.Key)}
}
if isPermissionKeyReserved(permission.Key) {
return API{}, &common.ValidationError{Message: fmt.Sprintf("the permission key %q is reserved by Pocket ID", permission.Key)}
}
_, ok := seen[permission.Key]
if ok {
return API{}, &common.ValidationError{Message: fmt.Sprintf("the permission key %q is listed more than once", permission.Key)}
}
seen[permission.Key] = struct{}{}
}
existing := make(map[string]Permission, len(api.Permissions))
for _, p := range api.Permissions {
existing[p.Key] = p
}
wanted := make(map[string]apiPermissionInputDto, len(input.Permissions))
var removedIDs []string
for _, in := range input.Permissions {
wanted[in.Key] = in
}
// Delete permissions whose key is no longer wanted
for key, p := range existing {
if _, ok := wanted[key]; !ok {
removedIDs = append(removedIDs, p.ID)
}
}
if err = s.deletePermissions(ctx, tx, removedIDs); err != nil {
return API{}, err
}
// Insert new keys and update the display fields of existing ones
for key, in := range wanted {
if cur, ok := existing[key]; ok {
err = tx.WithContext(ctx).
Model(&Permission{}).
Where("id = ?", cur.ID).
Updates(map[string]any{"name": in.Name, "description": in.Description}).
Error
if err != nil {
return API{}, err
}
continue
}
newPermission := Permission{
APIID: api.ID,
Key: in.Key,
Name: in.Name,
Description: in.Description,
}
if err = tx.WithContext(ctx).Create(&newPermission).Error; err != nil {
return API{}, err
}
}
err = tx.WithContext(ctx).
Model(&API{}).
Where("id = ?", api.ID).
Update("updated_at", new(datatype.DateTime(time.Now()))).
Error
if err != nil {
return API{}, err
}
api, err = s.Get(ctx, tx, id)
if err != nil {
return API{}, err
}
if err = tx.Commit().Error; err != nil {
return API{}, err
}
return api, nil
}
// ClientAPIAccess is the set of API permissions granted to a client, split by the subject the resulting tokens act for
// User-delegated permissions may be requested on behalf of a signed-in user, client permissions may be obtained by the client itself through the client credentials grant
type ClientAPIAccess struct {
UserDelegatedPermissionIDs []string
ClientPermissionIDs []string
}
// GetClientAPIAccess returns the API permissions a client is allowed to request, split by subject type
// Only custom-API permissions are tracked here because the identity scopes are freely requestable by every client
func (s *Service) GetClientAPIAccess(ctx context.Context, clientID string) (access ClientAPIAccess, err error) {
var rows []OidcClientAllowedAPIPermission
err = s.db.WithContext(ctx).
Where("oidc_client_id = ?", clientID).
Find(&rows).
Error
if err != nil {
return ClientAPIAccess{}, err
}
for _, row := range rows {
switch row.SubjectType {
case oidc.SubjectTypeClient:
access.ClientPermissionIDs = append(access.ClientPermissionIDs, row.APIPermissionID)
case oidc.SubjectTypeUser:
access.UserDelegatedPermissionIDs = append(access.UserDelegatedPermissionIDs, row.APIPermissionID)
default:
// Nop - ignore
}
}
return access, nil
}
// SetClientAPIAccess replaces the client's API-access grants for both subject types with the given permission IDs
// Unknown permission IDs are ignored
// It returns the access that was actually applied
func (s *Service) SetClientAPIAccess(ctx context.Context, clientID string, access ClientAPIAccess) (applied ClientAPIAccess, err error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
// Ensure the client exists so callers get a 404 for an unknown client
var client model.OidcClient
if err = tx.WithContext(ctx).Select("id").Where("id = ?", clientID).First(&client).Error; err != nil {
return ClientAPIAccess{}, err
}
applied.UserDelegatedPermissionIDs, err = s.filterAssignablePermissionIDs(ctx, tx, access.UserDelegatedPermissionIDs)
if err != nil {
return ClientAPIAccess{}, err
}
applied.ClientPermissionIDs, err = s.filterAssignablePermissionIDs(ctx, tx, access.ClientPermissionIDs)
if err != nil {
return ClientAPIAccess{}, err
}
// Replace the grants for this client
err = tx.WithContext(ctx).
Where("oidc_client_id = ?", clientID).
Delete(&OidcClientAllowedAPIPermission{}).
Error
if err != nil {
return ClientAPIAccess{}, err
}
rows := make([]OidcClientAllowedAPIPermission, 0, len(applied.UserDelegatedPermissionIDs)+len(applied.ClientPermissionIDs))
for _, permissionID := range applied.UserDelegatedPermissionIDs {
rows = append(rows, OidcClientAllowedAPIPermission{OidcClientID: clientID, APIPermissionID: permissionID, SubjectType: oidc.SubjectTypeUser})
}
for _, permissionID := range applied.ClientPermissionIDs {
rows = append(rows, OidcClientAllowedAPIPermission{OidcClientID: clientID, APIPermissionID: permissionID, SubjectType: oidc.SubjectTypeClient})
}
if len(rows) > 0 {
if err = tx.WithContext(ctx).Create(&rows).Error; err != nil {
return ClientAPIAccess{}, err
}
}
if err = tx.Commit().Error; err != nil {
return ClientAPIAccess{}, err
}
return applied, nil
}
// ClientAPIScopesAndAudiences returns the permission keys a client may request and the distinct audiences of the custom APIs those permissions belong to, across both subject types
// The OIDC module uses this to widen fosite's scope and audience validation for the client; the per-flow subject-type enforcement happens when the resource is resolved
func (s *Service) ClientAPIScopesAndAudiences(ctx context.Context, tx *gorm.DB, clientID string) (scopes []string, audiences []string, err error) {
if tx == nil {
tx = s.db
}
var rows []struct {
Key string
Audience string
}
err = tx.WithContext(ctx).
Table("oidc_clients_allowed_api_permissions AS g").
Select("api_permissions.key AS key, apis.audience AS audience").
Joins("JOIN api_permissions ON api_permissions.id = g.api_permission_id").
Joins("JOIN apis ON apis.id = api_permissions.api_id").
Where("g.oidc_client_id = ?", clientID).
Scan(&rows).
Error
if err != nil {
return nil, nil, err
}
scopeSeen := make(map[string]struct{}, len(rows))
audienceSeen := make(map[string]struct{}, len(rows))
scopes = make([]string, 0, len(rows))
audiences = make([]string, 0, len(rows))
for _, row := range rows {
if _, ok := scopeSeen[row.Key]; !ok {
scopeSeen[row.Key] = struct{}{}
scopes = append(scopes, row.Key)
}
if _, ok := audienceSeen[row.Audience]; !ok {
audienceSeen[row.Audience] = struct{}{}
audiences = append(audiences, row.Audience)
}
}
return scopes, audiences, nil
}
// AllowedScopesForAudience returns the permission keys the client is allowed for the API identified by the given audience and subject type, plus whether such an API exists
func (s *Service) AllowedScopesForAudience(ctx context.Context, tx *gorm.DB, clientID, audience string, subjectType oidc.SubjectType) (scopes []string, apiExists bool, err error) {
if tx == nil {
tx = s.db
}
var api API
err = tx.WithContext(ctx).
Select("id").
Where("audience = ?", audience).
First(&api).
Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, false, nil
}
if err != nil {
return nil, false, err
}
err = tx.WithContext(ctx).
Table("api_permissions").
Select("api_permissions.key").
Joins("JOIN oidc_clients_allowed_api_permissions g ON g.api_permission_id = api_permissions.id AND g.oidc_client_id = ? AND g.subject_type = ?", clientID, subjectType).
Where("api_permissions.api_id = ?", api.ID).
Pluck("api_permissions.key", &scopes).
Error
if err != nil {
return nil, true, err
}
return scopes, true, nil
}
// DescribePermissions returns the permission rows of the API identified by the given audience whose key is in keys
// The consent screen uses these to show friendly names instead of raw scope keys
func (s *Service) DescribePermissions(ctx context.Context, audience string, keys []string) ([]Permission, error) {
if len(keys) == 0 {
return nil, nil
}
var permissions []Permission
err := s.db.WithContext(ctx).
Model(&Permission{}).
Joins("JOIN apis ON apis.id = api_permissions.api_id").
Where("apis.audience = ? AND api_permissions.key IN ?", audience, keys).
Find(&permissions).
Error
if err != nil {
return nil, err
}
return permissions, nil
}
// filterAssignablePermissionIDs returns the subset of the given permission IDs that exist
func (s *Service) filterAssignablePermissionIDs(ctx context.Context, tx *gorm.DB, permissionIDs []string) ([]string, error) {
if len(permissionIDs) == 0 {
return nil, nil
}
var valid []string
err := tx.WithContext(ctx).
Model(&Permission{}).
Where("id IN ?", permissionIDs).
Pluck("id", &valid).
Error
if err != nil {
return nil, err
}
return valid, nil
}
// deletePermissions removes permissions by ID along with any client allow-list grants that reference them
// The explicit grant delete keeps this correct even when the database does not enforce ON DELETE CASCADE at runtime
func (s *Service) deletePermissions(ctx context.Context, tx *gorm.DB, permissionIDs []string) error {
if tx == nil {
tx = s.db
}
if len(permissionIDs) == 0 {
return nil
}
err := tx.WithContext(ctx).
Where("api_permission_id IN ?", permissionIDs).
Delete(&OidcClientAllowedAPIPermission{}).
Error
if err != nil {
return err
}
return tx.WithContext(ctx).
Where("id IN ?", permissionIDs).
Delete(&Permission{}).
Error
}
func collectIDs(permissions []Permission) []string {
ids := make([]string, len(permissions))
for i, p := range permissions {
ids[i] = p.ID
}
return ids
}

View File

@@ -0,0 +1,283 @@
package api
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"
"github.com/pocket-id/pocket-id/backend/internal/oidc"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
func TestAPICrudAndPermissionDiff(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
svc := New(Dependencies{DB: db}).service
created, err := svc.Create(t.Context(), apiCreateDto{Name: "Orders API", Resource: "https://api.orders.example.com"})
require.NoError(t, err)
assert.NotEmpty(t, created.ID)
// The resource is unique.
_, err = svc.Create(t.Context(), apiCreateDto{Name: "Dup", Resource: "https://api.orders.example.com"})
require.ErrorIs(t, err, &common.AlreadyInUseError{})
desc := "Read orders"
updated, err := svc.UpdatePermissions(t.Context(), created.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{
{Key: "read:orders", Name: "Read orders", Description: &desc},
{Key: "write:orders", Name: "Write orders"},
}})
require.NoError(t, err)
assert.Len(t, updated.Permissions, 2)
// Grant a client the read:orders permission for both subject types, then remove that permission
// and confirm the grants are cleaned up while write:orders (and its key) survives.
readPerm := findPermission(updated, "read:orders")
require.NotNil(t, readPerm)
require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: "client-1"}, Name: "Client 1"}).Error)
require.NoError(t, db.Create(&OidcClientAllowedAPIPermission{OidcClientID: "client-1", APIPermissionID: readPerm.ID, SubjectType: oidc.SubjectTypeUser}).Error)
require.NoError(t, db.Create(&OidcClientAllowedAPIPermission{OidcClientID: "client-1", APIPermissionID: readPerm.ID, SubjectType: oidc.SubjectTypeClient}).Error)
updated, err = svc.UpdatePermissions(t.Context(), created.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{
{Key: "write:orders", Name: "Write orders (renamed)"},
}})
require.NoError(t, err)
require.Len(t, updated.Permissions, 1)
assert.Equal(t, "write:orders", updated.Permissions[0].Key)
assert.Equal(t, "Write orders (renamed)", updated.Permissions[0].Name)
var grantCount int64
require.NoError(t, db.Model(&OidcClientAllowedAPIPermission{}).Where("api_permission_id = ?", readPerm.ID).Count(&grantCount).Error)
assert.Equal(t, int64(0), grantCount)
renamed, err := svc.Update(t.Context(), created.ID, apiUpdateDto{Name: "Orders"})
require.NoError(t, err)
assert.Equal(t, "Orders", renamed.Name)
require.NotNil(t, renamed.UpdatedAt)
require.NoError(t, svc.Delete(t.Context(), created.ID))
_, err = svc.Get(t.Context(), nil, created.ID)
require.Error(t, err)
}
func TestClientApiAccessAllowList(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
svc := New(Dependencies{DB: db}).service
require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: "client-1"}, Name: "Client 1"}).Error)
orders, err := svc.Create(t.Context(), apiCreateDto{Name: "Orders", Resource: "https://api.orders.example.com"})
require.NoError(t, err)
orders, err = svc.UpdatePermissions(t.Context(), orders.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{
{Key: "read:orders", Name: "Read"},
{Key: "write:orders", Name: "Write"},
}})
require.NoError(t, err)
readID := findPermission(orders, "read:orders").ID
writeID := findPermission(orders, "write:orders").ID
// Unknown IDs are filtered out, and the subject types are stored independently.
applied, err := svc.SetClientAPIAccess(t.Context(), "client-1", ClientAPIAccess{
UserDelegatedPermissionIDs: []string{readID, "does-not-exist"},
ClientPermissionIDs: []string{writeID, "does-not-exist"},
})
require.NoError(t, err)
assert.ElementsMatch(t, []string{readID}, applied.UserDelegatedPermissionIDs)
assert.ElementsMatch(t, []string{writeID}, applied.ClientPermissionIDs)
got, err := svc.GetClientAPIAccess(t.Context(), "client-1")
require.NoError(t, err)
assert.ElementsMatch(t, []string{readID}, got.UserDelegatedPermissionIDs)
assert.ElementsMatch(t, []string{writeID}, got.ClientPermissionIDs)
// The same permission can be granted for both subject types, and both sets are fully replaced on each call.
_, err = svc.SetClientAPIAccess(t.Context(), "client-1", ClientAPIAccess{
UserDelegatedPermissionIDs: []string{readID, writeID},
ClientPermissionIDs: []string{readID},
})
require.NoError(t, err)
got, err = svc.GetClientAPIAccess(t.Context(), "client-1")
require.NoError(t, err)
assert.ElementsMatch(t, []string{readID, writeID}, got.UserDelegatedPermissionIDs)
assert.ElementsMatch(t, []string{readID}, got.ClientPermissionIDs)
// Clearing one subject type leaves the other untouched.
_, err = svc.SetClientAPIAccess(t.Context(), "client-1", ClientAPIAccess{ClientPermissionIDs: []string{readID}})
require.NoError(t, err)
got, err = svc.GetClientAPIAccess(t.Context(), "client-1")
require.NoError(t, err)
assert.Empty(t, got.UserDelegatedPermissionIDs)
assert.ElementsMatch(t, []string{readID}, got.ClientPermissionIDs)
// Clearing everything.
_, err = svc.SetClientAPIAccess(t.Context(), "client-1", ClientAPIAccess{})
require.NoError(t, err)
got, err = svc.GetClientAPIAccess(t.Context(), "client-1")
require.NoError(t, err)
assert.Empty(t, got.UserDelegatedPermissionIDs)
assert.Empty(t, got.ClientPermissionIDs)
// An unknown client is rejected (surfaces as 404 at the HTTP layer).
_, err = svc.SetClientAPIAccess(t.Context(), "nope", ClientAPIAccess{UserDelegatedPermissionIDs: []string{readID}})
require.Error(t, err)
}
// TestAllowedScopesForAudienceFiltersBySubjectType guards that the scopes resolved for a flow
// only come from the grants of that flow's subject type.
func TestAllowedScopesForAudienceFiltersBySubjectType(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
svc := New(Dependencies{DB: db}).service
require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: "client-1"}, Name: "Client 1"}).Error)
orders, err := svc.Create(t.Context(), apiCreateDto{Name: "Orders", Resource: "https://api.orders.example.com"})
require.NoError(t, err)
orders, err = svc.UpdatePermissions(t.Context(), orders.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{
{Key: "read:orders", Name: "Read"},
{Key: "write:orders", Name: "Write"},
}})
require.NoError(t, err)
readID := findPermission(orders, "read:orders").ID
writeID := findPermission(orders, "write:orders").ID
_, err = svc.SetClientAPIAccess(t.Context(), "client-1", ClientAPIAccess{
UserDelegatedPermissionIDs: []string{readID},
ClientPermissionIDs: []string{writeID},
})
require.NoError(t, err)
userScopes, exists, err := svc.AllowedScopesForAudience(t.Context(), nil, "client-1", "https://api.orders.example.com", oidc.SubjectTypeUser)
require.NoError(t, err)
require.True(t, exists)
assert.ElementsMatch(t, []string{"read:orders"}, userScopes)
clientScopes, exists, err := svc.AllowedScopesForAudience(t.Context(), nil, "client-1", "https://api.orders.example.com", oidc.SubjectTypeClient)
require.NoError(t, err)
require.True(t, exists)
assert.ElementsMatch(t, []string{"write:orders"}, clientScopes)
// The fosite widening still sees the union of both subject types.
scopes, audiences, err := svc.ClientAPIScopesAndAudiences(t.Context(), nil, "client-1")
require.NoError(t, err)
assert.ElementsMatch(t, []string{"read:orders", "write:orders"}, scopes)
assert.ElementsMatch(t, []string{"https://api.orders.example.com"}, audiences)
}
func TestUpdatePermissionsRejectsReservedKeys(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
svc := New(Dependencies{DB: db}).service
orders, err := svc.Create(t.Context(), apiCreateDto{Name: "Orders", Resource: "https://api.orders.example.com"})
require.NoError(t, err)
for _, key := range []string{"openid", "profile", "email", "email_verified", "groups", "offline_access", "Email"} {
_, err := svc.UpdatePermissions(t.Context(), orders.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{
{Key: key, Name: "Reserved"},
}})
require.Error(t, err, "key %q must be rejected", key)
var validationErr *common.ValidationError
require.ErrorAs(t, err, &validationErr)
}
}
func TestUpdatePermissionsRejectsDuplicateKeys(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
svc := New(Dependencies{DB: db}).service
orders, err := svc.Create(t.Context(), apiCreateDto{Name: "Orders", Resource: "https://api.orders.example.com"})
require.NoError(t, err)
// Two rows with the same key must be rejected rather than silently coalesced last-wins
_, err = svc.UpdatePermissions(t.Context(), orders.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{
{Key: "read:orders", Name: "Read"},
{Key: "read:orders", Name: "Read again"},
}})
require.Error(t, err)
var validationErr *common.ValidationError
require.ErrorAs(t, err, &validationErr)
}
func TestUpdatePermissionsRejectsInvalidKeyCharacters(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
svc := New(Dependencies{DB: db}).service
orders, err := svc.Create(t.Context(), apiCreateDto{Name: "Orders", Resource: "https://api.orders.example.com"})
require.NoError(t, err)
// A space corrupts the space-delimited scope claim, and the unit separator is the consent delimiter
for _, key := range []string{"read orders", "read\x1forders", "read\"orders", "bad\\key", "tab\tkey"} {
_, err := svc.UpdatePermissions(t.Context(), orders.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{
{Key: key, Name: "Invalid"},
}})
require.Error(t, err, "key %q must be rejected", key)
var validationErr *common.ValidationError
require.ErrorAs(t, err, &validationErr)
}
// A valid scope-token key is accepted
_, err = svc.UpdatePermissions(t.Context(), orders.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{
{Key: "read:orders", Name: "Read"},
}})
require.NoError(t, err)
}
func TestCreateRejectsIssuerResource(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
const issuer = "https://id.example.com"
svc := New(Dependencies{DB: db, Issuer: issuer}).service
// The issuer itself, a trailing-slash variant, and a different-cased variant are all reserved
for _, resource := range []string{issuer, issuer + "/", "https://ID.example.com"} {
_, err := svc.Create(t.Context(), apiCreateDto{Name: "Reserved", Resource: resource})
require.Error(t, err, "resource %q must be rejected", resource)
var validationErr *common.ValidationError
require.ErrorAs(t, err, &validationErr)
}
// A normal resource is accepted
_, err := svc.Create(t.Context(), apiCreateDto{Name: "Orders", Resource: "https://api.orders.example.com"})
require.NoError(t, err)
}
func TestCreateAcceptsAbsoluteResourceURIs(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
svc := New(Dependencies{DB: db}).service
for _, resource := range []string{"https://api.orders.example.com", "api://PocketID", "urn:my-app"} {
_, err := svc.Create(t.Context(), apiCreateDto{Name: "Orders", Resource: resource})
require.NoError(t, err, "resource %q must be accepted", resource)
}
}
func TestDescribePermissions(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
svc := New(Dependencies{DB: db}).service
orders, err := svc.Create(t.Context(), apiCreateDto{Name: "Orders", Resource: "https://api.orders.example.com"})
require.NoError(t, err)
desc := "Read orders"
_, err = svc.UpdatePermissions(t.Context(), orders.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{
{Key: "read:orders", Name: "Read orders", Description: &desc},
{Key: "write:orders", Name: "Write orders"},
}})
require.NoError(t, err)
infos, err := svc.DescribePermissions(t.Context(), "https://api.orders.example.com", []string{"read:orders", "unknown"})
require.NoError(t, err)
require.Len(t, infos, 1)
assert.Equal(t, "read:orders", infos[0].Key)
assert.Equal(t, "Read orders", infos[0].Name)
require.NotNil(t, infos[0].Description)
assert.Equal(t, "Read orders", *infos[0].Description)
}
func findPermission(api API, key string) *Permission {
for i := range api.Permissions {
if api.Permissions[i].Key == key {
return &api.Permissions[i]
}
}
return nil
}

View File

@@ -1,16 +1,20 @@
package dto
package apikey
import (
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
)
type ApiKeyCreateDto struct {
type apiKeyCreateDto struct {
Name string `json:"name" binding:"required,min=3,max=50" unorm:"nfc"`
Description *string `json:"description" unorm:"nfc"`
ExpiresAt datatype.DateTime `json:"expiresAt" binding:"required"`
}
type ApiKeyDto struct {
type apiKeyRenewDto struct {
ExpiresAt datatype.DateTime `json:"expiresAt" binding:"required"`
}
type apiKeyDto struct {
ID string `json:"id"`
Name string `json:"name"`
Description *string `json:"description"`
@@ -20,7 +24,7 @@ type ApiKeyDto struct {
ExpirationEmailSent bool `json:"expirationEmailSent"`
}
type ApiKeyResponseDto struct {
ApiKey ApiKeyDto `json:"apiKey"`
type apiKeyResponseDto struct {
ApiKey apiKeyDto `json:"apiKey"`
Token string `json:"token"`
}

View File

@@ -0,0 +1,139 @@
package apikey
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
type handler struct {
service *Service
}
func newHandler(service *Service) *handler {
return &handler{service: service}
}
// list godoc
// @Summary List API keys
// @Description Get a paginated list of API keys belonging to the current user
// @Tags API Keys
// @Param pagination[page] query int false "Page number for pagination" default(1)
// @Param pagination[limit] query int false "Number of items per page" default(20)
// @Param sort[column] query string false "Column to sort by"
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Success 200 {object} dto.Paginated[apiKeyDto]
// @Router /api/api-keys [get]
func (h *handler) list(c *gin.Context) {
listRequestOptions := utils.ParseListRequestOptions(c)
userID := c.GetString("userID")
apiKeys, pagination, err := h.service.ListApiKeys(c.Request.Context(), userID, listRequestOptions)
if err != nil {
_ = c.Error(err)
return
}
var apiKeysDto []apiKeyDto
if err := dto.MapStructList(apiKeys, &apiKeysDto); err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, dto.Paginated[apiKeyDto]{
Data: apiKeysDto,
Pagination: pagination,
})
}
// create godoc
// @Summary Create API key
// @Description Create a new API key for the current user
// @Tags API Keys
// @Param api_key body apiKeyCreateDto true "API key information"
// @Success 201 {object} apiKeyResponseDto "Created API key with token"
// @Router /api/api-keys [post]
func (h *handler) create(c *gin.Context) {
userID := c.GetString("userID")
var input apiKeyCreateDto
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
}
apiKey, token, err := h.service.CreateApiKey(c.Request.Context(), userID, input)
if err != nil {
_ = c.Error(err)
return
}
var responseDto apiKeyDto
if err := dto.MapStruct(apiKey, &responseDto); err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusCreated, apiKeyResponseDto{
ApiKey: responseDto,
Token: token,
})
}
// renew godoc
// @Summary Renew API key
// @Description Renew an existing API key by ID
// @Tags API Keys
// @Param id path string true "API Key ID"
// @Success 200 {object} apiKeyResponseDto "Renewed API key with new token"
// @Router /api/api-keys/{id}/renew [post]
func (h *handler) renew(c *gin.Context) {
userID := c.GetString("userID")
apiKeyID := c.Param("id")
var input apiKeyRenewDto
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
}
apiKey, token, err := h.service.RenewApiKey(c.Request.Context(), userID, apiKeyID, input.ExpiresAt.ToTime())
if err != nil {
_ = c.Error(err)
return
}
var responseDto apiKeyDto
if err := dto.MapStruct(apiKey, &responseDto); err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, apiKeyResponseDto{
ApiKey: responseDto,
Token: token,
})
}
// revoke godoc
// @Summary Revoke API key
// @Description Revoke (delete) an existing API key by ID
// @Tags API Keys
// @Param id path string true "API Key ID"
// @Success 204 "No Content"
// @Router /api/api-keys/{id} [delete]
func (h *handler) revoke(c *gin.Context) {
userID := c.GetString("userID")
apiKeyID := c.Param("id")
if err := h.service.RevokeApiKey(c.Request.Context(), userID, apiKeyID); err != nil {
_ = c.Error(err)
return
}
c.Status(http.StatusNoContent)
}

View File

@@ -1,9 +1,13 @@
package model
package apikey
import datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
import (
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
)
// ApiKey is a personal access token a user can use to authenticate against the API
type ApiKey struct {
Base
model.Base
Name string `sortable:"true"`
Key string
@@ -13,5 +17,5 @@ type ApiKey struct {
ExpirationEmailSent bool
UserID string
User User
User model.User
}

View File

@@ -0,0 +1,58 @@
package apikey
import (
"context"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/model"
)
type Dependencies struct {
DB *gorm.DB
StaticApiKey string
}
type Module struct {
service *Service
handler *handler
}
func New(ctx context.Context, deps Dependencies) (*Module, error) {
service, err := newService(ctx, deps.DB, deps.StaticApiKey)
if err != nil {
return nil, err
}
return &Module{
service: service,
handler: newHandler(service),
}, nil
}
// RegisterRoutes mounts the API key management endpoints
// authWithoutApiKey disables API key authentication so an API key cannot be used to mint or renew further API keys
func (m *Module) RegisterRoutes(apiGroup *gin.RouterGroup, auth, authWithoutApiKey gin.HandlerFunc) {
group := apiGroup.Group("/api-keys")
group.GET("", auth, m.handler.list)
group.POST("", authWithoutApiKey, m.handler.create)
group.POST("/:id/renew", authWithoutApiKey, m.handler.renew)
group.DELETE("/:id", auth, m.handler.revoke)
}
// ValidateApiKey resolves the user that owns the given raw API key
// It is used by the authentication middleware
func (m *Module) ValidateApiKey(ctx context.Context, apiKey string) (model.User, error) {
return m.service.ValidateApiKey(ctx, apiKey)
}
// ListExpiringApiKeys returns API keys expiring within the given number of days that have not been notified yet
func (m *Module) ListExpiringApiKeys(ctx context.Context, daysAhead int) ([]ApiKey, error) {
return m.service.ListExpiringApiKeys(ctx, daysAhead)
}
// MarkExpirationEmailSent records that the expiration notification email was sent for the given API key
func (m *Module) MarkExpirationEmailSent(ctx context.Context, apiKeyID string) error {
return m.service.MarkExpirationEmailSent(ctx, apiKeyID)
}

View File

@@ -0,0 +1,257 @@
package apikey
import (
"context"
"errors"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"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/utils"
)
// Service holds the business logic for managing user API keys
type Service struct {
db *gorm.DB
staticApiKey string
}
func newService(ctx context.Context, db *gorm.DB, staticApiKey string) (*Service, error) {
s := &Service{
db: db,
staticApiKey: staticApiKey,
}
if staticApiKey == "" {
err := s.deleteStaticApiKeyUser(ctx)
if err != nil {
return nil, err
}
}
return s, nil
}
func (s *Service) ListApiKeys(ctx context.Context, userID string, listRequestOptions utils.ListRequestOptions) ([]ApiKey, utils.PaginationResponse, error) {
query := s.db.
WithContext(ctx).
Where("user_id = ?", userID).
Model(&ApiKey{})
var apiKeys []ApiKey
pagination, err := utils.PaginateFilterAndSort(listRequestOptions, query, &apiKeys)
if err != nil {
return nil, utils.PaginationResponse{}, err
}
return apiKeys, pagination, nil
}
func (s *Service) CreateApiKey(ctx context.Context, userID string, input apiKeyCreateDto) (ApiKey, string, error) {
// Check if expiration is in the future
if !input.ExpiresAt.ToTime().After(time.Now()) {
return ApiKey{}, "", &common.APIKeyExpirationDateError{}
}
// Generate a secure random API key
token, err := utils.GenerateRandomAlphanumericString(32)
if err != nil {
return ApiKey{}, "", err
}
apiKey := ApiKey{
Name: input.Name,
Key: utils.CreateSha256Hash(token), // Hash the token for storage
Description: input.Description,
ExpiresAt: input.ExpiresAt,
UserID: userID,
}
err = s.db.
WithContext(ctx).
Create(&apiKey).
Error
if err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
return ApiKey{}, "", &common.AlreadyInUseError{Property: "API key name"}
}
return ApiKey{}, "", err
}
// Return the raw token only once - it cannot be retrieved later
return apiKey, token, nil
}
func (s *Service) RenewApiKey(ctx context.Context, userID, apiKeyID string, expiration time.Time) (ApiKey, string, error) {
// Check if expiration is in the future
if !expiration.After(time.Now()) {
return ApiKey{}, "", &common.APIKeyExpirationDateError{}
}
tx := s.db.Begin()
defer tx.Rollback()
var apiKey ApiKey
err := tx.
WithContext(ctx).
Model(&ApiKey{}).
Where("id = ? AND user_id = ?", apiKeyID, userID).
First(&apiKey).
Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ApiKey{}, "", &common.APIKeyNotFoundError{}
}
return ApiKey{}, "", err
}
// Only allow renewal if the key has already expired
if apiKey.ExpiresAt.ToTime().After(time.Now()) {
return ApiKey{}, "", &common.APIKeyNotExpiredError{}
}
// Generate a secure random API key
token, err := utils.GenerateRandomAlphanumericString(32)
if err != nil {
return ApiKey{}, "", err
}
apiKey.Key = utils.CreateSha256Hash(token)
apiKey.ExpiresAt = datatype.DateTime(expiration)
err = tx.WithContext(ctx).Save(&apiKey).Error
if err != nil {
return ApiKey{}, "", err
}
if err := tx.Commit().Error; err != nil {
return ApiKey{}, "", err
}
return apiKey, token, nil
}
func (s *Service) RevokeApiKey(ctx context.Context, userID, apiKeyID string) error {
var apiKey ApiKey
err := s.db.
WithContext(ctx).
Where("id = ? AND user_id = ?", apiKeyID, userID).
Delete(&apiKey).
Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return &common.APIKeyNotFoundError{}
}
return err
}
return nil
}
func (s *Service) ValidateApiKey(ctx context.Context, apiKey string) (model.User, error) {
if apiKey == "" {
return model.User{}, &common.NoAPIKeyProvidedError{}
}
if s.staticApiKey != "" && apiKey == s.staticApiKey {
return s.initStaticApiKeyUser(ctx)
}
now := time.Now()
hashedKey := utils.CreateSha256Hash(apiKey)
var key ApiKey
err := s.db.
WithContext(ctx).
Model(&ApiKey{}).
Clauses(clause.Returning{}).
Where("key = ? AND expires_at > ?", hashedKey, datatype.DateTime(now)).
Updates(&ApiKey{
LastUsedAt: new(datatype.DateTime(now)),
}).
Preload("User").
First(&key).
Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return model.User{}, &common.InvalidAPIKeyError{}
}
return model.User{}, err
}
return key.User, nil
}
func (s *Service) ListExpiringApiKeys(ctx context.Context, daysAhead int) ([]ApiKey, error) {
var keys []ApiKey
now := time.Now()
cutoff := now.AddDate(0, 0, daysAhead)
err := s.db.
WithContext(ctx).
Preload("User").
Where("expires_at > ? AND expires_at <= ? AND expiration_email_sent = ?", datatype.DateTime(now), datatype.DateTime(cutoff), false).
Find(&keys).
Error
return keys, err
}
// MarkExpirationEmailSent records that the expiration notification email was sent for the given API key
func (s *Service) MarkExpirationEmailSent(ctx context.Context, apiKeyID string) error {
return s.db.WithContext(ctx).
Model(&ApiKey{}).
Where("id = ?", apiKeyID).
Update("expiration_email_sent", true).
Error
}
func (s *Service) initStaticApiKeyUser(ctx context.Context) (user model.User, err error) {
err = s.db.
WithContext(ctx).
First(&user, "id = ?", common.StaticApiKeyUserID).
Error
if err == nil {
return user, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return model.User{}, err
}
usernameSuffix, err := utils.GenerateRandomAlphanumericString(6)
if err != nil {
return model.User{}, err
}
user = model.User{
Base: model.Base{
ID: common.StaticApiKeyUserID,
},
FirstName: "Static API User",
Username: "static-api-user-" + usernameSuffix,
DisplayName: "Static API User",
IsAdmin: true,
}
err = s.db.
WithContext(ctx).
Create(&user).
Error
return user, err
}
func (s *Service) deleteStaticApiKeyUser(ctx context.Context) error {
return s.db.
WithContext(ctx).
Delete(&model.User{}, "id = ?", common.StaticApiKeyUserID).
Error
}

View File

@@ -0,0 +1,162 @@
package bootstrap
import (
"database/sql"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"time"
"github.com/italypaleale/francis/builtin/ratelimit"
"github.com/italypaleale/francis/components/postgres"
"github.com/italypaleale/francis/host/local"
"github.com/jackc/pgx/v5/pgxpool"
"gorm.io/gorm"
"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/middleware"
"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/crypto"
)
type NewActorsOpts struct {
SQLite *sql.DB
Postgres *pgxpool.Pool
EnvConfig *common.EnvConfigSchema
AppConfig *service.AppConfigService
HttpClient *http.Client
DB *gorm.DB
FileStorage storage.FileStorage
}
func NewActors(o NewActorsOpts) (*local.Host, map[string]*ratelimit.RateLimitService, error) {
log := slog.Default()
// Derive a PSK from the global encryption key
// The runtime PSK derives the cluster CA used for host-to-host mTLS
psk, err := o.getPSK()
if err != nil {
return nil, nil, fmt.Errorf("failed to derive PSK: %w", err)
}
// Options for the host
opts := []local.HostOption{
local.WithAddress(net.JoinHostPort(o.EnvConfig.ActorsHost, o.EnvConfig.ActorsPort)),
local.WithLogger(log.With("scope", "actor-host")),
local.WithRuntimePSKs(psk),
local.WithShutdownGracePeriod(10 * time.Second),
}
// Add all cron jobs
cronjobs, err := o.getCronJobs()
if err != nil {
return nil, nil, err
}
opts = append(opts, cronjobs...)
// Add the rate limiters
rateLimiters, rateLimiterOpts, err := o.getRateLimiters()
if err != nil {
return nil, nil, err
}
opts = append(opts, rateLimiterOpts...)
// Add the database connection
providerOpt, err := o.getProvider()
if err != nil {
return nil, nil, err
}
opts = append(opts, providerOpt)
// Create a new actor host
h, err := local.NewHost(opts...)
if err != nil {
return nil, nil, fmt.Errorf("failed to create actor host: %w", err)
}
// Bind a service for each rate limiter so the middleware can invoke them
rateLimitServices := make(map[string]*ratelimit.RateLimitService, len(rateLimiters))
for name, rl := range rateLimiters {
rateLimitServices[name] = rl.Service(h.Service())
}
return h, rateLimitServices, nil
}
// Derive a PSK from the global encryption key
func (o *NewActorsOpts) getPSK() ([]byte, error) {
// Note: changing the key derivation or the seed is a breaking change
return crypto.DeriveKey(o.EnvConfig.EncryptionKey, "pocketid/actors-psk")
}
func (o *NewActorsOpts) getProvider() (local.HostOption, error) {
switch {
case o.Postgres != nil && o.SQLite != nil:
return nil, errors.New("cannot have both Postgres and SQLite connections")
case o.Postgres != nil:
return local.WithPostgresProvider(postgres.PostgresProviderOptions{
DB: o.Postgres,
}), nil
case o.SQLite != nil:
return local.WithSQLiteProvider(local.SQLiteProviderOptions{
DB: o.SQLite,
}), nil
default:
return nil, errors.New("one of Postgres and SQLite must be set")
}
}
func (o *NewActorsOpts) getCronJobs() (opts []local.HostOption, err error) {
// In test mode, we do not register anything
if common.EnvConfig.AppEnv == "test" {
return opts, nil
}
// Register the analytics job
analyticsJob, err := job.GetAnalyticsJob(o.AppConfig, o.HttpClient)
if err != nil {
return nil, fmt.Errorf("failed to get analytics cron job: %w", err)
}
if analyticsJob != nil {
// This could be nil if analytics are disabled
opts = append(opts, local.WithBuiltInActor(analyticsJob))
}
// Register the file cleanup jobs
fileCleanupJobs, err := job.GetFileCleanupJobs(o.DB, o.FileStorage)
if err != nil {
return nil, fmt.Errorf("failed to get file cleanup cron jobs: %w", err)
}
for _, j := range fileCleanupJobs {
opts = append(opts, local.WithBuiltInActor(j))
}
return opts, nil
}
// getRateLimiters creates a built-in rate-limit actor for each middleware policy and returns both the created actors (keyed by policy name) and the host options to register them
// Unlike cron jobs, rate limiters keep no durable state, so they are registered in every environment
func (o *NewActorsOpts) getRateLimiters() (actors map[string]*ratelimit.RateLimit, opts []local.HostOption, err error) {
policies := middleware.RateLimitPolicies()
actors = make(map[string]*ratelimit.RateLimit, len(policies))
for _, p := range policies {
rl, err := ratelimit.New(
p.Name,
ratelimit.WithRate(p.Rate),
ratelimit.WithPer(p.Per),
ratelimit.WithBurst(p.Burst),
)
if err != nil {
return nil, nil, fmt.Errorf("error creating rate limiter %q: %w", p.Name, err)
}
actors[p.Name] = rl
opts = append(opts, local.WithBuiltInActor(rl))
}
return actors, opts, nil
}

View File

@@ -0,0 +1,26 @@
package bootstrap
import (
"encoding/hex"
"testing"
"github.com/stretchr/testify/require"
"github.com/pocket-id/pocket-id/backend/internal/common"
)
func TestNewActorsOptsGetPSKUsesStableValue(t *testing.T) {
opts := NewActorsOpts{
EnvConfig: &common.EnvConfigSchema{
EncryptionKey: []byte("test-encryption-key"),
},
}
expectedHex := "651300d35d48998d0fa66ac89091bcde8ed0fd0aa35fbb849f068410c64807e9"
expected, err := hex.DecodeString(expectedHex)
require.NoError(t, err)
actual, err := opts.getPSK()
require.NoError(t, err)
require.Equal(t, expected, actual)
}

View File

@@ -2,71 +2,95 @@ package bootstrap
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"io/fs"
"log/slog"
"os"
"path"
"strings"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/storage"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"github.com/pocket-id/pocket-id/backend/resources"
)
// initApplicationImages copies the images from the images directory to the application-images directory
// 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.
func initApplicationImages() (map[string]string, error) {
//
//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
legacyImageHashes := imageHashMap{
"background.jpg": mustDecodeHex("138d510030ed845d1d74de34658acabff562d306476454369a60ab8ade31933f"),
"background.jpg": mustDecodeHex("138d510030ed845d1d74de34658acabff562d306476454369a60ab8ade31933f"),
"background.webp": mustDecodeHex("3fc436a66d6b872b01d96a4e75046c46b5c3e2daccd51e98ecdf98fd445599ab"),
}
dirPath := common.EnvConfig.UploadPath + "/application-images"
sourceFiles, err := resources.FS.ReadDir("images")
if err != nil && !os.IsNotExist(err) {
return nil, fmt.Errorf("failed to read directory: %w", err)
}
destinationFiles, err := os.ReadDir(dirPath)
if err != nil && !os.IsNotExist(err) {
return nil, fmt.Errorf("failed to read directory: %w", err)
destinationFiles, err := fileStorage.List(ctx, "application-images")
if err != nil {
if storage.IsNotExist(err) {
destinationFiles = []storage.ObjectInfo{}
} else {
return nil, fmt.Errorf("failed to list application images: %w", err)
}
}
dstNameToExt := make(map[string]string, len(destinationFiles))
for _, f := range destinationFiles {
if f.IsDir() {
continue
}
name := f.Name()
nameWithoutExt, ext := utils.SplitFileName(name)
destFilePath := path.Join(dirPath, name)
// Skip directories
if f.IsDir() {
_, name := path.Split(f.Path)
if name == "" {
continue
}
h, err := utils.CreateSha256FileHash(destFilePath)
nameWithoutExt, ext := utils.SplitFileName(name)
reader, _, err := fileStorage.Open(ctx, f.Path)
if err != nil {
slog.Warn("Failed to get hash for file", slog.String("name", name), slog.Any("error", err))
if errors.Is(err, fs.ErrNotExist) {
continue
}
slog.Warn("Failed to open application image for hashing", slog.String("name", name), slog.Any("error", err))
continue
}
hash, err := hashStream(reader)
reader.Close()
if err != nil {
slog.Warn("Failed to hash application image", slog.String("name", name), slog.Any("error", err))
continue
}
// Check if the file is a legacy one - if so, delete it
if legacyImageHashes.Contains(h) {
if legacyImageHashes.Contains(hash) {
slog.Info("Found legacy application image that will be removed", slog.String("name", name))
err = os.Remove(destFilePath)
if err != nil {
if err := fileStorage.Delete(ctx, f.Path); err != nil {
return nil, fmt.Errorf("failed to remove legacy file '%s': %w", name, err)
}
continue
}
// Track existing files
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() {
@@ -76,21 +100,21 @@ func initApplicationImages() (map[string]string, error) {
name := sourceFile.Name()
nameWithoutExt, ext := utils.SplitFileName(name)
srcFilePath := path.Join("images", name)
destFilePath := path.Join(dirPath, name)
// Skip if there's already an image at the path
// We do not check the extension because users could have uploaded a different one
if _, exists := dstNameToExt[nameWithoutExt]; exists {
continue
}
slog.Info("Writing new application image", slog.String("name", name))
err := utils.CopyEmbeddedFileToDisk(srcFilePath, destFilePath)
srcFile, err := resources.FS.Open(srcFilePath)
if err != nil {
return nil, fmt.Errorf("failed to copy file: %w", err)
return nil, fmt.Errorf("failed to open embedded file '%s': %w", name, err)
}
// Track the newly copied file so it can be included in the extensions map later
if err := fileStorage.Save(ctx, path.Join("application-images", name), srcFile); err != nil {
srcFile.Close()
return nil, fmt.Errorf("failed to store application image '%s': %w", name, err)
}
srcFile.Close()
dstNameToExt[nameWithoutExt] = ext
}
@@ -118,3 +142,11 @@ func mustDecodeHex(str string) []byte {
}
return b
}
func hashStream(r io.Reader) ([]byte, error) {
h := sha256.New()
if _, err := io.Copy(h, r); err != nil {
return nil, err
}
return h.Sum(nil), nil
}

View File

@@ -2,75 +2,201 @@ package bootstrap
import (
"context"
"errors"
"fmt"
"log/slog"
"time"
_ "github.com/golang-migrate/migrate/v4/source/file"
"github.com/italypaleale/go-kit/servicerunner"
"gorm.io/gorm"
"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/utils"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/storage"
)
func Bootstrap(ctx context.Context) error {
// List of services to run
services := make([]servicerunner.Service, 0, 3)
shutdowns := &shutdownManager{
fns: make([]servicerunner.Service, 0, 4),
}
// Initialize the observability stack, including the logger, distributed tracing, and metrics
shutdownFns, httpClient, err := initObservability(ctx, common.EnvConfig.MetricsEnabled, common.EnvConfig.TracingEnabled)
shutdownFns, httpClient, err := initObservability(ctx)
if err != nil {
return fmt.Errorf("failed to initialize OpenTelemetry: %w", err)
}
shutdowns.Add(shutdownFns...)
slog.InfoContext(ctx, "Pocket ID is starting")
imageExtensions, err := initApplicationImages()
// Init database
db, pg, err := NewDatabase(ctx)
if err != nil {
return fmt.Errorf("failed to initialize database: %w", err)
}
if pg != nil {
defer func() {
// Close the database connection pool only after the shutdown functions have run: some of them (e.g. releasing the application lock) still need to query the database.
pg.Close()
}()
}
// Init storage
fileStorage, err := InitStorage(ctx, db)
if err != nil {
return fmt.Errorf("failed to initialize file storage (backend: %s): %w", common.EnvConfig.FileBackend, err)
}
// Init application images
imageExtensions, err := initApplicationImages(ctx, fileStorage)
if err != nil {
return fmt.Errorf("failed to initialize application images: %w", err)
}
// Connect to the database
db, err := NewDatabase()
if err != nil {
return fmt.Errorf("failed to initialize database: %w", err)
}
// Create all services
svc, err := initServices(ctx, db, httpClient, imageExtensions)
if err != nil {
return fmt.Errorf("failed to initialize services: %w", err)
}
// Init the job scheduler
// Init the scheduler
scheduler, err := job.NewScheduler()
if err != nil {
return fmt.Errorf("failed to create job scheduler: %w", err)
}
err = registerScheduledJobs(ctx, db, svc, httpClient, scheduler)
// Create all services
svc, err := initServices(ctx, db, httpClient, imageExtensions, fileStorage, scheduler)
if err != nil {
return fmt.Errorf("failed to register scheduled jobs: %w", err)
return fmt.Errorf("failed to initialize services: %w", err)
}
services = append(services, svc.appLockService.RunRenewal)
// Acquire the lock from the app lock service
waitUntil, err := svc.appLockService.Acquire(ctx, false)
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)
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(time.Until(waitUntil)):
}
shutdowns.Add(func(shutdownCtx context.Context) error {
sErr := svc.appLockService.Release(shutdownCtx)
if sErr != nil {
return fmt.Errorf("failed to release application lock: %w", sErr)
}
return nil
})
// Init the actors
actorsOpts := NewActorsOpts{
Postgres: pg,
EnvConfig: &common.EnvConfig,
AppConfig: svc.appConfigService,
HttpClient: httpClient,
DB: db,
FileStorage: fileStorage,
}
if pg == nil {
actorsOpts.SQLite, err = db.DB()
if err != nil {
return fmt.Errorf("failed to get *sql.DB connection from Gorm: %w", err)
}
}
actors, rateLimitServices, err := NewActors(actorsOpts)
if err != nil {
return fmt.Errorf("failed to initialize actors: %w", err)
}
services = append(services, actors.Run)
// Register scheduled jobs, only in non-test mode
if common.EnvConfig.AppEnv != "test" {
err = registerScheduledJobs(ctx, db, svc, scheduler)
if err != nil {
return fmt.Errorf("failed to register scheduled jobs: %w", err)
}
services = append(services, scheduler.Run)
}
// Init the router
router := initRouter(db, svc)
// The rate-limit middleware invokes the actor host with each request's own context, so the setup context is intentionally not threaded through the router
//nolint:contextcheck
router, err := initRouter(db, svc, rateLimitServices)
if err != nil {
return fmt.Errorf("failed to initialize router: %w", err)
}
services = append(services, router)
// Run all background services
// This call blocks until the context is canceled
err = utils.
NewServiceRunner(router, scheduler.Run).
Run(ctx)
err = servicerunner.NewServiceRunner(services...).Run(ctx)
if err != nil {
return fmt.Errorf("failed to run services: %w", err)
}
// Invoke all shutdown functions
// We give these a timeout of 5s
// Note: we use a background context because the run context has been canceled already
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer shutdownCancel()
err = utils.
NewServiceRunner(shutdownFns...).
Run(shutdownCtx) //nolint:contextcheck
if err != nil {
slog.Error("Error shutting down services", slog.Any("error", err))
}
// Run all shutdown functions
shutdowns.Run(ctx)
return nil
}
func InitStorage(ctx context.Context, db *gorm.DB) (fileStorage storage.FileStorage, err error) {
switch common.EnvConfig.FileBackend {
case storage.TypeFileSystem:
fileStorage, err = storage.NewFilesystemStorage(common.EnvConfig.UploadPath)
case storage.TypeDatabase:
fileStorage, err = storage.NewDatabaseStorage(db)
case storage.TypeS3:
s3Cfg := storage.S3Config{
Bucket: common.EnvConfig.S3Bucket,
Region: common.EnvConfig.S3Region,
Endpoint: common.EnvConfig.S3Endpoint,
AccessKeyID: common.EnvConfig.S3AccessKeyID,
SecretAccessKey: common.EnvConfig.S3SecretAccessKey,
ForcePathStyle: common.EnvConfig.S3ForcePathStyle,
DisableDefaultIntegrityChecks: common.EnvConfig.S3DisableDefaultIntegrityChecks,
Root: common.EnvConfig.UploadPath,
}
fileStorage, err = storage.NewS3Storage(ctx, s3Cfg)
default:
err = fmt.Errorf("unknown file storage backend: %s", common.EnvConfig.FileBackend)
}
if err != nil {
return fileStorage, err
}
return fileStorage, nil
}
type shutdownManager struct {
fns []servicerunner.Service
}
func (s *shutdownManager) Add(fns ...servicerunner.Service) {
for _, fn := range fns {
if fn == nil {
continue
}
s.fns = append(s.fns, fn)
}
}
func (s *shutdownManager) Run(ctx context.Context) {
// Cleanup functions are one-shot and must each run to completion independently, so we set WaitAll to true
sr := servicerunner.NewServiceRunner(s.fns...)
sr.WaitAll = true
shutdownCtx, shutdownCancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
defer shutdownCancel()
err := sr.Run(shutdownCtx)
if err != nil {
slog.ErrorContext(ctx, "Error shutting down services", slog.Any("error", err))
}
}

View File

@@ -1,143 +1,51 @@
package bootstrap
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"github.com/glebarez/sqlite"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database"
postgresMigrate "github.com/golang-migrate/migrate/v4/database/postgres"
sqliteMigrate "github.com/golang-migrate/migrate/v4/database/sqlite3"
_ "github.com/golang-migrate/migrate/v4/source/github"
"github.com/golang-migrate/migrate/v4/source/iofs"
sqlitekit "github.com/italypaleale/go-sql-utils/sqlite"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/jackc/pgx/v5/stdlib"
"github.com/libtnb/sqlite"
slogGorm "github.com/orandin/slog-gorm"
"gorm.io/driver/postgres"
"gorm.io/gorm"
gormLogger "gorm.io/gorm/logger"
tracingGorm "gorm.io/plugin/opentelemetry/tracing"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/utils"
sqliteutil "github.com/pocket-id/pocket-id/backend/internal/utils/sqlite"
"github.com/pocket-id/pocket-id/backend/resources"
)
func NewDatabase() (db *gorm.DB, err error) {
db, err = connectDatabase()
func NewDatabase(ctx context.Context) (db *gorm.DB, pg *pgxpool.Pool, err error) {
db, pg, err = ConnectDatabase(ctx)
if err != nil {
return nil, fmt.Errorf("failed to connect to database: %w", err)
return nil, nil, fmt.Errorf("failed to connect to database: %w", err)
}
sqlDb, err := db.DB()
if err != nil {
return nil, fmt.Errorf("failed to get sql.DB: %w", err)
}
// Choose the correct driver for the database provider
var driver database.Driver
switch common.EnvConfig.DbProvider {
case common.DbProviderSqlite:
driver, err = sqliteMigrate.WithInstance(sqlDb, &sqliteMigrate.Config{
NoTxWrap: true,
})
case common.DbProviderPostgres:
driver, err = postgresMigrate.WithInstance(sqlDb, &postgresMigrate.Config{})
default:
// Should never happen at this point
return nil, fmt.Errorf("unsupported database provider: %s", common.EnvConfig.DbProvider)
}
if err != nil {
return nil, fmt.Errorf("failed to create migration driver: %w", err)
return nil, nil, fmt.Errorf("failed to get sql.DB: %w", err)
}
// Run migrations
if err := migrateDatabase(driver); err != nil {
return nil, fmt.Errorf("failed to run migrations: %w", err)
err = utils.MigrateDatabase(ctx, sqlDb)
if err != nil {
return nil, nil, fmt.Errorf("failed to run migrations: %w", err)
}
return db, nil
return db, pg, nil
}
func migrateDatabase(driver database.Driver) error {
// Embedded migrations via iofs
path := "migrations/" + string(common.EnvConfig.DbProvider)
source, err := iofs.New(resources.FS, path)
if err != nil {
return fmt.Errorf("failed to create embedded migration source: %w", err)
}
m, err := migrate.NewWithInstance("iofs", source, "pocket-id", driver)
if err != nil {
return fmt.Errorf("failed to create migration instance: %w", err)
}
requiredVersion, err := getRequiredMigrationVersion(path)
if err != nil {
return fmt.Errorf("failed to get last migration version: %w", err)
}
currentVersion, _, _ := m.Version()
if currentVersion > requiredVersion {
slog.Warn("Database version is newer than the application supports, possible downgrade detected", slog.Uint64("db_version", uint64(currentVersion)), slog.Uint64("app_version", uint64(requiredVersion)))
if !common.EnvConfig.AllowDowngrade {
return fmt.Errorf("database version (%d) is newer than application version (%d), downgrades are not allowed (set ALLOW_DOWNGRADE=true to enable)", currentVersion, requiredVersion)
}
slog.Info("Fetching migrations from GitHub to handle possible downgrades")
return migrateDatabaseFromGitHub(driver, requiredVersion)
}
if err := m.Migrate(requiredVersion); err != nil && !errors.Is(err, migrate.ErrNoChange) {
return fmt.Errorf("failed to apply embedded migrations: %w", err)
}
return nil
}
func migrateDatabaseFromGitHub(driver database.Driver, version uint) error {
srcURL := "github://pocket-id/pocket-id/backend/resources/migrations/" + string(common.EnvConfig.DbProvider)
m, err := migrate.NewWithDatabaseInstance(srcURL, "pocket-id", driver)
if err != nil {
return fmt.Errorf("failed to create GitHub migration instance: %w", err)
}
if err := m.Migrate(version); err != nil && !errors.Is(err, migrate.ErrNoChange) {
return fmt.Errorf("failed to apply GitHub migrations: %w", err)
}
return nil
}
// getRequiredMigrationVersion reads the embedded migration files and returns the highest version number found.
func getRequiredMigrationVersion(path string) (uint, error) {
entries, err := resources.FS.ReadDir(path)
if err != nil {
return 0, fmt.Errorf("failed to read migration directory: %w", err)
}
var maxVersion uint
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
var version uint
n, err := fmt.Sscanf(name, "%d_", &version)
if err == nil && n == 1 {
if version > maxVersion {
maxVersion = version
}
}
}
return maxVersion, nil
}
func connectDatabase() (db *gorm.DB, err error) {
//nolint:gocognit
func ConnectDatabase(ctx context.Context) (db *gorm.DB, pg *pgxpool.Pool, err error) {
var dialector gorm.Dialector
// Choose the correct database provider
@@ -145,20 +53,36 @@ func connectDatabase() (db *gorm.DB, err error) {
switch common.EnvConfig.DbProvider {
case common.DbProviderSqlite:
if common.EnvConfig.DbConnectionString == "" {
return nil, errors.New("missing required env var 'DB_CONNECTION_STRING' for SQLite database")
return nil, nil, errors.New("missing required env var 'DB_CONNECTION_STRING' for SQLite database")
}
sqliteutil.RegisterSqliteFunctions()
connString, dbPath, isMemoryDB, err := parseSqliteConnectionString(common.EnvConfig.DbConnectionString)
connString, dbPath, isMemoryDB, err := sqlitekit.ParseConnectionString(common.EnvConfig.DbConnectionString, slog.Default())
if err != nil {
return nil, err
return nil, nil, err
}
if !isMemoryDB {
err = sqlitekit.EnsureDatabaseDir(dbPath)
if err != nil {
return nil, nil, err
}
var sqliteNetworkFilesystem bool
sqliteNetworkFilesystem, err = sqlitekit.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
err = ensureSqliteTempDir(filepath.Dir(dbPath))
err = sqlitekit.EnsureTempDir(filepath.Dir(dbPath), slog.Default())
if err != nil {
return nil, err
return nil, nil, err
}
if isMemoryDB {
@@ -172,13 +96,33 @@ func connectDatabase() (db *gorm.DB, err error) {
dialector = sqlite.Open(connString)
case common.DbProviderPostgres:
if common.EnvConfig.DbConnectionString == "" {
return nil, errors.New("missing required env var 'DB_CONNECTION_STRING' for Postgres database")
return nil, nil, errors.New("missing required env var 'DB_CONNECTION_STRING' for Postgres database")
}
dialector = postgres.Open(common.EnvConfig.DbConnectionString)
// We need a pgxpool object for francis, so we open this as a pgxpool...
pg, err = pgxpool.New(ctx, common.EnvConfig.DbConnectionString)
if err != nil {
return nil, nil, fmt.Errorf("failed to create Postgres pool: %w", err)
}
// ...test it with a ping...
pingCtx, pingCancel := context.WithTimeout(ctx, 10*time.Second)
defer pingCancel()
err = pg.Ping(pingCtx)
if err != nil {
pg.Close()
return nil, nil, fmt.Errorf("failed to ping Postgres database: %w", err)
}
// ...then create the dialector by adapting it to *sql.DB
dialector = postgres.New(postgres.Config{
Conn: stdlib.OpenDBFromPool(pg),
})
default:
return nil, fmt.Errorf("unsupported database provider: %s", common.EnvConfig.DbProvider)
return nil, nil, fmt.Errorf("unsupported database provider: %s", common.EnvConfig.DbProvider)
}
// Try connecting up to 3 times
for i := 1; i <= 3; i++ {
db, err = gorm.Open(dialector, &gorm.Config{
TranslateError: true,
@@ -187,247 +131,40 @@ func connectDatabase() (db *gorm.DB, err error) {
if err == nil {
slog.Info("Connected to database", slog.String("provider", string(common.EnvConfig.DbProvider)))
// Configure tracing and metrics
err = db.Use(tracingGorm.NewPlugin())
if err != nil {
return nil, nil, fmt.Errorf("failed to configure tracing for DB: %w", err)
}
// Invoke the onConnFn callback if any
if onConnFn != nil {
conn, err := db.DB()
if err != nil {
slog.Warn("Failed to get database connection, will retry in 3s", slog.Int("attempt", i), slog.String("provider", string(common.EnvConfig.DbProvider)), slog.Any("error", err))
time.Sleep(3 * time.Second)
if pg != nil {
pg.Close()
}
return nil, nil, fmt.Errorf("failed to get database connection for onConnFn callback: %w", err)
}
onConnFn(conn)
}
return db, nil
return db, pg, nil
}
// If we're here, the connection failed
slog.Warn("Failed to connect to database, will retry in 3s", slog.Int("attempt", i), slog.String("provider", string(common.EnvConfig.DbProvider)), slog.Any("error", err))
time.Sleep(3 * time.Second)
}
slog.Error("Failed to connect to database after 3 attempts", slog.String("provider", string(common.EnvConfig.DbProvider)), slog.Any("error", err))
return nil, err
}
func parseSqliteConnectionString(connString string) (parsedConnString string, dbPath string, isMemoryDB bool, err error) {
if !strings.HasPrefix(connString, "file:") {
connString = "file:" + connString
if pg != nil {
pg.Close()
}
// Check if we're using an in-memory database
isMemoryDB = isSqliteInMemory(connString)
// Parse the connection string
connStringUrl, err := url.Parse(connString)
if err != nil {
return "", "", false, fmt.Errorf("failed to parse SQLite connection string: %w", err)
}
// Convert options for the old SQLite driver to the new one
convertSqlitePragmaArgs(connStringUrl)
// Add the default and required params
err = addSqliteDefaultParameters(connStringUrl, isMemoryDB)
if err != nil {
return "", "", false, fmt.Errorf("invalid SQLite connection string: %w", err)
}
// Get the absolute path to the database
// Here, we know for a fact that the ? is present
parsedConnString = connStringUrl.String()
idx := strings.IndexRune(parsedConnString, '?')
dbPath, err = filepath.Abs(parsedConnString[len("file:"):idx])
if err != nil {
return "", "", false, fmt.Errorf("failed to determine absolute path to the database: %w", err)
}
return parsedConnString, dbPath, isMemoryDB, nil
}
// The official C implementation of SQLite allows some additional properties in the connection string
// that are not supported in the in the modernc.org/sqlite driver, and which must be passed as PRAGMA args instead.
// To ensure that people can use similar args as in the C driver, which was also used by Pocket ID
// previously (via github.com/mattn/go-sqlite3), we are converting some options.
// Note this function updates connStringUrl.
func convertSqlitePragmaArgs(connStringUrl *url.URL) {
// Reference: https://github.com/mattn/go-sqlite3?tab=readme-ov-file#connection-string
// This only includes a subset of options, excluding those that are not relevant to us
qs := make(url.Values, len(connStringUrl.Query()))
for k, v := range connStringUrl.Query() {
switch strings.ToLower(k) {
case "_auto_vacuum", "_vacuum":
qs.Add("_pragma", "auto_vacuum("+v[0]+")")
case "_busy_timeout", "_timeout":
qs.Add("_pragma", "busy_timeout("+v[0]+")")
case "_case_sensitive_like", "_cslike":
qs.Add("_pragma", "case_sensitive_like("+v[0]+")")
case "_foreign_keys", "_fk":
qs.Add("_pragma", "foreign_keys("+v[0]+")")
case "_locking_mode", "_locking":
qs.Add("_pragma", "locking_mode("+v[0]+")")
case "_secure_delete":
qs.Add("_pragma", "secure_delete("+v[0]+")")
case "_synchronous", "_sync":
qs.Add("_pragma", "synchronous("+v[0]+")")
default:
// Pass other query-string args as-is
qs[k] = v
}
}
// Update the connStringUrl object
connStringUrl.RawQuery = qs.Encode()
}
// Adds the default (and some required) parameters to the SQLite connection string.
// Note this function updates connStringUrl.
func addSqliteDefaultParameters(connStringUrl *url.URL, isMemoryDB bool) error {
// This function include code adapted from https://github.com/dapr/components-contrib/blob/v1.14.6/
// Copyright (C) 2023 The Dapr Authors
// License: Apache2
const defaultBusyTimeout = 2500 * time.Millisecond
// Get the "query string" from the connection string if present
qs := connStringUrl.Query()
if len(qs) == 0 {
qs = make(url.Values, 2)
}
// Check if the database is read-only or immutable
isReadOnly := false
if len(qs["mode"]) > 0 {
// Keep the first value only
qs["mode"] = []string{
strings.ToLower(qs["mode"][0]),
}
if qs["mode"][0] == "ro" {
isReadOnly = true
}
}
if len(qs["immutable"]) > 0 {
// Keep the first value only
qs["immutable"] = []string{
strings.ToLower(qs["immutable"][0]),
}
if qs["immutable"][0] == "1" {
isReadOnly = true
}
}
// We do not want to override a _txlock if set, but we'll show a warning if it's not "immediate"
if len(qs["_txlock"]) > 0 {
// Keep the first value only
qs["_txlock"] = []string{
strings.ToLower(qs["_txlock"][0]),
}
if qs["_txlock"][0] != "immediate" {
slog.Warn("SQLite connection is being created with a _txlock different from the recommended value 'immediate'")
}
} else {
qs["_txlock"] = []string{"immediate"}
}
// Add pragma values
var hasBusyTimeout, hasJournalMode bool
if len(qs["_pragma"]) == 0 {
qs["_pragma"] = make([]string, 0, 3)
} else {
for _, p := range qs["_pragma"] {
p = strings.ToLower(p)
switch {
case strings.HasPrefix(p, "busy_timeout"):
hasBusyTimeout = true
case strings.HasPrefix(p, "journal_mode"):
hasJournalMode = true
case strings.HasPrefix(p, "foreign_keys"):
return errors.New("found forbidden option '_pragma=foreign_keys' in the connection string")
}
}
}
if !hasBusyTimeout {
qs["_pragma"] = append(qs["_pragma"], fmt.Sprintf("busy_timeout(%d)", defaultBusyTimeout.Milliseconds()))
}
if !hasJournalMode {
switch {
case isMemoryDB:
// For in-memory databases, set the journal to MEMORY, the only allowed option besides OFF (which would make transactions ineffective)
qs["_pragma"] = append(qs["_pragma"], "journal_mode(MEMORY)")
case isReadOnly:
// Set the journaling mode to "DELETE" (the default) if the database is read-only
qs["_pragma"] = append(qs["_pragma"], "journal_mode(DELETE)")
default:
// Enable WAL
qs["_pragma"] = append(qs["_pragma"], "journal_mode(WAL)")
}
}
// Forcefully enable foreign keys
qs["_pragma"] = append(qs["_pragma"], "foreign_keys(1)")
// Update the connStringUrl object
connStringUrl.RawQuery = qs.Encode()
return nil
}
// isSqliteInMemory returns true if the connection string is for an in-memory database.
func isSqliteInMemory(connString string) bool {
lc := strings.ToLower(connString)
// First way to define an in-memory database is to use ":memory:" or "file::memory:" as connection string
if strings.HasPrefix(lc, ":memory:") || strings.HasPrefix(lc, "file::memory:") {
return true
}
// Another way is to pass "mode=memory" in the "query string"
idx := strings.IndexRune(lc, '?')
if idx < 0 {
return false
}
qs, _ := url.ParseQuery(lc[(idx + 1):])
return len(qs["mode"]) > 0 && qs["mode"][0] == "memory"
}
// ensureSqliteTempDir ensures that SQLite has a directory where it can write temporary files if needed
// The default directory may not be writable when using a container with a read-only root file system
// See: https://www.sqlite.org/tempfiles.html
func ensureSqliteTempDir(dbPath string) error {
// Per docs, SQLite tries these folders in order (excluding those that aren't applicable to us):
//
// - The SQLITE_TMPDIR environment variable
// - The TMPDIR environment variable
// - /var/tmp
// - /usr/tmp
// - /tmp
//
// Source: https://www.sqlite.org/tempfiles.html#temporary_file_storage_locations
//
// First, let's check if SQLITE_TMPDIR or TMPDIR are set, in which case we trust the user has taken care of the problem already
if os.Getenv("SQLITE_TMPDIR") != "" || os.Getenv("TMPDIR") != "" {
return nil
}
// Now, let's check if /var/tmp, /usr/tmp, or /tmp exist and are writable
for _, dir := range []string{"/var/tmp", "/usr/tmp", "/tmp"} {
ok, err := utils.IsWritableDir(dir)
if err != nil {
return fmt.Errorf("failed to check if %s is writable: %w", dir, err)
}
if ok {
// We found a folder that's writable
return nil
}
}
// If we're here, there's no temporary directory that's writable (not unusual for containers with a read-only root file system), so we set SQLITE_TMPDIR to the folder where the SQLite database is set
err := os.Setenv("SQLITE_TMPDIR", dbPath)
if err != nil {
return fmt.Errorf("failed to set SQLITE_TMPDIR environmental variable: %w", err)
}
slog.Debug("Set SQLITE_TMPDIR to the database directory", "path", dbPath)
return nil
return nil, nil, err
}
func getGormLogger() gormLogger.Interface {

View File

@@ -1,324 +0,0 @@
package bootstrap
import (
"net/url"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestIsSqliteInMemory(t *testing.T) {
tests := []struct {
name string
connStr string
expected bool
}{
{
name: "memory database with :memory:",
connStr: ":memory:",
expected: true,
},
{
name: "memory database with file::memory:",
connStr: "file::memory:",
expected: true,
},
{
name: "memory database with :MEMORY: (uppercase)",
connStr: ":MEMORY:",
expected: true,
},
{
name: "memory database with FILE::MEMORY: (uppercase)",
connStr: "FILE::MEMORY:",
expected: true,
},
{
name: "memory database with mixed case",
connStr: ":Memory:",
expected: true,
},
{
name: "has mode=memory",
connStr: "file:data?mode=memory",
expected: true,
},
{
name: "file database",
connStr: "data.db",
expected: false,
},
{
name: "file database with path",
connStr: "/path/to/data.db",
expected: false,
},
{
name: "file database with file: prefix",
connStr: "file:data.db",
expected: false,
},
{
name: "empty string",
connStr: "",
expected: false,
},
{
name: "string containing memory but not at start",
connStr: "data:memory:.db",
expected: false,
},
{
name: "has mode=ro",
connStr: "file:data?mode=ro",
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isSqliteInMemory(tt.connStr)
assert.Equal(t, tt.expected, result)
})
}
}
func TestConvertSqlitePragmaArgs(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "basic file path",
input: "file:test.db",
expected: "file:test.db",
},
{
name: "converts _busy_timeout to pragma",
input: "file:test.db?_busy_timeout=5000",
expected: "file:test.db?_pragma=busy_timeout%285000%29",
},
{
name: "converts _timeout to pragma",
input: "file:test.db?_timeout=5000",
expected: "file:test.db?_pragma=busy_timeout%285000%29",
},
{
name: "converts _foreign_keys to pragma",
input: "file:test.db?_foreign_keys=1",
expected: "file:test.db?_pragma=foreign_keys%281%29",
},
{
name: "converts _fk to pragma",
input: "file:test.db?_fk=1",
expected: "file:test.db?_pragma=foreign_keys%281%29",
},
{
name: "converts _synchronous to pragma",
input: "file:test.db?_synchronous=NORMAL",
expected: "file:test.db?_pragma=synchronous%28NORMAL%29",
},
{
name: "converts _sync to pragma",
input: "file:test.db?_sync=NORMAL",
expected: "file:test.db?_pragma=synchronous%28NORMAL%29",
},
{
name: "converts _auto_vacuum to pragma",
input: "file:test.db?_auto_vacuum=FULL",
expected: "file:test.db?_pragma=auto_vacuum%28FULL%29",
},
{
name: "converts _vacuum to pragma",
input: "file:test.db?_vacuum=FULL",
expected: "file:test.db?_pragma=auto_vacuum%28FULL%29",
},
{
name: "converts _case_sensitive_like to pragma",
input: "file:test.db?_case_sensitive_like=1",
expected: "file:test.db?_pragma=case_sensitive_like%281%29",
},
{
name: "converts _cslike to pragma",
input: "file:test.db?_cslike=1",
expected: "file:test.db?_pragma=case_sensitive_like%281%29",
},
{
name: "converts _locking_mode to pragma",
input: "file:test.db?_locking_mode=EXCLUSIVE",
expected: "file:test.db?_pragma=locking_mode%28EXCLUSIVE%29",
},
{
name: "converts _locking to pragma",
input: "file:test.db?_locking=EXCLUSIVE",
expected: "file:test.db?_pragma=locking_mode%28EXCLUSIVE%29",
},
{
name: "converts _secure_delete to pragma",
input: "file:test.db?_secure_delete=1",
expected: "file:test.db?_pragma=secure_delete%281%29",
},
{
name: "preserves unrecognized parameters",
input: "file:test.db?mode=rw&cache=shared",
expected: "file:test.db?cache=shared&mode=rw",
},
{
name: "handles multiple parameters",
input: "file:test.db?_fk=1&mode=rw&_timeout=5000",
expected: "file:test.db?_pragma=foreign_keys%281%29&_pragma=busy_timeout%285000%29&mode=rw",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resultURL, _ := url.Parse(tt.input)
convertSqlitePragmaArgs(resultURL)
// Parse both URLs to compare components independently
expectedURL, err := url.Parse(tt.expected)
require.NoError(t, err)
// Compare scheme and path components
compareQueryStrings(t, expectedURL, resultURL)
})
}
}
func TestAddSqliteDefaultParameters(t *testing.T) {
tests := []struct {
name string
input string
isMemoryDB bool
expected string
expectError bool
}{
{
name: "basic file database",
input: "file:test.db",
isMemoryDB: false,
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28WAL%29&_txlock=immediate",
},
{
name: "in-memory database",
input: "file::memory:",
isMemoryDB: true,
expected: "file::memory:?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28MEMORY%29&_txlock=immediate",
},
{
name: "read-only database with mode=ro",
input: "file:test.db?mode=ro",
isMemoryDB: false,
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28DELETE%29&_txlock=immediate&mode=ro",
},
{
name: "immutable database",
input: "file:test.db?immutable=1",
isMemoryDB: false,
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28DELETE%29&_txlock=immediate&immutable=1",
},
{
name: "database with existing _txlock",
input: "file:test.db?_txlock=deferred",
isMemoryDB: false,
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28WAL%29&_txlock=deferred",
},
{
name: "database with existing busy_timeout pragma",
input: "file:test.db?_pragma=busy_timeout%285000%29",
isMemoryDB: false,
expected: "file:test.db?_pragma=busy_timeout%285000%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28WAL%29&_txlock=immediate",
},
{
name: "database with existing journal_mode pragma",
input: "file:test.db?_pragma=journal_mode%28DELETE%29",
isMemoryDB: false,
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28DELETE%29&_txlock=immediate",
},
{
name: "database with forbidden foreign_keys pragma",
input: "file:test.db?_pragma=foreign_keys%280%29",
isMemoryDB: false,
expectError: true,
},
{
name: "database with multiple existing pragmas",
input: "file:test.db?_pragma=busy_timeout%283000%29&_pragma=journal_mode%28TRUNCATE%29&_pragma=synchronous%28NORMAL%29",
isMemoryDB: false,
expected: "file:test.db?_pragma=busy_timeout%283000%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28TRUNCATE%29&_pragma=synchronous%28NORMAL%29&_txlock=immediate",
},
{
name: "database with mode=rw (not read-only)",
input: "file:test.db?mode=rw",
isMemoryDB: false,
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28WAL%29&_txlock=immediate&mode=rw",
},
{
name: "database with immutable=0 (not immutable)",
input: "file:test.db?immutable=0",
isMemoryDB: false,
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28WAL%29&_txlock=immediate&immutable=0",
},
{
name: "database with mixed case mode=RO",
input: "file:test.db?mode=RO",
isMemoryDB: false,
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28DELETE%29&_txlock=immediate&mode=ro",
},
{
name: "database with mixed case immutable=1",
input: "file:test.db?immutable=1",
isMemoryDB: false,
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28DELETE%29&_txlock=immediate&immutable=1",
},
{
name: "complex database configuration",
input: "file:test.db?cache=shared&mode=rwc&_txlock=immediate&_pragma=synchronous%28FULL%29",
isMemoryDB: false,
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28WAL%29&_pragma=synchronous%28FULL%29&_txlock=immediate&cache=shared&mode=rwc",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resultURL, err := url.Parse(tt.input)
require.NoError(t, err)
err = addSqliteDefaultParameters(resultURL, tt.isMemoryDB)
if tt.expectError {
require.Error(t, err)
return
}
require.NoError(t, err)
expectedURL, err := url.Parse(tt.expected)
require.NoError(t, err)
compareQueryStrings(t, expectedURL, resultURL)
})
}
}
func compareQueryStrings(t *testing.T, expectedURL *url.URL, resultURL *url.URL) {
t.Helper()
// Compare scheme and path components
assert.Equal(t, expectedURL.Scheme, resultURL.Scheme)
assert.Equal(t, expectedURL.Path, resultURL.Path)
// Compare query parameters regardless of order
expectedQuery := expectedURL.Query()
resultQuery := resultURL.Query()
assert.Len(t, expectedQuery, len(resultQuery))
for key, expectedValues := range expectedQuery {
resultValues, ok := resultQuery[key]
_ = assert.True(t, ok) &&
assert.ElementsMatch(t, expectedValues, resultValues)
}
}

View File

@@ -17,7 +17,7 @@ import (
func init() {
registerTestControllers = []func(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services){
func(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services) {
testService, err := service.NewTestService(db, svc.appConfigService, svc.jwtService, svc.ldapService)
testService, err := service.NewTestService(db, svc.appConfigService, svc.jwtService, svc.ldapService, svc.appLockService, svc.fileStorage)
if err != nil {
slog.Error("Failed to initialize test service", slog.Any("error", err))
os.Exit(1)

View File

@@ -9,7 +9,7 @@ import (
"time"
sloggin "github.com/gin-contrib/slog"
"github.com/italypaleale/go-kit/servicerunner"
"github.com/lmittmann/tint"
"github.com/mattn/go-isatty"
"go.opentelemetry.io/contrib/bridges/otelslog"
@@ -17,17 +17,14 @@ import (
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
"go.opentelemetry.io/otel"
globallog "go.opentelemetry.io/otel/log/global"
metricnoop "go.opentelemetry.io/otel/metric/noop"
"go.opentelemetry.io/otel/propagation"
sdklog "go.opentelemetry.io/otel/sdk/log"
"go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.30.0"
tracenoop "go.opentelemetry.io/otel/trace/noop"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
func defaultResource() (*resource.Resource, error) {
@@ -40,13 +37,13 @@ func defaultResource() (*resource.Resource, error) {
)
}
func initObservability(ctx context.Context, metrics, traces bool) (shutdownFns []utils.Service, httpClient *http.Client, err error) {
func initObservability(ctx context.Context) (shutdownFns []servicerunner.Service, httpClient *http.Client, err error) {
resource, err := defaultResource()
if err != nil {
return nil, nil, fmt.Errorf("failed to create OpenTelemetry resource: %w", err)
}
shutdownFns = make([]utils.Service, 0, 2)
shutdownFns = make([]servicerunner.Service, 0, 3)
httpClient = &http.Client{}
defaultTransport, ok := http.DefaultTransport.(*http.Transport)
@@ -57,13 +54,15 @@ func initObservability(ctx context.Context, metrics, traces bool) (shutdownFns [
httpClient.Transport = defaultTransport.Clone()
// Logging
err = initOtelLogging(ctx, resource)
loggingShutdownFn, err := initOtelLogging(ctx, resource)
if err != nil {
return nil, nil, err
} else if loggingShutdownFn != nil {
shutdownFns = append(shutdownFns, loggingShutdownFn)
}
// Tracing
tracingShutdownFn, err := initOtelTracing(ctx, traces, resource, httpClient)
tracingShutdownFn, err := initOtelTracing(ctx, resource, httpClient)
if err != nil {
return nil, nil, err
} else if tracingShutdownFn != nil {
@@ -71,7 +70,7 @@ func initObservability(ctx context.Context, metrics, traces bool) (shutdownFns [
}
// Metrics
metricsShutdownFn, err := initOtelMetrics(ctx, metrics, resource)
metricsShutdownFn, err := initOtelMetrics(ctx, resource)
if err != nil {
return nil, nil, err
} else if metricsShutdownFn != nil {
@@ -81,19 +80,19 @@ func initObservability(ctx context.Context, metrics, traces bool) (shutdownFns [
return shutdownFns, httpClient, nil
}
func initOtelLogging(ctx context.Context, resource *resource.Resource) error {
func initOtelLogging(ctx context.Context, resource *resource.Resource) (shutdownFn servicerunner.Service, err error) {
// If the env var OTEL_LOGS_EXPORTER is empty, we set it to "none", for autoexport to work
if os.Getenv("OTEL_LOGS_EXPORTER") == "" {
os.Setenv("OTEL_LOGS_EXPORTER", "none")
}
exp, err := autoexport.NewLogExporter(ctx)
if err != nil {
return fmt.Errorf("failed to initialize OpenTelemetry log exporter: %w", err)
return nil, fmt.Errorf("failed to initialize OpenTelemetry log exporter: %w", err)
}
level, _ := sloggin.ParseLevel(common.EnvConfig.LogLevel)
// Create the handler
// Create the console handler
var handler slog.Handler
if common.EnvConfig.LogJSON {
handler = slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
@@ -107,21 +106,34 @@ func initOtelLogging(ctx context.Context, resource *resource.Resource) error {
})
}
// Create the logger provider
provider := sdklog.NewLoggerProvider(
sdklog.WithProcessor(
sdklog.NewBatchProcessor(exp),
),
sdklog.WithResource(resource),
)
// When log export is enabled, also send logs to OpenTelemetry through a batch processor.
// When it's disabled (the exporter is a no-op), we skip the OTel handler entirely so we don't convert and buffer every log record just to drop it.
if !autoexport.IsNoneLogExporter(exp) {
// Create the logger provider
provider := sdklog.NewLoggerProvider(
sdklog.WithProcessor(
sdklog.NewBatchProcessor(exp),
),
sdklog.WithResource(resource),
)
// Set the logger provider globally
globallog.SetLoggerProvider(provider)
// Set the logger provider globally
globallog.SetLoggerProvider(provider)
// Wrap the handler in a "fanout" one
handler = utils.LogFanoutHandler{
handler,
otelslog.NewHandler(common.Name, otelslog.WithLoggerProvider(provider)),
handler = slog.NewMultiHandler(
handler,
otelslog.NewHandler(common.Name, otelslog.WithLoggerProvider(provider)),
)
shutdownFn = func(shutdownCtx context.Context) error { //nolint:contextcheck
lpCtx, lpCancel := context.WithTimeout(shutdownCtx, 10*time.Second)
defer lpCancel()
shutdownErr := provider.Shutdown(lpCtx)
if shutdownErr != nil {
return fmt.Errorf("failed to gracefully shut down logs exporter: %w", shutdownErr)
}
return nil
}
}
// Set the default slog to send logs to OTel and add the app name
@@ -130,13 +142,13 @@ func initOtelLogging(ctx context.Context, resource *resource.Resource) error {
With(slog.String("version", common.Version))
slog.SetDefault(log)
return nil
return shutdownFn, nil
}
func initOtelTracing(ctx context.Context, traces bool, resource *resource.Resource, httpClient *http.Client) (shutdownFn utils.Service, err error) {
if !traces {
otel.SetTracerProvider(tracenoop.NewTracerProvider())
return nil, nil
func initOtelTracing(ctx context.Context, resource *resource.Resource, httpClient *http.Client) (shutdownFn servicerunner.Service, err error) {
// If the env var OTEL_TRACES_EXPORTER is empty, we set it to "none"
if os.Getenv("OTEL_TRACES_EXPORTER") == "" {
os.Setenv("OTEL_TRACES_EXPORTER", "none")
}
tr, err := autoexport.NewSpanExporter(ctx)
@@ -172,10 +184,10 @@ func initOtelTracing(ctx context.Context, traces bool, resource *resource.Resour
return shutdownFn, nil
}
func initOtelMetrics(ctx context.Context, metrics bool, resource *resource.Resource) (shutdownFn utils.Service, err error) {
if !metrics {
otel.SetMeterProvider(metricnoop.NewMeterProvider())
return nil, nil
func initOtelMetrics(ctx context.Context, resource *resource.Resource) (shutdownFn servicerunner.Service, err error) {
// If the env var OTEL_METRICS_EXPORTER is empty, we set it to "none"
if os.Getenv("OTEL_METRICS_EXPORTER") == "" {
os.Setenv("OTEL_METRICS_EXPORTER", "none")
}
mr, err := autoexport.NewMetricReader(ctx)

View File

@@ -2,183 +2,354 @@ package bootstrap
import (
"context"
"crypto/tls"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"os"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/fsnotify/fsnotify"
sloggin "github.com/gin-contrib/slog"
"github.com/gin-gonic/gin"
"github.com/italypaleale/francis/builtin/ratelimit"
"github.com/italypaleale/go-kit/servicerunner"
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
"golang.org/x/time/rate"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/frontend"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/controller"
"github.com/pocket-id/pocket-id/backend/internal/middleware"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"github.com/pocket-id/pocket-id/backend/internal/tracing"
"github.com/pocket-id/pocket-id/backend/internal/utils/systemd"
)
// This is used to register additional controllers for tests
var registerTestControllers []func(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services)
func initRouter(db *gorm.DB, svc *services) utils.Service {
runner, err := initRouterInternal(db, svc)
func initRouter(db *gorm.DB, svc *services, rateLimitServices map[string]*ratelimit.RateLimitService) (servicerunner.Service, error) {
r, err := initEngine()
if err != nil {
slog.Error("Failed to init router", "error", err)
os.Exit(1)
return nil, err
}
return runner
err = registerRoutes(r, db, svc, rateLimitServices)
if err != nil {
return nil, err
}
serverConfig, err := initServer(r)
if err != nil {
return nil, err
}
runFn := func(ctx context.Context) error {
return runServer(ctx, serverConfig)
}
return runFn, nil
}
func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
// Set the appropriate Gin mode based on the environment
switch common.EnvConfig.AppEnv {
case "production":
gin.SetMode(gin.ReleaseMode)
case "development":
gin.SetMode(gin.DebugMode)
case "test":
gin.SetMode(gin.TestMode)
}
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)
return r, nil
}
func setGinMode() {
// Set the appropriate Gin mode based on the environment
switch common.EnvConfig.AppEnv {
case common.AppEnvProduction:
gin.SetMode(gin.ReleaseMode)
case common.AppEnvDevelopment:
gin.SetMode(gin.DebugMode)
case common.AppEnvTest:
gin.SetMode(gin.TestMode)
}
}
func configureEngine(r *gin.Engine) {
if !common.EnvConfig.TrustProxy {
_ = r.SetTrustedProxies(nil)
}
if common.EnvConfig.TracingEnabled {
r.Use(otelgin.Middleware(common.Name))
if common.EnvConfig.TrustedPlatform != "" {
r.TrustedPlatform = common.EnvConfig.TrustedPlatform
}
rateLimitMiddleware := middleware.NewRateLimitMiddleware().Add(rate.Every(time.Second), 60)
r.Use(otelgin.Middleware(
common.Name,
otelgin.WithFilter(shouldTraceRequest)),
)
}
// Setup global middleware
// shouldTraceRequest reports whether an incoming request should be traced.
// It traces only requests handled by real backend routes (the API, the OIDC/OAuth endpoints, and the well-known documents).
// Everything else falls through to the frontend NoRoute handler, which serves the SPA shell and static assets; tracing those would produce noisy, unparented spans named just "GET" with an empty http.route.
func shouldTraceRequest(r *http.Request) bool {
p := r.URL.Path
switch {
case strings.HasPrefix(p, "/api/"),
strings.HasPrefix(p, "/.well-known/"),
p == "/authorize":
return true
default:
return false
}
}
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())
}
func registerRoutes(r *gin.Engine, db *gorm.DB, svc *services, rateLimitServices map[string]*ratelimit.RateLimitService) error {
err := frontend.RegisterFrontend(r)
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)
return fmt.Errorf("failed to register frontend: %w", err)
}
// Initialize middleware for specific routes
authMiddleware := middleware.NewAuthMiddleware(svc.apiKeyService, svc.userService, svc.jwtService)
authMiddleware := middleware.NewAuthMiddleware(svc.apiKeyModule, svc.userService, svc.jwtService)
fileSizeLimitMiddleware := middleware.NewFileSizeLimitMiddleware()
rateLimitMiddleware := middleware.NewRateLimitMiddleware(rateLimitServices)
apiRateLimitMiddleware := rateLimitMiddleware.Add(middleware.RateLimitAPI)
// Set up API routes
apiGroup := r.Group("/api", rateLimitMiddleware)
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.appConfigService)
apiGroup := r.Group("/api", apiRateLimitMiddleware)
baseGroup := r.Group("/", apiRateLimitMiddleware)
svc.apiKeyModule.RegisterRoutes(apiGroup,
authMiddleware.WithAdminNotRequired().Add(),
authMiddleware.WithAdminNotRequired().WithApiKeyAuthDisabled().Add(),
)
svc.webauthnModule.RegisterRoutes(apiGroup,
authMiddleware.WithAdminNotRequired().Add(),
rateLimitMiddleware.Add(middleware.RateLimitWebauthnLogin),
rateLimitMiddleware.Add(middleware.RateLimitWebauthnReauthenticate),
)
controller.NewOidcController(apiGroup, authMiddleware, fileSizeLimitMiddleware, svc.oidcService)
controller.NewUserController(apiGroup, authMiddleware, rateLimitMiddleware, svc.userService, svc.oneTimeAccessService, svc.webauthnModule, svc.appConfigService)
controller.NewAppConfigController(apiGroup, authMiddleware, svc.appConfigService, svc.emailService, svc.ldapService)
controller.NewAppImagesController(apiGroup, authMiddleware, svc.appImagesService)
controller.NewAuditLogController(apiGroup, svc.auditLogService, authMiddleware)
controller.NewUserGroupController(apiGroup, authMiddleware, svc.userGroupService)
svc.apiModule.RegisterRoutes(apiGroup, authMiddleware.Add())
controller.NewCustomClaimController(apiGroup, authMiddleware, svc.customClaimService)
controller.NewVersionController(apiGroup, svc.versionService)
controller.NewVersionController(apiGroup, authMiddleware, svc.versionService)
controller.NewScimController(apiGroup, authMiddleware, svc.scimService)
svc.userSignUpModule.RegisterRoutes(apiGroup,
authMiddleware.Add(),
rateLimitMiddleware.Add(middleware.RateLimitSignup),
)
// Add test controller in non-production environments
if common.EnvConfig.AppEnv != "production" {
for _, f := range registerTestControllers {
f(apiGroup, db, svc)
}
}
optionalBrowserAuth := authMiddleware.WithAdminNotRequired().WithSuccessOptional().WithApiKeyAuthDisabled().Add()
browserAuth := authMiddleware.WithAdminNotRequired().WithApiKeyAuthDisabled().Add()
svc.oidcModule.RegisterRoutes(baseGroup, apiGroup, optionalBrowserAuth, browserAuth)
registerTestRoutes(apiGroup, db, svc)
// Set up base routes
baseGroup := r.Group("/", rateLimitMiddleware)
controller.NewWellKnownController(baseGroup, svc.jwtService)
// Set up healthcheck routes
// These are not rate-limited
// These are not rate-limited.
controller.NewHealthzController(r)
// Set up the server
srv := &http.Server{
// Receives OTLP trace payloads from the browser SPA (POST /internal/telemetry/traces) and forwards them to the collector, when trace export is enabled.
// Outside /api, so it's unauthenticated and not traced, but it is rate-limited.
tracing.NewTelemetryController(r, rateLimitMiddleware.Add(middleware.RateLimitInternal))
return nil
}
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
}
var socketFn func() (*socket, error)
switch {
case common.EnvConfig.SystemdSocket:
socketFn = systemdSocket
case common.EnvConfig.UnixSocket != "":
socketFn = unixSocket
default:
socketFn = tcpSocket
}
socket, err := socketFn()
if err != nil {
return nil, err
}
addr := socket.addr
listener := socket.listener
server := newHTTPServer(r, protocols)
return &serverConfig{addr, certProvider, listener, server, tlsConfig}, nil
}
func initServerProtocols() (*http.Protocols, *tls.Config, *tlsCertProvider, error) {
protocols := new(http.Protocols)
protocols.SetHTTP1(true)
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,
Handler: r,
}
// 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
}
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)
Protocols: protocols,
Handler: 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
if req.Method == http.MethodHead {
req.Method = http.MethodGet
ctx := context.WithValue(req.Context(), middleware.IsHeadRequestCtxKey{}, true)
req = req.WithContext(ctx)
}
}()
// 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)
}
r.ServeHTTP(w, req)
}),
}
}
// Block until the context is canceled
<-ctx.Done()
func runServer(ctx context.Context, config *serverConfig) error {
slog.Info("Server listening", slog.String("addr", config.addr), slog.Bool("tls", config.tlsConfig != nil))
// 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)
}
certWatcher, err := startCertWatcher(ctx, config.certProvider)
if err != nil {
return err
}
defer closeCertWatcher(certWatcher)
return nil
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
}
return runFn, 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) {
@@ -186,6 +357,7 @@ func initLogger(r *gin.Engine) {
"GET /api/application-images/logo",
"GET /api/application-images/background",
"GET /api/application-images/favicon",
"GET /api/application-images/email",
"GET /_app",
"GET /fonts",
"GET /healthz",
@@ -206,3 +378,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

@@ -3,14 +3,13 @@ package bootstrap
import (
"context"
"fmt"
"net/http"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/job"
)
func registerScheduledJobs(ctx context.Context, db *gorm.DB, svc *services, httpClient *http.Client, scheduler *job.Scheduler) error {
func registerScheduledJobs(ctx context.Context, db *gorm.DB, svc *services, scheduler *job.Scheduler) error {
err := scheduler.RegisterLdapJobs(ctx, svc.ldapService, svc.appConfigService)
if err != nil {
return fmt.Errorf("failed to register LDAP jobs in scheduler: %w", err)
@@ -23,17 +22,13 @@ func registerScheduledJobs(ctx context.Context, db *gorm.DB, svc *services, http
if err != nil {
return fmt.Errorf("failed to register DB cleanup jobs in scheduler: %w", err)
}
err = scheduler.RegisterFileCleanupJobs(ctx, db)
if err != nil {
return fmt.Errorf("failed to register file cleanup jobs in scheduler: %w", err)
}
err = scheduler.RegisterApiKeyExpiryJob(ctx, svc.apiKeyService, svc.appConfigService)
err = scheduler.RegisterApiKeyExpiryJob(ctx, svc.apiKeyModule, svc.appConfigService, svc.emailService)
if err != nil {
return fmt.Errorf("failed to register API key expiration jobs in scheduler: %w", err)
}
err = scheduler.RegisterAnalyticsJob(ctx, svc.appConfigService, httpClient)
err = scheduler.RegisterScimJobs(ctx, svc.scimService)
if err != nil {
return fmt.Errorf("failed to register analytics job in scheduler: %w", err)
return fmt.Errorf("failed to register SCIM scheduler job: %w", err)
}
return nil

View File

@@ -5,30 +5,46 @@ import (
"fmt"
"net/http"
"github.com/pocket-id/pocket-id/backend/internal/apikey"
"github.com/pocket-id/pocket-id/backend/internal/job"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/api"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/oidc"
"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/usersignup"
"github.com/pocket-id/pocket-id/backend/internal/webauthn"
)
type services struct {
appConfigService *service.AppConfigService
appImagesService *service.AppImagesService
emailService *service.EmailService
geoLiteService *service.GeoLiteService
auditLogService *service.AuditLogService
jwtService *service.JwtService
webauthnService *service.WebAuthnService
userService *service.UserService
customClaimService *service.CustomClaimService
oidcService *service.OidcService
userGroupService *service.UserGroupService
ldapService *service.LdapService
apiKeyService *service.ApiKeyService
versionService *service.VersionService
appConfigService *service.AppConfigService
appImagesService *service.AppImagesService
emailService *service.EmailService
geoLiteService *service.GeoLiteService
auditLogService *service.AuditLogService
jwtService *service.JwtService
scimService *service.ScimService
userService *service.UserService
customClaimService *service.CustomClaimService
oidcService *service.OidcService
userGroupService *service.UserGroupService
ldapService *service.LdapService
versionService *service.VersionService
fileStorage storage.FileStorage
appLockService *service.AppLockService
oneTimeAccessService *service.OneTimeAccessService
apiKeyModule *apikey.Module
oidcModule *oidc.Module
webauthnModule *webauthn.Module
userSignUpModule *usersignup.Module
apiModule *api.Module
}
// Initializes all services
func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client, imageExtensions map[string]string) (svc *services, err error) {
func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client, imageExtensions map[string]string, fileStorage storage.FileStorage, scheduler *job.Scheduler) (svc *services, err error) {
svc = &services{}
svc.appConfigService, err = service.NewAppConfigService(ctx, db)
@@ -36,7 +52,9 @@ func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client, ima
return nil, fmt.Errorf("failed to create app config service: %w", err)
}
svc.appImagesService = service.NewAppImagesService(imageExtensions)
svc.fileStorage = fileStorage
svc.appImagesService = service.NewAppImagesService(imageExtensions, fileStorage)
svc.appLockService = service.NewAppLockService(db)
svc.emailService, err = service.NewEmailService(db, svc.appConfigService)
if err != nil {
@@ -45,26 +63,70 @@ func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client, ima
svc.geoLiteService = service.NewGeoLiteService(httpClient)
svc.auditLogService = service.NewAuditLogService(db, svc.appConfigService, svc.emailService, svc.geoLiteService)
svc.jwtService, err = service.NewJwtService(db, svc.appConfigService)
svc.jwtService, err = service.NewJwtService(ctx, db, svc.appConfigService)
if err != nil {
return nil, fmt.Errorf("failed to create JWT service: %w", err)
}
svc.customClaimService = service.NewCustomClaimService(db)
svc.webauthnService, err = service.NewWebAuthnService(db, svc.jwtService, svc.auditLogService, svc.appConfigService)
svc.webauthnModule, err = webauthn.New(webauthn.Dependencies{
DB: db,
AppURL: common.EnvConfig.AppURL,
Signer: svc.jwtService,
AuditLog: svc.auditLogService,
AppConfig: svc.appConfigService,
})
if err != nil {
return nil, fmt.Errorf("failed to create WebAuthn service: %w", err)
return nil, fmt.Errorf("failed to create WebAuthn module: %w", err)
}
svc.oidcService, err = service.NewOidcService(ctx, db, svc.jwtService, svc.appConfigService, svc.auditLogService, svc.customClaimService, svc.webauthnService, httpClient)
svc.scimService = service.NewScimService(db, scheduler, httpClient)
svc.apiModule = api.New(api.Dependencies{DB: db, Issuer: common.EnvConfig.AppURL})
svc.oidcModule, err = oidc.New(ctx, oidc.Dependencies{
DB: db,
HTTPClient: httpClient,
Config: oidc.Config{
BaseURL: common.EnvConfig.AppURL,
TokenBaseURL: common.EnvConfig.AppURL,
Secret: common.EnvConfig.EncryptionKey,
},
Signer: svc.jwtService,
CustomClaims: svc.customClaimService,
Reauth: svc.webauthnModule,
AuditLog: svc.auditLogService,
APIAccess: svc.apiModule,
})
if err != nil {
return nil, fmt.Errorf("failed to create OIDC module: %w", err)
}
svc.oidcService, err = service.NewOidcService(db, svc.jwtService, svc.appConfigService, svc.oidcModule.Preview, svc.scimService, httpClient, fileStorage)
if err != nil {
return nil, fmt.Errorf("failed to create OIDC service: %w", err)
}
svc.userGroupService = service.NewUserGroupService(db, svc.appConfigService)
svc.userService = service.NewUserService(db, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService, svc.customClaimService)
svc.ldapService = service.NewLdapService(db, httpClient, svc.appConfigService, svc.userService, svc.userGroupService)
svc.apiKeyService = service.NewApiKeyService(db, svc.emailService)
svc.userGroupService = service.NewUserGroupService(db, svc.appConfigService, svc.scimService)
svc.userService = service.NewUserService(db, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService, svc.customClaimService, svc.appImagesService, svc.scimService, fileStorage)
svc.ldapService = service.NewLdapService(db, httpClient, svc.appConfigService, svc.userService, svc.userGroupService, fileStorage)
svc.apiKeyModule, err = apikey.New(ctx, apikey.Dependencies{
DB: db,
StaticApiKey: common.EnvConfig.StaticApiKey,
})
if err != nil {
return nil, fmt.Errorf("failed to create API key module: %w", err)
}
svc.userSignUpModule = usersignup.New(usersignup.Dependencies{
DB: db,
Signer: svc.jwtService,
AuditLog: svc.auditLogService,
AppConfig: svc.appConfigService,
UserCreator: svc.userService,
})
svc.oneTimeAccessService = service.NewOneTimeAccessService(db, svc.userService, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService)
svc.versionService = service.NewVersionService(httpClient)

View File

@@ -0,0 +1,51 @@
package bootstrap
import (
"fmt"
"net"
"os"
"strconv"
"github.com/pocket-id/pocket-id/backend/internal/common"
)
type socket struct {
addr string
listener net.Listener
}
func unixSocket() (*socket, error) {
addr := common.EnvConfig.UnixSocket
os.Remove(addr) // remove dangling the socket file to avoid file-exist error
listener, err := net.Listen("unix", addr) //nolint:noctx
if err != nil {
return nil, fmt.Errorf("failed to create UNIX socket: %w", err)
}
if common.EnvConfig.UnixSocketMode != "" {
mode, err := strconv.ParseUint(common.EnvConfig.UnixSocketMode, 8, 32)
if err != nil {
listener.Close()
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 {
listener.Close()
return nil, fmt.Errorf("failed to set UNIX socket mode '%s': %w", common.EnvConfig.UnixSocketMode, err)
}
}
return &socket{addr, listener}, nil
}
func tcpSocket() (*socket, error) {
addr := net.JoinHostPort(common.EnvConfig.Host, common.EnvConfig.Port)
listener, err := net.Listen("tcp", addr) //nolint:noctx
if err != nil {
return nil, fmt.Errorf("failed to create TCP socket: %w", err)
}
return &socket{addr, listener}, nil
}

View File

@@ -0,0 +1,27 @@
//go:build linux
package bootstrap
import (
"errors"
"fmt"
"github.com/coreos/go-systemd/activation"
)
func systemdSocket() (*socket, error) {
listeners, err := activation.Listeners()
if err != nil {
return nil, fmt.Errorf("failed to receive socket from systemd: %w", err)
}
if len(listeners) == 0 {
return nil, errors.New("did not receive any sockets from systemd")
}
if len(listeners) > 1 {
return nil, errors.New("received too many sockets from systemd")
}
return &socket{"(systemd)", listeners[0]}, nil
}

View File

@@ -0,0 +1,9 @@
//go:build !linux
package bootstrap
import "errors"
func systemdSocket() (*socket, error) {
return nil, errors.New("systemd socket activation is only supported on Linux")
}

View File

@@ -0,0 +1,187 @@
package cmds
import (
"context"
"errors"
"fmt"
"os"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/spf13/cobra"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/bootstrap"
"github.com/pocket-id/pocket-id/backend/internal/common"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/utils"
jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk"
)
type encryptionKeyRotateFlags struct {
NewKey string
Yes bool
}
func init() {
var flags encryptionKeyRotateFlags
encryptionKeyRotateCmd := &cobra.Command{
Use: "encryption-key-rotate",
Short: "Re-encrypts data using a new encryption key",
RunE: func(cmd *cobra.Command, args []string) error {
db, _, err := bootstrap.NewDatabase(cmd.Context())
if err != nil {
return err
}
return encryptionKeyRotate(cmd.Context(), flags, db, &common.EnvConfig)
},
}
encryptionKeyRotateCmd.Flags().StringVar(&flags.NewKey, "new-key", "", "New encryption key to re-encrypt data with")
encryptionKeyRotateCmd.Flags().BoolVarP(&flags.Yes, "yes", "y", false, "Do not prompt for confirmation")
rootCmd.AddCommand(encryptionKeyRotateCmd)
}
func encryptionKeyRotate(ctx context.Context, flags encryptionKeyRotateFlags, db *gorm.DB, envConfig *common.EnvConfigSchema) error {
oldKey := envConfig.EncryptionKey
newKey := []byte(flags.NewKey)
if len(newKey) == 0 {
return errors.New("new encryption key is required (--new-key)")
}
if len(newKey) < 16 {
return errors.New("new encryption key must be at least 16 bytes long")
}
if !flags.Yes {
fmt.Println("WARNING: Rotating the encryption key will re-encrypt secrets in the database. Pocket-ID must be restarted with the new ENCRYPTION_KEY after rotation is complete.")
ok, err := utils.PromptForConfirmation("Continue")
if err != nil {
return err
}
if !ok {
fmt.Println("Aborted")
os.Exit(1)
}
}
appConfigService, err := service.NewAppConfigService(ctx, db)
if err != nil {
return fmt.Errorf("failed to create app config service: %w", err)
}
instanceID := appConfigService.GetDbConfig().InstanceID.Value
// Derive the encryption keys used for the JWK encryption
oldKek, err := jwkutils.LoadKeyEncryptionKey(&common.EnvConfigSchema{EncryptionKey: oldKey}, instanceID)
if err != nil {
return fmt.Errorf("failed to derive old key encryption key: %w", err)
}
newKek, err := jwkutils.LoadKeyEncryptionKey(&common.EnvConfigSchema{EncryptionKey: newKey}, instanceID)
if err != nil {
return fmt.Errorf("failed to derive new key encryption key: %w", err)
}
// Derive the encryption keys used for EncryptedString fields
oldEncKey, err := datatype.DeriveEncryptedStringKey(oldKey)
if err != nil {
return fmt.Errorf("failed to derive old encrypted string key: %w", err)
}
newEncKey, err := datatype.DeriveEncryptedStringKey(newKey)
if err != nil {
return fmt.Errorf("failed to derive new encrypted string key: %w", err)
}
err = db.Transaction(func(tx *gorm.DB) error {
err = rotateSigningKeyEncryption(ctx, tx, oldKek, newKek)
if err != nil {
return err
}
err = rotateScimTokens(tx, oldEncKey, newEncKey)
if err != nil {
return err
}
return nil
})
if err != nil {
return err
}
fmt.Println("Encryption key rotation completed successfully.")
fmt.Println("Restart pocket-id with the new ENCRYPTION_KEY to use the rotated data.")
return nil
}
func rotateSigningKeyEncryption(ctx context.Context, db *gorm.DB, oldKek []byte, newKek []byte) error {
oldProvider := &jwkutils.KeyProviderDatabase{}
err := oldProvider.Init(jwkutils.KeyProviderOpts{
DB: db,
Kek: oldKek,
})
if err != nil {
return fmt.Errorf("failed to init key provider with old encryption key: %w", err)
}
key, err := oldProvider.LoadKey(ctx)
if err != nil {
return fmt.Errorf("failed to load signing key using old encryption key: %w", err)
}
if key == nil {
return nil
}
newProvider := &jwkutils.KeyProviderDatabase{}
err = newProvider.Init(jwkutils.KeyProviderOpts{
DB: db,
Kek: newKek,
})
if err != nil {
return fmt.Errorf("failed to init key provider with new encryption key: %w", err)
}
if err := newProvider.SaveKey(ctx, key); err != nil {
return fmt.Errorf("failed to store signing key with new encryption key: %w", err)
}
return nil
}
type scimTokenRow struct {
ID string
Token string
}
func rotateScimTokens(db *gorm.DB, oldEncKey []byte, newEncKey []byte) error {
var rows []scimTokenRow
err := db.Model(&model.ScimServiceProvider{}).Select("id, token").Scan(&rows).Error
if err != nil {
return fmt.Errorf("failed to list SCIM service providers: %w", err)
}
for _, row := range rows {
if row.Token == "" {
continue
}
decBytes, err := datatype.DecryptEncryptedStringWithKey(oldEncKey, row.Token)
if err != nil {
return fmt.Errorf("failed to decrypt SCIM token for provider %s: %w", row.ID, err)
}
encValue, err := datatype.EncryptEncryptedStringWithKey(newEncKey, decBytes)
if err != nil {
return fmt.Errorf("failed to encrypt SCIM token for provider %s: %w", row.ID, err)
}
err = db.Model(&model.ScimServiceProvider{}).Where("id = ?", row.ID).Update("token", encValue).Error
if err != nil {
return fmt.Errorf("failed to update SCIM token for provider %s: %w", row.ID, err)
}
}
return nil
}

View File

@@ -0,0 +1,89 @@
package cmds
import (
"testing"
"time"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/pocket-id/pocket-id/backend/internal/common"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/service"
jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk"
testingutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
func TestEncryptionKeyRotate(t *testing.T) {
oldKey := []byte("old-encryption-key-123456")
newKey := []byte("new-encryption-key-654321")
envConfig := &common.EnvConfigSchema{
EncryptionKey: oldKey,
}
db := testingutils.NewDatabaseForTest(t)
appConfigService, err := service.NewAppConfigService(t.Context(), db)
require.NoError(t, err)
instanceID := appConfigService.GetDbConfig().InstanceID.Value
oldKek, err := jwkutils.LoadKeyEncryptionKey(envConfig, instanceID)
require.NoError(t, err)
oldProvider := &jwkutils.KeyProviderDatabase{}
require.NoError(t, oldProvider.Init(jwkutils.KeyProviderOpts{
DB: db,
Kek: oldKek,
}))
signingKey, err := jwkutils.GenerateKey("RS256", "")
require.NoError(t, err)
require.NoError(t, oldProvider.SaveKey(t.Context(), signingKey))
oldEncKey, err := datatype.DeriveEncryptedStringKey(oldKey)
require.NoError(t, err)
encToken, err := datatype.EncryptEncryptedStringWithKey(oldEncKey, []byte("scim-token-123"))
require.NoError(t, err)
err = db.Exec(
`INSERT INTO scim_service_providers (id, created_at, endpoint, token, oidc_client_id) VALUES (?, ?, ?, ?, ?)`,
"scim-1",
time.Now(),
"https://example.com/scim",
encToken,
"client-1",
).Error
require.NoError(t, err)
flags := encryptionKeyRotateFlags{
NewKey: string(newKey),
Yes: true,
}
require.NoError(t, encryptionKeyRotate(t.Context(), flags, db, envConfig))
newKek, err := jwkutils.LoadKeyEncryptionKey(&common.EnvConfigSchema{EncryptionKey: newKey}, instanceID)
require.NoError(t, err)
newProvider := &jwkutils.KeyProviderDatabase{}
require.NoError(t, newProvider.Init(jwkutils.KeyProviderOpts{
DB: db,
Kek: newKek,
}))
rotatedKey, err := newProvider.LoadKey(t.Context())
require.NoError(t, err)
require.NotNil(t, rotatedKey)
var storedToken string
err = db.Model(&model.ScimServiceProvider{}).Where("id = ?", "scim-1").Pluck("token", &storedToken).Error
require.NoError(t, err)
newEncKey, err := datatype.DeriveEncryptedStringKey(newKey)
require.NoError(t, err)
decBytes, err := datatype.DecryptEncryptedStringWithKey(newEncKey, storedToken)
require.NoError(t, err)
assert.Equal(t, "scim-token-123", string(decBytes))
}

View File

@@ -0,0 +1,70 @@
package cmds
import (
"context"
"fmt"
"io"
"os"
"github.com/pocket-id/pocket-id/backend/internal/bootstrap"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/spf13/cobra"
)
type exportFlags struct {
Path string
}
func init() {
var flags exportFlags
exportCmd := &cobra.Command{
Use: "export",
Short: "Exports all data of Pocket ID into a ZIP file",
RunE: func(cmd *cobra.Command, args []string) error {
return runExport(cmd.Context(), flags)
},
}
exportCmd.Flags().StringVarP(&flags.Path, "path", "p", "pocket-id-export.zip", "Path to the ZIP file to export the data to, or '-' to write to stdout")
rootCmd.AddCommand(exportCmd)
}
// runExport orchestrates the export flow
func runExport(ctx context.Context, flags exportFlags) error {
db, _, err := bootstrap.NewDatabase(ctx)
if err != nil {
return fmt.Errorf("failed to connect to database: %w", err)
}
storage, err := bootstrap.InitStorage(ctx, db)
if err != nil {
return fmt.Errorf("failed to initialize storage: %w", err)
}
exportService := service.NewExportService(db, storage)
var w io.Writer
if flags.Path == "-" {
w = os.Stdout
} else {
file, err := os.Create(flags.Path)
if err != nil {
return fmt.Errorf("failed to create export file: %w", err)
}
defer file.Close()
w = file
}
if err := exportService.ExportToZip(ctx, w); err != nil {
return fmt.Errorf("failed to export data: %w", err)
}
if flags.Path != "-" {
fmt.Printf("Exported data to %s\n", flags.Path)
}
return nil
}

View File

@@ -2,9 +2,12 @@ package cmds
import (
"context"
"fmt"
"log/slog"
"net"
"net/http"
"os"
"strings"
"time"
"github.com/spf13/cobra"
@@ -13,8 +16,14 @@ import (
)
type healthcheckFlags struct {
Endpoint string
Verbose bool
Endpoint string
UnixSocket string
Verbose bool
}
type healthcheckResult struct {
StatusCode int
URL string
}
func init() {
@@ -29,47 +38,26 @@ func init() {
ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second)
defer cancel()
url := flags.Endpoint + "/healthz"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if flags.UnixSocket == "" && !cmd.Flags().Changed("endpoint") {
flags.UnixSocket = common.EnvConfig.UnixSocket
}
result, err := healthcheck(ctx, flags)
if err != nil {
slog.ErrorContext(ctx,
"Failed to create request object",
"Healthcheck failed",
"error", err,
"url", url,
"ms", time.Since(start).Milliseconds(),
)
os.Exit(1)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
slog.ErrorContext(ctx,
"Failed to perform request",
"error", err,
"url", url,
"ms", time.Since(start).Milliseconds(),
)
os.Exit(1)
}
defer res.Body.Close()
if res.StatusCode < 200 || res.StatusCode >= 300 {
if err != nil {
slog.ErrorContext(ctx,
"Healthcheck failed",
"status", res.StatusCode,
"url", url,
"ms", time.Since(start).Milliseconds(),
)
os.Exit(1)
}
}
if flags.Verbose {
slog.InfoContext(ctx,
"Healthcheck succeeded",
"status", res.StatusCode,
"url", url,
"status", result.StatusCode,
"url", result.URL,
"unixSocket", flags.UnixSocket,
"ms", time.Since(start).Milliseconds(),
)
}
@@ -77,7 +65,42 @@ func init() {
}
healthcheckCmd.Flags().StringVarP(&flags.Endpoint, "endpoint", "e", "http://localhost:"+common.EnvConfig.Port, "Endpoint for Pocket ID")
healthcheckCmd.Flags().StringVar(&flags.UnixSocket, "unix-socket", "", "UNIX socket path for Pocket ID")
healthcheckCmd.Flags().BoolVarP(&flags.Verbose, "verbose", "v", false, "Enable verbose mode")
rootCmd.AddCommand(healthcheckCmd)
}
func healthcheck(ctx context.Context, flags healthcheckFlags) (*healthcheckResult, error) {
url := strings.TrimRight(flags.Endpoint, "/") + "/healthz"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request object for %q: %w", url, err)
}
client := http.DefaultClient
if flags.UnixSocket != "" {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.Proxy = nil
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
dialer := net.Dialer{}
return dialer.DialContext(ctx, "unix", flags.UnixSocket)
}
client = &http.Client{Transport: transport}
}
res, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to perform request to %q: %w", url, err)
}
defer res.Body.Close()
if res.StatusCode < 200 || res.StatusCode >= 300 {
return nil, fmt.Errorf("unexpected status %d from %q", res.StatusCode, url)
}
return &healthcheckResult{
StatusCode: res.StatusCode,
URL: url,
}, nil
}

View File

@@ -0,0 +1,79 @@
package cmds
import (
"context"
"net"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestHealthcheckTCPSuccess(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/healthz", r.URL.Path)
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
result, err := healthcheck(t.Context(), healthcheckFlags{
Endpoint: server.URL,
})
require.NoError(t, err)
require.Equal(t, http.StatusNoContent, result.StatusCode)
require.Equal(t, server.URL+"/healthz", result.URL)
}
func TestHealthcheckFailsOnUnexpectedStatus(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
_, err := healthcheck(t.Context(), healthcheckFlags{
Endpoint: server.URL,
})
require.Error(t, err)
require.ErrorContains(t, err, "unexpected status 500")
}
func TestHealthcheckUnixSocket(t *testing.T) {
socketPath := filepath.Join(t.TempDir(), "pocket-id.sock")
listener, err := (&net.ListenConfig{}).Listen(t.Context(), "unix", socketPath)
require.NoError(t, err)
server := &http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/healthz", r.URL.Path)
w.WriteHeader(http.StatusNoContent)
}),
ReadHeaderTimeout: time.Second,
}
errCh := make(chan error, 1)
go func() {
errCh <- server.Serve(listener)
}()
t.Cleanup(func() {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
require.NoError(t, server.Shutdown(ctx))
require.ErrorIs(t, <-errCh, http.ErrServerClosed)
})
result, err := healthcheck(t.Context(), healthcheckFlags{
Endpoint: "http://localhost:1411",
UnixSocket: socketPath,
})
require.NoError(t, err)
require.Equal(t, http.StatusNoContent, result.StatusCode)
require.Equal(t, "http://localhost:1411/healthz", result.URL)
}

View File

@@ -0,0 +1,190 @@
package cmds
import (
"archive/zip"
"context"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"time"
"github.com/spf13/cobra"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/bootstrap"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
type importFlags struct {
Path string
Yes bool
ForcefullyAcquireLock bool
}
func init() {
var flags importFlags
importCmd := &cobra.Command{
Use: "import",
Short: "Imports all data of Pocket ID from a ZIP file",
RunE: func(cmd *cobra.Command, args []string) error {
return runImport(cmd.Context(), flags)
},
}
importCmd.Flags().StringVarP(&flags.Path, "path", "p", "pocket-id-export.zip", "Path to the ZIP file to import the data from, or '-' to read from stdin")
importCmd.Flags().BoolVarP(&flags.Yes, "yes", "y", false, "Skip confirmation prompts")
importCmd.Flags().BoolVarP(&flags.ForcefullyAcquireLock, "forcefully-acquire-lock", "", false, "Forcefully acquire the application lock by terminating the Pocket ID instance")
rootCmd.AddCommand(importCmd)
}
// runImport handles the high-level orchestration of the import process
func runImport(ctx context.Context, flags importFlags) error {
if !flags.Yes {
ok, err := askForConfirmation()
if err != nil {
return fmt.Errorf("failed to get confirmation: %w", err)
}
if !ok {
fmt.Println("Aborted")
os.Exit(1)
}
}
var (
zipReader *zip.ReadCloser
cleanup func()
err error
)
if flags.Path == "-" {
zipReader, cleanup, err = readZipFromStdin()
defer cleanup()
} else {
zipReader, err = zip.OpenReader(flags.Path)
}
if err != nil {
return fmt.Errorf("failed to open zip: %w", err)
}
defer zipReader.Close()
db, _, err := bootstrap.ConnectDatabase(ctx)
if err != nil {
return err
}
err = acquireImportLock(ctx, db, flags.ForcefullyAcquireLock)
if err != nil {
return err
}
storage, err := bootstrap.InitStorage(ctx, db)
if err != nil {
return fmt.Errorf("failed to initialize storage: %w", err)
}
importService := service.NewImportService(db, storage)
err = importService.ImportFromZip(ctx, &zipReader.Reader)
if err != nil {
return fmt.Errorf("failed to import data from zip: %w", err)
}
fmt.Println("Import completed successfully.")
return nil
}
func acquireImportLock(ctx context.Context, db *gorm.DB, force bool) error {
// Check if the kv table exists, in case we are starting from an empty database
exists, err := utils.DBTableExists(db, "kv")
if err != nil {
return fmt.Errorf("failed to check if kv table exists: %w", err)
}
if !exists {
// This either means the database is empty, or the import is into an old version of PocketID that doesn't support locks
// In either case, there's no lock to acquire
fmt.Println("Could not acquire a lock because the 'kv' table does not exist. This is fine if you're importing into a new database, but make sure that there isn't an instance of Pocket ID currently running and using the same database.")
return nil
}
// Note that we do not call a deferred Release if the data was imported
// This is because we are overriding the contents of the database, so the lock is automatically lost
appLockService := service.NewAppLockService(db)
opCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
waitUntil, err := appLockService.Acquire(opCtx, force)
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)
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(time.Until(waitUntil)):
}
return nil
}
func askForConfirmation() (bool, error) {
fmt.Println("WARNING: This feature is experimental and may not work correctly. Please create a backup before proceeding and report any issues you encounter.")
fmt.Println()
fmt.Println("WARNING: Import will erase all existing data at the following locations:")
fmt.Printf("Database: %s\n", absolutePathOrOriginal(common.EnvConfig.DbConnectionString))
fmt.Printf("Uploads Path: %s\n", absolutePathOrOriginal(common.EnvConfig.UploadPath))
ok, err := utils.PromptForConfirmation("Do you want to continue?")
if err != nil {
return false, err
}
return ok, nil
}
// absolutePathOrOriginal returns the absolute path of the given path, or the original if it fails
func absolutePathOrOriginal(path string) string {
abs, err := filepath.Abs(path)
if err != nil {
return path
}
return abs
}
func readZipFromStdin() (*zip.ReadCloser, func(), error) {
tmpFile, err := os.CreateTemp("", "pocket-id-import-*.zip")
if err != nil {
return nil, nil, fmt.Errorf("failed to create temporary file: %w", err)
}
cleanup := func() {
_ = os.Remove(tmpFile.Name())
}
if _, err := io.Copy(tmpFile, os.Stdin); err != nil {
tmpFile.Close()
cleanup()
return nil, nil, fmt.Errorf("failed to read data from stdin: %w", err)
}
if err := tmpFile.Close(); err != nil {
cleanup()
return nil, nil, fmt.Errorf("failed to close temporary file: %w", err)
}
r, err := zip.OpenReader(tmpFile.Name())
if err != nil {
cleanup()
return nil, nil, err
}
return r, cleanup, nil
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"os"
"strings"
"github.com/lestrrat-go/jwx/v3/jwa"
@@ -30,7 +31,7 @@ func init() {
Use: "key-rotate",
Short: "Generates a new token signing key and replaces the current one",
RunE: func(cmd *cobra.Command, args []string) error {
db, err := bootstrap.NewDatabase()
db, _, err := bootstrap.NewDatabase(cmd.Context())
if err != nil {
return err
}
@@ -78,7 +79,7 @@ func keyRotate(ctx context.Context, flags keyRotateFlags, db *gorm.DB, envConfig
}
if !ok {
fmt.Println("Aborted")
return nil
os.Exit(1)
}
}
@@ -101,7 +102,7 @@ func keyRotate(ctx context.Context, flags keyRotateFlags, db *gorm.DB, envConfig
}
// Save the key
err = keyProvider.SaveKey(key)
err = keyProvider.SaveKey(ctx, key)
if err != nil {
return fmt.Errorf("failed to store new key: %w", err)
}

View File

@@ -1,8 +1,6 @@
package cmds
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
@@ -69,78 +67,14 @@ func TestKeyRotate(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Run("file storage", func(t *testing.T) {
testKeyRotateWithFileStorage(t, tt.flags, tt.wantErr, tt.errMsg)
})
t.Run("database storage", func(t *testing.T) {
testKeyRotateWithDatabaseStorage(t, tt.flags, tt.wantErr, tt.errMsg)
})
testKeyRotateWithDatabaseStorage(t, tt.flags, tt.wantErr, tt.errMsg)
})
}
}
func testKeyRotateWithFileStorage(t *testing.T, flags keyRotateFlags, wantErr bool, errMsg string) {
// Create temporary directory for keys
tempDir := t.TempDir()
keysPath := filepath.Join(tempDir, "keys")
err := os.MkdirAll(keysPath, 0755)
require.NoError(t, err)
// Set up file storage config
envConfig := &common.EnvConfigSchema{
KeysStorage: "file",
KeysPath: keysPath,
}
// Create test database
db := testingutils.NewDatabaseForTest(t)
// Initialize app config service and create instance
appConfigService, err := service.NewAppConfigService(t.Context(), db)
require.NoError(t, err)
instanceID := appConfigService.GetDbConfig().InstanceID.Value
// Check if key exists before rotation
keyProvider, err := jwkutils.GetKeyProvider(db, envConfig, instanceID)
require.NoError(t, err)
// Run the key rotation
err = keyRotate(t.Context(), flags, db, envConfig)
if wantErr {
require.Error(t, err)
if errMsg != "" {
require.ErrorContains(t, err, errMsg)
}
return
}
require.NoError(t, err)
// Verify key was created
key, err := keyProvider.LoadKey()
require.NoError(t, err)
require.NotNil(t, key)
// Verify the algorithm matches what we requested
alg, _ := key.Algorithm()
assert.NotEmpty(t, alg)
if flags.Alg != "" {
expectedAlg := flags.Alg
if expectedAlg == "EdDSA" {
// EdDSA keys should have the EdDSA algorithm
assert.Equal(t, "EdDSA", alg.String())
} else {
assert.Equal(t, expectedAlg, alg.String())
}
}
}
func testKeyRotateWithDatabaseStorage(t *testing.T, flags keyRotateFlags, wantErr bool, errMsg string) {
// Set up database storage config
envConfig := &common.EnvConfigSchema{
KeysStorage: "database",
EncryptionKey: []byte("test-encryption-key-characters-long"),
}
@@ -170,7 +104,7 @@ func testKeyRotateWithDatabaseStorage(t *testing.T, flags keyRotateFlags, wantEr
require.NoError(t, err)
// Verify key was created
key, err := keyProvider.LoadKey()
key, err := keyProvider.LoadKey(t.Context())
require.NoError(t, err)
require.NotNil(t, key)

View File

@@ -24,7 +24,7 @@ var oneTimeAccessTokenCmd = &cobra.Command{
userArg := args[0]
// Connect to the database
db, err := bootstrap.NewDatabase()
db, _, err := bootstrap.NewDatabase(cmd.Context())
if err != nil {
return err
}
@@ -51,7 +51,7 @@ var oneTimeAccessTokenCmd = &cobra.Command{
}
// Create a new access token that expires in 1 hour
oneTimeAccessToken, txErr = service.NewOneTimeAccessToken(user.ID, time.Hour)
oneTimeAccessToken, txErr = service.NewOneTimeAccessToken(user.ID, time.Hour, false)
if txErr != nil {
return fmt.Errorf("failed to generate access token: %w", txErr)
}

View File

@@ -5,16 +5,17 @@ import (
"log/slog"
"os"
"github.com/italypaleale/go-kit/signals"
"github.com/spf13/cobra"
"github.com/pocket-id/pocket-id/backend/internal/bootstrap"
"github.com/pocket-id/pocket-id/backend/internal/utils/signals"
)
var rootCmd = &cobra.Command{
Use: "pocket-id",
Short: "A simple and easy-to-use OIDC provider that allows users to authenticate with their passkeys to your services.",
Long: "By default, this command starts the pocket-id server.",
Use: "pocket-id",
Short: "A simple and easy-to-use OIDC provider that allows users to authenticate with their passkeys to your services.",
Long: "By default, this command starts the pocket-id server.",
SilenceUsage: true,
Run: func(cmd *cobra.Command, args []string) {
// Start the server
err := bootstrap.Bootstrap(cmd.Context())

View File

@@ -0,0 +1,8 @@
package common
// AuthenticationMethodsClaim is the JWT claim ("amr") used to identify how the user
// authenticated. It is shared between the session JWTs and the OIDC tokens.
const AuthenticationMethodsClaim = "amr"
// TokenTypeClaim is the JWT claim ("type") used to identify the type of token.
const TokenTypeClaim = "type"

View File

@@ -15,6 +15,7 @@ import (
_ "github.com/joho/godotenv/autoload"
)
type AppEnv string
type DbProvider string
const (
@@ -25,39 +26,63 @@ const (
)
const (
AppEnvProduction AppEnv = "production"
AppEnvDevelopment AppEnv = "development"
AppEnvTest AppEnv = "test"
DbProviderSqlite DbProvider = "sqlite"
DbProviderPostgres DbProvider = "postgres"
MaxMindGeoLiteCityUrl string = "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-City&license_key=%s&suffix=tar.gz"
defaultSqliteConnString string = "data/pocket-id.db"
defaultFsUploadPath string = "data/uploads"
AppUrl string = "http://localhost:1411"
)
type EnvConfigSchema struct {
AppEnv string `env:"APP_ENV" options:"toLower"`
LogLevel string `env:"LOG_LEVEL" options:"toLower"`
AppURL string `env:"APP_URL" options:"toLower,trimTrailingSlash"`
DbProvider DbProvider `env:"DB_PROVIDER" options:"toLower"`
DbConnectionString string `env:"DB_CONNECTION_STRING" options:"file"`
UploadPath string `env:"UPLOAD_PATH"`
KeysPath string `env:"KEYS_PATH"`
KeysStorage string `env:"KEYS_STORAGE"`
EncryptionKey []byte `env:"ENCRYPTION_KEY" options:"file"`
Port string `env:"PORT"`
Host string `env:"HOST" options:"toLower"`
UnixSocket string `env:"UNIX_SOCKET"`
UnixSocketMode string `env:"UNIX_SOCKET_MODE"`
MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY" options:"file"`
GeoLiteDBPath string `env:"GEOLITE_DB_PATH"`
GeoLiteDBUrl string `env:"GEOLITE_DB_URL"`
LocalIPv6Ranges string `env:"LOCAL_IPV6_RANGES"`
UiConfigDisabled bool `env:"UI_CONFIG_DISABLED"`
MetricsEnabled bool `env:"METRICS_ENABLED"`
TracingEnabled bool `env:"TRACING_ENABLED"`
LogJSON bool `env:"LOG_JSON"`
TrustProxy bool `env:"TRUST_PROXY"`
AnalyticsDisabled bool `env:"ANALYTICS_DISABLED"`
AllowDowngrade bool `env:"ALLOW_DOWNGRADE"`
InternalAppURL string `env:"INTERNAL_APP_URL"`
AppEnv AppEnv `env:"APP_ENV" options:"toLower"`
EncryptionKey []byte `env:"ENCRYPTION_KEY" options:"file"`
AppURL string `env:"APP_URL" options:"toLower,trimTrailingSlash"`
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"`
InternalAppURL string `env:"INTERNAL_APP_URL"`
UiConfigDisabled bool `env:"UI_CONFIG_DISABLED"`
DisableRateLimiting bool `env:"DISABLE_RATE_LIMITING"`
VersionCheckDisabled bool `env:"VERSION_CHECK_DISABLED"`
StaticApiKey string `env:"STATIC_API_KEY" options:"file"`
FileBackend string `env:"FILE_BACKEND" options:"toLower"`
UploadPath string `env:"UPLOAD_PATH"`
S3Bucket string `env:"S3_BUCKET"`
S3Region string `env:"S3_REGION"`
S3Endpoint string `env:"S3_ENDPOINT"`
S3AccessKeyID string `env:"S3_ACCESS_KEY_ID"`
S3SecretAccessKey string `env:"S3_SECRET_ACCESS_KEY" options:"file"`
S3ForcePathStyle bool `env:"S3_FORCE_PATH_STYLE"`
S3DisableDefaultIntegrityChecks bool `env:"S3_DISABLE_DEFAULT_INTEGRITY_CHECKS"`
Port string `env:"PORT"`
Host string `env:"HOST" options:"toLower"`
UnixSocket string `env:"UNIX_SOCKET"`
UnixSocketMode string `env:"UNIX_SOCKET_MODE"`
SystemdSocket bool `env:"SYSTEMD_SOCKET"`
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"`
ActorsPort string `env:"ACTORS_PORT"`
ActorsHost string `env:"ACTORS_HOST" options:"toLower"`
LogLevel string `env:"LOG_LEVEL" options:"toLower"`
LogJSON bool `env:"LOG_JSON"`
}
var EnvConfig = defaultConfig()
@@ -72,36 +97,24 @@ func init() {
func defaultConfig() EnvConfigSchema {
return EnvConfigSchema{
AppEnv: "production",
LogLevel: "info",
DbProvider: "sqlite",
DbConnectionString: "",
UploadPath: "data/uploads",
KeysPath: "data/keys",
KeysStorage: "", // "database" or "file"
EncryptionKey: nil,
AppURL: AppUrl,
Port: "1411",
Host: "0.0.0.0",
UnixSocket: "",
UnixSocketMode: "",
MaxMindLicenseKey: "",
GeoLiteDBPath: "data/GeoLite2-City.mmdb",
GeoLiteDBUrl: MaxMindGeoLiteCityUrl,
LocalIPv6Ranges: "",
UiConfigDisabled: false,
MetricsEnabled: false,
TracingEnabled: false,
TrustProxy: false,
AnalyticsDisabled: false,
AllowDowngrade: false,
InternalAppURL: "",
AppEnv: AppEnvProduction,
LogLevel: "info",
DbProvider: "sqlite",
FileBackend: "filesystem",
AuditLogRetentionDays: 90,
AppURL: AppUrl,
Port: "1411",
Host: "0.0.0.0",
ActorsPort: "1414",
ActorsHost: "0.0.0.0",
GeoLiteDBPath: "data/GeoLite2-City.mmdb",
GeoLiteDBUrl: MaxMindGeoLiteCityUrl,
}
}
func parseEnvConfig() error {
parsers := map[reflect.Type]env.ParserFunc{
reflect.TypeOf([]byte{}): func(value string) (interface{}, error) {
reflect.TypeFor[[]byte](): func(value string) (any, error) {
return []byte(value), nil
},
}
@@ -118,90 +131,179 @@ func parseEnvConfig() error {
return fmt.Errorf("error preparing env config: %w", err)
}
err = validateEnvConfig(&EnvConfig)
return nil
}
// ValidateEnvConfig checks the EnvConfig for required fields and valid values
func ValidateEnvConfig(config *EnvConfigSchema) error {
if shouldSkipEnvValidation(os.Args) {
return nil
}
_, err := sloggin.ParseLevel(config.LogLevel)
if err != nil {
return errors.New("invalid LOG_LEVEL value. Must be 'debug', 'info', 'warn' or 'error'")
}
// Check required properties
if len(config.EncryptionKey) < 16 {
return errors.New("ENCRYPTION_KEY must be at least 16 bytes long")
}
if config.SystemdSocket && config.UnixSocket != "" {
return errors.New("SYSTEMD_SOCKET and UNIX_SOCKET are mutually exclusive")
}
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("when set, STATIC_API_KEY must be at least 16 characters long")
}
// Prepare the DB config
prepareDbConfig(config)
// Validate other required options
err = validateAppURLs(config)
if err != nil {
return err
}
err = validateFileBackend(config)
if err != nil {
return err
}
err = validateLocalIPv6Ranges(config.LocalIPv6Ranges)
if err != nil {
return err
}
err = validateTLSConfig(config)
if err != nil {
return err
}
return nil
}
func prepareDbConfig(config *EnvConfigSchema) {
switch {
case config.DbConnectionString == "":
config.DbProvider = DbProviderSqlite
config.DbConnectionString = defaultSqliteConnString
case strings.HasPrefix(config.DbConnectionString, "postgres://") || strings.HasPrefix(config.DbConnectionString, "postgresql://"):
config.DbProvider = DbProviderPostgres
default:
config.DbProvider = DbProviderSqlite
}
}
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
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":
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'")
}
}
func validateLocalIPv6Ranges(localIPv6Ranges string) error {
ranges := strings.SplitSeq(localIPv6Ranges, ",")
for rangeStr := range ranges {
rangeStr = strings.TrimSpace(rangeStr)
if rangeStr == "" {
continue
}
if err := validateLocalIPv6Range(rangeStr); err != nil {
return err
}
}
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 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
}
// validateEnvConfig checks the EnvConfig for required fields and valid values
func validateEnvConfig(config *EnvConfigSchema) error {
if _, err := sloggin.ParseLevel(config.LogLevel); err != nil {
return errors.New("invalid LOG_LEVEL value. Must be 'debug', 'info', 'warn' or 'error'")
}
switch config.DbProvider {
case DbProviderSqlite:
if config.DbConnectionString == "" {
config.DbConnectionString = defaultSqliteConnString
}
case DbProviderPostgres:
if config.DbConnectionString == "" {
return errors.New("missing required env var 'DB_CONNECTION_STRING' for Postgres database")
}
default:
return errors.New("invalid DB_PROVIDER value. Must be 'sqlite' or 'postgres'")
}
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")
}
// 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")
func shouldSkipEnvValidation(args []string) bool {
for _, arg := range args[1:] {
switch arg {
case "-h", "--help", "help", "version":
return true
}
}
switch config.KeysStorage {
// KeysStorage defaults to "file" if empty
case "":
config.KeysStorage = "file"
case "database":
if config.EncryptionKey == nil {
return errors.New("ENCRYPTION_KEY must be non-empty when KEYS_STORAGE is database")
}
case "file":
// All good, these are valid values
default:
return fmt.Errorf("invalid value for KEYS_STORAGE: %s", config.KeysStorage)
}
// Validate LOCAL_IPV6_RANGES
ranges := strings.Split(config.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 ipNet.IP.To4() != nil {
return fmt.Errorf("range '%s' is not a valid IPv6 range", rangeStr)
}
}
return nil
return false
}
// prepareEnvConfig processes special options for EnvConfig fields
@@ -214,9 +316,9 @@ func prepareEnvConfig(config *EnvConfigSchema) error {
fieldType := typ.Field(i)
optionsTag := fieldType.Tag.Get("options")
options := strings.Split(optionsTag, ",")
options := strings.SplitSeq(optionsTag, ",")
for _, option := range options {
for option := range options {
switch option {
case "toLower":
if field.Kind() == reflect.String {
@@ -266,6 +368,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)
@@ -279,3 +382,11 @@ func resolveFileBasedEnvVariable(field reflect.Value, fieldType reflect.StructFi
return nil
}
func (a AppEnv) IsProduction() bool {
return a == AppEnvProduction
}
func (a AppEnv) IsTest() bool {
return a == AppEnvTest
}

View File

@@ -8,6 +8,20 @@ import (
"github.com/stretchr/testify/require"
)
func parseAndValidateEnvConfig(t *testing.T) error {
t.Helper()
if _, exists := os.LookupEnv("ENCRYPTION_KEY"); !exists {
t.Setenv("ENCRYPTION_KEY", "0123456789abcdef")
}
if err := parseEnvConfig(); err != nil {
return err
}
return ValidateEnvConfig(&EnvConfig)
}
func TestParseEnvConfig(t *testing.T) {
// Store original config to restore later
originalConfig := EnvConfig
@@ -17,11 +31,10 @@ func TestParseEnvConfig(t *testing.T) {
t.Run("should parse valid SQLite config correctly", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "SQLITE") // should be lowercased automatically
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("APP_URL", "HTTP://LOCALHOST:3000")
err := parseEnvConfig()
err := parseAndValidateEnvConfig(t)
require.NoError(t, err)
assert.Equal(t, DbProviderSqlite, EnvConfig.DbProvider)
assert.Equal(t, "http://localhost:3000", EnvConfig.AppURL)
@@ -29,147 +42,76 @@ func TestParseEnvConfig(t *testing.T) {
t.Run("should parse valid Postgres config correctly", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "POSTGRES")
t.Setenv("DB_CONNECTION_STRING", "postgres://user:pass@localhost/db")
t.Setenv("APP_URL", "https://example.com")
err := parseEnvConfig()
err := parseAndValidateEnvConfig(t)
require.NoError(t, err)
assert.Equal(t, DbProviderPostgres, EnvConfig.DbProvider)
})
t.Run("should fail with invalid DB_PROVIDER", func(t *testing.T) {
t.Run("should fail when ENCRYPTION_KEY is too short", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "invalid")
t.Setenv("DB_CONNECTION_STRING", "test")
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("APP_URL", "http://localhost:3000")
t.Setenv("ENCRYPTION_KEY", "short")
err := parseEnvConfig()
err := parseAndValidateEnvConfig(t)
require.Error(t, err)
assert.ErrorContains(t, err, "invalid DB_PROVIDER value")
assert.ErrorContains(t, err, "ENCRYPTION_KEY must be at least 16 bytes long")
})
t.Run("should set default SQLite connection string when DB_CONNECTION_STRING is empty", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "sqlite")
t.Setenv("APP_URL", "http://localhost:3000")
err := parseEnvConfig()
err := parseAndValidateEnvConfig(t)
require.NoError(t, err)
assert.Equal(t, defaultSqliteConnString, EnvConfig.DbConnectionString)
})
t.Run("should fail when Postgres DB_CONNECTION_STRING is missing", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "postgres")
t.Setenv("APP_URL", "http://localhost:3000")
err := parseEnvConfig()
require.Error(t, err)
assert.ErrorContains(t, err, "missing required env var 'DB_CONNECTION_STRING' for Postgres")
})
t.Run("should fail with invalid APP_URL", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "sqlite")
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("APP_URL", "€://not-a-valid-url")
err := parseEnvConfig()
err := parseAndValidateEnvConfig(t)
require.Error(t, err)
assert.ErrorContains(t, err, "APP_URL is not a valid URL")
})
t.Run("should fail when APP_URL contains path", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "sqlite")
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("APP_URL", "http://localhost:3000/path")
err := parseEnvConfig()
err := parseAndValidateEnvConfig(t)
require.Error(t, err)
assert.ErrorContains(t, err, "APP_URL must not contain a path")
})
t.Run("should fail with invalid INTERNAL_APP_URL", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "sqlite")
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("INTERNAL_APP_URL", "€://not-a-valid-url")
err := parseEnvConfig()
err := parseAndValidateEnvConfig(t)
require.Error(t, err)
assert.ErrorContains(t, err, "INTERNAL_APP_URL is not a valid URL")
})
t.Run("should fail when INTERNAL_APP_URL contains path", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "sqlite")
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("INTERNAL_APP_URL", "http://localhost:3000/path")
err := parseEnvConfig()
err := parseAndValidateEnvConfig(t)
require.Error(t, err)
assert.ErrorContains(t, err, "INTERNAL_APP_URL must not contain a path")
})
t.Run("should default KEYS_STORAGE to 'file' when empty", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "sqlite")
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("APP_URL", "http://localhost:3000")
err := parseEnvConfig()
require.NoError(t, err)
assert.Equal(t, "file", EnvConfig.KeysStorage)
})
t.Run("should fail when KEYS_STORAGE is 'database' but no encryption key", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "sqlite")
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("APP_URL", "http://localhost:3000")
t.Setenv("KEYS_STORAGE", "database")
err := parseEnvConfig()
require.Error(t, err)
assert.ErrorContains(t, err, "ENCRYPTION_KEY must be non-empty when KEYS_STORAGE is database")
})
t.Run("should accept valid KEYS_STORAGE values", func(t *testing.T) {
validStorageTypes := []string{"file", "database"}
for _, storage := range validStorageTypes {
EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "sqlite")
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("APP_URL", "http://localhost:3000")
t.Setenv("KEYS_STORAGE", storage)
if storage == "database" {
t.Setenv("ENCRYPTION_KEY", "test-key")
}
err := parseEnvConfig()
require.NoError(t, err)
assert.Equal(t, storage, EnvConfig.KeysStorage)
}
})
t.Run("should fail with invalid KEYS_STORAGE value", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "sqlite")
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("APP_URL", "http://localhost:3000")
t.Setenv("KEYS_STORAGE", "invalid")
err := parseEnvConfig()
require.Error(t, err)
assert.ErrorContains(t, err, "invalid value for KEYS_STORAGE")
})
t.Run("should parse boolean environment variables correctly", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "sqlite")
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("APP_URL", "http://localhost:3000")
t.Setenv("UI_CONFIG_DISABLED", "true")
@@ -178,35 +120,146 @@ func TestParseEnvConfig(t *testing.T) {
t.Setenv("TRUST_PROXY", "true")
t.Setenv("ANALYTICS_DISABLED", "false")
err := parseEnvConfig()
err := parseAndValidateEnvConfig(t)
require.NoError(t, err)
assert.True(t, EnvConfig.UiConfigDisabled)
assert.True(t, EnvConfig.MetricsEnabled)
assert.False(t, EnvConfig.TracingEnabled)
assert.True(t, EnvConfig.TrustProxy)
assert.False(t, EnvConfig.AnalyticsDisabled)
})
t.Run("should default audit log retention days to 90", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "sqlite")
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("APP_URL", "http://localhost:3000")
err := parseEnvConfig()
require.NoError(t, err)
assert.Equal(t, 90, EnvConfig.AuditLogRetentionDays)
})
t.Run("should parse audit log retention days override", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "sqlite")
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("APP_URL", "http://localhost:3000")
t.Setenv("AUDIT_LOG_RETENTION_DAYS", "365")
err := parseEnvConfig()
require.NoError(t, err)
assert.Equal(t, 365, EnvConfig.AuditLogRetentionDays)
})
t.Run("should fail when AUDIT_LOG_RETENTION_DAYS is non-positive", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "sqlite")
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("APP_URL", "http://localhost:3000")
t.Setenv("AUDIT_LOG_RETENTION_DAYS", "0")
err := parseAndValidateEnvConfig(t)
require.Error(t, err)
assert.ErrorContains(t, err, "AUDIT_LOG_RETENTION_DAYS must be greater than 0")
})
t.Run("should parse string environment variables correctly", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "postgres")
t.Setenv("DB_CONNECTION_STRING", "postgres://test")
t.Setenv("APP_URL", "https://prod.example.com")
t.Setenv("APP_ENV", "STAGING")
t.Setenv("APP_ENV", "PRODUCTION")
t.Setenv("UPLOAD_PATH", "/custom/uploads")
t.Setenv("KEYS_PATH", "/custom/keys")
t.Setenv("PORT", "8080")
t.Setenv("HOST", "LOCALHOST")
t.Setenv("UNIX_SOCKET", "/tmp/app.sock")
t.Setenv("MAXMIND_LICENSE_KEY", "test-license")
t.Setenv("GEOLITE_DB_PATH", "/custom/geolite.mmdb")
t.Setenv("ACTORS_PORT", "9999")
t.Setenv("ACTORS_HOST", "LOCALHOST")
err := parseEnvConfig()
err := parseAndValidateEnvConfig(t)
require.NoError(t, err)
assert.Equal(t, "staging", EnvConfig.AppEnv) // lowercased
assert.Equal(t, AppEnvProduction, EnvConfig.AppEnv) // lowercased
assert.Equal(t, "/custom/uploads", EnvConfig.UploadPath)
assert.Equal(t, "8080", EnvConfig.Port)
assert.Equal(t, "localhost", EnvConfig.Host) // lowercased
assert.Equal(t, "9999", EnvConfig.ActorsPort)
assert.Equal(t, "localhost", EnvConfig.ActorsHost) // lowercased
})
t.Run("should normalize file backend and default upload path", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("APP_URL", "http://localhost:3000")
t.Setenv("FILE_BACKEND", "FILESYSTEM")
t.Setenv("UPLOAD_PATH", "")
err := parseAndValidateEnvConfig(t)
require.NoError(t, err)
assert.Equal(t, "filesystem", EnvConfig.FileBackend)
assert.Equal(t, defaultFsUploadPath, EnvConfig.UploadPath)
})
t.Run("should fail with invalid FILE_BACKEND value", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("APP_URL", "http://localhost:3000")
t.Setenv("FILE_BACKEND", "invalid")
err := parseAndValidateEnvConfig(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")
})
}
@@ -221,7 +274,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)
@@ -241,7 +294,7 @@ func TestPrepareEnvConfig_FileBasedAndToLower(t *testing.T) {
err := prepareEnvConfig(&config)
require.NoError(t, err)
assert.Equal(t, "staging", config.AppEnv)
assert.Equal(t, AppEnv("staging"), config.AppEnv)
assert.Equal(t, "localhost", config.Host)
assert.Equal(t, []byte(encryptionKeyContent), config.EncryptionKey)
assert.Equal(t, dbConnContent, config.DbConnectionString)
@@ -255,4 +308,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

@@ -8,383 +8,272 @@ import (
type AppError interface {
error
HttpStatusCode() int
}
type AppErrorDescription interface {
AppError
Description() string
}
// Custom error types for various conditions
type AlreadyInUseError struct {
Property string
}
func (e *AlreadyInUseError) Error() string {
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 {
func (e AlreadyInUseError) Is(target error) bool {
// Ignore the field property when checking if an error is of the type AlreadyInUseError
x := &AlreadyInUseError{}
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) Error() string { return "token is invalid or expired" }
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 http.StatusUnauthorized }
type TokenInvalidError struct{}
func (e *TokenInvalidError) Error() string {
return "Token is invalid"
}
func (e *TokenInvalidError) HttpStatusCode() int { return 400 }
func (e TokenInvalidError) Error() string { return "Token is invalid" }
func (e TokenInvalidError) HttpStatusCode() int { return http.StatusUnauthorized }
type OidcMissingAuthorizationError struct{}
func (e *OidcMissingAuthorizationError) Error() string { return "missing authorization" }
func (e *OidcMissingAuthorizationError) HttpStatusCode() int { return http.StatusForbidden }
type OidcGrantTypeNotSupportedError struct{}
func (e *OidcGrantTypeNotSupportedError) Error() string { return "grant type not supported" }
func (e *OidcGrantTypeNotSupportedError) HttpStatusCode() int { return 400 }
type OidcMissingClientCredentialsError struct{}
func (e *OidcMissingClientCredentialsError) Error() string { return "client id or secret not provided" }
func (e *OidcMissingClientCredentialsError) HttpStatusCode() int { return 400 }
type OidcClientSecretInvalidError struct{}
func (e *OidcClientSecretInvalidError) Error() string { return "invalid client secret" }
func (e *OidcClientSecretInvalidError) HttpStatusCode() int { return 400 }
type OidcClientAssertionInvalidError struct{}
func (e *OidcClientAssertionInvalidError) Error() string { return "invalid client assertion" }
func (e *OidcClientAssertionInvalidError) HttpStatusCode() int { return 400 }
type OidcInvalidAuthorizationCodeError struct{}
func (e *OidcInvalidAuthorizationCodeError) Error() string { return "invalid authorization code" }
func (e *OidcInvalidAuthorizationCodeError) HttpStatusCode() int { return 400 }
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 OidcMissingAuthorizationError) Error() string { return "missing authorization" }
func (e OidcMissingAuthorizationError) HttpStatusCode() int { return http.StatusForbidden }
type OidcInvalidCallbackURLError struct{}
func (e *OidcInvalidCallbackURLError) Error() string {
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) Error() string { return "file type not supported" }
func (e FileTypeNotSupportedError) HttpStatusCode() int { return http.StatusBadRequest }
type FileTooLargeError struct {
MaxSize string
}
func (e *FileTooLargeError) Error() string {
func (e FileTooLargeError) Error() string {
return fmt.Sprintf("The file can't be larger than %s", e.MaxSize)
}
func (e *FileTooLargeError) HttpStatusCode() int { return http.StatusRequestEntityTooLarge }
func (e FileTooLargeError) HttpStatusCode() int { return http.StatusRequestEntityTooLarge }
type NotSignedInError struct{}
func (e *NotSignedInError) Error() string { return "You are not signed in" }
func (e *NotSignedInError) HttpStatusCode() int { return http.StatusUnauthorized }
type MissingAccessToken struct{}
func (e *MissingAccessToken) Error() string { return "Missing access token" }
func (e *MissingAccessToken) HttpStatusCode() int { return http.StatusUnauthorized }
func (e NotSignedInError) Error() string { return "You are not signed in" }
func (e NotSignedInError) HttpStatusCode() int { return http.StatusUnauthorized }
type MissingPermissionError struct{}
func (e *MissingPermissionError) Error() string {
func (e MissingPermissionError) Error() string {
return "You don't have permission to perform this action"
}
func (e *MissingPermissionError) HttpStatusCode() int { return http.StatusForbidden }
func (e MissingPermissionError) HttpStatusCode() int { return http.StatusForbidden }
type TooManyRequestsError struct{}
func (e *TooManyRequestsError) Error() string {
return "Too many requests"
}
func (e *TooManyRequestsError) HttpStatusCode() int { return http.StatusTooManyRequests }
func (e TooManyRequestsError) Error() string { return "Too many requests" }
func (e TooManyRequestsError) HttpStatusCode() int { return http.StatusTooManyRequests }
type ClientIdOrSecretNotProvidedError struct{}
type UserIdNotProvidedError struct{}
func (e *ClientIdOrSecretNotProvidedError) Error() string {
return "Client id or secret not provided"
}
func (e *ClientIdOrSecretNotProvidedError) HttpStatusCode() int { return http.StatusBadRequest }
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 WrongFileTypeError struct {
ExpectedFileType string
}
func (e *WrongFileTypeError) Error() string {
func (e WrongFileTypeError) Error() string {
return fmt.Sprintf("File must be of type %s", e.ExpectedFileType)
}
func (e *WrongFileTypeError) HttpStatusCode() int { return http.StatusBadRequest }
func (e WrongFileTypeError) HttpStatusCode() int { return http.StatusBadRequest }
type MissingSessionIdError struct{}
func (e *MissingSessionIdError) Error() string {
return "Missing session id"
}
func (e *MissingSessionIdError) HttpStatusCode() int { return http.StatusBadRequest }
func (e MissingSessionIdError) Error() string { return "Missing session id" }
func (e MissingSessionIdError) HttpStatusCode() int { return http.StatusBadRequest }
type ReservedClaimError struct {
Key string
}
func (e *ReservedClaimError) Error() string {
func (e ReservedClaimError) Error() string {
return fmt.Sprintf("Claim %s is reserved and can't be used", e.Key)
}
func (e *ReservedClaimError) HttpStatusCode() int { return http.StatusBadRequest }
func (e ReservedClaimError) HttpStatusCode() int { return http.StatusBadRequest }
type DuplicateClaimError struct {
Key string
}
func (e *DuplicateClaimError) Error() string {
func (e DuplicateClaimError) Error() string {
return fmt.Sprintf("Claim %s is already defined", e.Key)
}
func (e *DuplicateClaimError) HttpStatusCode() int { return http.StatusBadRequest }
type OidcInvalidCodeVerifierError struct{}
func (e *OidcInvalidCodeVerifierError) Error() string {
return "Invalid code verifier"
}
func (e *OidcInvalidCodeVerifierError) HttpStatusCode() int { return http.StatusBadRequest }
type OidcMissingCodeChallengeError struct{}
func (e *OidcMissingCodeChallengeError) Error() string {
return "Missing code challenge"
}
func (e *OidcMissingCodeChallengeError) HttpStatusCode() int { return http.StatusBadRequest }
func (e DuplicateClaimError) HttpStatusCode() int { return http.StatusBadRequest }
type LdapUserUpdateError struct{}
func (e *LdapUserUpdateError) Error() string {
return "LDAP users can't be updated"
}
func (e *LdapUserUpdateError) HttpStatusCode() int { return http.StatusForbidden }
func (e LdapUserUpdateError) Error() string { return "LDAP users can't be updated" }
func (e LdapUserUpdateError) HttpStatusCode() int { return http.StatusForbidden }
type LdapUserGroupUpdateError struct{}
func (e *LdapUserGroupUpdateError) Error() string {
return "LDAP user groups can't be updated"
}
func (e *LdapUserGroupUpdateError) HttpStatusCode() int { return http.StatusForbidden }
func (e LdapUserGroupUpdateError) Error() string { return "LDAP user groups can't be updated" }
func (e LdapUserGroupUpdateError) HttpStatusCode() int { return http.StatusForbidden }
type OidcAccessDeniedError struct{}
func (e *OidcAccessDeniedError) Error() string {
return "You're not allowed to access this service"
}
func (e *OidcAccessDeniedError) HttpStatusCode() int { return http.StatusForbidden }
func (e OidcAccessDeniedError) Error() string { return "You're not allowed to access this service" }
func (e OidcAccessDeniedError) HttpStatusCode() int { return http.StatusForbidden }
type OidcClientIdNotMatchingError struct{}
func (e *OidcClientIdNotMatchingError) Error() string {
func (e OidcClientIdNotMatchingError) Error() string {
return "Client id in request doesn't match client id in token"
}
func (e *OidcClientIdNotMatchingError) HttpStatusCode() int { return http.StatusBadRequest }
type OidcNoCallbackURLError struct{}
func (e *OidcNoCallbackURLError) Error() string {
return "No callback URL provided"
}
func (e *OidcNoCallbackURLError) HttpStatusCode() int { return http.StatusBadRequest }
func (e OidcClientIdNotMatchingError) HttpStatusCode() int { return http.StatusBadRequest }
type UiConfigDisabledError struct{}
func (e *UiConfigDisabledError) Error() string {
func (e UiConfigDisabledError) Error() string {
return "The configuration can't be changed since the UI configuration is disabled"
}
func (e *UiConfigDisabledError) HttpStatusCode() int { return http.StatusForbidden }
func (e UiConfigDisabledError) HttpStatusCode() int { return http.StatusForbidden }
type InvalidUUIDError struct{}
func (e *InvalidUUIDError) Error() string {
return "Invalid UUID"
}
func (e *InvalidUUIDError) HttpStatusCode() int { return http.StatusBadRequest }
func (e InvalidUUIDError) Error() string { return "Invalid UUID" }
func (e InvalidUUIDError) HttpStatusCode() int { return http.StatusBadRequest }
type OneTimeAccessDisabledError struct{}
func (e *OneTimeAccessDisabledError) Error() string {
return "One-time access is disabled"
}
func (e *OneTimeAccessDisabledError) HttpStatusCode() int { return http.StatusBadRequest }
func (e OneTimeAccessDisabledError) Error() string { return "One-time access is disabled" }
func (e OneTimeAccessDisabledError) HttpStatusCode() int { return http.StatusBadRequest }
type InvalidAPIKeyError struct{}
func (e *InvalidAPIKeyError) Error() string {
return "Invalid Api Key"
}
func (e *InvalidAPIKeyError) HttpStatusCode() int { return http.StatusUnauthorized }
func (e InvalidAPIKeyError) Error() string { return "Invalid Api Key" }
func (e InvalidAPIKeyError) HttpStatusCode() int { return http.StatusUnauthorized }
type NoAPIKeyProvidedError struct{}
func (e *NoAPIKeyProvidedError) Error() string {
return "No API Key Provided"
}
func (e *NoAPIKeyProvidedError) HttpStatusCode() int { return http.StatusUnauthorized }
func (e NoAPIKeyProvidedError) Error() string { return "No API Key Provided" }
func (e NoAPIKeyProvidedError) HttpStatusCode() int { return http.StatusUnauthorized }
type APIKeyNotFoundError struct{}
func (e *APIKeyNotFoundError) Error() string {
return "API Key Not Found"
}
func (e *APIKeyNotFoundError) HttpStatusCode() int { return http.StatusUnauthorized }
func (e APIKeyNotFoundError) Error() string { return "API Key Not Found" }
func (e APIKeyNotFoundError) HttpStatusCode() int { return http.StatusUnauthorized }
type APIKeyNotExpiredError struct{}
func (e APIKeyNotExpiredError) Error() string { return "API Key is not expired yet" }
func (e APIKeyNotExpiredError) HttpStatusCode() int { return http.StatusBadRequest }
type APIKeyExpirationDateError struct{}
func (e *APIKeyExpirationDateError) Error() string {
func (e APIKeyExpirationDateError) Error() string {
return "API Key expiration time must be in the future"
}
func (e *APIKeyExpirationDateError) HttpStatusCode() int { return http.StatusBadRequest }
func (e APIKeyExpirationDateError) HttpStatusCode() int { return http.StatusBadRequest }
type OidcInvalidRefreshTokenError struct{}
type APIKeyAuthNotAllowedError struct{}
func (e *OidcInvalidRefreshTokenError) Error() string {
return "refresh token is invalid or expired"
}
func (e *OidcInvalidRefreshTokenError) HttpStatusCode() int {
return http.StatusBadRequest
}
type OidcMissingRefreshTokenError struct{}
func (e *OidcMissingRefreshTokenError) Error() string {
return "refresh token is required"
}
func (e *OidcMissingRefreshTokenError) HttpStatusCode() int {
return http.StatusBadRequest
}
type OidcMissingAuthorizationCodeError struct{}
func (e *OidcMissingAuthorizationCodeError) Error() string {
return "authorization code is required"
}
func (e *OidcMissingAuthorizationCodeError) HttpStatusCode() int {
return http.StatusBadRequest
func (e APIKeyAuthNotAllowedError) Error() string {
return "API key authentication is not allowed for this endpoint"
}
func (e APIKeyAuthNotAllowedError) HttpStatusCode() int { return http.StatusForbidden }
type UserDisabledError struct{}
func (e *UserDisabledError) Error() string {
return "User account is disabled"
}
func (e *UserDisabledError) HttpStatusCode() int {
return http.StatusForbidden
}
func (e UserDisabledError) Error() string { return "User account is disabled" }
func (e UserDisabledError) HttpStatusCode() int { return http.StatusForbidden }
type ValidationError struct {
Message string
}
type ValidationError struct{ Message string }
func (e *ValidationError) Error() string {
return e.Message
}
func (e ValidationError) Error() string { return e.Message }
func (e *ValidationError) HttpStatusCode() int {
return http.StatusBadRequest
}
func (e ValidationError) HttpStatusCode() int { return http.StatusBadRequest }
type OidcDeviceCodeExpiredError struct{}
func (e *OidcDeviceCodeExpiredError) Error() string {
return "device code has expired"
}
func (e *OidcDeviceCodeExpiredError) HttpStatusCode() int {
return http.StatusBadRequest
}
func (e OidcDeviceCodeExpiredError) Error() string { return "device code has expired" }
func (e OidcDeviceCodeExpiredError) HttpStatusCode() int { return http.StatusBadRequest }
type OidcInvalidDeviceCodeError struct{}
func (e *OidcInvalidDeviceCodeError) Error() string {
return "invalid device code"
}
func (e *OidcInvalidDeviceCodeError) HttpStatusCode() int {
return http.StatusBadRequest
}
type OidcSlowDownError struct{}
func (e *OidcSlowDownError) Error() string {
return "polling too frequently"
}
func (e *OidcSlowDownError) HttpStatusCode() int {
return http.StatusTooManyRequests
}
type OidcAuthorizationPendingError struct{}
func (e *OidcAuthorizationPendingError) Error() string {
return "authorization is still pending"
}
func (e *OidcAuthorizationPendingError) HttpStatusCode() int {
return http.StatusBadRequest
}
func (e OidcInvalidDeviceCodeError) Error() string { return "invalid device code" }
func (e OidcInvalidDeviceCodeError) HttpStatusCode() int { return http.StatusBadRequest }
type ReauthenticationRequiredError struct{}
func (e *ReauthenticationRequiredError) Error() string {
return "reauthentication required"
}
func (e *ReauthenticationRequiredError) HttpStatusCode() int {
return http.StatusUnauthorized
}
func (e ReauthenticationRequiredError) Error() string { return "reauthentication required" }
func (e ReauthenticationRequiredError) HttpStatusCode() int { return http.StatusUnauthorized }
type OpenSignupDisabledError struct{}
func (e *OpenSignupDisabledError) Error() string {
return "Open user signup is not enabled"
}
func (e OpenSignupDisabledError) Error() string { return "Open user signup is not enabled" }
func (e *OpenSignupDisabledError) HttpStatusCode() int {
return http.StatusForbidden
}
func (e OpenSignupDisabledError) HttpStatusCode() int { return http.StatusForbidden }
type ClientIdAlreadyExistsError struct{}
func (e *ClientIdAlreadyExistsError) Error() string {
return "Client ID already in use"
}
func (e ClientIdAlreadyExistsError) Error() string { return "Client ID already in use" }
func (e *ClientIdAlreadyExistsError) HttpStatusCode() int {
return http.StatusBadRequest
}
func (e ClientIdAlreadyExistsError) HttpStatusCode() int { return http.StatusBadRequest }
type UserEmailNotSetError struct{}
func (e *UserEmailNotSetError) Error() string {
return "The user does not have an email address set"
}
func (e UserEmailNotSetError) Error() string { return "The user does not have an email address set" }
func (e *UserEmailNotSetError) HttpStatusCode() int {
return http.StatusBadRequest
func (e UserEmailNotSetError) HttpStatusCode() int { return http.StatusBadRequest }
type ImageNotFoundError struct{}
func (e ImageNotFoundError) Error() string { return "Image not found" }
func (e ImageNotFoundError) HttpStatusCode() int { return http.StatusNotFound }
type OidcPARRequiredError struct{}
func (e OidcPARRequiredError) Error() string {
return "this client requires pushed authorization requests"
}
func (e OidcPARRequiredError) HttpStatusCode() int { return http.StatusBadRequest }
type InvalidEmailVerificationTokenError struct{}
func (e InvalidEmailVerificationTokenError) Error() string { return "Invalid email verification token" }
func (e InvalidEmailVerificationTokenError) HttpStatusCode() int { return http.StatusBadRequest }

View File

@@ -0,0 +1,5 @@
package common
// StaticApiKeyUserID is the fixed ID of the synthetic admin user that the static API key authenticates as
// It is excluded from real-user counts such as the initial-admin-setup check
const StaticApiKeyUserID = "00000000-0000-0000-0000-000000000000"

View File

@@ -1,121 +0,0 @@
package controller
import (
"net/http"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"github.com/gin-gonic/gin"
"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"
)
// swag init -g cmd/main.go -o ./docs/swagger --parseDependency
// ApiKeyController manages API keys for authenticated users
type ApiKeyController struct {
apiKeyService *service.ApiKeyService
}
// NewApiKeyController creates a new controller for API key management
// @Summary API key management controller
// @Description Initializes API endpoints for managing API keys
// @Tags API Keys
func NewApiKeyController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, apiKeyService *service.ApiKeyService) {
uc := &ApiKeyController{apiKeyService: apiKeyService}
apiKeyGroup := group.Group("/api-keys")
apiKeyGroup.Use(authMiddleware.WithAdminNotRequired().Add())
{
apiKeyGroup.GET("", uc.listApiKeysHandler)
apiKeyGroup.POST("", uc.createApiKeyHandler)
apiKeyGroup.DELETE("/:id", uc.revokeApiKeyHandler)
}
}
// listApiKeysHandler godoc
// @Summary List API keys
// @Description Get a paginated list of API keys belonging to the current user
// @Tags API Keys
// @Param pagination[page] query int false "Page number for pagination" default(1)
// @Param pagination[limit] query int false "Number of items per page" default(20)
// @Param sort[column] query string false "Column to sort by"
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Success 200 {object} dto.Paginated[dto.ApiKeyDto]
// @Router /api/api-keys [get]
func (c *ApiKeyController) listApiKeysHandler(ctx *gin.Context) {
listRequestOptions := utils.ParseListRequestOptions(ctx)
userID := ctx.GetString("userID")
apiKeys, pagination, err := c.apiKeyService.ListApiKeys(ctx.Request.Context(), userID, listRequestOptions)
if err != nil {
_ = ctx.Error(err)
return
}
var apiKeysDto []dto.ApiKeyDto
if err := dto.MapStructList(apiKeys, &apiKeysDto); err != nil {
_ = ctx.Error(err)
return
}
ctx.JSON(http.StatusOK, dto.Paginated[dto.ApiKeyDto]{
Data: apiKeysDto,
Pagination: pagination,
})
}
// createApiKeyHandler godoc
// @Summary Create API key
// @Description Create a new API key for the current user
// @Tags API Keys
// @Param api_key body dto.ApiKeyCreateDto true "API key information"
// @Success 201 {object} dto.ApiKeyResponseDto "Created API key with token"
// @Router /api/api-keys [post]
func (c *ApiKeyController) createApiKeyHandler(ctx *gin.Context) {
userID := ctx.GetString("userID")
var input dto.ApiKeyCreateDto
if err := dto.ShouldBindWithNormalizedJSON(ctx, &input); err != nil {
_ = ctx.Error(err)
return
}
apiKey, token, err := c.apiKeyService.CreateApiKey(ctx.Request.Context(), userID, input)
if err != nil {
_ = ctx.Error(err)
return
}
var apiKeyDto dto.ApiKeyDto
if err := dto.MapStruct(apiKey, &apiKeyDto); err != nil {
_ = ctx.Error(err)
return
}
ctx.JSON(http.StatusCreated, dto.ApiKeyResponseDto{
ApiKey: apiKeyDto,
Token: token,
})
}
// revokeApiKeyHandler godoc
// @Summary Revoke API key
// @Description Revoke (delete) an existing API key by ID
// @Tags API Keys
// @Param id path string true "API Key ID"
// @Success 204 "No Content"
// @Router /api/api-keys/{id} [delete]
func (c *ApiKeyController) revokeApiKeyHandler(ctx *gin.Context) {
userID := ctx.GetString("userID")
apiKeyID := ctx.Param("id")
if err := c.apiKeyService.RevokeApiKey(ctx.Request.Context(), userID, apiKeyID); err != nil {
_ = ctx.Error(err)
return
}
ctx.Status(http.StatusNoContent)
}

View File

@@ -9,6 +9,7 @@ import (
"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"
"github.com/pocket-id/pocket-id/backend/internal/tracing"
)
// NewAppConfigController creates a new controller for application configuration endpoints
@@ -49,7 +50,7 @@ type AppConfigController struct {
// @Accept json
// @Produce json
// @Success 200 {array} dto.PublicAppConfigVariableDto
// @Router /application-configuration [get]
// @Router /api/application-configuration [get]
func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
configuration := acc.appConfigService.ListAppConfig(false)
@@ -66,6 +67,13 @@ func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
Type: "boolean",
})
// Manually add tracingEnabled, derived from the OTel environment, so the frontend only exports traces when the backend can forward them to a collector
configVariablesDto = append(configVariablesDto, dto.PublicAppConfigVariableDto{
Key: "tracingEnabled",
Value: strconv.FormatBool(tracing.FrontendTracingEnabled()),
Type: "boolean",
})
c.JSON(http.StatusOK, configVariablesDto)
}
@@ -76,7 +84,7 @@ func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
// @Accept json
// @Produce json
// @Success 200 {array} dto.AppConfigVariableDto
// @Router /application-configuration/all [get]
// @Router /api/application-configuration/all [get]
func (acc *AppConfigController) listAllAppConfigHandler(c *gin.Context) {
configuration := acc.appConfigService.ListAppConfig(true)

View File

@@ -2,7 +2,9 @@ package controller
import (
"net/http"
"slices"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
@@ -23,12 +25,19 @@ func NewAppImagesController(
}
group.GET("/application-images/logo", controller.getLogoHandler)
group.GET("/application-images/email", controller.getEmailLogoHandler)
group.GET("/application-images/background", controller.getBackgroundImageHandler)
group.GET("/application-images/favicon", controller.getFaviconHandler)
group.GET("/application-images/default-profile-picture", authMiddleware.Add(), controller.getDefaultProfilePicture)
group.PUT("/application-images/logo", authMiddleware.Add(), controller.updateLogoHandler)
group.PUT("/application-images/email", authMiddleware.Add(), controller.updateEmailLogoHandler)
group.PUT("/application-images/background", authMiddleware.Add(), controller.updateBackgroundImageHandler)
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)
}
type AppImagesController struct {
@@ -55,6 +64,18 @@ func (c *AppImagesController) getLogoHandler(ctx *gin.Context) {
c.getImage(ctx, imageName)
}
// getEmailLogoHandler godoc
// @Summary Get email logo image
// @Description Get the email logo image for use in emails
// @Tags Application Images
// @Produce image/png
// @Produce image/jpeg
// @Success 200 {file} binary "Email logo image"
// @Router /api/application-images/email [get]
func (c *AppImagesController) getEmailLogoHandler(ctx *gin.Context) {
c.getImage(ctx, "logoEmail")
}
// getBackgroundImageHandler godoc
// @Summary Get background image
// @Description Get the background image for the application
@@ -78,6 +99,18 @@ func (c *AppImagesController) getFaviconHandler(ctx *gin.Context) {
c.getImage(ctx, "favicon")
}
// getDefaultProfilePicture godoc
// @Summary Get default profile picture image
// @Description Get the default profile picture image for the application
// @Tags Application Images
// @Produce image/png
// @Produce image/jpeg
// @Success 200 {file} binary "Default profile picture image"
// @Router /api/application-images/default-profile-picture [get]
func (c *AppImagesController) getDefaultProfilePicture(ctx *gin.Context) {
c.getImage(ctx, "default-profile-picture")
}
// updateLogoHandler godoc
// @Summary Update logo
// @Description Update the application logo
@@ -100,7 +133,38 @@ func (c *AppImagesController) updateLogoHandler(ctx *gin.Context) {
imageName = "logoDark"
}
if err := c.appImagesService.UpdateImage(file, imageName); err != nil {
if err := c.appImagesService.UpdateImage(ctx.Request.Context(), file, imageName); err != nil {
_ = ctx.Error(err)
return
}
ctx.Status(http.StatusNoContent)
}
// updateEmailLogoHandler godoc
// @Summary Update email logo
// @Description Update the email logo for use in emails
// @Tags Application Images
// @Accept multipart/form-data
// @Param file formData file true "Email logo image file"
// @Success 204 "No Content"
// @Router /api/application-images/email [put]
func (c *AppImagesController) updateEmailLogoHandler(ctx *gin.Context) {
file, err := ctx.FormFile("file")
if err != nil {
_ = ctx.Error(err)
return
}
fileType := utils.GetFileExtension(file.Filename)
mimeType := utils.GetImageMimeType(fileType)
if mimeType != "image/png" && mimeType != "image/jpeg" {
_ = ctx.Error(&common.WrongFileTypeError{ExpectedFileType: ".png or .jpg/jpeg"})
return
}
if err := c.appImagesService.UpdateImage(ctx.Request.Context(), file, "logoEmail"); err != nil {
_ = ctx.Error(err)
return
}
@@ -123,7 +187,22 @@ func (c *AppImagesController) updateBackgroundImageHandler(ctx *gin.Context) {
return
}
if err := c.appImagesService.UpdateImage(file, "background"); err != nil {
if err := c.appImagesService.UpdateImage(ctx.Request.Context(), file, "background"); err != nil {
_ = ctx.Error(err)
return
}
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
}
@@ -136,7 +215,7 @@ func (c *AppImagesController) updateBackgroundImageHandler(ctx *gin.Context) {
// @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) {
@@ -147,12 +226,13 @@ 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
}
if err := c.appImagesService.UpdateImage(file, "favicon"); err != nil {
if err := c.appImagesService.UpdateImage(ctx.Request.Context(), file, "favicon"); err != nil {
_ = ctx.Error(err)
return
}
@@ -161,13 +241,52 @@ func (c *AppImagesController) updateFaviconHandler(ctx *gin.Context) {
}
func (c *AppImagesController) getImage(ctx *gin.Context, name string) {
imagePath, mimeType, err := c.appImagesService.GetImage(name)
reader, size, mimeType, err := c.appImagesService.GetImage(ctx.Request.Context(), name)
if err != nil {
_ = ctx.Error(err)
return
}
defer reader.Close()
ctx.Header("Content-Type", mimeType)
utils.SetCacheControlHeader(ctx, 15*time.Minute, 24*time.Hour)
ctx.DataFromReader(http.StatusOK, size, mimeType, reader, nil)
}
// updateDefaultProfilePicture godoc
// @Summary Update default profile picture image
// @Description Update the default profile picture image
// @Tags Application Images
// @Accept multipart/form-data
// @Param file formData file true "Profile picture image file"
// @Success 204 "No Content"
// @Router /api/application-images/default-profile-picture [put]
func (c *AppImagesController) updateDefaultProfilePicture(ctx *gin.Context) {
file, err := ctx.FormFile("file")
if err != nil {
_ = ctx.Error(err)
return
}
ctx.Header("Content-Type", mimeType)
utils.SetCacheControlHeader(ctx, 15*time.Minute, 24*time.Hour)
ctx.File(imagePath)
if err := c.appImagesService.UpdateImage(ctx.Request.Context(), file, "default-profile-picture"); err != nil {
_ = ctx.Error(err)
return
}
ctx.Status(http.StatusNoContent)
}
// deleteDefaultProfilePicture godoc
// @Summary Delete default profile picture image
// @Description Delete the default profile picture image
// @Tags Application Images
// @Success 204 "No Content"
// @Router /api/application-images/default-profile-picture [delete]
func (c *AppImagesController) deleteDefaultProfilePicture(ctx *gin.Context) {
if err := c.appImagesService.DeleteImage(ctx.Request.Context(), "default-profile-picture"); err != nil {
_ = ctx.Error(err)
return
}
ctx.Status(http.StatusNoContent)
}

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

@@ -14,6 +14,7 @@ func NewTestController(group *gin.RouterGroup, testService *service.TestService)
testController := &TestController{TestService: testService}
group.POST("/test/reset", testController.resetAndSeedHandler)
group.POST("/test/accesstoken", testController.signAccessToken)
group.POST("/test/refreshtoken", testController.signRefreshToken)
group.GET("/externalidp/jwks.json", testController.externalIdPJWKS)
@@ -40,6 +41,11 @@ func (tc *TestController) resetAndSeedHandler(c *gin.Context) {
return
}
if err := tc.TestService.ResetLock(c.Request.Context()); err != nil {
_ = c.Error(err)
return
}
if err := tc.TestService.ResetApplicationImages(c.Request.Context()); err != nil {
_ = c.Error(err)
return
@@ -69,8 +75,6 @@ func (tc *TestController) resetAndSeedHandler(c *gin.Context) {
}
}
tc.TestService.SetJWTKeys()
c.Status(http.StatusNoContent)
}
@@ -105,6 +109,27 @@ func (tc *TestController) externalIdPSignToken(c *gin.Context) {
c.Writer.WriteString(token)
}
func (tc *TestController) signAccessToken(c *gin.Context) {
var input struct {
UserID string `json:"user"`
ClientID string `json:"client"`
Expired bool `json:"expired"`
}
err := c.ShouldBindJSON(&input)
if err != nil {
_ = c.Error(err)
return
}
token, err := tc.TestService.SignAccessToken(c.Request.Context(), input.UserID, input.ClientID, input.Expired)
if err != nil {
_ = c.Error(err)
return
}
c.Writer.WriteString(token)
}
func (tc *TestController) signRefreshToken(c *gin.Context) {
var input struct {
UserID string `json:"user"`
@@ -117,7 +142,7 @@ func (tc *TestController) signRefreshToken(c *gin.Context) {
return
}
token, err := tc.TestService.SignRefreshToken(input.UserID, input.ClientID, input.RefreshToken)
token, err := tc.TestService.SignRefreshToken(c.Request.Context(), input.UserID, input.ClientID, input.RefreshToken)
if err != nil {
_ = c.Error(err)
return

View File

@@ -1,10 +1,7 @@
package controller
import (
"errors"
"log/slog"
"net/http"
"net/url"
"strconv"
"strings"
"time"
@@ -16,25 +13,16 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/middleware"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
)
// NewOidcController creates a new controller for OIDC related endpoints
// @Summary OIDC controller
// @Description Initializes all OIDC-related API endpoints for authentication and client management
// @Tags OIDC
func NewOidcController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, fileSizeLimitMiddleware *middleware.FileSizeLimitMiddleware, oidcService *service.OidcService, jwtService *service.JwtService) {
oc := &OidcController{oidcService: oidcService, jwtService: jwtService}
group.POST("/oidc/authorize", authMiddleware.WithAdminNotRequired().Add(), oc.authorizeHandler)
group.POST("/oidc/authorization-required", authMiddleware.WithAdminNotRequired().Add(), oc.authorizationConfirmationRequiredHandler)
group.POST("/oidc/token", oc.createTokensHandler)
group.GET("/oidc/userinfo", oc.userInfoHandler)
group.POST("/oidc/userinfo", oc.userInfoHandler)
group.POST("/oidc/end-session", authMiddleware.WithAdminNotRequired().WithSuccessOptional().Add(), oc.EndSessionHandler)
group.GET("/oidc/end-session", authMiddleware.WithAdminNotRequired().WithSuccessOptional().Add(), oc.EndSessionHandler)
group.POST("/oidc/introspect", oc.introspectTokenHandler)
func NewOidcController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, fileSizeLimitMiddleware *middleware.FileSizeLimitMiddleware, oidcService *service.OidcService) {
oc := &OidcController{
oidcService: oidcService,
}
group.GET("/oidc/clients", authMiddleware.Add(), oc.listClientsHandler)
group.POST("/oidc/clients", authMiddleware.Add(), oc.createClientHandler)
@@ -47,15 +35,11 @@ func NewOidcController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
group.POST("/oidc/clients/:id/secret", authMiddleware.Add(), oc.createClientSecretHandler)
group.GET("/oidc/clients/:id/logo", oc.getClientLogoHandler)
group.DELETE("/oidc/clients/:id/logo", oc.deleteClientLogoHandler)
group.DELETE("/oidc/clients/:id/logo", authMiddleware.Add(), oc.deleteClientLogoHandler)
group.POST("/oidc/clients/:id/logo", authMiddleware.Add(), fileSizeLimitMiddleware.Add(2<<20), oc.updateClientLogoHandler)
group.GET("/oidc/clients/:id/preview/:userId", authMiddleware.Add(), oc.getClientPreviewHandler)
group.POST("/oidc/device/authorize", oc.deviceAuthorizationHandler)
group.POST("/oidc/device/verify", authMiddleware.WithAdminNotRequired().Add(), oc.verifyDeviceCodeHandler)
group.GET("/oidc/device/info", authMiddleware.WithAdminNotRequired().Add(), oc.getDeviceCodeInfoHandler)
group.GET("/oidc/users/me/authorized-clients", authMiddleware.WithAdminNotRequired().Add(), oc.listOwnAuthorizedClientsHandler)
group.GET("/oidc/users/:id/authorized-clients", authMiddleware.Add(), oc.listAuthorizedClientsHandler)
@@ -63,280 +47,12 @@ func NewOidcController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
group.GET("/oidc/users/me/clients", authMiddleware.WithAdminNotRequired().Add(), oc.listOwnAccessibleClientsHandler)
group.GET("/oidc/clients/:id/scim-service-provider", authMiddleware.Add(), oc.getClientScimServiceProviderHandler)
}
type OidcController struct {
oidcService *service.OidcService
jwtService *service.JwtService
}
// authorizeHandler godoc
// @Summary Authorize OIDC client
// @Description Start the OIDC authorization process for a client
// @Tags OIDC
// @Accept json
// @Produce json
// @Param request body dto.AuthorizeOidcClientRequestDto true "Authorization request parameters"
// @Success 200 {object} dto.AuthorizeOidcClientResponseDto "Authorization code and callback URL"
// @Router /api/oidc/authorize [post]
func (oc *OidcController) authorizeHandler(c *gin.Context) {
var input dto.AuthorizeOidcClientRequestDto
if err := c.ShouldBindJSON(&input); err != nil {
_ = c.Error(err)
return
}
code, callbackURL, err := oc.oidcService.Authorize(c.Request.Context(), input, c.GetString("userID"), c.ClientIP(), c.Request.UserAgent())
if err != nil {
_ = c.Error(err)
return
}
response := dto.AuthorizeOidcClientResponseDto{
Code: code,
CallbackURL: callbackURL,
Issuer: common.EnvConfig.AppURL,
}
c.JSON(http.StatusOK, response)
}
// authorizationConfirmationRequiredHandler godoc
// @Summary Check if authorization confirmation is required
// @Description Check if the user needs to confirm authorization for the client
// @Tags OIDC
// @Accept json
// @Produce json
// @Param request body dto.AuthorizationRequiredDto true "Authorization check parameters"
// @Success 200 {object} object "{ \"authorizationRequired\": true/false }"
// @Router /api/oidc/authorization-required [post]
func (oc *OidcController) authorizationConfirmationRequiredHandler(c *gin.Context) {
var input dto.AuthorizationRequiredDto
if err := c.ShouldBindJSON(&input); err != nil {
_ = c.Error(err)
return
}
hasAuthorizedClient, err := oc.oidcService.HasAuthorizedClient(c.Request.Context(), input.ClientID, c.GetString("userID"), input.Scope)
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, gin.H{"authorizationRequired": !hasAuthorizedClient})
}
// createTokensHandler godoc
// @Summary Create OIDC tokens
// @Description Exchange authorization code or refresh token for access tokens
// @Tags OIDC
// @Produce json
// @Param client_id formData string false "Client ID (if not using Basic Auth)"
// @Param client_secret formData string false "Client secret (if not using Basic Auth or client assertions)"
// @Param code formData string false "Authorization code (required for 'authorization_code' grant)"
// @Param grant_type formData string true "Grant type ('authorization_code' or 'refresh_token')"
// @Param code_verifier formData string false "PKCE code verifier (for authorization_code with PKCE)"
// @Param refresh_token formData string false "Refresh token (required for 'refresh_token' grant)"
// @Param client_assertion formData string false "Client assertion type (for 'authorization_code' grant when using client assertions)"
// @Param client_assertion_type formData string false "Client assertion type (for 'authorization_code' grant when using client assertions)"
// @Success 200 {object} dto.OidcTokenResponseDto "Token response with access_token and optional id_token and refresh_token"
// @Router /api/oidc/token [post]
func (oc *OidcController) createTokensHandler(c *gin.Context) {
var input dto.OidcCreateTokensDto
if err := c.ShouldBind(&input); err != nil {
_ = c.Error(err)
return
}
// Validate that code is provided for authorization_code grant type
if input.GrantType == service.GrantTypeAuthorizationCode && input.Code == "" {
_ = c.Error(&common.OidcMissingAuthorizationCodeError{})
return
}
// Validate that refresh_token is provided for refresh_token grant type
if input.GrantType == service.GrantTypeRefreshToken && input.RefreshToken == "" {
_ = c.Error(&common.OidcMissingRefreshTokenError{})
return
}
// Client id and secret can also be passed over the Authorization header
if input.ClientID == "" && input.ClientSecret == "" {
input.ClientID, input.ClientSecret, _ = c.Request.BasicAuth()
}
tokens, err := oc.oidcService.CreateTokens(c.Request.Context(), input)
switch {
case errors.Is(err, &common.OidcAuthorizationPendingError{}):
c.JSON(http.StatusBadRequest, gin.H{
"error": "authorization_pending",
})
return
case errors.Is(err, &common.OidcSlowDownError{}):
c.JSON(http.StatusBadRequest, gin.H{
"error": "slow_down",
})
return
case err != nil:
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, dto.OidcTokenResponseDto{
AccessToken: tokens.AccessToken,
TokenType: "Bearer",
ExpiresIn: int(tokens.ExpiresIn.Seconds()),
IdToken: tokens.IdToken, // May be empty
RefreshToken: tokens.RefreshToken, // May be empty
})
}
// userInfoHandler godoc
// @Summary Get user information
// @Description Get user information based on the access token
// @Tags OIDC
// @Accept json
// @Produce json
// @Success 200 {object} object "User claims based on requested scopes"
// @Security OAuth2AccessToken
// @Router /api/oidc/userinfo [get]
func (oc *OidcController) userInfoHandler(c *gin.Context) {
_, authToken, ok := strings.Cut(c.GetHeader("Authorization"), " ")
if !ok || authToken == "" {
_ = c.Error(&common.MissingAccessToken{})
return
}
token, err := oc.jwtService.VerifyOAuthAccessToken(authToken)
if err != nil {
_ = c.Error(err)
return
}
userID, ok := token.Subject()
if !ok {
_ = c.Error(&common.TokenInvalidError{})
return
}
clientID, ok := token.Audience()
if !ok || len(clientID) != 1 {
_ = c.Error(&common.TokenInvalidError{})
return
}
claims, err := oc.oidcService.GetUserClaimsForClient(c.Request.Context(), userID, clientID[0])
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, claims)
}
// EndSessionHandler godoc
// @Summary End OIDC session
// @Description End user session and handle OIDC logout
// @Tags OIDC
// @Accept application/x-www-form-urlencoded
// @Param id_token_hint query string false "ID token"
// @Param post_logout_redirect_uri query string false "URL to redirect to after logout"
// @Param state query string false "State parameter to include in the redirect"
// @Success 302 "Redirect to post-logout URL or application logout page"
// @Router /api/oidc/end-session [get]
func (oc *OidcController) EndSessionHandler(c *gin.Context) {
var input dto.OidcLogoutDto
// Bind query parameters to the struct
switch c.Request.Method {
case http.MethodGet:
if err := c.ShouldBindQuery(&input); err != nil {
_ = c.Error(err)
return
}
case http.MethodPost:
// Bind form parameters to the struct
if err := c.ShouldBind(&input); err != nil {
_ = c.Error(err)
return
}
}
callbackURL, err := oc.oidcService.ValidateEndSession(c.Request.Context(), input, c.GetString("userID"))
if err != nil {
// If the validation fails, the user has to confirm the logout manually and doesn't get redirected
slog.WarnContext(c.Request.Context(), "Error getting logout callback URL, the user has to confirm the logout manually", "error", err)
c.Redirect(http.StatusFound, common.EnvConfig.AppURL+"/logout")
return
}
// The validation was successful, so we can log out and redirect the user to the callback URL without confirmation
cookie.AddAccessTokenCookie(c, 0, "")
logoutCallbackURL, _ := url.Parse(callbackURL)
if input.State != "" {
q := logoutCallbackURL.Query()
q.Set("state", input.State)
logoutCallbackURL.RawQuery = q.Encode()
}
c.Redirect(http.StatusFound, logoutCallbackURL.String())
}
// EndSessionHandler godoc (POST method)
// @Summary End OIDC session (POST method)
// @Description End user session and handle OIDC logout using POST
// @Tags OIDC
// @Accept application/x-www-form-urlencoded
// @Produce html
// @Param id_token_hint formData string false "ID token"
// @Param post_logout_redirect_uri formData string false "URL to redirect to after logout"
// @Param state formData string false "State parameter to include in the redirect"
// @Success 302 "Redirect to post-logout URL or application logout page"
// @Router /api/oidc/end-session [post]
func (oc *OidcController) EndSessionHandlerPost(c *gin.Context) {
// Implementation is the same as GET
}
// introspectToken godoc
// @Summary Introspect OIDC tokens
// @Description Pass an access_token to verify if it is considered valid.
// @Tags OIDC
// @Produce json
// @Param token formData string true "The token to be introspected."
// @Success 200 {object} dto.OidcIntrospectionResponseDto "Response with the introspection result."
// @Router /api/oidc/introspect [post]
func (oc *OidcController) introspectTokenHandler(c *gin.Context) {
var input dto.OidcIntrospectDto
if err := c.ShouldBind(&input); err != nil {
_ = c.Error(err)
return
}
// Client id and secret have to be passed over the Authorization header. This kind of
// authentication allows us to keep the endpoint protected (since it could be used to
// find valid tokens) while still allowing it to be used by an application that is
// supposed to interact with our IdP (since that needs to have a client_id
// and client_secret anyway).
var (
creds service.ClientAuthCredentials
ok bool
)
creds.ClientID, creds.ClientSecret, ok = c.Request.BasicAuth()
if !ok {
// If there's no basic auth, check if we have a bearer token
bearer, ok := utils.BearerAuth(c.Request)
if ok {
creds.ClientAssertionType = service.ClientAssertionTypeJWTBearer
creds.ClientAssertion = bearer
}
}
response, err := oc.oidcService.IntrospectToken(c.Request.Context(), creds, input.Token)
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, response)
}
// getClientMetaDataHandler godoc
@@ -548,16 +264,17 @@ func (oc *OidcController) createClientSecretHandler(c *gin.Context) {
func (oc *OidcController) getClientLogoHandler(c *gin.Context) {
lightLogo, _ := strconv.ParseBool(c.DefaultQuery("light", "true"))
imagePath, mimeType, err := oc.oidcService.GetClientLogo(c.Request.Context(), c.Param("id"), lightLogo)
reader, size, mimeType, err := oc.oidcService.GetClientLogo(c.Request.Context(), c.Param("id"), lightLogo)
if err != nil {
_ = c.Error(err)
return
}
defer reader.Close()
utils.SetCacheControlHeader(c, 15*time.Minute, 12*time.Hour)
c.Header("Content-Type", mimeType)
c.File(imagePath)
c.DataFromReader(http.StatusOK, size, mimeType, reader, nil)
}
// updateClientLogoHandler godoc
@@ -586,7 +303,6 @@ func (oc *OidcController) updateClientLogoHandler(c *gin.Context) {
}
c.Status(http.StatusNoContent)
}
// deleteClientLogoHandler godoc
@@ -613,7 +329,6 @@ func (oc *OidcController) deleteClientLogoHandler(c *gin.Context) {
}
c.Status(http.StatusNoContent)
}
// updateAllowedUserGroupsHandler godoc
@@ -649,27 +364,6 @@ func (oc *OidcController) updateAllowedUserGroupsHandler(c *gin.Context) {
c.JSON(http.StatusOK, oidcClientDto)
}
func (oc *OidcController) deviceAuthorizationHandler(c *gin.Context) {
var input dto.OidcDeviceAuthorizationRequestDto
if err := c.ShouldBind(&input); err != nil {
_ = c.Error(err)
return
}
// Client id and secret can also be passed over the Authorization header
if input.ClientID == "" && input.ClientSecret == "" {
input.ClientID, input.ClientSecret, _ = c.Request.BasicAuth()
}
response, err := oc.oidcService.CreateDeviceAuthorization(c.Request.Context(), input)
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, response)
}
// listOwnAuthorizedClientsHandler godoc
// @Summary List authorized clients for current user
// @Description Get a paginated list of OIDC clients that the current user has authorized
@@ -771,42 +465,6 @@ func (oc *OidcController) listOwnAccessibleClientsHandler(c *gin.Context) {
})
}
func (oc *OidcController) verifyDeviceCodeHandler(c *gin.Context) {
userCode := c.Query("code")
if userCode == "" {
_ = c.Error(&common.ValidationError{Message: "code is required"})
return
}
// Get IP address and user agent from the request context
ipAddress := c.ClientIP()
userAgent := c.Request.UserAgent()
err := oc.oidcService.VerifyDeviceCode(c.Request.Context(), userCode, c.GetString("userID"), ipAddress, userAgent)
if err != nil {
_ = c.Error(err)
return
}
c.Status(http.StatusNoContent)
}
func (oc *OidcController) getDeviceCodeInfoHandler(c *gin.Context) {
userCode := c.Query("code")
if userCode == "" {
_ = c.Error(&common.ValidationError{Message: "code is required"})
return
}
deviceCodeInfo, err := oc.oidcService.GetDeviceCodeInfo(c.Request.Context(), userCode, c.GetString("userID"))
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, deviceCodeInfo)
}
// getClientPreviewHandler godoc
// @Summary Preview OIDC client data for user
// @Description Get a preview of the OIDC data (ID token, access token, userinfo) that would be sent to the client for a specific user
@@ -838,7 +496,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
@@ -846,3 +510,29 @@ func (oc *OidcController) getClientPreviewHandler(c *gin.Context) {
c.JSON(http.StatusOK, preview)
}
// getClientScimServiceProviderHandler godoc
// @Summary Get SCIM service provider
// @Description Get the SCIM service provider configuration for an OIDC client
// @Tags OIDC
// @Produce json
// @Param id path string true "Client ID"
// @Success 200 {object} dto.ScimServiceProviderDTO "SCIM service provider configuration"
// @Router /api/oidc/clients/{id}/scim-service-provider [get]
func (oc *OidcController) getClientScimServiceProviderHandler(c *gin.Context) {
clientID := c.Param("id")
provider, err := oc.oidcService.GetClientScimServiceProvider(c.Request.Context(), clientID)
if err != nil {
_ = c.Error(err)
return
}
var providerDto dto.ScimServiceProviderDTO
if err := dto.MapStruct(provider, &providerDto); err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, providerDto)
}

View File

@@ -0,0 +1,122 @@
package controller
import (
"net/http"
"github.com/gin-gonic/gin"
"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"
)
func NewScimController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, scimService *service.ScimService) {
ugc := ScimController{
scimService: scimService,
}
group.POST("/scim/service-provider", authMiddleware.Add(), ugc.createServiceProviderHandler)
group.POST("/scim/service-provider/:id/sync", authMiddleware.Add(), ugc.syncServiceProviderHandler)
group.PUT("/scim/service-provider/:id", authMiddleware.Add(), ugc.updateServiceProviderHandler)
group.DELETE("/scim/service-provider/:id", authMiddleware.Add(), ugc.deleteServiceProviderHandler)
}
type ScimController struct {
scimService *service.ScimService
}
// syncServiceProviderHandler godoc
// @Summary Sync SCIM service provider
// @Description Trigger synchronization for a SCIM service provider
// @Tags SCIM
// @Param id path string true "Service Provider ID"
// @Success 200 "OK"
// @Router /api/scim/service-provider/{id}/sync [post]
func (c *ScimController) syncServiceProviderHandler(ctx *gin.Context) {
err := c.scimService.SyncServiceProvider(ctx.Request.Context(), ctx.Param("id"))
if err != nil {
_ = ctx.Error(err)
return
}
ctx.Status(http.StatusOK)
}
// createServiceProviderHandler godoc
// @Summary Create SCIM service provider
// @Description Create a new SCIM service provider
// @Tags SCIM
// @Accept json
// @Produce json
// @Param serviceProvider body dto.ScimServiceProviderCreateDTO true "SCIM service provider information"
// @Success 201 {object} dto.ScimServiceProviderDTO "Created SCIM service provider"
// @Router /api/scim/service-provider [post]
func (c *ScimController) createServiceProviderHandler(ctx *gin.Context) {
var input dto.ScimServiceProviderCreateDTO
if err := ctx.ShouldBindJSON(&input); err != nil {
_ = ctx.Error(err)
return
}
provider, err := c.scimService.CreateServiceProvider(ctx.Request.Context(), &input)
if err != nil {
_ = ctx.Error(err)
return
}
var providerDTO dto.ScimServiceProviderDTO
if err := dto.MapStruct(provider, &providerDTO); err != nil {
_ = ctx.Error(err)
return
}
ctx.JSON(http.StatusCreated, providerDTO)
}
// updateServiceProviderHandler godoc
// @Summary Update SCIM service provider
// @Description Update an existing SCIM service provider
// @Tags SCIM
// @Accept json
// @Produce json
// @Param id path string true "Service Provider ID"
// @Param serviceProvider body dto.ScimServiceProviderCreateDTO true "SCIM service provider information"
// @Success 200 {object} dto.ScimServiceProviderDTO "Updated SCIM service provider"
// @Router /api/scim/service-provider/{id} [put]
func (c *ScimController) updateServiceProviderHandler(ctx *gin.Context) {
var input dto.ScimServiceProviderCreateDTO
if err := ctx.ShouldBindJSON(&input); err != nil {
_ = ctx.Error(err)
return
}
provider, err := c.scimService.UpdateServiceProvider(ctx.Request.Context(), ctx.Param("id"), &input)
if err != nil {
_ = ctx.Error(err)
return
}
var providerDTO dto.ScimServiceProviderDTO
if err := dto.MapStruct(provider, &providerDTO); err != nil {
_ = ctx.Error(err)
return
}
ctx.JSON(http.StatusOK, providerDTO)
}
// deleteServiceProviderHandler godoc
// @Summary Delete SCIM service provider
// @Description Delete a SCIM service provider by ID
// @Tags SCIM
// @Param id path string true "Service Provider ID"
// @Success 204 "No Content"
// @Router /api/scim/service-provider/{id} [delete]
func (c *ScimController) deleteServiceProviderHandler(ctx *gin.Context) {
err := c.scimService.DeleteServiceProvider(ctx.Request.Context(), ctx.Param("id"))
if err != nil {
_ = ctx.Error(err)
return
}
ctx.Status(http.StatusNoContent)
}

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"
@@ -11,22 +12,21 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/middleware"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"golang.org/x/time/rate"
"github.com/pocket-id/pocket-id/backend/internal/webauthn"
)
const (
defaultOneTimeAccessTokenDuration = 15 * time.Minute
defaultSignupTokenDuration = time.Hour
)
const defaultOneTimeAccessTokenDuration = 15 * time.Minute
// NewUserController creates a new controller for user management endpoints
// @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, appConfigService *service.AppConfigService) {
func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, userService *service.UserService, oneTimeAccessService *service.OneTimeAccessService, webAuthnService *webauthn.Module, appConfigService *service.AppConfigService) {
uc := UserController{
userService: userService,
appConfigService: appConfigService,
userService: userService,
oneTimeAccessService: oneTimeAccessService,
webAuthnService: webAuthnService,
appConfigService: appConfigService,
}
group.GET("/users", authMiddleware.Add(), uc.listUsersHandler)
@@ -35,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)
@@ -48,23 +50,21 @@ func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
group.POST("/users/me/one-time-access-token", authMiddleware.WithAdminNotRequired().Add(), uc.createOwnOneTimeAccessTokenHandler)
group.POST("/users/:id/one-time-access-token", authMiddleware.Add(), uc.createAdminOneTimeAccessTokenHandler)
group.POST("/users/:id/one-time-access-email", authMiddleware.Add(), uc.RequestOneTimeAccessEmailAsAdminHandler)
group.POST("/one-time-access-token/:token", rateLimitMiddleware.Add(rate.Every(10*time.Second), 5), uc.exchangeOneTimeAccessTokenHandler)
group.POST("/one-time-access-email", rateLimitMiddleware.Add(rate.Every(10*time.Minute), 3), uc.RequestOneTimeAccessEmailAsUnauthenticatedUserHandler)
group.POST("/one-time-access-token/:token", rateLimitMiddleware.Add(middleware.RateLimitOneTimeAccessToken), uc.exchangeOneTimeAccessTokenHandler)
group.POST("/one-time-access-email", rateLimitMiddleware.Add(middleware.RateLimitOneTimeAccessEmail), uc.RequestOneTimeAccessEmailAsUnauthenticatedUserHandler)
group.DELETE("/users/:id/profile-picture", authMiddleware.Add(), uc.resetUserProfilePictureHandler)
group.DELETE("/users/me/profile-picture", authMiddleware.WithAdminNotRequired().Add(), uc.resetCurrentUserProfilePictureHandler)
group.POST("/signup-tokens", authMiddleware.Add(), uc.createSignupTokenHandler)
group.GET("/signup-tokens", authMiddleware.Add(), uc.listSignupTokensHandler)
group.DELETE("/signup-tokens/:id", authMiddleware.Add(), uc.deleteSignupTokenHandler)
group.POST("/signup", rateLimitMiddleware.Add(rate.Every(1*time.Minute), 10), uc.signupHandler)
group.POST("/signup/setup", uc.signUpInitialAdmin)
group.POST("/users/me/send-email-verification", rateLimitMiddleware.Add(middleware.RateLimitSendEmailVerification), authMiddleware.WithAdminNotRequired().Add(), uc.sendEmailVerificationHandler)
group.POST("/users/me/verify-email", rateLimitMiddleware.Add(middleware.RateLimitVerifyEmail), authMiddleware.WithAdminNotRequired().Add(), uc.verifyEmailHandler)
}
type UserController struct {
userService *service.UserService
appConfigService *service.AppConfigService
userService *service.UserService
oneTimeAccessService *service.OneTimeAccessService
webAuthnService *webauthn.Module
appConfigService *service.AppConfigService
}
// getUserGroupsHandler godoc
@@ -72,7 +72,7 @@ type UserController struct {
// @Description Retrieve all groups a specific user belongs to
// @Tags Users,User Groups
// @Param id path string true "User ID"
// @Success 200 {array} dto.UserGroupDtoWithUsers
// @Success 200 {array} dto.UserGroupDto
// @Router /api/users/{id}/groups [get]
func (uc *UserController) getUserGroupsHandler(c *gin.Context) {
userID := c.Param("id")
@@ -82,7 +82,7 @@ func (uc *UserController) getUserGroupsHandler(c *gin.Context) {
return
}
var groupsDto []dto.UserGroupDtoWithUsers
var groupsDto []dto.UserGroupDto
if err := dto.MapStructList(groups, &groupsDto); err != nil {
_ = c.Error(err)
return
@@ -91,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
@@ -185,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
@@ -286,7 +341,7 @@ func (uc *UserController) updateUserProfilePictureHandler(c *gin.Context) {
}
defer file.Close()
if err := uc.userService.UpdateProfilePicture(userID, file); err != nil {
if err := uc.userService.UpdateProfilePicture(c.Request.Context(), userID, file); err != nil {
_ = c.Error(err)
return
}
@@ -317,7 +372,7 @@ func (uc *UserController) updateCurrentUserProfilePictureHandler(c *gin.Context)
}
defer file.Close()
if err := uc.userService.UpdateProfilePicture(userID, file); err != nil {
if err := uc.userService.UpdateProfilePicture(c.Request.Context(), userID, file); err != nil {
_ = c.Error(err)
return
}
@@ -327,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.userService.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
@@ -391,12 +458,13 @@ func (uc *UserController) RequestOneTimeAccessEmailAsUnauthenticatedUserHandler(
return
}
err := uc.userService.RequestOneTimeAccessEmailAsUnauthenticatedUser(c.Request.Context(), input.Email, input.RedirectPath)
deviceToken, err := uc.oneTimeAccessService.RequestOneTimeAccessEmailAsUnauthenticatedUser(c.Request.Context(), input.Email, input.RedirectPath)
if err != nil {
_ = c.Error(err)
return
}
cookie.AddDeviceTokenCookie(c, deviceToken)
c.Status(http.StatusNoContent)
}
@@ -423,7 +491,7 @@ func (uc *UserController) RequestOneTimeAccessEmailAsAdminHandler(c *gin.Context
if ttl <= 0 {
ttl = defaultOneTimeAccessTokenDuration
}
err := uc.userService.RequestOneTimeAccessEmailAsAdmin(c.Request.Context(), userID, ttl)
err := uc.oneTimeAccessService.RequestOneTimeAccessEmailAsAdmin(c.Request.Context(), userID, ttl)
if err != nil {
_ = c.Error(err)
return
@@ -440,41 +508,15 @@ func (uc *UserController) RequestOneTimeAccessEmailAsAdminHandler(c *gin.Context
// @Success 200 {object} dto.UserDto
// @Router /api/one-time-access-token/{token} [post]
func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) {
user, token, err := uc.userService.ExchangeOneTimeAccessToken(c.Request.Context(), c.Param("token"), c.ClientIP(), c.Request.UserAgent())
if err != nil {
_ = c.Error(err)
loginCode := c.Param("token")
// reject invalid length login codes
if len(loginCode) != 6 && len(loginCode) != 16 {
_ = c.Error(&common.TokenInvalidOrExpiredError{})
return
}
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
_ = c.Error(err)
return
}
maxAge := int(uc.appConfigService.GetDbConfig().SessionDuration.AsDurationMinutes().Seconds())
cookie.AddAccessTokenCookie(c, maxAge, token)
c.JSON(http.StatusOK, userDto)
}
// signUpInitialAdmin godoc
// @Summary Sign up initial admin user
// @Description Sign up and generate setup access token for initial admin user
// @Tags Users
// @Accept json
// @Produce json
// @Param body body dto.SignUpDto true "User information"
// @Success 200 {object} dto.UserDto
// @Router /api/signup/setup [post]
func (uc *UserController) signUpInitialAdmin(c *gin.Context) {
var input dto.SignUpDto
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
}
user, token, err := uc.userService.SignUpInitialAdmin(c.Request.Context(), input)
deviceToken, _ := c.Cookie(cookie.DeviceTokenCookieName)
user, token, err := uc.oneTimeAccessService.ExchangeOneTimeAccessToken(c.Request.Context(), loginCode, deviceToken, c.ClientIP(), c.Request.UserAgent())
if err != nil {
_ = c.Error(err)
return
@@ -522,130 +564,6 @@ func (uc *UserController) updateUserGroups(c *gin.Context) {
c.JSON(http.StatusOK, userDto)
}
// createSignupTokenHandler godoc
// @Summary Create signup token
// @Description Create a new signup token that allows user registration
// @Tags Users
// @Accept json
// @Produce json
// @Param token body dto.SignupTokenCreateDto true "Signup token information"
// @Success 201 {object} dto.SignupTokenDto
// @Router /api/signup-tokens [post]
func (uc *UserController) createSignupTokenHandler(c *gin.Context) {
var input dto.SignupTokenCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
_ = c.Error(err)
return
}
ttl := input.TTL.Duration
if ttl <= 0 {
ttl = defaultSignupTokenDuration
}
signupToken, err := uc.userService.CreateSignupToken(c.Request.Context(), ttl, input.UsageLimit)
if err != nil {
_ = c.Error(err)
return
}
var tokenDto dto.SignupTokenDto
err = dto.MapStruct(signupToken, &tokenDto)
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusCreated, tokenDto)
}
// listSignupTokensHandler godoc
// @Summary List signup tokens
// @Description Get a paginated list of signup tokens
// @Tags Users
// @Param pagination[page] query int false "Page number for pagination" default(1)
// @Param pagination[limit] query int false "Number of items per page" default(20)
// @Param sort[column] query string false "Column to sort by"
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Success 200 {object} dto.Paginated[dto.SignupTokenDto]
// @Router /api/signup-tokens [get]
func (uc *UserController) listSignupTokensHandler(c *gin.Context) {
listRequestOptions := utils.ParseListRequestOptions(c)
tokens, pagination, err := uc.userService.ListSignupTokens(c.Request.Context(), listRequestOptions)
if err != nil {
_ = c.Error(err)
return
}
var tokensDto []dto.SignupTokenDto
if err := dto.MapStructList(tokens, &tokensDto); err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, dto.Paginated[dto.SignupTokenDto]{
Data: tokensDto,
Pagination: pagination,
})
}
// deleteSignupTokenHandler godoc
// @Summary Delete signup token
// @Description Delete a signup token by ID
// @Tags Users
// @Param id path string true "Token ID"
// @Success 204 "No Content"
// @Router /api/signup-tokens/{id} [delete]
func (uc *UserController) deleteSignupTokenHandler(c *gin.Context) {
tokenID := c.Param("id")
err := uc.userService.DeleteSignupToken(c.Request.Context(), tokenID)
if err != nil {
_ = c.Error(err)
return
}
c.Status(http.StatusNoContent)
}
// signupWithTokenHandler godoc
// @Summary Sign up
// @Description Create a new user account
// @Tags Users
// @Accept json
// @Produce json
// @Param user body dto.SignUpDto true "User information"
// @Success 201 {object} dto.SignUpDto
// @Router /api/signup [post]
func (uc *UserController) signupHandler(c *gin.Context) {
var input dto.SignUpDto
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
}
ipAddress := c.ClientIP()
userAgent := c.GetHeader("User-Agent")
user, accessToken, err := uc.userService.SignUp(c.Request.Context(), input, ipAddress, userAgent)
if err != nil {
_ = c.Error(err)
return
}
maxAge := int(uc.appConfigService.GetDbConfig().SessionDuration.AsDurationMinutes().Seconds())
cookie.AddAccessTokenCookie(c, maxAge, accessToken)
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusCreated, userDto)
}
// updateUser is an internal helper method, not exposed as an API endpoint
func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
var input dto.UserCreateDto
@@ -687,7 +605,7 @@ func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
func (uc *UserController) resetUserProfilePictureHandler(c *gin.Context) {
userID := c.Param("id")
if err := uc.userService.ResetProfilePicture(userID); err != nil {
if err := uc.userService.ResetProfilePicture(c.Request.Context(), userID); err != nil {
_ = c.Error(err)
return
}
@@ -705,7 +623,48 @@ func (uc *UserController) resetUserProfilePictureHandler(c *gin.Context) {
func (uc *UserController) resetCurrentUserProfilePictureHandler(c *gin.Context) {
userID := c.GetString("userID")
if err := uc.userService.ResetProfilePicture(userID); err != nil {
if err := uc.userService.ResetProfilePicture(c.Request.Context(), userID); err != nil {
_ = c.Error(err)
return
}
c.Status(http.StatusNoContent)
}
// sendEmailVerificationHandler godoc
// @Summary Send email verification
// @Description Send an email verification to the currently authenticated user
// @Tags Users
// @Produce json
// @Success 204 "No Content"
// @Router /api/users/me/send-email-verification [post]
func (uc *UserController) sendEmailVerificationHandler(c *gin.Context) {
userID := c.GetString("userID")
if err := uc.userService.SendEmailVerification(c.Request.Context(), userID); err != nil {
_ = c.Error(err)
return
}
c.Status(http.StatusNoContent)
}
// verifyEmailHandler godoc
// @Summary Verify email
// @Description Verify the currently authenticated user's email using a verification token
// @Tags Users
// @Param body body dto.EmailVerificationDto true "Email verification token"
// @Success 204 "No Content"
// @Router /api/users/me/verify-email [post]
func (uc *UserController) verifyEmailHandler(c *gin.Context) {
var input dto.EmailVerificationDto
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
}
userID := c.GetString("userID")
if err := uc.userService.VerifyEmail(c.Request.Context(), userID, input.Token); err != nil {
_ = c.Error(err)
return
}

View File

@@ -28,6 +28,7 @@ func NewUserGroupController(group *gin.RouterGroup, authMiddleware *middleware.A
userGroupsGroup.PUT("/:id", ugc.update)
userGroupsGroup.DELETE("/:id", ugc.delete)
userGroupsGroup.PUT("/:id/users", ugc.updateUsers)
userGroupsGroup.PUT("/:id/allowed-oidc-clients", ugc.updateAllowedOidcClients)
}
}
@@ -44,7 +45,7 @@ type UserGroupController struct {
// @Param pagination[limit] query int false "Number of items per page" default(20)
// @Param sort[column] query string false "Column to sort by"
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Success 200 {object} dto.Paginated[dto.UserGroupDtoWithUserCount]
// @Success 200 {object} dto.Paginated[dto.UserGroupMinimalDto]
// @Router /api/user-groups [get]
func (ugc *UserGroupController) list(c *gin.Context) {
searchTerm := c.Query("search")
@@ -57,9 +58,9 @@ func (ugc *UserGroupController) list(c *gin.Context) {
}
// Map the user groups to DTOs
var groupsDto = make([]dto.UserGroupDtoWithUserCount, len(groups))
var groupsDto = make([]dto.UserGroupMinimalDto, len(groups))
for i, group := range groups {
var groupDto dto.UserGroupDtoWithUserCount
var groupDto dto.UserGroupMinimalDto
if err := dto.MapStruct(group, &groupDto); err != nil {
_ = c.Error(err)
return
@@ -72,7 +73,7 @@ func (ugc *UserGroupController) list(c *gin.Context) {
groupsDto[i] = groupDto
}
c.JSON(http.StatusOK, dto.Paginated[dto.UserGroupDtoWithUserCount]{
c.JSON(http.StatusOK, dto.Paginated[dto.UserGroupMinimalDto]{
Data: groupsDto,
Pagination: pagination,
})
@@ -85,7 +86,7 @@ func (ugc *UserGroupController) list(c *gin.Context) {
// @Accept json
// @Produce json
// @Param id path string true "User Group ID"
// @Success 200 {object} dto.UserGroupDtoWithUsers
// @Success 200 {object} dto.UserGroupDto
// @Router /api/user-groups/{id} [get]
func (ugc *UserGroupController) get(c *gin.Context) {
group, err := ugc.UserGroupService.Get(c.Request.Context(), c.Param("id"))
@@ -94,7 +95,7 @@ func (ugc *UserGroupController) get(c *gin.Context) {
return
}
var groupDto dto.UserGroupDtoWithUsers
var groupDto dto.UserGroupDto
if err := dto.MapStruct(group, &groupDto); err != nil {
_ = c.Error(err)
return
@@ -110,7 +111,7 @@ func (ugc *UserGroupController) get(c *gin.Context) {
// @Accept json
// @Produce json
// @Param userGroup body dto.UserGroupCreateDto true "User group information"
// @Success 201 {object} dto.UserGroupDtoWithUsers "Created user group"
// @Success 201 {object} dto.UserGroupDto "Created user group"
// @Router /api/user-groups [post]
func (ugc *UserGroupController) create(c *gin.Context) {
var input dto.UserGroupCreateDto
@@ -125,7 +126,7 @@ func (ugc *UserGroupController) create(c *gin.Context) {
return
}
var groupDto dto.UserGroupDtoWithUsers
var groupDto dto.UserGroupDto
if err := dto.MapStruct(group, &groupDto); err != nil {
_ = c.Error(err)
return
@@ -142,7 +143,7 @@ func (ugc *UserGroupController) create(c *gin.Context) {
// @Produce json
// @Param id path string true "User Group ID"
// @Param userGroup body dto.UserGroupCreateDto true "User group information"
// @Success 200 {object} dto.UserGroupDtoWithUsers "Updated user group"
// @Success 200 {object} dto.UserGroupDto "Updated user group"
// @Router /api/user-groups/{id} [put]
func (ugc *UserGroupController) update(c *gin.Context) {
var input dto.UserGroupCreateDto
@@ -157,7 +158,7 @@ func (ugc *UserGroupController) update(c *gin.Context) {
return
}
var groupDto dto.UserGroupDtoWithUsers
var groupDto dto.UserGroupDto
if err := dto.MapStruct(group, &groupDto); err != nil {
_ = c.Error(err)
return
@@ -192,7 +193,7 @@ func (ugc *UserGroupController) delete(c *gin.Context) {
// @Produce json
// @Param id path string true "User Group ID"
// @Param users body dto.UserGroupUpdateUsersDto true "List of user IDs to assign to this group"
// @Success 200 {object} dto.UserGroupDtoWithUsers
// @Success 200 {object} dto.UserGroupDto
// @Router /api/user-groups/{id}/users [put]
func (ugc *UserGroupController) updateUsers(c *gin.Context) {
var input dto.UserGroupUpdateUsersDto
@@ -207,7 +208,7 @@ func (ugc *UserGroupController) updateUsers(c *gin.Context) {
return
}
var groupDto dto.UserGroupDtoWithUsers
var groupDto dto.UserGroupDto
if err := dto.MapStruct(group, &groupDto); err != nil {
_ = c.Error(err)
return
@@ -215,3 +216,35 @@ func (ugc *UserGroupController) updateUsers(c *gin.Context) {
c.JSON(http.StatusOK, groupDto)
}
// updateAllowedOidcClients godoc
// @Summary Update allowed OIDC clients
// @Description Update the OIDC clients allowed for a specific user group
// @Tags OIDC
// @Accept json
// @Produce json
// @Param id path string true "User Group ID"
// @Param groups body dto.UserGroupUpdateAllowedOidcClientsDto true "OIDC client IDs to allow"
// @Success 200 {object} dto.UserGroupDto "Updated user group"
// @Router /api/user-groups/{id}/allowed-oidc-clients [put]
func (ugc *UserGroupController) updateAllowedOidcClients(c *gin.Context) {
var input dto.UserGroupUpdateAllowedOidcClientsDto
if err := c.ShouldBindJSON(&input); err != nil {
_ = c.Error(err)
return
}
userGroup, err := ugc.UserGroupService.UpdateAllowedOidcClient(c.Request.Context(), c.Param("id"), input)
if err != nil {
_ = c.Error(err)
return
}
var userGroupDto dto.UserGroupDto
if err := dto.MapStruct(userGroup, &userGroupDto); err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, userGroupDto)
}

View File

@@ -5,14 +5,17 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/middleware"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
// NewVersionController registers version-related routes.
func NewVersionController(group *gin.RouterGroup, versionService *service.VersionService) {
func NewVersionController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, versionService *service.VersionService) {
vc := &VersionController{versionService: versionService}
group.GET("/version/latest", vc.getLatestVersionHandler)
group.GET("/version/current", authMiddleware.WithAdminNotRequired().Add(), vc.getCurrentVersionHandler)
}
type VersionController struct {
@@ -38,3 +41,16 @@ func (vc *VersionController) getLatestVersionHandler(c *gin.Context) {
"latestVersion": tag,
})
}
// getCurrentVersionHandler godoc
// @Summary Get current deployed version of Pocket ID
// @Tags Version
// @Produce json
// @Success 200 {object} map[string]string "Current version information"
// @Router /api/version/current [get]
func (vc *VersionController) getCurrentVersionHandler(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"currentVersion": common.Version,
})
}

View File

@@ -1,205 +0,0 @@
package controller
import (
"net/http"
"time"
"github.com/go-webauthn/webauthn/protocol"
"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/utils/cookie"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/service"
"golang.org/x/time/rate"
)
func NewWebauthnController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, webauthnService *service.WebAuthnService, appConfigService *service.AppConfigService) {
wc := &WebauthnController{webAuthnService: webauthnService, appConfigService: appConfigService}
group.GET("/webauthn/register/start", authMiddleware.WithAdminNotRequired().Add(), wc.beginRegistrationHandler)
group.POST("/webauthn/register/finish", authMiddleware.WithAdminNotRequired().Add(), wc.verifyRegistrationHandler)
group.GET("/webauthn/login/start", wc.beginLoginHandler)
group.POST("/webauthn/login/finish", rateLimitMiddleware.Add(rate.Every(10*time.Second), 5), wc.verifyLoginHandler)
group.POST("/webauthn/logout", authMiddleware.WithAdminNotRequired().Add(), wc.logoutHandler)
group.POST("/webauthn/reauthenticate", authMiddleware.WithAdminNotRequired().Add(), rateLimitMiddleware.Add(rate.Every(10*time.Second), 5), wc.reauthenticateHandler)
group.GET("/webauthn/credentials", authMiddleware.WithAdminNotRequired().Add(), wc.listCredentialsHandler)
group.PATCH("/webauthn/credentials/:id", authMiddleware.WithAdminNotRequired().Add(), wc.updateCredentialHandler)
group.DELETE("/webauthn/credentials/:id", authMiddleware.WithAdminNotRequired().Add(), wc.deleteCredentialHandler)
}
type WebauthnController struct {
webAuthnService *service.WebAuthnService
appConfigService *service.AppConfigService
}
func (wc *WebauthnController) beginRegistrationHandler(c *gin.Context) {
userID := c.GetString("userID")
options, err := wc.webAuthnService.BeginRegistration(c.Request.Context(), userID)
if err != nil {
_ = c.Error(err)
return
}
cookie.AddSessionIdCookie(c, int(options.Timeout.Seconds()), options.SessionID)
c.JSON(http.StatusOK, options.Response)
}
func (wc *WebauthnController) verifyRegistrationHandler(c *gin.Context) {
sessionID, err := c.Cookie(cookie.SessionIdCookieName)
if err != nil {
_ = c.Error(&common.MissingSessionIdError{})
return
}
userID := c.GetString("userID")
credential, err := wc.webAuthnService.VerifyRegistration(c.Request.Context(), sessionID, userID, c.Request)
if err != nil {
_ = c.Error(err)
return
}
var credentialDto dto.WebauthnCredentialDto
if err := dto.MapStruct(credential, &credentialDto); err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, credentialDto)
}
func (wc *WebauthnController) beginLoginHandler(c *gin.Context) {
options, err := wc.webAuthnService.BeginLogin(c.Request.Context())
if err != nil {
_ = c.Error(err)
return
}
cookie.AddSessionIdCookie(c, int(options.Timeout.Seconds()), options.SessionID)
c.JSON(http.StatusOK, options.Response)
}
func (wc *WebauthnController) verifyLoginHandler(c *gin.Context) {
sessionID, err := c.Cookie(cookie.SessionIdCookieName)
if err != nil {
_ = c.Error(&common.MissingSessionIdError{})
return
}
credentialAssertionData, err := protocol.ParseCredentialRequestResponseBody(c.Request.Body)
if err != nil {
_ = c.Error(err)
return
}
user, token, err := wc.webAuthnService.VerifyLogin(c.Request.Context(), sessionID, credentialAssertionData, c.ClientIP(), c.Request.UserAgent())
if err != nil {
_ = c.Error(err)
return
}
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
_ = c.Error(err)
return
}
maxAge := int(wc.appConfigService.GetDbConfig().SessionDuration.AsDurationMinutes().Seconds())
cookie.AddAccessTokenCookie(c, maxAge, token)
c.JSON(http.StatusOK, userDto)
}
func (wc *WebauthnController) listCredentialsHandler(c *gin.Context) {
userID := c.GetString("userID")
credentials, err := wc.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)
}
func (wc *WebauthnController) deleteCredentialHandler(c *gin.Context) {
userID := c.GetString("userID")
credentialID := c.Param("id")
err := wc.webAuthnService.DeleteCredential(c.Request.Context(), userID, credentialID)
if err != nil {
_ = c.Error(err)
return
}
c.Status(http.StatusNoContent)
}
func (wc *WebauthnController) updateCredentialHandler(c *gin.Context) {
userID := c.GetString("userID")
credentialID := c.Param("id")
var input dto.WebauthnCredentialUpdateDto
if err := c.ShouldBindJSON(&input); err != nil {
_ = c.Error(err)
return
}
credential, err := wc.webAuthnService.UpdateCredential(c.Request.Context(), userID, credentialID, input.Name)
if err != nil {
_ = c.Error(err)
return
}
var credentialDto dto.WebauthnCredentialDto
if err := dto.MapStruct(credential, &credentialDto); err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, credentialDto)
}
func (wc *WebauthnController) logoutHandler(c *gin.Context) {
cookie.AddAccessTokenCookie(c, 0, "")
c.Status(http.StatusNoContent)
}
func (wc *WebauthnController) reauthenticateHandler(c *gin.Context) {
sessionID, err := c.Cookie(cookie.SessionIdCookieName)
if err != nil {
_ = c.Error(&common.MissingSessionIdError{})
return
}
var token string
// Try to create a reauthentication token with WebAuthn
credentialAssertionData, err := protocol.ParseCredentialRequestResponseBody(c.Request.Body)
if err == nil {
token, err = wc.webAuthnService.CreateReauthenticationTokenWithWebauthn(c.Request.Context(), sessionID, credentialAssertionData)
if err != nil {
_ = c.Error(err)
return
}
} else {
// If WebAuthn fails, try to create a reauthentication token with the access token
accessToken, _ := c.Cookie(cookie.AccessTokenCookieName)
token, err = wc.webAuthnService.CreateReauthenticationTokenWithAccessToken(c.Request.Context(), accessToken)
if err != nil {
_ = c.Error(err)
return
}
}
c.JSON(http.StatusOK, gin.H{"reauthenticationToken": token})
}

View File

@@ -84,13 +84,20 @@ func (wkc *WellKnownController) computeOIDCConfiguration() ([]byte, error) {
"device_authorization_endpoint": appUrl + "/api/oidc/device/authorize",
"jwks_uri": internalAppUrl + "/.well-known/jwks.json",
"grant_types_supported": []string{service.GrantTypeAuthorizationCode, service.GrantTypeRefreshToken, service.GrantTypeDeviceCode, service.GrantTypeClientCredentials},
"scopes_supported": []string{"openid", "profile", "email", "groups"},
"claims_supported": []string{"sub", "given_name", "family_name", "name", "email", "email_verified", "preferred_username", "picture", "groups"},
"scopes_supported": []string{"openid", "profile", "email", "groups", "offline_access"},
"claims_supported": []string{"sub", "given_name", "family_name", "name", "display_name", "email", "email_verified", "preferred_username", "picture", "groups", "auth_time", "amr"},
"response_types_supported": []string{"code", "id_token"},
"subject_types_supported": []string{"public"},
"id_token_signing_alg_values_supported": []string{alg.String()},
"authorization_response_iss_parameter_supported": true,
"code_challenge_methods_supported": []string{"plain", "S256"},
"request_parameter_supported": true,
"request_uri_parameter_supported": false,
"request_object_signing_alg_values_supported": []string{"none"},
"prompt_values_supported": []string{"none", "login", "consent", "select_account"},
"token_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post", "none"},
"pushed_authorization_request_endpoint": internalAppUrl + "/api/oidc/par",
"require_pushed_authorization_requests": false,
}
return json.Marshal(config)
}

View File

@@ -14,6 +14,7 @@ type AppConfigVariableDto struct {
type AppConfigUpdateDto struct {
AppName string `json:"appName" binding:"required,min=1,max=30" unorm:"nfc"`
SessionDuration string `json:"sessionDuration" binding:"required"`
HomePageURL string `json:"homePageUrl" binding:"required"`
EmailsVerified string `json:"emailsVerified" binding:"required"`
DisableAnimations string `json:"disableAnimations" binding:"required"`
AllowOwnAccountEdit string `json:"allowOwnAccountEdit" binding:"required"`
@@ -47,10 +48,11 @@ type AppConfigUpdateDto struct {
LdapAttributeGroupMember string `json:"ldapAttributeGroupMember"`
LdapAttributeGroupUniqueIdentifier string `json:"ldapAttributeGroupUniqueIdentifier"`
LdapAttributeGroupName string `json:"ldapAttributeGroupName"`
LdapAttributeAdminGroup string `json:"ldapAttributeAdminGroup"`
LdapAdminGroupName string `json:"ldapAdminGroupName"`
LdapSoftDeleteUsers string `json:"ldapSoftDeleteUsers"`
EmailOneTimeAccessAsAdminEnabled string `json:"emailOneTimeAccessAsAdminEnabled" binding:"required"`
EmailOneTimeAccessAsUnauthenticatedEnabled string `json:"emailOneTimeAccessAsUnauthenticatedEnabled" binding:"required"`
EmailLoginNotificationEnabled string `json:"emailLoginNotificationEnabled" binding:"required"`
EmailApiKeyExpirationEnabled string `json:"emailApiKeyExpirationEnabled" binding:"required"`
EmailVerificationEnabled string `json:"emailVerificationEnabled" binding:"required"`
}

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

@@ -9,7 +9,6 @@ import (
"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/utils"
)
type sourceStruct struct {
@@ -60,11 +59,11 @@ type embeddedStruct struct {
func TestMapStruct(t *testing.T) {
src := sourceStruct{
AString: "abcd",
AStringPtr: utils.Ptr("xyz"),
AStringPtr: new("xyz"),
ABool: true,
ABoolPtr: utils.Ptr(false),
ABoolPtr: new(false),
ACustomDateTime: datatype.DateTime(time.Date(2025, 1, 2, 3, 4, 5, 0, time.UTC)),
ACustomDateTimePtr: utils.Ptr(datatype.DateTime(time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC))),
ACustomDateTimePtr: new(datatype.DateTime(time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC))),
ANilStringPtr: nil,
ASlice: []string{"a", "b", "c"},
AMap: map[string]int{
@@ -80,8 +79,8 @@ func TestMapStruct(t *testing.T) {
Bar: 111,
},
StringPtrToString: utils.Ptr("foobar"),
EmptyStringPtrToString: utils.Ptr(""),
StringPtrToString: new("foobar"),
EmptyStringPtrToString: new(""),
NilStringPtrToString: nil,
IntToInt64: 99,
AuditLogEventToString: model.AuditLogEventAccountCreated,
@@ -118,11 +117,11 @@ func TestMapStructList(t *testing.T) {
sources := []sourceStruct{
{
AString: "first",
AStringPtr: utils.Ptr("one"),
AStringPtr: new("one"),
ABool: true,
ABoolPtr: utils.Ptr(false),
ABoolPtr: new(false),
ACustomDateTime: datatype.DateTime(time.Date(2025, 1, 2, 3, 4, 5, 0, time.UTC)),
ACustomDateTimePtr: utils.Ptr(datatype.DateTime(time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC))),
ACustomDateTimePtr: new(datatype.DateTime(time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC))),
ASlice: []string{"a", "b"},
AMap: map[string]int{
"a": 1,
@@ -136,11 +135,11 @@ func TestMapStructList(t *testing.T) {
},
{
AString: "second",
AStringPtr: utils.Ptr("two"),
AStringPtr: new("two"),
ABool: false,
ABoolPtr: utils.Ptr(true),
ABoolPtr: new(true),
ACustomDateTime: datatype.DateTime(time.Date(2026, 6, 7, 8, 9, 10, 0, time.UTC)),
ACustomDateTimePtr: utils.Ptr(datatype.DateTime(time.Date(2023, 6, 7, 8, 9, 10, 0, time.UTC))),
ACustomDateTimePtr: new(datatype.DateTime(time.Date(2023, 6, 7, 8, 9, 10, 0, time.UTC))),
ASlice: []string{"c", "d", "e"},
AMap: map[string]int{
"c": 3,

View File

@@ -1,7 +1,6 @@
package dto
import (
"net/http"
"reflect"
"github.com/gin-gonic/gin"
@@ -12,7 +11,7 @@ import (
// Normalize iterates through an object and performs Unicode normalization on all string fields with the `unorm` tag.
func Normalize(obj any) {
v := reflect.ValueOf(obj)
if v.Kind() != reflect.Ptr || v.IsNil() {
if v.Kind() != reflect.Pointer || v.IsNil() {
return
}
v = v.Elem()
@@ -21,7 +20,7 @@ func Normalize(obj any) {
if v.Kind() == reflect.Slice {
for i := 0; i < v.Len(); i++ {
elem := v.Index(i)
if elem.Kind() == reflect.Ptr && !elem.IsNil() && elem.Elem().Kind() == reflect.Struct {
if elem.Kind() == reflect.Pointer && !elem.IsNil() && elem.Elem().Kind() == reflect.Struct {
Normalize(elem.Interface())
} else if elem.Kind() == reflect.Struct && elem.CanAddr() {
Normalize(elem.Addr().Interface())
@@ -73,22 +72,3 @@ loop:
func ShouldBindWithNormalizedJSON(ctx *gin.Context, obj any) error {
return ctx.ShouldBindWith(obj, binding.JSON)
}
type NormalizerJSONBinding struct{}
func (NormalizerJSONBinding) Name() string {
return "json"
}
func (NormalizerJSONBinding) Bind(req *http.Request, obj any) error {
// Use the default JSON binder
err := binding.JSON.Bind(req, obj)
if err != nil {
return err
}
// Perform normalization
Normalize(obj)
return nil
}

View File

@@ -5,6 +5,7 @@ import datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
type OidcClientMetaDataDto struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
HasLogo bool `json:"hasLogo"`
HasDarkLogo bool `json:"hasDarkLogo"`
LaunchURL *string `json:"launchURL"`
@@ -13,16 +14,20 @@ type OidcClientMetaDataDto struct {
type OidcClientDto struct {
OidcClientMetaDataDto
CallbackURLs []string `json:"callbackURLs"`
LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"`
Credentials OidcClientCredentialsDto `json:"credentials"`
CallbackURLs []string `json:"callbackURLs"`
LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"`
RequiresPushedAuthorizationRequests bool `json:"requiresPushedAuthorizationRequests"`
SkipConsent bool `json:"skipConsent"`
Credentials OidcClientCredentialsDto `json:"credentials"`
IsGroupRestricted bool `json:"isGroupRestricted"`
PkceSupported bool `json:"pkceSupported,omitempty"`
}
type OidcClientWithAllowedUserGroupsDto struct {
OidcClientDto
AllowedUserGroups []UserGroupDtoWithUserCount `json:"allowedUserGroups"`
AllowedUserGroups []UserGroupMinimalDto `json:"allowedUserGroups"`
}
type OidcClientWithAllowedGroupsCountDto struct {
@@ -31,18 +36,22 @@ 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"`
IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"`
RequiresReauthentication bool `json:"requiresReauthentication"`
Credentials OidcClientCredentialsDto `json:"credentials"`
LaunchURL *string `json:"launchURL" binding:"omitempty,url"`
HasLogo bool `json:"hasLogo"`
HasDarkLogo bool `json:"hasDarkLogo"`
LogoURL *string `json:"logoUrl"`
DarkLogoURL *string `json:"darkLogoUrl"`
Name string `json:"name" binding:"required,max=50" unorm:"nfc"`
Description string `json:"description" binding:"omitempty,max=150" unorm:"nfc"`
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"`
RequiresPushedAuthorizationRequests bool `json:"requiresPushedAuthorizationRequests"`
SkipConsent bool `json:"skipConsent"`
Credentials OidcClientCredentialsDto `json:"credentials"`
LaunchURL *string `json:"launchURL" binding:"omitempty,url"`
HasLogo bool `json:"hasLogo"`
HasDarkLogo bool `json:"hasDarkLogo"`
LogoURL *string `json:"logoUrl"`
DarkLogoURL *string `json:"darkLogoUrl"`
IsGroupRestricted bool `json:"isGroupRestricted"`
}
type OidcClientCreateDto struct {
@@ -55,48 +64,11 @@ type OidcClientCredentialsDto struct {
}
type OidcClientFederatedIdentityDto struct {
Issuer string `json:"issuer"`
Subject string `json:"subject,omitempty"`
Audience string `json:"audience,omitempty"`
JWKS string `json:"jwks,omitempty"`
}
type AuthorizeOidcClientRequestDto struct {
ClientID string `json:"clientID" binding:"required"`
Scope string `json:"scope" binding:"required"`
CallbackURL string `json:"callbackURL"`
Nonce string `json:"nonce"`
CodeChallenge string `json:"codeChallenge"`
CodeChallengeMethod string `json:"codeChallengeMethod"`
ReauthenticationToken string `json:"reauthenticationToken"`
}
type AuthorizeOidcClientResponseDto struct {
Code string `json:"code"`
CallbackURL string `json:"callbackURL"`
Issuer string `json:"issuer"`
}
type AuthorizationRequiredDto struct {
ClientID string `json:"clientID" binding:"required"`
Scope string `json:"scope" binding:"required"`
}
type OidcCreateTokensDto struct {
GrantType string `form:"grant_type" binding:"required"`
Code string `form:"code"`
DeviceCode string `form:"device_code"`
ClientID string `form:"client_id"`
ClientSecret string `form:"client_secret"`
CodeVerifier string `form:"code_verifier"`
RefreshToken string `form:"refresh_token"`
ClientAssertion string `form:"client_assertion"`
ClientAssertionType string `form:"client_assertion_type"`
Resource string `form:"resource"`
}
type OidcIntrospectDto struct {
Token string `form:"token" binding:"required"`
Issuer string `json:"issuer"`
Subject string `json:"subject,omitempty"`
Audience string `json:"audience,omitempty"`
JWKS string `json:"jwks,omitempty"`
ReplayProtection bool `json:"replayProtection"`
}
type OidcUpdateAllowedUserGroupsDto struct {
@@ -110,35 +82,6 @@ type OidcLogoutDto struct {
State string `form:"state"`
}
type OidcTokenResponseDto struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
IdToken string `json:"id_token,omitempty"`
RefreshToken string `json:"refresh_token,omitempty"`
ExpiresIn int `json:"expires_in"`
}
type OidcIntrospectionResponseDto struct {
Active bool `json:"active"`
TokenType string `json:"token_type,omitempty"`
Scope string `json:"scope,omitempty"`
Expiration int64 `json:"exp,omitempty"`
IssuedAt int64 `json:"iat,omitempty"`
NotBefore int64 `json:"nbf,omitempty"`
Subject string `json:"sub,omitempty"`
Audience []string `json:"aud,omitempty"`
Issuer string `json:"iss,omitempty"`
Identifier string `json:"jti,omitempty"`
}
type OidcDeviceAuthorizationRequestDto struct {
ClientID string `form:"client_id" binding:"required"`
Scope string `form:"scope" binding:"required"`
ClientSecret string `form:"client_secret"`
ClientAssertion string `form:"client_assertion"`
ClientAssertionType string `form:"client_assertion_type"`
}
type OidcDeviceAuthorizationResponseDto struct {
DeviceCode string `json:"device_code"`
UserCode string `json:"user_code"`
@@ -146,20 +89,20 @@ type OidcDeviceAuthorizationResponseDto struct {
VerificationURIComplete string `json:"verification_uri_complete"`
ExpiresIn int `json:"expires_in"`
Interval int `json:"interval"`
RequiresAuthorization bool `json:"requires_authorization"`
}
type OidcDeviceTokenRequestDto struct {
GrantType string `form:"grant_type" binding:"required,eq=urn:ietf:params:oauth:grant-type:device_code"`
DeviceCode string `form:"device_code" binding:"required"`
ClientID string `form:"client_id"`
ClientSecret string `form:"client_secret"`
type ScopeInfoDto struct {
Key string `json:"key"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
}
type DeviceCodeInfoDto struct {
Scope string `json:"scope"`
AuthorizationRequired bool `json:"authorizationRequired"`
Client OidcClientMetaDataDto `json:"client"`
Scope []string `json:"scope"`
ScopeInfo []ScopeInfoDto `json:"scopeInfo"`
AuthorizationRequired bool `json:"authorizationRequired"`
ReauthenticationRequired bool `json:"reauthenticationRequired"`
Client OidcClientMetaDataDto `json:"client"`
}
type AuthorizedOidcClientDto struct {

View File

@@ -0,0 +1,16 @@
package dto
import "github.com/pocket-id/pocket-id/backend/internal/utils"
type OneTimeAccessTokenCreateDto struct {
TTL utils.JSONDuration `json:"ttl" binding:"ttl"`
}
type OneTimeAccessEmailAsUnauthenticatedUserDto struct {
Email string `json:"email" binding:"required,email" unorm:"nfc"`
RedirectPath string `json:"redirectPath"`
}
type OneTimeAccessEmailAsAdminDto struct {
TTL utils.JSONDuration `json:"ttl" binding:"ttl"`
}

View File

@@ -0,0 +1,96 @@
package dto
import (
"time"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
)
type ScimServiceProviderDTO struct {
ID string `json:"id"`
Endpoint string `json:"endpoint"`
Token string `json:"token"`
LastSyncedAt *datatype.DateTime `json:"lastSyncedAt"`
OidcClient OidcClientMetaDataDto `json:"oidcClient"`
CreatedAt datatype.DateTime `json:"createdAt"`
}
type ScimServiceProviderCreateDTO struct {
Endpoint string `json:"endpoint" binding:"required,url"`
Token string `json:"token"`
OidcClientID string `json:"oidcClientId" binding:"required"`
}
type ScimUser struct {
ScimResourceData
UserName string `json:"userName"`
Name *ScimName `json:"name,omitempty"`
Display string `json:"displayName,omitempty"`
Active bool `json:"active"`
Emails []ScimEmail `json:"emails,omitempty"`
}
type ScimName struct {
GivenName string `json:"givenName,omitempty"`
FamilyName string `json:"familyName,omitempty"`
}
type ScimEmail struct {
Value string `json:"value"`
Primary bool `json:"primary,omitempty"`
}
type ScimGroup struct {
ScimResourceData
Display string `json:"displayName"`
Members []ScimGroupMember `json:"members,omitempty"`
}
type ScimGroupMember struct {
Value string `json:"value"`
}
type ScimListResponse[T any] struct {
Resources []T `json:"Resources"`
TotalResults int `json:"totalResults"`
StartIndex int `json:"startIndex"`
ItemsPerPage int `json:"itemsPerPage"`
}
type ScimResourceData struct {
ID string `json:"id,omitempty"`
ExternalID string `json:"externalId,omitempty"`
Schemas []string `json:"schemas"`
Meta ScimResourceMeta `json:"meta,omitempty"`
}
type ScimResourceMeta struct {
Location string `json:"location,omitempty"`
ResourceType string `json:"resourceType,omitempty"`
Created time.Time `json:"created"`
LastModified time.Time `json:"lastModified,omitempty"`
Version string `json:"version,omitempty"`
}
func (r ScimResourceData) GetID() string {
return r.ID
}
func (r ScimResourceData) GetExternalID() string {
return r.ExternalID
}
func (r ScimResourceData) GetSchemas() []string {
return r.Schemas
}
func (r ScimResourceData) GetMeta() ScimResourceMeta {
return r.Meta
}
type ScimResource interface {
GetID() string
GetExternalID() string
GetSchemas() []string
GetMeta() ScimResourceMeta
}

View File

@@ -1,20 +0,0 @@
package dto
import (
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
type SignupTokenCreateDto struct {
TTL utils.JSONDuration `json:"ttl" binding:"required,ttl"`
UsageLimit int `json:"usageLimit" binding:"required,min=1,max=100"`
}
type SignupTokenDto struct {
ID string `json:"id"`
Token string `json:"token"`
ExpiresAt datatype.DateTime `json:"expiresAt"`
UsageLimit int `json:"usageLimit"`
UsageCount int `json:"usageCount"`
CreatedAt datatype.DateTime `json:"createdAt"`
}

View File

@@ -4,34 +4,36 @@ import (
"errors"
"github.com/gin-gonic/gin/binding"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
type UserDto struct {
ID string `json:"id"`
Username string `json:"username"`
Email *string `json:"email" `
FirstName string `json:"firstName"`
LastName *string `json:"lastName"`
DisplayName string `json:"displayName"`
IsAdmin bool `json:"isAdmin"`
Locale *string `json:"locale"`
CustomClaims []CustomClaimDto `json:"customClaims"`
UserGroups []UserGroupDto `json:"userGroups"`
LdapID *string `json:"ldapId"`
Disabled bool `json:"disabled"`
ID string `json:"id"`
Username string `json:"username"`
Email *string `json:"email"`
EmailVerified bool `json:"emailVerified"`
FirstName string `json:"firstName"`
LastName *string `json:"lastName"`
DisplayName string `json:"displayName"`
IsAdmin bool `json:"isAdmin"`
Locale *string `json:"locale"`
CustomClaims []CustomClaimDto `json:"customClaims"`
UserGroups []UserGroupMinimalDto `json:"userGroups"`
LdapID *string `json:"ldapId"`
Disabled bool `json:"disabled"`
}
type UserCreateDto struct {
Username string `json:"username" binding:"required,username,min=2,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"`
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
DisplayName string `json:"displayName" binding:"required,min=1,max=100" unorm:"nfc"`
IsAdmin bool `json:"isAdmin"`
Locale *string `json:"locale"`
Disabled bool `json:"disabled"`
LdapID string `json:"-"`
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:"max=50" unorm:"nfc"`
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
DisplayName string `json:"displayName" binding:"max=100" unorm:"nfc"`
IsAdmin bool `json:"isAdmin"`
Locale *string `json:"locale"`
Disabled bool `json:"disabled"`
UserGroupIds []string `json:"userGroupIds"`
LdapID string `json:"-"`
}
func (u UserCreateDto) Validate() error {
@@ -45,28 +47,10 @@ func (u UserCreateDto) Validate() error {
return e.Struct(u)
}
type OneTimeAccessTokenCreateDto struct {
UserID string `json:"userId"`
TTL utils.JSONDuration `json:"ttl" binding:"ttl"`
}
type OneTimeAccessEmailAsUnauthenticatedUserDto struct {
Email string `json:"email" binding:"required,email" unorm:"nfc"`
RedirectPath string `json:"redirectPath"`
}
type OneTimeAccessEmailAsAdminDto struct {
TTL utils.JSONDuration `json:"ttl" binding:"ttl"`
type EmailVerificationDto struct {
Token string `json:"token" binding:"required"`
}
type UserUpdateUserGroupDto struct {
UserGroupIds []string `json:"userGroupIds" binding:"required"`
}
type SignUpDto struct {
Username string `json:"username" binding:"required,username,min=2,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"`
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
Token string `json:"token"`
}

View File

@@ -3,7 +3,6 @@ package dto
import (
"testing"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"github.com/stretchr/testify/require"
)
@@ -17,7 +16,7 @@ func TestUserCreateDto_Validate(t *testing.T) {
name: "valid input",
input: UserCreateDto{
Username: "testuser",
Email: utils.Ptr("test@example.com"),
Email: new("test@example.com"),
FirstName: "John",
LastName: "Doe",
DisplayName: "John Doe",
@@ -27,27 +26,37 @@ func TestUserCreateDto_Validate(t *testing.T) {
{
name: "missing username",
input: UserCreateDto{
Email: utils.Ptr("test@example.com"),
Email: new("test@example.com"),
FirstName: "John",
LastName: "Doe",
DisplayName: "John Doe",
},
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{
Email: utils.Ptr("test@example.com"),
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",
input: UserCreateDto{
Username: "test/ser",
Email: utils.Ptr("test@example.com"),
Email: new("test@example.com"),
FirstName: "John",
LastName: "Doe",
DisplayName: "John Doe",
@@ -58,7 +67,7 @@ func TestUserCreateDto_Validate(t *testing.T) {
name: "invalid email",
input: UserCreateDto{
Username: "testuser",
Email: utils.Ptr("not-an-email"),
Email: new("not-an-email"),
FirstName: "John",
LastName: "Doe",
DisplayName: "John Doe",
@@ -69,18 +78,18 @@ func TestUserCreateDto_Validate(t *testing.T) {
name: "first name too short",
input: UserCreateDto{
Username: "testuser",
Email: utils.Ptr("test@example.com"),
Email: new("test@example.com"),
FirstName: "",
LastName: "Doe",
DisplayName: "John Doe",
},
wantErr: "Field validation for 'FirstName' failed on the 'required' tag",
wantErr: "",
},
{
name: "last name too long",
input: UserCreateDto{
Username: "testuser",
Email: utils.Ptr("test@example.com"),
Email: new("test@example.com"),
FirstName: "John",
LastName: "abcdfghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz",
DisplayName: "John Doe",

View File

@@ -8,25 +8,17 @@ import (
)
type UserGroupDto struct {
ID string `json:"id"`
FriendlyName string `json:"friendlyName"`
Name string `json:"name"`
CustomClaims []CustomClaimDto `json:"customClaims"`
LdapID *string `json:"ldapId"`
CreatedAt datatype.DateTime `json:"createdAt"`
ID string `json:"id"`
FriendlyName string `json:"friendlyName"`
Name string `json:"name"`
CustomClaims []CustomClaimDto `json:"customClaims"`
LdapID *string `json:"ldapId"`
CreatedAt datatype.DateTime `json:"createdAt"`
Users []UserDto `json:"users"`
AllowedOidcClients []OidcClientMetaDataDto `json:"allowedOidcClients"`
}
type UserGroupDtoWithUsers struct {
ID string `json:"id"`
FriendlyName string `json:"friendlyName"`
Name string `json:"name"`
CustomClaims []CustomClaimDto `json:"customClaims"`
Users []UserDto `json:"users"`
LdapID *string `json:"ldapId"`
CreatedAt datatype.DateTime `json:"createdAt"`
}
type UserGroupDtoWithUserCount struct {
type UserGroupMinimalDto struct {
ID string `json:"id"`
FriendlyName string `json:"friendlyName"`
Name string `json:"name"`
@@ -36,6 +28,10 @@ type UserGroupDtoWithUserCount struct {
CreatedAt datatype.DateTime `json:"createdAt"`
}
type UserGroupUpdateAllowedOidcClientsDto struct {
OidcClientIDs []string `json:"oidcClientIds" binding:"required"`
}
type UserGroupCreateDto struct {
FriendlyName string `json:"friendlyName" binding:"required,min=2,max=50" unorm:"nfc"`
Name string `json:"name" binding:"required,min=2,max=255" unorm:"nfc"`

View File

@@ -6,6 +6,7 @@ import (
"strings"
"time"
"github.com/ory/fosite"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"github.com/gin-gonic/gin/binding"
@@ -15,43 +16,47 @@ 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())
},
"resource_uri": func(fl validator.FieldLevel) bool {
return ValidateResourceURI(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 +70,39 @@ 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, "*") {
// isActiveContentScheme reports whether the URL scheme can carry executable content, so it must never be accepted where a URL might later be rendered as a link
func isActiveContentScheme(scheme string) bool {
switch strings.ToLower(scheme) {
case "javascript", "data":
return true
default:
return false
}
}
// ValidateResourceURI validates RFC 8707 resource identifiers
func ValidateResourceURI(str string) bool {
if !fosite.IsValidResourceIndicatorURI(str) {
return false
}
u, err := url.Parse(raw)
// Reject active-content schemes so a resource identifier can never carry executable content if it is ever surfaced as a link
u, _ := url.Parse(str)
return !isActiveContentScheme(u.Scheme)
}
// 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() {
return false
}
return true
return !isActiveContentScheme(u.Scheme)
}
// ValidateCallbackURLPattern validates callback URL patterns, with support for wildcards.
func ValidateCallbackURLPattern(raw string) bool {
return utils.ValidateCallbackURLPattern(raw) == 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,80 @@ func TestValidateClientID(t *testing.T) {
})
}
}
func TestValidateResourceURI(t *testing.T) {
tests := []struct {
name string
input string
expected bool
}{
{"valid https URI", "https://api.example.com", true},
{"valid custom scheme URI", "api://PocketID", true},
{"valid URN", "urn:my-app", true},
{"valid query component", "https://api.example.com?tenant=1", true},
{"invalid relative path", "/foo", false},
{"invalid plain string", "foo", false},
{"invalid fragment", "https://api.example.com#tokens", false},
{"invalid empty fragment", "https://api.example.com#", false},
{"invalid unescaped path space", "https://api.example.com/a b", false},
{"invalid unescaped opaque space", "urn:my app", false},
{"invalid malformed URI", "http://[::1", false},
{"invalid javascript scheme", "javascript:alert(1)", false},
{"invalid data scheme", "data:text/html,<script>alert(1)</script>", false},
{"empty", "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expected, ValidateResourceURI(tt.input))
})
}
}
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))
})
}
}
func TestValidateCallbackURLPattern(t *testing.T) {
tests := []struct {
name string
input string
expected bool
}{
{"valid exact URL", "https://example.com/callback", true},
{"valid wildcard URL", "https://*.example.com/callback", true},
{"valid custom scheme", "pocketid://callback", true},
{"valid global wildcard", "*", true},
{"invalid relative URL", "/callback", false},
{"invalid malformed URL", "http://[::1", false},
{"rejects javascript scheme", "javascript:alert(1)", false},
{"rejects 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, ValidateCallbackURLPattern(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

@@ -5,11 +5,12 @@ import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"time"
backoff "github.com/cenkalti/backoff/v5"
"github.com/go-co-op/gocron/v2"
"github.com/italypaleale/francis/builtin/cronjob"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/service"
@@ -17,49 +18,76 @@ import (
const heartbeatUrl = "https://analytics.pocket-id.org/heartbeat"
func (s *Scheduler) RegisterAnalyticsJob(ctx context.Context, appConfig *service.AppConfigService, httpClient *http.Client) error {
// GetAnalyticsJob returns the CronJob actor
func GetAnalyticsJob(appConfig *service.AppConfigService, httpClient *http.Client) (*cronjob.CronJob, error) {
// Skip if analytics are disabled or not in production environment
if common.EnvConfig.AnalyticsDisabled || common.EnvConfig.AppEnv != "production" {
return nil
if common.EnvConfig.AnalyticsDisabled || !common.EnvConfig.AppEnv.IsProduction() {
return nil, nil
}
// Send every 24 hours
jobs := &AnalyticsJob{
appConfig: appConfig,
job := &AnalyticsJob{
httpClient: httpClient,
}
return s.registerJob(ctx, "SendHeartbeat", gocron.DurationJob(24*time.Hour), jobs.sendHeartbeat, true)
err := job.createBody(appConfig)
if err != nil {
return nil, fmt.Errorf("error pre-computing request body: %w", err)
}
// Create the built-in actor
cj, err := cronjob.New(
"Analytics",
cronjob.WithJob(job.sendHeartbeat),
// Run every 24 hours
cronjob.WithInterval(24*time.Hour),
// Run immediately upon registration too
cronjob.WithImmediate(),
cronjob.WithLogger(slog.Default()),
)
if err != nil {
return nil, fmt.Errorf("error creating Analytics job: %w", err)
}
return cj, nil
}
type AnalyticsJob struct {
appConfig *service.AppConfigService
httpClient *http.Client
body []byte
}
// sendHeartbeat sends a heartbeat to the analytics service
func (j *AnalyticsJob) sendHeartbeat(parentCtx context.Context) error {
// Skip if analytics are disabled or not in production environment
if common.EnvConfig.AnalyticsDisabled || common.EnvConfig.AppEnv != "production" {
return nil
}
// createBody pre-computes the body for all requests
func (j *AnalyticsJob) createBody(appConfig *service.AppConfigService) error {
body, err := json.Marshal(struct {
Version string `json:"version"`
InstanceID string `json:"instance_id"`
}{
Version: common.Version,
InstanceID: j.appConfig.GetDbConfig().InstanceID.Value,
InstanceID: appConfig.GetDbConfig().InstanceID.Value,
})
if err != nil {
return fmt.Errorf("failed to marshal heartbeat body: %w", err)
}
_, err = backoff.Retry(
// Set the body in the object
j.body = body
return nil
}
// sendHeartbeat sends a heartbeat to the analytics service
func (j *AnalyticsJob) sendHeartbeat(parentCtx context.Context) error {
// Skip if analytics are disabled or not in production environment
if common.EnvConfig.AnalyticsDisabled || !common.EnvConfig.AppEnv.IsProduction() {
return nil
}
// Use a backoff to retry
_, err := backoff.Retry(
parentCtx,
func() (struct{}, error) {
ctx, cancel := context.WithTimeout(parentCtx, 20*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, heartbeatUrl, bytes.NewReader(body))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, heartbeatUrl, bytes.NewReader(j.body))
if err != nil {
return struct{}{}, fmt.Errorf("failed to create request: %w", err)
}
@@ -77,7 +105,6 @@ func (j *AnalyticsJob) sendHeartbeat(parentCtx context.Context) error {
backoff.WithBackOff(backoff.NewExponentialBackOff()),
backoff.WithMaxTries(3),
)
if err != nil {
return fmt.Errorf("heartbeat request failed: %w", err)
}

View File

@@ -7,22 +7,26 @@ import (
"github.com/go-co-op/gocron/v2"
"github.com/pocket-id/pocket-id/backend/internal/apikey"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/utils/email"
)
type ApiKeyEmailJobs struct {
apiKeyService *service.ApiKeyService
apiKeyModule *apikey.Module
appConfigService *service.AppConfigService
emailService *service.EmailService
}
func (s *Scheduler) RegisterApiKeyExpiryJob(ctx context.Context, apiKeyService *service.ApiKeyService, appConfigService *service.AppConfigService) error {
func (s *Scheduler) RegisterApiKeyExpiryJob(ctx context.Context, apiKeyModule *apikey.Module, appConfigService *service.AppConfigService, emailService *service.EmailService) error {
jobs := &ApiKeyEmailJobs{
apiKeyService: apiKeyService,
apiKeyModule: apiKeyModule,
appConfigService: appConfigService,
emailService: emailService,
}
// 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 {
@@ -31,7 +35,7 @@ func (j *ApiKeyEmailJobs) checkAndNotifyExpiringApiKeys(ctx context.Context) err
return nil
}
apiKeys, err := j.apiKeyService.ListExpiringApiKeys(ctx, 7)
apiKeys, err := j.apiKeyModule.ListExpiringApiKeys(ctx, 7)
if err != nil {
return fmt.Errorf("failed to list expiring API keys: %w", err)
}
@@ -40,9 +44,29 @@ func (j *ApiKeyEmailJobs) checkAndNotifyExpiringApiKeys(ctx context.Context) err
if key.User.Email == nil {
continue
}
err = j.apiKeyService.SendApiKeyExpiringSoonEmail(ctx, key)
err = service.SendEmail(ctx, j.emailService, email.Address{
Name: key.User.FullName(),
Email: *key.User.Email,
}, service.ApiKeyExpiringSoonTemplate, &service.ApiKeyExpiringSoonTemplateData{
Name: key.User.FirstName,
ApiKeyName: key.Name,
ExpiresAt: key.ExpiresAt.ToTime(),
})
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),
)
continue
}
if err = j.apiKeyModule.MarkExpirationEmailSent(ctx, key.ID); err != nil {
slog.ErrorContext(ctx, "Failed to record that the expiration email was sent",
slog.String("key", key.ID),
slog.Any("error", err),
)
}
}
return nil

View File

@@ -7,26 +7,41 @@ 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/oidc"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/usersignup"
"github.com/pocket-id/pocket-id/backend/internal/webauthn"
)
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, "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, "ClearOAuth2Sessions", jobDefWithJitter(24*time.Hour), jobs.clearOAuth2Sessions, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
s.RegisterJob(ctx, "ClearOAuth2JTIs", jobDefWithJitter(24*time.Hour), jobs.clearOAuth2JTIs, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
s.RegisterJob(ctx, "ClearInteractionSessions", jobDefWithJitter(24*time.Hour), jobs.clearInteractionSessions, 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()}),
)
}
@@ -34,16 +49,14 @@ type DbCleanupJobs struct {
db *gorm.DB
}
// ClearWebauthnSessions deletes WebAuthn sessions that have expired
// clearWebauthnSessions deletes expired WebAuthn challenge sessions.
func (j *DbCleanupJobs) clearWebauthnSessions(ctx context.Context) error {
st := j.db.
WithContext(ctx).
Delete(&model.WebauthnSession{}, "expires_at < ?", datatype.DateTime(time.Now()))
if st.Error != nil {
return fmt.Errorf("failed to clean expired WebAuthn sessions: %w", st.Error)
count, err := webauthn.CleanupExpiredSessions(ctx, j.db)
if err != nil {
return fmt.Errorf("failed to clean expired WebAuthn sessions: %w", err)
}
slog.InfoContext(ctx, "Cleaned expired WebAuthn sessions", slog.Int64("count", st.RowsAffected))
slog.InfoContext(ctx, "Cleaned expired WebAuthn sessions", slog.Int64("count", count))
return nil
}
@@ -62,68 +75,74 @@ func (j *DbCleanupJobs) clearOneTimeAccessTokens(ctx context.Context) error {
return nil
}
// ClearSignupTokens deletes signup tokens that have expired
// clearSignupTokens deletes signup tokens that have expired
func (j *DbCleanupJobs) clearSignupTokens(ctx context.Context) error {
// Delete tokens that are expired OR have reached their usage limit
st := j.db.
WithContext(ctx).
Delete(&model.SignupToken{}, "expires_at < ?", datatype.DateTime(time.Now()))
if st.Error != nil {
return fmt.Errorf("failed to clean expired tokens: %w", st.Error)
count, err := usersignup.CleanupExpiredSignupTokens(ctx, j.db)
if err != nil {
return fmt.Errorf("failed to clean expired signup tokens: %w", err)
}
slog.InfoContext(ctx, "Cleaned expired tokens", slog.Int64("count", st.RowsAffected))
slog.InfoContext(ctx, "Cleaned expired signup tokens", slog.Int64("count", count))
return nil
}
// ClearOidcAuthorizationCodes deletes OIDC authorization codes that have expired
func (j *DbCleanupJobs) clearOidcAuthorizationCodes(ctx context.Context) error {
st := j.db.
WithContext(ctx).
Delete(&model.OidcAuthorizationCode{}, "expires_at < ?", datatype.DateTime(time.Now()))
if st.Error != nil {
return fmt.Errorf("failed to clean expired OIDC authorization codes: %w", st.Error)
// clearOAuth2Sessions deletes expired and invalidated OAuth2 sessions.
func (j *DbCleanupJobs) clearOAuth2Sessions(ctx context.Context) error {
count, err := oidc.CleanupExpiredOAuth2Sessions(ctx, j.db)
if err != nil {
return fmt.Errorf("failed to clean OAuth2 sessions: %w", err)
}
slog.InfoContext(ctx, "Cleaned expired OIDC authorization codes", slog.Int64("count", st.RowsAffected))
slog.InfoContext(ctx, "Cleaned OAuth2 sessions", slog.Int64("count", count))
return nil
}
// ClearOidcAuthorizationCodes deletes OIDC authorization codes that have expired
func (j *DbCleanupJobs) clearOidcRefreshTokens(ctx context.Context) error {
st := j.db.
WithContext(ctx).
Delete(&model.OidcRefreshToken{}, "expires_at < ?", datatype.DateTime(time.Now()))
if st.Error != nil {
return fmt.Errorf("failed to clean expired OIDC refresh tokens: %w", st.Error)
// clearOAuth2JTIs deletes expired JWT IDs used for client assertion replay protection.
func (j *DbCleanupJobs) clearOAuth2JTIs(ctx context.Context) error {
count, err := oidc.CleanupExpiredClientAssertionJTIs(ctx, j.db)
if err != nil {
return fmt.Errorf("failed to clean OAuth2 client assertion JTIs: %w", err)
}
slog.InfoContext(ctx, "Cleaned expired OIDC refresh tokens", slog.Int64("count", st.RowsAffected))
slog.InfoContext(ctx, "Cleaned OAuth2 client assertion JTIs", slog.Int64("count", count))
return nil
}
// ClearReauthenticationTokens deletes reauthentication tokens that have expired
// clearInteractionSessions deletes abandoned OIDC interaction sessions.
func (j *DbCleanupJobs) clearInteractionSessions(ctx context.Context) error {
count, err := oidc.CleanupAbandonedInteractionSessions(ctx, j.db)
if err != nil {
return fmt.Errorf("failed to clean interaction sessions: %w", err)
}
slog.InfoContext(ctx, "Cleaned interaction sessions", slog.Int64("count", count))
return nil
}
// clearReauthenticationTokens deletes expired reauthentication tokens.
// What counts as expired is owned by the webauthn module.
func (j *DbCleanupJobs) clearReauthenticationTokens(ctx context.Context) error {
st := j.db.
WithContext(ctx).
Delete(&model.ReauthenticationToken{}, "expires_at < ?", datatype.DateTime(time.Now()))
if st.Error != nil {
return fmt.Errorf("failed to clean expired reauthentication tokens: %w", st.Error)
count, err := webauthn.CleanupExpiredReauthenticationTokens(ctx, j.db)
if err != nil {
return fmt.Errorf("failed to clean expired reauthentication tokens: %w", err)
}
slog.InfoContext(ctx, "Cleaned expired reauthentication tokens", slog.Int64("count", st.RowsAffected))
slog.InfoContext(ctx, "Cleaned expired reauthentication tokens", slog.Int64("count", count))
return nil
}
// ClearAuditLogs deletes audit logs older than 90 days
// ClearAuditLogs deletes audit logs older than the configured retention window
func (j *DbCleanupJobs) clearAuditLogs(ctx context.Context) error {
cutoff := time.Now().AddDate(0, 0, -common.EnvConfig.AuditLogRetentionDays)
st := j.db.
WithContext(ctx).
Delete(&model.AuditLog{}, "created_at < ?", datatype.DateTime(time.Now().AddDate(0, 0, -90)))
Delete(&model.AuditLog{}, "created_at < ?", datatype.DateTime(cutoff))
if st.Error != nil {
return fmt.Errorf("failed to delete old audit logs: %w", st.Error)
}
@@ -132,3 +151,16 @@ func (j *DbCleanupJobs) clearAuditLogs(ctx context.Context) error {
return nil
}
// ClearEmailVerificationTokens deletes email verification tokens that have expired
func (j *DbCleanupJobs) clearEmailVerificationTokens(ctx context.Context) error {
st := j.db.
WithContext(ctx).
Delete(&model.EmailVerificationToken{}, "expires_at < ?", datatype.DateTime(time.Now()))
if st.Error != nil {
return fmt.Errorf("failed to clean expired email verification tokens: %w", st.Error)
}
slog.InfoContext(ctx, "Cleaned expired email verification tokens", slog.Int64("count", st.RowsAffected))
return nil
}

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