Compare commits

..

561 Commits

Author SHA1 Message Date
ItalyPaleAle
669834d2d3 More WIP 2026-07-14 08:59:08 -07:00
ItalyPaleAle
2b11164532 More WIP 2026-07-14 08:57:47 -07:00
ItalyPaleAle
1d2f991be5 More WIP 2026-07-13 07:13:00 -07:00
ItalyPaleAle
eac837fc22 Merge branch 'main' of https://github.com/pocket-id/pocket-id into actors/config 2026-07-13 06:22:24 -07:00
Elias Schneider
525946e94e release: 2.11.0 2026-07-13 11:16:16 +02:00
Elias Schneider
bb03660bd7 ci/cd: remove low demand issue closer 2026-07-13 11:08:32 +02:00
Elias Schneider
9714296efb fix: block link-local addresses in SSRF protection 2026-07-13 11:05:24 +02:00
Elias Schneider
a3f27ec2ec fix: ignore tab URL hashes in navigation history 2026-07-13 10:52:01 +02:00
Elias Schneider
316cf47ceb fix: INTERNAL_APP_URL not reflected in UI URLs 2026-07-13 10:51:24 +02:00
dependabot[bot]
ef9ed8de32 chore(deps): Bump the "all-dependencies" group with 3 updates across multiple ecosystems (#1578)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2026-07-13 09:58:41 +02:00
James18232
217bf75b7c fix: login code mobile ux (#1584)
Co-authored-by: james <james@goldfish.net>
2026-07-13 09:52:57 +02:00
Elias Schneider
77398a558d fix: sort signup tokens by creation date explicitly 2026-07-13 09:50:25 +02:00
Elias Schneider
187cd8ddcd feat: add support for CIDR and IP address lists in TRUST_PROXY 2026-07-13 09:32:05 +02:00
Elias Schneider
d9ead47d19 fix: allow insecure callback URLs by default until next major release 2026-07-13 09:21:53 +02:00
Elias Schneider
7348bdac50 chore(translations): update translations via Crowdin (#1589) 2026-07-12 12:48:39 -05:00
ItalyPaleAle
0f6ff76165 Add Replace and Update funtions to model 2026-07-11 16:00:16 -07:00
ItalyPaleAle
e4d0cbefbe WIP 2026-07-11 12:19:04 -07:00
Elias Schneider
69a6e22c7c chore(translations): update translations via Crowdin (#1577) 2026-07-11 16:16:47 +02:00
Alessandro (Ale) Segala
cfda5f693b refactor: manage instance ID in the KV table (#1579) 2026-07-11 16:16:05 +02:00
Marco Scabbiolo
ff8e34cccf fix: postgres migration 20250822 remove public schema references (#1582)
Co-authored-by: Marco <marco.scabbiolo@redb.ee>
2026-07-11 16:11:07 +02:00
Elias Schneider
ff5e565eeb docs: update README 2026-07-11 14:54:22 +02:00
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
Elias Schneider
73392b5837 release: 1.14.1 2025-10-27 14:07:34 +01:00
Elias Schneider
65616f65e5 fix: ignore trailing slashes in APP_URL 2025-10-27 10:48:27 +01:00
Elias Schneider
98a99fbb0a chore(translations): update translations via Crowdin (#1048) 2025-10-27 09:48:39 +01:00
Quentin L'Hours
3f3b6b88fd fix: use credProps to save passkey on firefox android (#1055) 2025-10-27 09:48:24 +01:00
Mufeed Ali
8f98d8c0b4 fix: Prevent blinding FOUC in dark mode (#1054) 2025-10-26 20:40:25 +01:00
Elias Schneider
c9308472a9 release: 1.14.0 2025-10-24 13:47:58 +02:00
Elias Schneider
6362ff9861 chore: upgrade dependencies 2025-10-24 12:18:38 +02:00
Elias Schneider
10d640385f fix: prevent page flickering on redirection based on auth state 2025-10-24 12:12:42 +02:00
Elias Schneider
47927d1574 fix: make pkce requirement visible in the oidc form if client is public 2025-10-24 10:57:44 +02:00
Elias Schneider
b356cef766 fix: only animate login background on initial page load 2025-10-24 10:53:51 +02:00
Elias Schneider
9fc45930a8 chore(translations): update translations via Crowdin (#1033) 2025-10-24 09:58:00 +02:00
Kyle Mendell
028d1c858e feat: add support for dark mode oidc client icons (#1039)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-10-24 09:57:12 +02:00
Alessandro (Ale) Segala
eb3963d0fc fix: use constant time comparisons when validating PKCE challenges (#1047) 2025-10-24 08:30:50 +02:00
dependabot[bot]
35d913f905 chore(deps-dev): bump vite from 7.0.7 to 7.0.8 in the npm_and_yarn group across 1 directory (#1042)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-21 18:04:28 -05:00
github-actions[bot]
32485f4c7c chore: update AAGUIDs (#1041)
Co-authored-by: stonith404 <58886915+stonith404@users.noreply.github.com>
2025-10-20 08:43:27 +02:00
Elias Schneider
ceb38b0825 chore(translations): update translations via Crowdin (#1025) 2025-10-16 08:28:13 +02:00
dependabot[bot]
c0b6ede5be chore(deps): bump sveltekit-superforms from 2.27.1 to 2.27.4 in the npm_and_yarn group across 1 directory (#1031)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-16 08:27:52 +02:00
Elias Schneider
c20e93b55c feat: add various improvements to the table component (#961)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
2025-10-13 09:12:55 +00:00
Elias Schneider
24ca6a106d chore(translations): update translations via Crowdin (#1014) 2025-10-12 09:58:22 -05:00
Elias Schneider
9f0aa55be6 fix: ignore trailing slash in URL 2025-10-09 20:27:15 +02:00
Kyle Mendell
068fcc65a6 chore(translations): add Japanese files 2025-10-07 18:29:13 -05:00
Elias Schneider
f2dfb3da5d release: 1.13.1 2025-10-07 08:21:41 +02:00
Elias Schneider
cbf0e3117d fix: mark any callback url as valid if they contain a wildcard (#1006) 2025-10-07 08:18:53 +02:00
CzBiX
694f266dea fix: uploading a client logo with an URL fails (#1008) 2025-10-06 10:37:43 -05:00
Kyle Mendell
29fc185376 chore: cleanup root of repo, update workflow actions (#1003)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-10-05 14:49:06 -05:00
Elias Schneider
781be37416 release: 1.13.0 2025-10-05 17:30:47 +02:00
Elias Schneider
b1f97e05a1 chore(translations): update translations via Crowdin (#999) 2025-10-05 17:30:08 +02:00
Elias Schneider
2c74865173 feat: add link to API docs on API key page 2025-10-04 23:45:37 +02:00
Elias Schneider
ad8a90c839 fix: uploading a client logo with an URL fails if folder doesn't exist 2025-10-04 23:44:37 +02:00
Elias Schneider
f9839a978c release: 1.12.0 2025-10-03 11:59:38 +02:00
Elias Schneider
b81de45166 fix: date locale can't be loaded if locale is en 2025-10-03 11:54:07 +02:00
Elias Schneider
22f4254932 fix: allow any image source but disallow base64 2025-10-03 11:50:39 +02:00
Elias Schneider
507f9490fa feat: add the ability to make email optional (#994) 2025-10-03 11:24:53 +02:00
Elias Schneider
043cce615d feat: add required indicator for required inputs (#993) 2025-10-01 13:44:17 +02:00
Elias Schneider
69e2083722 chore(translations): update translations via Crowdin (#992) 2025-09-30 23:04:54 -05:00
Elias Schneider
d47b20326f fix: improve back button handling on auth pages 2025-09-30 14:44:08 +02:00
Elias Schneider
fc9939d1f1 fix: prevent endless effect loop in login wrapper 2025-09-30 14:06:13 +02:00
Elias Schneider
2c1c67b5e4 fix: include port in OIDC client details 2025-09-30 12:18:44 +02:00
Elias Schneider
d010be4c88 feat: hide alternative sign in methods page if email login disabled 2025-09-30 12:15:08 +02:00
Elias Schneider
01db8c0a46 fix: make logo and oidc client images sizes consistent 2025-09-30 12:12:37 +02:00
Alessandro (Ale) Segala
fe5917d96d fix: tokens issued with refresh token flow don't contain groups (#989)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-09-30 09:44:38 +00:00
Elias Schneider
4f0b434c54 chore(translations): update translations via Crowdin (#973) 2025-09-30 11:39:47 +02:00
Kyle Mendell
6bdf5fa37a feat: support for url based icons (#840)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-09-29 15:07:55 +00:00
Caian Benedicto
47bd5ba1ba fix: remove previous socket file to prevent bind error (#979) 2025-09-24 10:10:05 +00:00
Elias Schneider
b746ac0835 chore(email): remove unnecessary logo fallback 2025-09-24 12:08:24 +02:00
Elias Schneider
79989fb176 fix(email): display login location correctly if country or city is not present 2025-09-24 12:01:20 +02:00
Clément Contini
ecc7e224e9 fix: show only country in audit log location if no city instead of Unknown (#977) 2025-09-23 18:41:54 -05:00
Alessandro (Ale) Segala
549d219f44 fix(unit-tests): do not use cache=shared for in-memory SQLite (#971) 2025-09-21 19:32:41 -05:00
github-actions[bot]
ffe18db2fb chore: update AAGUIDs (#972)
Co-authored-by: stonith404 <58886915+stonith404@users.noreply.github.com>
2025-09-21 19:31:44 -05:00
Elias Schneider
e8b172f1c3 chore(release notes): fix whitespace after commit message 2025-09-20 22:54:55 +02:00
Elias Schneider
097bda349a release: 1.11.2 2025-09-20 22:05:21 +02:00
Elias Schneider
6e24517197 chore(translations): update translations via Crowdin (#963) 2025-09-20 21:43:03 +02:00
Elias Schneider
a3da943aa6 fix: decouple images from app config service (#965)
Co-authored-by: Alessandro (Ale) Segala <43508+ItalyPaleAle@users.noreply.github.com>
2025-09-20 21:42:52 +02:00
Alessandro (Ale) Segala
cc34aca2a0 fix: do not treat certain failures in app images bootstrap as fatal (#966) 2025-09-20 21:32:00 +02:00
Elias Schneider
fde4e9b38a chore: use git cliff for release notes 2025-09-20 21:30:44 +02:00
Elias Schneider
c55143d8c9 fix: embedded paths not found on windows 2025-09-19 13:43:49 +02:00
Elias Schneider
8973e93cb6 release: 1.11.1 2025-09-18 22:33:22 +02:00
Elias Schneider
8c9cac2655 chore(translations): update translations via Crowdin (#957) 2025-09-18 22:26:38 +02:00
Elias Schneider
ed8547ccc1 release: 1.11.0 2025-09-18 22:16:32 +02:00
Elias Schneider
e7e53a8b8c fix: my apps card shouldn't take full width if only one item exists 2025-09-18 21:55:43 +02:00
Elias Schneider
02249491f8 feat: allow uppercase usernames (#958) 2025-09-17 14:43:12 -05:00
Elias Schneider
cf0892922b chore: include version in changelog 2025-09-17 18:00:04 +02:00
Elias Schneider
99f31a7c26 fix: make environment variables case insensitive where necessary (#954)
fix #935
2025-09-17 08:21:54 -07:00
Kyle Mendell
68373604dd feat: add user display name field (#898)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-09-17 17:18:27 +02:00
Elias Schneider
2d6d5df0e7 feat: add support for LOG_LEVEL env variable (#942) 2025-09-14 08:26:21 -07:00
Alessandro (Ale) Segala
a897b31166 chore: minify background image (#933)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-09-14 08:24:28 -07:00
dependabot[bot]
fb92906c3a chore(deps): bump axios from 1.11.0 to 1.12.0 in the npm_and_yarn group across 1 directory (#943)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-13 12:20:18 -05:00
Alessandro (Ale) Segala
c018f29ad7 fix: key-rotate doesn't work with database storage (#940) 2025-09-12 20:04:45 -05:00
Elias Schneider
5367463239 feat: add PWA support (#938) 2025-09-12 10:17:35 -05:00
Elias Schneider
6c9147483c fix: add validation for callback URLs (#929) 2025-09-10 10:14:54 -07:00
Elias Schneider
d123d7f335 chore(translations): update translations via Crowdin (#931) 2025-09-10 07:57:58 -05:00
dependabot[bot]
da8ca08c36 chore(deps-dev): bump vite from 7.0.6 to 7.0.7 in the npm_and_yarn group across 1 directory (#932)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-09 17:32:14 -05:00
Alessandro (Ale) Segala
307caaa3ef feat: return new id_token when using refresh token (#925) 2025-09-09 11:31:50 +02:00
Elias Schneider
6c696b46c8 fix: list items on previous page get unselected if other items selected on next page 2025-09-09 10:02:59 +02:00
Alessandro (Ale) Segala
42155238b7 fix: ensure users imported from LDAP have fields validated (#923) 2025-09-09 09:31:49 +02:00
Elias Schneider
92edc26a30 chore(translations): update translations via Crowdin (#924) 2025-09-08 08:12:21 -05:00
github-actions[bot]
e36499c483 chore: update AAGUIDs (#926)
Co-authored-by: stonith404 <58886915+stonith404@users.noreply.github.com>
2025-09-08 00:14:56 -05:00
Elias Schneider
6215e1ac01 feat: add CSP header (#908)
Co-authored-by: Alessandro (Ale) Segala <43508+ItalyPaleAle@users.noreply.github.com>
2025-09-07 11:45:06 -07:00
Elias Schneider
74b39e16f9 chore(translations): update translations via Crowdin (#915) 2025-09-07 20:31:30 +02:00
Elias Schneider
a1d8538c64 feat: add info box to app settings if UI config is disabled 2025-09-07 19:49:07 +02:00
Elias Schneider
1d7cbc2a4e fix: disable sign up options in UI if UI_CONFIG_DISABLED 2025-09-07 19:42:20 +02:00
Kyle Mendell
954fb4f0c8 chore(translations): add Swedish files 2025-09-05 19:57:54 -05:00
Savely Krasovsky
901333f7e4 feat: client_credentials flow support (#901) 2025-09-02 18:33:01 -05:00
Elias Schneider
0b381467ca chore(translations): update translations via Crowdin (#904) 2025-09-02 09:57:31 -05:00
github-actions[bot]
6188dc6fb7 chore: update AAGUIDs (#903)
Co-authored-by: stonith404 <58886915+stonith404@users.noreply.github.com>
2025-08-31 19:40:23 -05:00
Kyle Mendell
802754c24c refactor: use react email for email templates (#734)
Co-authored-by: Alessandro (Ale) Segala <43508+ItalyPaleAle@users.noreply.github.com>
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-08-31 16:54:13 +00:00
Elias Schneider
6c843228eb chore(translations): update translations via Crowdin (#893) 2025-08-30 13:20:35 -05:00
Stephan H.
a3979f63e0 feat: add custom base url (#858)
Co-authored-by: Stephan Höhn <me@steph.ovh>
Co-authored-by: Kyle Mendell <ksm@ofkm.us>
2025-08-30 13:13:57 -05:00
Elias Schneider
52c560c30d chore(translations): update translations via Crowdin (#887)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
2025-08-27 16:44:26 -05:00
Kyle Mendell
e88be7e61a fix: update localized name and description of ldap group name attribute (#892) 2025-08-27 15:52:50 -05:00
Kyle Mendell
a4e965434f release: 1.10.0 2025-08-27 15:24:57 -05:00
Kyle Mendell
096d214a88 feat: redesigned sidebar with administrative dropdown (#881)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-08-27 16:39:22 +00:00
Savely Krasovsky
afb7fc32e7 chore(translations): add missing translations (#884) 2025-08-27 18:13:35 +02:00
Elias Schneider
641bbc9351 fix: apps showed multiple times if user is in multiple groups 2025-08-27 17:53:21 +02:00
Kyle Mendell
136c6082f6 chore(deps): bump sveltekit to 2.36.3 and devalue to 5.3.2 (#889) 2025-08-26 18:59:35 -05:00
github-actions[bot]
b9a20d2923 chore: update AAGUIDs (#885)
Co-authored-by: stonith404 <58886915+stonith404@users.noreply.github.com>
2025-08-25 08:13:32 +02:00
Elias Schneider
74eb2ac0b9 release: 1.9.1 2025-08-24 23:17:31 +02:00
Elias Schneider
51222f5607 tests: add no tx wrap to unit tests 2025-08-24 23:16:49 +02:00
Elias Schneider
d6d1a4ced2 fix: sqlite migration drops allowed user groups 2025-08-24 23:07:50 +02:00
Elias Schneider
4b086cebcd release: 1.9.0 2025-08-24 20:54:03 +02:00
Alessandro (Ale) Segala
1f3550c9bd fix: ensure SQLite has a writable temporary directory (#876)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-08-24 20:50:51 +02:00
dependabot[bot]
912008b048 chore(deps): bump golang.org/x/oauth2 from 0.26.0 to 0.27.0 in /backend in the go_modules group across 1 directory (#879)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-08-24 20:50:30 +02:00
Elias Schneider
5ad8b03831 chore(translations): update translations via Crowdin (#878) 2025-08-24 20:42:58 +02:00
Elias Schneider
c1e515a05f ci/cd: use matrix for e2e tests 2025-08-24 20:35:30 +02:00
Elias Schneider
654593b4b6 chore(migrations): use TEXT instead of VARCHAR for client ID 2025-08-24 20:22:06 +02:00
Elias Schneider
8999173aa0 ci/cd: fix playwright browsers not installed 2025-08-24 20:16:57 +02:00
Elias Schneider
10b087640f tests: fix postgres e2e tests (#877) 2025-08-24 19:15:26 +02:00
Elias Schneider
d0392d25ed fix: sort order incorrect for apps when using postgres 2025-08-24 19:08:33 +02:00
Elias Schneider
2ffc6ba42a fix: don't force uuid for client id in postgres 2025-08-24 18:29:41 +02:00
Elias Schneider
c114a2edaa feat: support automatic db migration rollbacks (#874) 2025-08-24 16:56:28 +02:00
Elias Schneider
63db4d5120 chore(migrations): add postgres down migration to 20250822000000 2025-08-24 15:30:18 +02:00
Elias Schneider
d8c73ed472 release: 1.8.1 2025-08-24 15:12:14 +02:00
Elias Schneider
5971bfbfa6 fix: migration clears allowed users groups 2025-08-24 15:05:45 +02:00
Alessandro (Ale) Segala
29eacd6424 chore: update issue template (#870) 2025-08-24 14:35:39 +02:00
Elias Schneider
21ca87be38 chore(translations): update translations via Crowdin (#860) 2025-08-24 14:34:44 +02:00
Alessandro (Ale) Segala
1283314f77 fix: wrong column type for reauthentication tokens in Postgres (#869) 2025-08-24 14:34:29 +02:00
Elias Schneider
9c54e2e6b0 release: 1.8.0 2025-08-23 18:57:19 +02:00
Elias Schneider
a5efb95065 feat: allow custom client IDs (#864) 2025-08-23 18:41:05 +02:00
Elias Schneider
625f235740 fix: enable foreign key check for sqlite (#863)
Co-authored-by: Alessandro (Ale) Segala <43508+ItalyPaleAle@users.noreply.github.com>
2025-08-23 17:54:51 +02:00
Elias Schneider
2c122d413d refactor: run formatter 2025-08-23 17:46:59 +02:00
Elias Schneider
fc0c99a232 fix: oidc client advanced options color 2025-08-23 17:40:58 +02:00
Elias Schneider
24e274200f fix: ferated identities can't be cleared 2025-08-23 17:40:06 +02:00
Elias Schneider
0aab3f3c7a fix: authorization can't be revoked 2025-08-23 17:28:27 +02:00
Zeedif
182d809028 feat(signup): add default user groups and claims for new users (#812)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-08-22 14:25:02 +02:00
Elias Schneider
c51265dafb chore(translations): change alternative sign in methods text 2025-08-22 13:06:38 +02:00
Robert Mang
0cb039d35d feat: add option to OIDC client to require re-authentication (#747)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-08-22 08:56:40 +02:00
Alessandro (Ale) Segala
7ab0fd3028 fix: for one-time access tokens and signup tokens, pass TTLs instead of absolute expiration date (#855) 2025-08-22 08:02:56 +02:00
Maxime R
49f0fa423c chore: strip debug symbol from backend binary (#856) 2025-08-21 15:46:45 +00:00
Elias Schneider
61e63e411d chore(translations): update translations via Crowdin (#850) 2025-08-20 17:07:08 -05:00
Alessandro (Ale) Segala
9339e88a5a fix: move audit log call before TX is committed (#854) 2025-08-20 17:01:53 -05:00
Elias Schneider
fe003b927c fix: delete webauthn session after login to prevent replay attacks 2025-08-20 15:49:19 +02:00
Kyle Mendell
f5b5b1bd85 tests: use proper async calls for cleanupBackend function (#846) 2025-08-20 10:38:03 +02:00
James18232
d28bfac81f feat: login code font change (#851)
Co-authored-by: James18232 <80368042+James18232@users.noreply.github.com>
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-08-19 14:10:57 +00:00
Elias Schneider
b04e3e8ecf chore(translations): update translations via Crowdin (#848) 2025-08-19 12:03:51 +02:00
Kyle Mendell
d77d8eb068 chore(translations): add Korean files 2025-08-18 14:53:19 -05:00
Elias Schneider
7cd88aca25 chore(translations): update translations via Crowdin (#841) 2025-08-18 11:21:27 -05:00
Gergő Gutyina
b5e6371eaa fix(deps): bump rollup from 4.45.3 to 4.46.3 (#845) 2025-08-18 07:44:42 -05:00
github-actions[bot]
544b98c1d0 chore: update AAGUIDs (#844)
Co-authored-by: stonith404 <58886915+stonith404@users.noreply.github.com>
2025-08-17 22:52:58 -05:00
Elias Schneider
3188e92257 feat: display all accessible oidc clients in the dashboard (#832)
Co-authored-by: Kyle Mendell <ksm@ofkm.us>
2025-08-17 22:47:34 +02:00
Elias Schneider
3fa2f9a162 chore(translations): update translations via Crowdin (#821) 2025-08-16 22:50:21 -05:00
James18232
7b1f6b8857 fix: ignore client secret if client is public (#836)
Co-authored-by: James18232 <80368042+James18232@users.noreply.github.com>
2025-08-16 17:55:32 +02:00
Alessandro (Ale) Segala
17d8893bdb chore: update deps and Go 1.25 (#833) 2025-08-14 22:33:27 -05:00
Elias Schneider
0e44f245af fix: non admin users can't revoke oidc client but see edit link 2025-08-12 09:46:15 +02:00
github-actions[bot]
824e8f1a0f chore: update AAGUIDs (#826)
Co-authored-by: stonith404 <58886915+stonith404@users.noreply.github.com>
2025-08-10 21:33:29 -05:00
Elias Schneider
6e4d2a4a33 release: 1.7.0 2025-08-10 20:01:03 +02:00
Elias Schneider
6c65bd34cd chore(translations): update translations via Crowdin (#820) 2025-08-10 19:50:36 +02:00
Kyle Mendell
7bfe4834d0 chore: switch from npm to pnpm (#786)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-08-10 12:16:30 -05:00
Kyle Mendell
484c2f6ef2 feat: user application dashboard (#727)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-08-10 15:56:03 +00:00
Elias Schneider
87956ea725 chore(translations): update translations via Crowdin (#819) 2025-08-10 10:18:30 -05:00
Elias Schneider
32dd403038 chore(translations): update translations via Crowdin (#817) 2025-08-10 14:49:24 +02:00
Elias Schneider
4d59e72866 fix: custom claims input suggestions instantly close after opening 2025-08-08 15:11:44 +02:00
Elias Schneider
9ac5d51187 fix: authorization animation not working 2025-08-08 12:23:32 +02:00
Elias Schneider
5a031f5d1b refactor: use reflection to mark file based env variables (#815) 2025-08-07 20:41:00 +02:00
Alessandro (Ale) Segala
535bc9f46b chore: additional logs for database connections (#813) 2025-08-06 18:04:25 +02:00
Kyle Mendell
f0c144c51c fix: admins can not delete or disable their own account 2025-08-05 16:14:25 -05:00
Elias Schneider
61e4ea45fb chore(translations): update translations via Crowdin (#811) 2025-08-05 15:56:45 -05:00
Etienne
06e1656923 feat: add robots.txt to block indexing (#806) 2025-08-02 18:30:50 +00:00
Alessandro (Ale) Segala
0a3b1c6530 feat: support reading secret env vars from _FILE (#799)
Co-authored-by: Kyle Mendell <ksm@ofkm.us>
2025-07-30 11:59:25 -05:00
Kyle Mendell
d479817b6a feat: add support for code_challenge_methods_supported (#794) 2025-07-29 17:34:49 -05:00
Elias Schneider
01bf31d23d chore(translations): update translations via Crowdin (#791) 2025-07-27 20:21:37 -05:00
Alessandro (Ale) Segala
42a861d206 refactor: complete conversion of log calls to slog (#787) 2025-07-27 04:34:23 +00:00
Alessandro (Ale) Segala
78266e3e4c feat: Support OTel and JSON for logs (via log/slog) (#760)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
2025-07-27 01:03:52 +00:00
Alessandro (Ale) Segala
c8478d75be fix: delete WebAuthn registration session after use (#783) 2025-07-26 18:45:54 -05:00
Elias Schneider
28d93b00a3 chore(translations): update translations via Crowdin (#785) 2025-07-26 16:37:42 -05:00
Kyle Mendell
12a7a6a5c5 chore: update Vietnamese display name 2025-07-26 15:33:36 -05:00
Elias Schneider
a6d5071724 chore(translations): update translations via Crowdin (#782) 2025-07-25 15:48:52 -05:00
Elias Schneider
cebe2242b9 chore(translations): update translations via Crowdin (#779) 2025-07-24 20:28:07 -05:00
Kyle Mendell
56ee7d946f chore: fix federated credentials type error 2025-07-24 20:22:34 -05:00
Kyle Mendell
f3c6521f2b chore: update dependencies and fix zod/4 import path 2025-07-24 20:16:17 -05:00
Kyle Mendell
ffed465f09 chore: update dependencies and fix zod/4 import path 2025-07-24 20:14:25 -05:00
Kyle Mendell
c359b5be06 chore: rename glass-row-item to passkey-row 2025-07-24 19:50:27 -05:00
Elias Schneider
e9a023bb71 chore(translations): update translations via Crowdin (#778)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
2025-07-24 19:35:16 -05:00
Kyle Mendell
60f0b28076 chore(transaltions): add Vietnamese files 2025-07-24 10:11:01 -05:00
Alessandro (Ale) Segala
d541c9ab4a fix: set input type 'email' for email-based login (#776) 2025-07-23 12:39:50 -05:00
dependabot[bot]
024ed53022 chore(deps): bump axios from 1.10.0 to 1.11.0 in /frontend in the npm_and_yarn group across 1 directory (#777)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-23 12:38:00 -05:00
Elias Schneider
2c78bd1b46 chore(translations): update translations via Crowdin (#767) 2025-07-22 15:08:04 -05:00
dependabot[bot]
5602d79611 chore(deps): bump form-data from 4.0.1 to 4.0.4 in /frontend in the npm_and_yarn group across 1 directory (#771)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-22 15:07:43 -05:00
Kyle Mendell
51b73c9c31 chore(translations): add Ukrainian files 2025-07-21 15:56:59 -05:00
Elias Schneider
10f0580a43 chore(translations): update translations via Crowdin (#763) 2025-07-21 07:32:57 -05:00
972 changed files with 65872 additions and 22392 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,4 +1,4 @@
node_modules
**/node_modules
# Output
.output

View File

@@ -1,5 +1,17 @@
# 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

View File

@@ -36,13 +36,29 @@ body:
value: |
### Additional Information
- type: textarea
id: extra-information
id: version
validations:
required: true
attributes:
label: "Version and Environment"
description: "Please specify the version of Pocket ID, along with any environment-specific configurations, such your reverse proxy, that might be relevant."
label: "Pocket ID Version"
description: "Please specify the version of Pocket ID."
placeholder: "e.g., v0.24.1"
- type: textarea
id: database
validations:
required: true
attributes:
label: "Database"
description: "Please specify the database in use: SQLite or Postgres (including version)."
placeholder: "e.g., SQLite or Postgres 17"
- type: textarea
id: environment
validations:
required: true
attributes:
label: "OS and Environment"
description: "Please include the OS, whether you're using containers (Docker, Podman, etc) along with any environment-specific configurations, such your reverse proxy, that might be relevant."
placeholder: "e.g., Docker on Ubuntu 24.04, served using Traefik"
- type: textarea
id: log-files
validations:

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,24 +17,25 @@ 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@v4
uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v5
uses: actions/setup-go@v6
with:
go-version-file: backend/go.mod
- name: Run Golangci-lint
uses: golangci/golangci-lint-action@dec74fa03096ff515422f71d18d41307cacde373 # v7.0.0
uses: golangci/golangci-lint-action@v9.3.0
with:
version: v2.0.2
args: --build-tags=exclude_frontend
version: v2.12.2
args: --config=.golangci.yml
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@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@v5
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: 22
cache: "npm"
cache-dependency-path: frontend/package-lock.json
node-version: 24
cache: pnpm
- name: Setup Go
uses: actions/setup-go@v5
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.4.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Install frontend dependencies
working-directory: frontend
run: npm ci
- name: Build frontend
working-directory: frontend
run: npm 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.4.0
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ env.DOCKER_IMAGE_NAME }}:next
file: 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.3
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ env.DOCKER_IMAGE_NAME }}:next-distroless
file: 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

@@ -1,237 +1,173 @@
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:
test:
if: github.event.pull_request.head.ref != 'i18n_crowdin'
timeout-minutes: 20
permissions:
contents: read
actions: write
runs-on: ubuntu-latest
runs-on: depot-ubuntu-24.04-32
strategy:
fail-fast: false
matrix:
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@v4
- name: Checkout code
uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Setup pnpm
uses: pnpm/action-setup@v5
- name: Build and export
uses: docker/build-push-action@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
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
node-version: 24
cache: "pnpm"
- name: Upload Docker image artifact
uses: actions/upload-artifact@v4
with:
name: docker-image
path: /tmp/docker-image.tar
retention-days: 1
- name: Set up Depot CLI
uses: depot/setup-action@v1
test-sqlite:
if: github.event.pull_request.head.ref != 'i18n_crowdin'
permissions:
contents: read
actions: write
runs-on: ubuntu-latest
needs: build
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- 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('frontend/package-lock.json') }}
restore-keys: |
${{ runner.os }}-playwright-
key: ${{ runner.os }}-playwright-${{ hashFiles('pnpm-lock.yaml') }}
- name: Download Docker image artifact
uses: actions/download-artifact@v4
- name: Cache PostgreSQL Docker image
uses: actions/cache@v5
id: postgres-cache
with:
name: docker-image
path: /tmp
- name: Load Docker image
run: docker load -i /tmp/docker-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'
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 nitnelave/lldap:stable
docker save nitnelave/lldap:stable > /tmp/lldap-image.tar
docker pull postgres:17
docker save postgres:17 > /tmp/postgres-image.tar
- name: Load PostgreSQL image
if: matrix.db == 'postgres' && steps.postgres-cache.outputs.cache-hit == 'true'
run: docker load < /tmp/postgres-image.tar
- name: Load LLDAP image from cache
if: steps.lldap-cache.outputs.cache-hit == 'true'
run: docker load < /tmp/lldap-image.tar
- 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 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: Cache Localstack S3 Docker image
if: matrix.storage == 's3'
uses: actions/cache@v5
id: s3-cache
with:
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
working-directory: ./tests
run: npm ci
run: pnpm --filter pocket-id-tests install --frozen-lockfile
- name: Install Playwright Browsers
working-directory: ./tests
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: npx playwright install --with-deps chromium
run: pnpm exec playwright install --with-deps chromium
- name: Run Docker Container with Sqlite DB and LDAP
- name: Run Docker containers
working-directory: ./tests/setup
run: |
docker compose up -d
docker compose 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: npx playwright test
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-sqlite
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-sqlite
path: /tmp/backend.log
include-hidden-files: true
retention-days: 15
test-postgres:
if: github.event.pull_request.head.ref != 'i18n_crowdin'
permissions:
contents: read
actions: write
runs-on: ubuntu-latest
needs: build
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Cache Playwright Browsers
uses: actions/cache@v3
id: playwright-cache
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('frontend/package-lock.json') }}
restore-keys: |
${{ runner.os }}-playwright-
- name: Cache PostgreSQL Docker image
uses: actions/cache@v3
id: postgres-cache
with:
path: /tmp/postgres-image.tar
key: postgres-17-${{ runner.os }}
- name: Pull and save PostgreSQL image
if: 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
if: 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'
run: |
docker pull nitnelave/lldap:stable
docker save nitnelave/lldap:stable > /tmp/lldap-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
with:
name: docker-image
path: /tmp
- name: Load Docker image
run: docker load -i /tmp/docker-image.tar
- name: Install test dependencies
working-directory: ./tests
run: npm ci
- name: Install Playwright Browsers
working-directory: ./tests
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: npx playwright install --with-deps chromium
- name: Run Docker Container with Postgres DB and LDAP
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 &
- name: Run Playwright tests
working-directory: ./tests
run: npx playwright test
- name: Upload Test Report
uses: actions/upload-artifact@v4
if: always() && github.event.pull_request.head.ref != 'i18n_crowdin'
with:
name: playwright-report-postgres
path: frontend/tests/.report
include-hidden-files: true
retention-days: 15
- name: Upload Backend Test Report
uses: actions/upload-artifact@v4
if: always() && github.event.pull_request.head.ref != 'i18n_crowdin'
with:
name: backend-postgres
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,122 +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@v5
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: 22
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- uses: actions/setup-go@v5
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.4.0
with:
registry: ghcr.io
username: ${{github.repository_owner}}
password: ${{secrets.GITHUB_TOKEN}}
- name: Docker metadata
id: meta
uses: docker/metadata-action@v5
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub
uses: docker/login-action@v4.4.0
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
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v7.2.3
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
working-directory: frontend
run: npm ci
- name: Build frontend
working-directory: frontend
run: npm run build
- 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
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
file: Dockerfile-prebuilt
- name: Build and push container image (distroless)
uses: docker/build-push-action@v6
id: container-build-push-distroless
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta-distroless.outputs.tags }}
labels: ${{ steps.meta-distroless.outputs.labels }}
file: 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@v3
- 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,39 +21,42 @@ 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@v4
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v5
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: 22
cache: "npm"
cache-dependency-path: frontend/package-lock.json
node-version: 24
cache: "pnpm"
- name: Install dependencies
working-directory: frontend
run: npm ci
run: pnpm --filter pocket-id-frontend install --frozen-lockfile
- name: Build Pocket ID Frontend
working-directory: frontend
run: npm run build
run: pnpm --filter pocket-id-frontend build
- name: Add svelte-check problem matcher
run: echo "::add-matcher::.github/svelte-check-matcher.json"
- name: Run svelte-check
working-directory: frontend
run: npm run check
run: pnpm --filter pocket-id-frontend check

View File

@@ -5,19 +5,21 @@ 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@v4
- uses: actions/setup-go@v5
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: "backend/go.mod"
cache-dependency-path: "backend/go.sum"
@@ -29,8 +31,8 @@ jobs:
working-directory: backend
run: |
set -e -o pipefail
go test -tags=exclude_frontend -v ./... | tee /tmp/TestResults.log
- uses: actions/upload-artifact@v4
go test -tags=exclude_frontend,unit -v ./... | tee /tmp/TestResults.log
- 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@v4
uses: actions/checkout@v6
- name: Fetch JSON data
run: |

7
.gitignore vendored
View File

@@ -1,8 +1,12 @@
# JetBrains
**/.idea
# Node
node_modules
# PNPM
.pnpm-store/
# Output
.output
.vercel
@@ -11,6 +15,7 @@ node_modules
/backend/bin
pocket-id
/tests/test-results/*.json
.tmp/
# OS
.DS_Store
@@ -55,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.6.4
2.11.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 unit",
"prettier.documentSelectors": ["**/*.svelte"]
}

102
AGENTS.md Normal file
View File

@@ -0,0 +1,102 @@
# 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 and unit tags are mandatory locally; CI uses them too
go test -tags=exclude_frontend,unit ./... # unit/integration tests
go test -tags=exclude_frontend,unit -run TestName ./internal/... # a single test
golangci-lint run # lint (config: backend/.golangci.yml - includes build tags)
# 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` and `unit` build tags.** Without them plain `go run`/`go test`/`golangci-lint` fail
to run. Always pass `-tags exclude_frontend,unit` for backend dev/test/lint.
- **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:

File diff suppressed because it is too large Load Diff

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,24 +44,23 @@ 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
- **refactor** - code change that neither fixes a bug nor adds a feature
- Your pull request has a detailed description
- You run `npm run format` to format the code
- 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.24
- [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,30 +87,42 @@ 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:
1. Open the `frontend` folder
2. Copy the `.env.development-example` file to `.env` and edit the variables as needed
3. Install the dependencies with `npm install`
4. Start the frontend with `npm run dev`
1. Open the `pocket-id` project folder
2. Copy the `frontend/.env.development-example` file to `frontend/.env` and edit the variables as needed
3. Install the dependencies with `pnpm install`
4. Start the frontend with `pnpm dev`
You're all set! The application is now listening on `localhost:3000`. The backend gets proxied trough the frontend in development mode.
### 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:
1. Visit the setup folder by running `cd tests/setup`
1. Install the dependencies from the root of the project `pnpm install`
2. Start the test environment by running `docker compose up -d --build`
2. Visit the setup folder by running `cd tests/setup`
3. Go back to the test folder by running `cd ..`
4. Run the tests with `npx playwright test`
3. Start the test environment by running `docker compose up -d --build`
4. Go back to the test folder by running `cd ..`
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 -tags=exclude_frontend,unit ./...` from the root of the `backend` folder.

View File

@@ -1,15 +1,19 @@
# <div align="center"><img src="https://github.com/user-attachments/assets/4ceb2708-9f29-4694-b797-be833efce17d" width="100"/> </br>Pocket ID</div>
Pocket ID is a simple OIDC provider that allows users to authenticate with their passkeys to your services.
Pocket ID is an easy-to-use OpenID Connect Certified™ and OAuth 2.0 provider that lets users sign in to your applications with passkeys.
→ 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.
The goal of Pocket ID is to be a simple and easy-to-use. There are other self-hosted OIDC and OAuth 2.0 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.
Additionally, what makes Pocket ID special is that it only supports [passkey](https://www.passkeys.io/) authentication, which means you dont need a password. Some people might not like this idea at first, but I believe passkeys are the future, and once you try them, youll love them. For example, you can now use a physical Yubikey to sign in to all your self-hosted services easily and securely.
<a href="https://openid.net/certification/certified-openid-providers-profiles">
<img src="https://github.com/user-attachments/assets/697a8b8f-61bc-412f-8222-5410da7292c7" height="80" alt="OpenID Connect Certified">
</a>
## Setup
Pocket ID can be set up in multiple ways. The easiest and recommended way is to use Docker.

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

@@ -2,6 +2,9 @@ version: "2"
run:
tests: true
timeout: 5m
build-tags:
- unit
- exclude_frontend
linters:
default: none
enable:
@@ -61,4 +64,4 @@ formatters:
paths:
- third_party$
- builtin$
- examples$
- examples$

11
backend/Makefile Normal file
View File

@@ -0,0 +1,11 @@
.PHONY: test
test:
go test -tags exclude_frontend,unit ./...
.PHONY: test-race
test-race:
CGO_ENABLED=1 go test -race -tags exclude_frontend,unit ./...
.PHONY: lint
lint:
golangci-lint run -c .golangci.yml

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

@@ -3,75 +3,256 @@
package frontend
import (
"bytes"
"embed"
"fmt"
"io"
"io/fs"
"mime"
"net/http"
"os"
"path"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/middleware"
)
//go:embed all:dist/*
var frontendFS embed.FS
// This function, created by the init() method, writes to "w" the index.html page, populating the nonce
var writeIndexFn func(w io.Writer, nonce string) error
func init() {
const scriptTag = "<script>"
// Read the index.html from the bundle
index, iErr := fs.ReadFile(frontendFS, "dist/index.html")
if iErr != nil {
panic(fmt.Errorf("failed to read index.html: %w", iErr))
}
writeIndexFn = func(w io.Writer, nonce string) (err error) {
// If there's no nonce, write the index as-is
if nonce == "" {
_, err = w.Write(index)
return err
}
// Add nonce to all <script> tags
// We replace "<script" with `<script nonce="..."` everywhere it appears
modified := bytes.ReplaceAll(
index,
[]byte(scriptTag),
[]byte(`<script nonce="`+nonce+`">`),
)
_, err = w.Write(modified)
return err
}
}
func RegisterFrontend(router *gin.Engine) error {
distFS, err := fs.Sub(frontendFS, "dist")
if err != nil {
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) {
// Try to serve the requested file
// 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 _, err := fs.Stat(distFS, path); os.IsNotExist(err) {
// File doesn't exist, serve index.html instead
c.Request.URL.Path = "/"
if strings.HasSuffix(path, "/") {
c.Redirect(http.StatusMovedPermanently, strings.TrimRight(c.Request.URL.String(), "/"))
return
}
if strings.HasPrefix(path, "api/") || strings.HasPrefix(path, ".well-known/") {
c.JSON(http.StatusNotFound, gin.H{"error": "API endpoint not found"})
return
}
if isSPARequest(path, distFS) {
nonce := middleware.GetCSPNonce(c)
// Do not cache the HTML shell, as it embeds a per-request nonce
c.Header("Content-Type", "text/html; charset=utf-8")
c.Header("Cache-Control", "no-store")
c.Status(http.StatusOK)
if err := writeIndexFn(c.Writer, nonce); err != nil {
_ = c.Error(fmt.Errorf("failed to write index.html file: %w", err))
}
return
}
// 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,140 +1,250 @@
module github.com/pocket-id/pocket-id/backend
go 1.24.0
go 1.26.5
require (
github.com/caarlos0/env/v11 v11.3.1
github.com/cenkalti/backoff/v5 v5.0.2
github.com/aws/aws-sdk-go-v2 v1.42.1
github.com/aws/aws-sdk-go-v2/config v1.32.27
github.com/aws/aws-sdk-go-v2/credentials v1.19.26
github.com/aws/aws-sdk-go-v2/service/s3 v1.104.2
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-20200509203442-7bfe0ed36a21
github.com/emersion/go-smtp v0.21.3
github.com/fxamacker/cbor/v2 v2.7.0
github.com/gin-gonic/gin v1.10.0
github.com/glebarez/go-sqlite v1.21.2
github.com/glebarez/sqlite v1.11.0
github.com/go-co-op/gocron/v2 v2.15.0
github.com/go-ldap/ldap/v3 v3.4.10
github.com/go-playground/validator/v10 v10.25.0
github.com/go-webauthn/webauthn v0.11.2
github.com/golang-migrate/migrate/v4 v4.18.2
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.10
github.com/italypaleale/go-kit v0.0.0-20260708054611-e276b65dd3be
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.0-beta2
github.com/lestrrat-go/jwx/v3 v3.0.1
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/oschwald/maxminddb-golang/v2 v2.0.0-beta.2
github.com/spf13/cobra v1.9.1
github.com/stretchr/testify v1.10.0
go.opentelemetry.io/contrib/exporters/autoexport v0.59.0
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.60.0
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0
go.opentelemetry.io/otel v1.35.0
go.opentelemetry.io/otel/metric v1.35.0
go.opentelemetry.io/otel/sdk v1.35.0
go.opentelemetry.io/otel/sdk/metric v1.35.0
go.opentelemetry.io/otel/trace v1.35.0
golang.org/x/crypto v0.39.0
golang.org/x/image v0.24.0
golang.org/x/text v0.26.0
golang.org/x/time v0.9.0
gorm.io/driver/postgres v1.5.11
gorm.io/gorm v1.25.12
github.com/orandin/slog-gorm v1.4.0
github.com/ory/fosite v0.49.1-0.20250703093431-a5f0b09bf31c
github.com/oschwald/maxminddb-golang/v2 v2.4.1
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
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.22.0
golang.org/x/text v0.39.0
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.2
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.14 // 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.23 // 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.31 // 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/sonic v1.12.10 // indirect
github.com/bytedance/sonic/loader v0.2.3 // 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.5 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
github.com/disintegration/gift v1.1.2 // 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.8 // indirect
github.com/gin-contrib/sse v1.0.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect
github.com/go-logr/logr v1.4.2 // 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.16 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
github.com/google/go-tpm v0.9.3 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // 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.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.2 // 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/cpuid/v2 v2.2.10 // 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.3 // indirect
github.com/lestrrat-go/blackmagic v1.0.4 // 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/lib/pq v1.10.9 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.24 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/lestrrat-go/option/v2 v2.0.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 v0.1.9 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_golang v1.22.0 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.62.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/ncruces/go-strftime 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.69.0 // indirect
github.com/prometheus/otlptranslator v1.0.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.11.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/segmentio/asm v1.2.0 // indirect
github.com/spf13/pflag v1.0.6 // 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.2.12 // 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.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/bridges/prometheus v0.59.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.10.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.10.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.35.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.35.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0 // indirect
go.opentelemetry.io/otel/exporters/prometheus v0.57.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.10.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.35.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.35.0 // indirect
go.opentelemetry.io/otel/log v0.10.0 // indirect
go.opentelemetry.io/otel/sdk/log v0.10.0 // indirect
go.opentelemetry.io/proto/otlp v1.5.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/arch v0.14.0 // indirect
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 // indirect
golang.org/x/net v0.38.0 // indirect
golang.org/x/sync v0.15.0 // indirect
golang.org/x/sys v0.33.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a // indirect
google.golang.org/grpc v1.71.0 // indirect
google.golang.org/protobuf v1.36.5 // 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.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/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.47.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.65.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.38.0 // 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,26 +1,30 @@
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"`
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"`
Description *string `json:"description"`
ExpiresAt datatype.DateTime `json:"expiresAt"`
LastUsedAt *datatype.DateTime `json:"lastUsedAt"`
CreatedAt datatype.DateTime `json:"createdAt"`
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,64 @@
package appconfig
import (
"context"
"errors"
"sync"
"github.com/gin-gonic/gin"
)
// appConfigCtxKey is the context key used to store the AppConfigResolver in the http.Request's context
type appConfigCtxKey struct{}
type appConfigResolver func(ctx context.Context) (*AppConfigModel, error)
// AppConfigMiddleware is a Gin middleware that makes the application configuration available to all downstream handlers through the request's context
type AppConfigMiddleware struct {
appConfigService *AppConfigService
}
func NewAppConfigMiddleware(appConfigService *AppConfigService) *AppConfigMiddleware {
return &AppConfigMiddleware{
appConfigService: appConfigService,
}
}
// Add returns a Gin middleware that stores an AppConfigResolver in the http.Request's context
// The resolver loads the application configuration lazily on the first call and caches it for the duration of the request
func (m *AppConfigMiddleware) Add() gin.HandlerFunc {
return func(c *gin.Context) {
reqCtx := c.Request.Context()
// Create a cache for each request in the middleware's scope, so it's unique per each request
var (
once sync.Once
cfg *AppConfigModel
err error
)
// Note: the resolver accepts a context argument, it doesn't use the request's own
// This can be used for example for tracing
resolver := appConfigResolver(func(ctx context.Context) (*AppConfigModel, error) {
once.Do(func() {
cfg, err = m.appConfigService.GetConfig(ctx)
})
return cfg, err
})
// Store the resolver in the request's context
c.Request = c.Request.WithContext(context.WithValue(reqCtx, appConfigCtxKey{}, resolver))
c.Next()
}
}
// FromCtx retrieves the app config from the context
func FromCtx(ctx context.Context) (*AppConfigModel, error) {
resolver, ok := ctx.Value(appConfigCtxKey{}).(appConfigResolver)
if !ok || resolver == nil {
// Indicates a development-time error
return nil, errors.New("middleware AppConfigMiddleware was not registered for the handler")
}
return resolver(ctx)
}

View File

@@ -0,0 +1,112 @@
package appconfig
import (
"context"
"fmt"
"log/slog"
"time"
"github.com/italypaleale/francis/actor"
"github.com/pocket-id/pocket-id/backend/internal/common"
)
// The AppConfig singleton actor maintains the dynamic configuration for the Pocket ID cluster
// Instances of Pocket ID should bootstrap the AppConfig's actor upon startup to ensure the config is loaded (and migrated if needed)
// After startup, Peek can be used for read-only operations such as retrieving the config or listing it
// AppConfigActorType is the actor type for the AppConfig actor
const AppConfigActorType = "AppConfig"
// appConfigActor is a singleton actor that manages the dynamic app configuration
type appConfigActor struct {
log *slog.Logger
client actor.Client[*AppConfigModel]
}
// appConfigActorBootstrap is the type for the payload of the init method
type appConfigActorBootstrap struct {
LegacyConfig map[string]string
}
// NewAppConfigActor allocates a new AppConfig actor
// It satisfies actor.Factory
func NewAppConfigActor(actorID string, service *actor.Service) actor.Actor {
log := slog.
With(
slog.String("scope", "actor"),
slog.String("actorType", AppConfigActorType),
slog.String("actorID", actorID),
)
log.Info("AppConfig actor created")
return &appConfigActor{
log: log,
client: actor.NewActorClient[*AppConfigModel](AppConfigActorType, actorID, service),
}
}
// Bootstrap implements actor.ActorBootstrapper for the singleton actor
func (a *appConfigActor) Bootstrap(parentCtx context.Context, data actor.Envelope) error {
// Load the actor state
ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second)
defer cancel()
state, err := a.client.GetState(ctx)
if err != nil {
return fmt.Errorf("error retrieving actor state: %w", err)
}
// If we already have a state, nothing else to do
if state != nil {
return nil
}
// Check if the request data contains legacy config to init from
if data != nil {
payload := appConfigActorBootstrap{}
err = data.Decode(&payload)
if err != nil {
return fmt.Errorf("request body is not valid for method 'init': %w", err)
}
if len(payload.LegacyConfig) > 0 {
state, err = fromLegacyConfig(payload.LegacyConfig)
if err != nil {
return fmt.Errorf("request body is not valid for method 'init': LegacyConfig property could not be parsed: %w", err)
}
}
}
// If we still have no state, generate a new default config
if state == nil {
state = getDefaultConfig()
}
// Save the updated state
ctx, cancel = context.WithTimeout(parentCtx, 10*time.Second)
defer cancel()
err = a.client.SetState(ctx, state, nil)
if err != nil {
return fmt.Errorf("error saving actor state: %w", err)
}
return nil
}
func (a *appConfigActor) Peek(parentCtx context.Context, method string, data actor.Envelope) (any, error) {
// Only supported method is "get"
if method != "get" {
return nil, common.ErrUnsupportedActorMethod{Method: method}
}
// Load the actor state
ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second)
defer cancel()
state, err := a.client.GetState(ctx)
if err != nil {
return nil, fmt.Errorf("error retrieving actor state: %w", err)
}
// Return the state
return state, nil
}

View File

@@ -0,0 +1,47 @@
package appconfig
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/model"
)
// loadLegacyConfig loads the legacy config from the database
// This was migrated to the "config_migrated" key in the kv table
func LoadLegacyConfig(ctx context.Context, db *gorm.DB) (map[string]string, error) {
// Retrieve the migrated config from the kv table
row := model.KV{
Key: "config_migrated",
}
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
err := db.WithContext(ctx).First(&row).Error
switch {
case errors.Is(err, gorm.ErrRecordNotFound):
// There's no migrated config in the database, nothing to do
return nil, nil
case err != nil:
return nil, fmt.Errorf("failed to load migrated config from the database: %w", err)
case row.Value == nil || len(*row.Value) == 0:
// Also no migrated config, nothing to do
return nil, nil
}
// The value is a JSON-encoded dictionary
res := map[string]string{}
err = json.Unmarshal([]byte(*row.Value), &res)
if err != nil {
return nil, fmt.Errorf("error parsing migrated config: %w", err)
}
if len(res) == 0 {
return nil, nil
}
return res, nil
}

View File

@@ -0,0 +1,271 @@
package appconfig
import (
"errors"
"fmt"
"reflect"
"strconv"
"strings"
"time"
"github.com/italypaleale/go-kit/utils"
"github.com/pocket-id/pocket-id/backend/internal/dto"
)
type AppConfigModel struct {
// General
AppName AppConfigValue `json:"appName" public:"true"`
SessionDuration AppConfigValue `json:"sessionDuration" type:"int"` // In minutes
HomePageURL AppConfigValue `json:"homePageUrl" public:"true"`
EmailsVerified AppConfigValue `json:"emailsVerified" type:"bool"`
AccentColor AppConfigValue `json:"accentColor" public:"true"`
DisableAnimations AppConfigValue `json:"disableAnimations" type:"bool" public:"true"`
AllowOwnAccountEdit AppConfigValue `json:"allowOwnAccountEdit" type:"bool" public:"true"`
AllowUserSignups AppConfigValue `json:"allowUserSignups" public:"true"`
SignupDefaultUserGroupIDs AppConfigValue `json:"signupDefaultUserGroupIDs"` // JSON-encoded array of strings
SignupDefaultCustomClaims AppConfigValue `json:"signupDefaultCustomClaims"` // JSON-encoded array of {key:string,value:string}
// Email
RequireUserEmail AppConfigValue `json:"requireUserEmail" type:"bool" public:"true"`
SmtpHost AppConfigValue `json:"smtpHost"`
SmtpPort AppConfigValue `json:"smtpPort"`
SmtpFrom AppConfigValue `json:"smtpFrom"`
SmtpUser AppConfigValue `json:"smtpUser"`
SmtpPassword AppConfigValue `json:"smtpPassword" sensitive:"true"`
SmtpTls AppConfigValue `json:"smtpTls"`
SmtpSkipCertVerify AppConfigValue `json:"smtpSkipCertVerify" type:"bool"`
EmailLoginNotificationEnabled AppConfigValue `json:"emailLoginNotificationEnabled" type:"bool"`
EmailOneTimeAccessAsUnauthenticatedEnabled AppConfigValue `json:"emailOneTimeAccessAsUnauthenticatedEnabled" type:"bool" public:"true"`
EmailOneTimeAccessAsAdminEnabled AppConfigValue `json:"emailOneTimeAccessAsAdminEnabled" type:"bool" public:"true"`
EmailApiKeyExpirationEnabled AppConfigValue `json:"emailApiKeyExpirationEnabled" type:"bool"`
EmailVerificationEnabled AppConfigValue `json:"emailVerificationEnabled" type:"bool" public:"true"`
// LDAP
LdapEnabled AppConfigValue `json:"ldapEnabled" type:"bool" public:"true"`
LdapUrl AppConfigValue `json:"ldapUrl"`
LdapBindDn AppConfigValue `json:"ldapBindDn"`
LdapBindPassword AppConfigValue `json:"ldapBindPassword" sensitive:"true"`
LdapBase AppConfigValue `json:"ldapBase"`
LdapUserSearchFilter AppConfigValue `json:"ldapUserSearchFilter"`
LdapUserGroupSearchFilter AppConfigValue `json:"ldapUserGroupSearchFilter"`
LdapSkipCertVerify AppConfigValue `json:"ldapSkipCertVerify" type:"bool"`
LdapAttributeUserUniqueIdentifier AppConfigValue `json:"ldapAttributeUserUniqueIdentifier"`
LdapAttributeUserUsername AppConfigValue `json:"ldapAttributeUserUsername"`
LdapAttributeUserEmail AppConfigValue `json:"ldapAttributeUserEmail"`
LdapAttributeUserFirstName AppConfigValue `json:"ldapAttributeUserFirstName"`
LdapAttributeUserLastName AppConfigValue `json:"ldapAttributeUserLastName"`
LdapAttributeUserDisplayName AppConfigValue `json:"ldapAttributeUserDisplayName"`
LdapAttributeUserProfilePicture AppConfigValue `json:"ldapAttributeUserProfilePicture"`
LdapAttributeGroupMember AppConfigValue `json:"ldapAttributeGroupMember"`
LdapAttributeGroupUniqueIdentifier AppConfigValue `json:"ldapAttributeGroupUniqueIdentifier"`
LdapAttributeGroupName AppConfigValue `json:"ldapAttributeGroupName"`
LdapAdminGroupName AppConfigValue `json:"ldapAdminGroupName"`
LdapSoftDeleteUsers AppConfigValue `json:"ldapSoftDeleteUsers" type:"bool"`
}
// AppConfigValue holds a value
type AppConfigValue string
// IsTrue returns true if the value is a truthy string, such as "true", "t", "yes", "1", etc.
func (a AppConfigValue) IsTrue() bool {
return utils.IsTruthy(string(a))
}
// AsDurationMinutes returns the value as a time.Duration, interpreting the string as a whole number of minutes.
func (a AppConfigValue) AsDurationMinutes() time.Duration {
val, err := strconv.Atoi(string(a))
if err != nil {
return 0
}
return time.Duration(val) * time.Minute
}
// String implements fmt.Stringer
func (a AppConfigValue) String() string {
return string(a)
}
func getDefaultConfig() *AppConfigModel {
// Values are the default ones
return &AppConfigModel{
// General
AppName: "Pocket ID",
SessionDuration: "60",
HomePageURL: "/settings/account",
EmailsVerified: "false",
DisableAnimations: "false",
AllowOwnAccountEdit: "true",
AllowUserSignups: "disabled",
SignupDefaultUserGroupIDs: "[]",
SignupDefaultCustomClaims: "[]",
AccentColor: "default",
// Email
RequireUserEmail: "true",
SmtpHost: "",
SmtpPort: "",
SmtpFrom: "",
SmtpUser: "",
SmtpPassword: "",
SmtpTls: "none",
SmtpSkipCertVerify: "false",
EmailLoginNotificationEnabled: "false",
EmailOneTimeAccessAsUnauthenticatedEnabled: "false",
EmailOneTimeAccessAsAdminEnabled: "false",
EmailApiKeyExpirationEnabled: "false",
EmailVerificationEnabled: "false",
// LDAP
LdapEnabled: "false",
LdapUrl: "",
LdapBindDn: "",
LdapBindPassword: "",
LdapBase: "",
LdapUserSearchFilter: "(objectClass=person)",
LdapUserGroupSearchFilter: "(objectClass=groupOfNames)",
LdapSkipCertVerify: "false",
LdapAttributeUserUniqueIdentifier: "",
LdapAttributeUserUsername: "",
LdapAttributeUserEmail: "",
LdapAttributeUserFirstName: "",
LdapAttributeUserLastName: "",
LdapAttributeUserDisplayName: "cn",
LdapAttributeUserProfilePicture: "",
LdapAttributeGroupMember: "member",
LdapAttributeGroupUniqueIdentifier: "",
LdapAttributeGroupName: "",
LdapAdminGroupName: "",
LdapSoftDeleteUsers: "true",
}
}
// fromLegacyConfig builds an appConfigModel from a legacy config map
// The map's keys correspond to the "json" tags on appConfigModel, and all values are strings that are cast to each field's type
// Keys that are missing (or have an empty value) retain the default value
func fromLegacyConfig(legacyCfg map[string]string) (*AppConfigModel, error) {
// Start from the default configuration, then override with the values from the legacy config
dest := getDefaultConfig()
rt := reflect.ValueOf(dest).Elem().Type()
rv := reflect.ValueOf(dest).Elem()
for i := range rt.NumField() {
field := rt.Field(i)
// Get the value of the json tag, taking only what's before the comma
key, _, _ := strings.Cut(field.Tag.Get("json"), ",")
// Look up the value in the legacy config
// If the key is missing or the value is empty, we keep the default value
value, ok := legacyCfg[key]
if !ok || value == "" {
continue
}
// Cast the string value to the field's type
fv := rv.Field(i)
switch fv.Kind() { //nolint:exhaustive
case reflect.String:
fv.SetString(value)
case reflect.Bool:
fv.SetBool(utils.IsTruthy(value))
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
n, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return nil, fmt.Errorf("failed to parse integer value for key '%s': %w", key, err)
}
fv.SetInt(n)
default:
return nil, fmt.Errorf("unsupported field type '%s' for key '%s'", fv.Kind(), key)
}
}
return dest, nil
}
// Replace updates every configuration property with the values from the input DTO
// An empty string value resets the corresponding property to its default value
func (m *AppConfigModel) Replace(input dto.AppConfigUpdateDto) error {
// Collect the values from the input DTO into a map, keyed by the "json" tag
inRv := reflect.ValueOf(input)
inRt := inRv.Type()
values := make(map[string]string, inRt.NumField())
for i := range inRt.NumField() {
// Get the value of the json tag, taking only what's before the comma
key, _, _ := strings.Cut(inRt.Field(i).Tag.Get("json"), ",")
values[key] = inRv.Field(i).String()
}
// Iterate through all the properties, setting each one from the input
// Properties that are missing from the input or have an empty value are reset to their default
defaults := reflect.ValueOf(getDefaultConfig()).Elem()
rv := reflect.ValueOf(m).Elem()
rt := rv.Type()
for i := range rt.NumField() {
key, _, _ := strings.Cut(rt.Field(i).Tag.Get("json"), ",")
value, ok := values[key]
if !ok || value == "" {
value = defaults.Field(i).String()
}
rv.Field(i).SetString(value)
}
return nil
}
// Update sets configuration properties from the provided key-value pairs
// Keys correspond to the "json" tags on the model
// An empty string value resets the property to its default value
func (m *AppConfigModel) Update(keysAndValues ...string) error {
// Count of keysAndValues must be even
if len(keysAndValues)%2 != 0 {
return errors.New("invalid number of arguments received")
}
rv := reflect.ValueOf(m).Elem()
rt := rv.Type()
defaults := reflect.ValueOf(getDefaultConfig()).Elem()
// Iterate through the key-value pairs
// (Note the += 2, as we are iterating through key-value pairs)
for i := 1; i < len(keysAndValues); i += 2 {
key := keysAndValues[i-1]
value := keysAndValues[i]
// Find the field in the struct whose "json" tag matches
fieldIdx := -1
for j := range rt.NumField() {
// Separate the key (before the comma) from any optional attributes after
tagValue, _, _ := strings.Cut(rt.Field(j).Tag.Get("json"), ",")
if tagValue == key {
fieldIdx = j
break
}
}
if fieldIdx < 0 {
return AppConfigKeyNotFoundError{field: key}
}
// An empty string means we use the default value for the property
if value == "" {
value = defaults.Field(fieldIdx).String()
}
rv.Field(fieldIdx).SetString(value)
}
return nil
}
type AppConfigKeyNotFoundError struct {
field string
}
func (e AppConfigKeyNotFoundError) Error() string {
return "cannot find config key '" + e.field + "'"
}
func (e AppConfigKeyNotFoundError) Is(target error) bool {
// Ignore the field property when checking if an error is of the type AppConfigKeyNotFoundError
_, ok := errors.AsType[*AppConfigKeyNotFoundError](target)
return ok
}

View File

@@ -0,0 +1,201 @@
package appconfig
import (
"errors"
"reflect"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/pocket-id/pocket-id/backend/internal/dto"
)
// dtoWithMarkerValues returns a DTO where every field is set to a unique, non-empty marker derived from its "json" key, so we can assert each value lands in the right place.
func dtoWithMarkerValues() dto.AppConfigUpdateDto {
var input dto.AppConfigUpdateDto
rv := reflect.ValueOf(&input).Elem()
rt := rv.Type()
for i := range rt.NumField() {
key, _, _ := strings.Cut(rt.Field(i).Tag.Get("json"), ",")
rv.Field(i).SetString("marker-" + key)
}
return input
}
func TestAppConfigModel_Replace(t *testing.T) {
t.Run("populates every property from the DTO", func(t *testing.T) {
input := dtoWithMarkerValues()
var m AppConfigModel
err := m.Replace(input)
require.NoError(t, err)
// Each model property must hold the marker built from its own "json" key.
// This also asserts that the model and the DTO share the same set of keys.
rv := reflect.ValueOf(&m).Elem()
rt := rv.Type()
for i := range rt.NumField() {
key, _, _ := strings.Cut(rt.Field(i).Tag.Get("json"), ",")
assert.Equalf(t, "marker-"+key, rv.Field(i).String(), "property %s (key %q)", rt.Field(i).Name, key)
}
})
t.Run("empty values fall back to their default", func(t *testing.T) {
defaults := getDefaultConfig()
// Start from all-markers, then blank out a few properties whose default is non-empty
input := dtoWithMarkerValues()
input.AppName = ""
input.SessionDuration = ""
input.SmtpTls = ""
input.LdapUserSearchFilter = ""
var m AppConfigModel
err := m.Replace(input)
require.NoError(t, err)
// Blanked properties are reset to their default
assert.Equal(t, defaults.AppName, m.AppName)
assert.Equal(t, defaults.SessionDuration, m.SessionDuration)
assert.Equal(t, defaults.SmtpTls, m.SmtpTls)
assert.Equal(t, defaults.LdapUserSearchFilter, m.LdapUserSearchFilter)
// A property that was provided keeps the provided value
assert.Equal(t, "marker-homePageUrl", m.HomePageURL)
})
t.Run("an empty DTO resets every property to its default", func(t *testing.T) {
// Pre-populate with junk to prove Replace overwrites existing state
m := AppConfigModel{
AppName: "Custom Name",
LdapEnabled: "true",
SmtpHost: "smtp.example.com",
}
err := m.Replace(dto.AppConfigUpdateDto{})
require.NoError(t, err)
assert.Equal(t, *getDefaultConfig(), m)
})
t.Run("provided values overwrite existing non-default values", func(t *testing.T) {
m := getDefaultConfig()
m.AppName = "Old Name"
m.LdapEnabled = "true"
input := dto.AppConfigUpdateDto{}
input.AppName = "New Name"
err := m.Replace(input)
require.NoError(t, err)
// Explicitly provided value wins
assert.Equal(t, "New Name", m.AppName)
// Everything else in the DTO was empty, so it is reset to the default
assert.Equal(t, getDefaultConfig().LdapEnabled, m.LdapEnabled)
})
t.Run("stores raw string values without type coercion", func(t *testing.T) {
input := dto.AppConfigUpdateDto{}
input.SessionDuration = "120" // int-tagged property
input.LdapEnabled = "true" // bool-tagged property
var m AppConfigModel
err := m.Replace(input)
require.NoError(t, err)
assert.Equal(t, "120", m.SessionDuration)
assert.Equal(t, "true", m.LdapEnabled)
})
}
func TestAppConfigModel_Update(t *testing.T) {
t.Run("updates a single property", func(t *testing.T) {
m := getDefaultConfig()
err := m.Update("appName", "My App")
require.NoError(t, err)
assert.Equal(t, "My App", m.AppName)
})
t.Run("updates multiple properties and leaves others untouched", func(t *testing.T) {
m := getDefaultConfig()
err := m.Update("appName", "My App", "homePageUrl", "/home", "ldapEnabled", "true")
require.NoError(t, err)
assert.Equal(t, "My App", m.AppName)
assert.Equal(t, "/home", m.HomePageURL)
assert.Equal(t, "true", m.LdapEnabled)
// A property that was not part of the update keeps its previous value
assert.Equal(t, getDefaultConfig().SessionDuration, m.SessionDuration)
})
t.Run("an empty value resets the property to its default", func(t *testing.T) {
m := getDefaultConfig()
m.SmtpTls = "tls" // default is "none"
m.SessionDuration = "120" // default is "60"
err := m.Update("smtpTls", "", "sessionDuration", "")
require.NoError(t, err)
assert.Equal(t, getDefaultConfig().SmtpTls, m.SmtpTls)
assert.Equal(t, getDefaultConfig().SessionDuration, m.SessionDuration)
})
t.Run("stores raw string values without type coercion", func(t *testing.T) {
m := getDefaultConfig()
err := m.Update("sessionDuration", "120", "disableAnimations", "true")
require.NoError(t, err)
assert.Equal(t, "120", m.SessionDuration)
assert.Equal(t, "true", m.DisableAnimations)
})
t.Run("later value wins for a repeated key", func(t *testing.T) {
m := getDefaultConfig()
err := m.Update("appName", "First", "appName", "Second")
require.NoError(t, err)
assert.Equal(t, "Second", m.AppName)
})
t.Run("no arguments is a no-op", func(t *testing.T) {
m := getDefaultConfig()
before := *m
err := m.Update()
require.NoError(t, err)
assert.Equal(t, before, *m)
})
t.Run("an odd number of arguments returns an error", func(t *testing.T) {
m := getDefaultConfig()
before := *m
err := m.Update("appName")
require.Error(t, err)
assert.EqualError(t, err, "invalid number of arguments received")
// The config must not have been modified
assert.Equal(t, before, *m)
})
t.Run("an unknown key returns AppConfigKeyNotFoundError", func(t *testing.T) {
m := getDefaultConfig()
err := m.Update("thisKeyDoesNotExist", "value")
require.Error(t, err)
assert.EqualError(t, err, "cannot find config key 'thisKeyDoesNotExist'")
notFound, ok := errors.AsType[AppConfigKeyNotFoundError](err)
require.True(t, ok)
assert.Equal(t, "thisKeyDoesNotExist", notFound.field)
})
}

View File

@@ -0,0 +1,301 @@
package appconfig
import (
"context"
"errors"
"fmt"
"os"
"reflect"
"strings"
"time"
"github.com/italypaleale/francis/actor"
"github.com/italypaleale/francis/host/local"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"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/model"
"github.com/pocket-id/pocket-id/backend/internal/tracing"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
type AppConfigService struct {
actSvc *actor.Service
envConfig *AppConfigModel
}
func NewService(ctx context.Context, actors *local.Host, db *gorm.DB) (service *AppConfigService, err error) {
service = &AppConfigService{}
// If the UI config is disabled, we do not need to init the config actor
if common.EnvConfig.UiConfigDisabled {
service.envConfig, err = service.loadDbConfigFromEnv()
if err != nil {
return nil, fmt.Errorf("error loading app config from the env: %w", err)
}
return service, nil
}
// Note: we need to assign to the "err" variable in this method (for tracing), do not inline this into the "if"
ctx, span := tracing.Start(ctx, "pocketid.appconfig.init")
defer tracing.End(span, err)
// Load the legacy config if any, which we need to send to the actor as bootstrap data
legacyCfg, err := LoadLegacyConfig(ctx, db)
if err != nil {
return nil, fmt.Errorf("error loading legacy config: %w", err)
}
// Register the AppConfig actor
// This is a singleton actor and it's bootstrapped with the legacy config if present
bootstrapData := &appConfigActorBootstrap{
LegacyConfig: legacyCfg,
}
err = actors.RegisterSingletonActor(
AppConfigActorType, NewAppConfigActor,
local.WithBootstrapData(bootstrapData),
local.WithIdleTimeout(-1), // Disable idle timeout for this actor
)
if err != nil {
return nil, fmt.Errorf("error registering the %s actor: %w", AppConfigActorType, err)
}
service.actSvc = actors.Service()
return service, nil
}
// GetConfig returns the application configuration
// Important: Treat the object as read-only: do not modify its properties directly!
func (s *AppConfigService) GetConfig(parentCtx context.Context) (*AppConfigModel, error) {
// If the UI config is disabled, only load from the env
if common.EnvConfig.UiConfigDisabled {
return s.envConfig, nil
}
// Retrieve the config from the actor
ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second)
defer cancel()
res, err := s.actSvc.Peek(ctx, AppConfigActorType, actor.SingletonActorID, "get", nil)
if err != nil {
return nil, fmt.Errorf("error retrieving config from actor: %w", err)
}
if res == nil {
return nil, errors.New("config actor response was empty")
}
var cfg AppConfigModel
err = res.Decode(&cfg)
if err != nil {
return nil, fmt.Errorf("error decoding config actor response: %w", err)
}
return &cfg, nil
}
func (s *AppConfigService) updateAppConfigUpdateDatabase(ctx context.Context, tx *gorm.DB, dbUpdate *[]model.AppConfigVariable) error {
err := tx.
WithContext(ctx).
Clauses(clause.OnConflict{
// Perform an "upsert" if the key already exists, replacing the value
Columns: []clause.Column{{Name: "key"}},
DoUpdates: clause.AssignmentColumns([]string{"value"}),
}).
Create(&dbUpdate).
Error
if err != nil {
return fmt.Errorf("failed to update config in database: %w", err)
}
return nil
}
func (s *AppConfigService) UpdateAppConfig(ctx context.Context, input dto.AppConfigUpdateDto) ([]model.AppConfigVariable, error) {
if common.EnvConfig.UiConfigDisabled {
return nil, &common.UiConfigDisabledError{}
}
// From here onwards, we know we are the only process/goroutine with exclusive access to the config
// Re-load the config from the database to be sure we have the correct data
cfg, err := s.loadDbConfigInternal(ctx, tx)
if err != nil {
return nil, fmt.Errorf("failed to reload config from database: %w", err)
}
defaultCfg := getDefaultConfig()
// Iterate through all the fields to update
// We update the in-memory data (in the cfg struct) and collect values to update in the database
rt := reflect.ValueOf(input).Type()
rv := reflect.ValueOf(input)
dbUpdate := make([]model.AppConfigVariable, 0, rt.NumField())
for field := range rt.Fields() {
value := rv.FieldByName(field.Name).String()
// Get the value of the json tag, taking only what's before the comma
key, _, _ := strings.Cut(field.Tag.Get("json"), ",")
// Update the in-memory config value
// If the new value is an empty string, then we set the in-memory value to the default one
if value == "" {
// Ignore errors here as we know the key exists
defaultValue, _ := defaultCfg.FieldByKey(key)
err = cfg.UpdateField(key, defaultValue)
} else {
err = cfg.UpdateField(key, value)
}
if err != nil {
return nil, fmt.Errorf("failed to update in-memory config for key '%s': %w", key, err)
}
// We always save "value" which can be an empty string
dbUpdate = append(dbUpdate, model.AppConfigVariable{
Key: key,
Value: value,
})
}
// Update the values in the database
err = s.updateAppConfigUpdateDatabase(ctx, tx, &dbUpdate)
if err != nil {
return nil, err
}
// Commit the changes to the DB, then finally save the updated config in the object
err = tx.Commit().Error
if err != nil {
return nil, fmt.Errorf("failed to commit transaction: %w", err)
}
s.dbConfig.Store(cfg)
// Return the updated config
res := cfg.ToAppConfigVariableSlice(true, false)
return res, nil
}
// UpdateAppConfigValues updates the application configuration values in the database.
func (s *AppConfigService) UpdateAppConfigValues(ctx context.Context, keysAndValues ...string) error {
// Count of keysAndValues must be even
if len(keysAndValues)%2 != 0 {
return errors.New("invalid number of arguments received")
}
if common.EnvConfig.UiConfigDisabled {
return &common.UiConfigDisabledError{}
}
// Start the transaction
tx, err := s.updateAppConfigStartTransaction(ctx)
if err != nil {
return err
}
defer tx.Rollback()
// From here onwards, we know we are the only process/goroutine with exclusive access to the config
// Re-load the config from the database to be sure we have the correct data
cfg, err := s.loadDbConfigInternal(ctx, tx)
if err != nil {
return fmt.Errorf("failed to reload config from database: %w", err)
}
defaultCfg := getDefaultDbConfig()
// Iterate through all the fields to update
// We update the in-memory data (in the cfg struct) and collect values to update in the database
// (Note the += 2, as we are iterating through key-value pairs)
dbUpdate := make([]model.AppConfigVariable, 0, len(keysAndValues)/2)
for i := 1; i < len(keysAndValues); i += 2 {
key := keysAndValues[i-1]
value := keysAndValues[i]
// Ensure that the field is valid
// We do this by grabbing the default value
var defaultValue string
defaultValue, err := defaultCfg.FieldByKey(key)
if err != nil {
return fmt.Errorf("invalid configuration key '%s': %w", key, err)
}
// Update the in-memory config value
// If the new value is an empty string, then we set the in-memory value to the default one
if value == "" {
err = cfg.UpdateField(key, defaultValue)
} else {
err = cfg.UpdateField(key, value)
}
if err != nil {
return fmt.Errorf("failed to update in-memory config for key '%s': %w", key, err)
}
// We always save "value" which can be an empty string
dbUpdate = append(dbUpdate, model.AppConfigVariable{
Key: key,
Value: value,
})
}
// Update the values in the database
err = s.updateAppConfigUpdateDatabase(ctx, tx, &dbUpdate)
if err != nil {
return err
}
// Commit the changes to the DB, then finally save the updated config in the object
err = tx.Commit().Error
if err != nil {
return fmt.Errorf("failed to commit transaction: %w", err)
}
s.dbConfig.Store(cfg)
return nil
}
func (s *AppConfigService) ListAppConfig(showAll bool) []model.AppConfigVariable {
return s.GetDbConfig().ToAppConfigVariableSlice(showAll, true)
}
func (s *AppConfigService) loadDbConfigFromEnv() (*AppConfigModel, error) {
// First, start from the default configuration
dest := getDefaultConfig()
// Iterate through each field
rt := reflect.ValueOf(dest).Elem().Type()
rv := reflect.ValueOf(dest).Elem()
for i := range rt.NumField() {
field := rt.Field(i)
// Get the key and internal tag values
key, attrs, _ := strings.Cut(field.Tag.Get("key"), ",")
envVarName := utils.CamelCaseToScreamingSnakeCase(key)
// Set the value if it's set
value, ok := os.LookupEnv(envVarName)
if ok {
rv.Field(i).Set(reflect.ValueOf(value))
continue
}
// If it's sensitive, we also allow reading from file
if attrs == "sensitive" {
fileName := os.Getenv(envVarName + "_FILE")
if fileName != "" {
// #nosec G703 - Value is provided by admin
b, err := os.ReadFile(fileName)
if err != nil {
return nil, fmt.Errorf("failed to read secret '%s' from file '%s': %w", envVarName, fileName, err)
}
rv.Field(i).Set(reflect.ValueOf(string(b)))
continue
}
}
}
return dest, nil
}

View File

@@ -0,0 +1,18 @@
//go:build unit
// This file contains utils for unit tests and it's only built when the "unit" tag is set
package appconfig
// NewTestAppConfigService is a function used by tests to create AppConfigService objects with pre-defined configuration values
func NewTestAppConfigService(config *AppConfigModel) *AppConfigService {
if config == nil {
// If there's no config, set the default one
config = getDefaultConfig()
}
service := &AppConfigService{
envConfig: config,
}
return service
}

View File

@@ -0,0 +1,171 @@
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/storage"
"github.com/pocket-id/pocket-id/backend/internal/utils/crypto"
)
type NewActorsOpts struct {
SQLite *sql.DB
Postgres *pgxpool.Pool
EnvConfig *common.EnvConfigSchema
InstanceID string
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 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)
}
// Add all cron jobs
err = o.registerCronJobs(h)
if err != nil {
return nil, nil, err
}
// Add the rate limiters
rateLimiters, err := o.registerRateLimiters(h)
if err != nil {
return nil, nil, 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) {
// This is tied to the instance ID of the Pocket ID deployment/cluster
// Note: changing the key derivation or the seed is a breaking change
return crypto.DeriveKey(o.EnvConfig.EncryptionKey, "pocketid/actors-psk/"+o.InstanceID)
}
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) registerCronJobs(host *local.Host) (err error) {
// In test mode, we do not register anything
if common.EnvConfig.AppEnv == "test" {
return nil
}
// Register the analytics job
analyticsJob, err := job.GetAnalyticsJob(o.HttpClient, o.InstanceID)
if err != nil {
return fmt.Errorf("failed to get analytics cron job: %w", err)
}
// This could be nil if analytics are disabled
if analyticsJob != nil {
err = host.RegisterBuiltInActor(analyticsJob)
if err != nil {
return fmt.Errorf("error registering built-in actor for analytics job: %w", err)
}
}
// Register the file cleanup jobs
fileCleanupJobs, err := job.GetFileCleanupJobs(o.DB, o.FileStorage)
if err != nil {
return fmt.Errorf("failed to get file cleanup cron jobs: %w", err)
}
for _, j := range fileCleanupJobs {
err = host.RegisterBuiltInActor(j)
if err != nil {
return fmt.Errorf("error registering built-in actor for cleanup job: %w", err)
}
}
return nil
}
// registerRateLimiters 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) registerRateLimiters(host *local.Host) (actors map[string]*ratelimit.RateLimit, 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, fmt.Errorf("error creating rate limiter %q: %w", p.Name, err)
}
actors[p.Name] = rl
err = host.RegisterBuiltInActor(rl)
if err != nil {
return nil, fmt.Errorf("error registering built-in actor for rate limiter '%s': %w", p.Name, err)
}
}
return actors, nil
}

View File

@@ -0,0 +1,28 @@
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"),
},
// Constant value for this test
InstanceID: "ee05c3eb-8129-47a6-a1c7-849998b6f876",
}
expectedHex := "db09067fa194c3731bf77b6415a1c5d903f03d4557605ba3236b31f6eddfc8d7"
expected, err := hex.DecodeString(expectedHex)
require.NoError(t, err)
actual, err := opts.getPSK()
require.NoError(t, err)
require.Equalf(t, expected, actual, "actual result: %s", actual)
}

View File

@@ -0,0 +1,152 @@
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/storage"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"github.com/pocket-id/pocket-id/backend/resources"
)
// initApplicationImages copies the images from the embedded directory to the storage backend
// and returns a map containing the detected file extensions in the application-images directory.
//
//nolint:gocognit
func initApplicationImages(ctx context.Context, fileStorage storage.FileStorage) (map[string]string, error) {
// Previous versions of images
// If these are found, they are deleted
legacyImageHashes := imageHashMap{
"background.jpg": mustDecodeHex("138d510030ed845d1d74de34658acabff562d306476454369a60ab8ade31933f"),
"background.webp": mustDecodeHex("3fc436a66d6b872b01d96a4e75046c46b5c3e2daccd51e98ecdf98fd445599ab"),
}
sourceFiles, err := resources.FS.ReadDir("images")
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 {
// Skip directories
_, name := path.Split(f.Path)
if name == "" {
continue
}
nameWithoutExt, ext := utils.SplitFileName(name)
reader, _, err := fileStorage.Open(ctx, f.Path)
if err != nil {
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(hash) {
slog.Info("Found legacy application image that will be removed", slog.String("name", name))
if err := fileStorage.Delete(ctx, f.Path); err != nil {
return nil, fmt.Errorf("failed to remove legacy file '%s': %w", name, err)
}
continue
}
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() {
continue
}
name := sourceFile.Name()
nameWithoutExt, ext := utils.SplitFileName(name)
srcFilePath := path.Join("images", name)
if _, exists := dstNameToExt[nameWithoutExt]; exists {
continue
}
slog.Info("Writing new application image", slog.String("name", name))
srcFile, err := resources.FS.Open(srcFilePath)
if err != nil {
return nil, fmt.Errorf("failed to open embedded file '%s': %w", name, err)
}
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
}
return dstNameToExt, nil
}
type imageHashMap map[string][]byte
func (m imageHashMap) Contains(target []byte) bool {
if len(target) == 0 {
return false
}
for _, h := range m {
if bytes.Equal(h, target) {
return true
}
}
return false
}
func mustDecodeHex(str string) []byte {
b, err := hex.DecodeString(str)
if err != nil {
panic(err)
}
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

@@ -1,64 +0,0 @@
package bootstrap
import (
"log"
"os"
"path"
"strings"
"github.com/pocket-id/pocket-id/backend/internal/common"
"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
func initApplicationImages() {
dirPath := common.EnvConfig.UploadPath + "/application-images"
sourceFiles, err := resources.FS.ReadDir("images")
if err != nil && !os.IsNotExist(err) {
log.Fatalf("Error reading directory: %v", err)
}
destinationFiles, err := os.ReadDir(dirPath)
if err != nil && !os.IsNotExist(err) {
log.Fatalf("Error reading directory: %v", 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() || imageAlreadyExists(sourceFile.Name(), destinationFiles) {
continue
}
srcFilePath := path.Join("images", sourceFile.Name())
destFilePath := path.Join(dirPath, sourceFile.Name())
err := utils.CopyEmbeddedFileToDisk(srcFilePath, destFilePath)
if err != nil {
log.Fatalf("Error copying file: %v", err)
}
}
}
func imageAlreadyExists(fileName string, destinationFiles []os.DirEntry) bool {
for _, destinationFile := range destinationFiles {
sourceFileWithoutExtension := getImageNameWithoutExtension(fileName)
destinationFileWithoutExtension := getImageNameWithoutExtension(destinationFile.Name())
if sourceFileWithoutExtension == destinationFileWithoutExtension {
return true
}
}
return false
}
func getImageNameWithoutExtension(fileName string) string {
idx := strings.LastIndexByte(fileName, '.')
if idx < 1 {
// No dot found, or fileName starts with a dot
return fileName
}
return fileName[:idx]
}

View File

@@ -2,68 +2,247 @@ package bootstrap
import (
"context"
"errors"
"fmt"
"log"
"log/slog"
"time"
_ "github.com/golang-migrate/migrate/v4/source/file"
"github.com/italypaleale/francis/host/local"
"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/instanceid"
"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 {
initApplicationImages()
// List of services to run
services := make([]servicerunner.Service, 0, 3)
shutdowns := &shutdownManager{
fns: make([]servicerunner.Service, 0, 4),
}
// Initialize the tracer and metrics exporter
shutdownFns, httpClient, err := initOtel(ctx, common.EnvConfig.MetricsEnabled, common.EnvConfig.TracingEnabled)
// Initialize the observability stack, including the logger, distributed tracing, and metrics
shutdownFns, httpClient, err := initObservability(ctx)
if err != nil {
return fmt.Errorf("failed to initialize OpenTelemetry: %w", err)
}
shutdowns.Add(shutdownFns...)
// Connect to the database
db := NewDatabase()
slog.InfoContext(ctx, "Pocket ID is starting")
// Create all services
svc, err := initServices(ctx, db, httpClient)
// Init database
db, pg, err := NewDatabase(ctx)
if err != nil {
return fmt.Errorf("failed to initialize services: %w", err)
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 the job scheduler
// Load the instance ID
// This is stored in the "kv" table, and generated on first startup
instanceID, err := instanceid.Load(ctx, db)
if err != nil {
return fmt.Errorf("failed to initialize instance ID: %w", err)
}
// 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)
}
// 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)
// Init the actors
// The actor host is created and started before the services, so services can depend on it once it's ready
actorsOpts := NewActorsOpts{
Postgres: pg,
EnvConfig: &common.EnvConfig,
InstanceID: instanceID,
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 register scheduled jobs: %w", err)
return fmt.Errorf("failed to initialize actors: %w", err)
}
// Run the actor host as a background service and get a "ready" signal that other services can wait on
actorsRun, actorsReady := actorsRunServiceFn(actors)
services = append(services, actorsRun)
// Create all services
svc, err := initServices(ctx, db, instanceID, actors, httpClient, imageExtensions, fileStorage, scheduler)
if err != nil {
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
})
// 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)
}
// The router must wait on the actor host being ready, since the rate-limit middleware invokes actors
services = append(services, actorsReady.Await(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 {
log.Printf("Error shutting down services: %v", err)
}
// Run all shutdown functions
shutdowns.Run(ctx)
return nil
}
// actorsRunServiceFn wraps the actor host's Run method in a background service and returns a "ready" signal that other services can wait on
func actorsRunServiceFn(actors *local.Host) (servicerunner.Service, *servicerunner.Ready) {
actorsReady := servicerunner.NewReady()
fn := func(ctx context.Context) error {
runErrCh := make(chan error, 1)
go func() {
runErrCh <- actors.Run(ctx)
}()
// Wait for the right signal
select {
case <-actors.Ready():
// Actor host is ready, signal actorsReady
actorsReady.Signal()
case runErr := <-runErrCh:
// Run returned with an error
return runErr
case <-ctx.Done():
// Context canceled
return ctx.Err()
}
// Now the actor host is running
// This goroutine must stay up until the actor host returns
// Here, context cancellation will surface through this channel too
return <-runErrCh
}
return fn, actorsReady
}
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,187 +1,192 @@
package bootstrap
import (
"context"
"database/sql"
"errors"
"fmt"
"log"
"net/url"
"os"
"strings"
"log/slog"
"path/filepath"
"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/iofs"
_ "github.com/golang-migrate/migrate/v4/source/github"
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"
"gorm.io/gorm/logger"
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) {
db, err := connectDatabase()
func NewDatabase(ctx context.Context) (db *gorm.DB, pg *pgxpool.Pool, err error) {
db, pg, err = ConnectDatabase(ctx)
if err != nil {
log.Fatalf("failed to connect to database: %v", err)
return nil, nil, fmt.Errorf("failed to connect to database: %w", err)
}
sqlDb, err := db.DB()
if err != nil {
log.Fatalf("failed to get sql.DB: %v", 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{})
case common.DbProviderPostgres:
driver, err = postgresMigrate.WithInstance(sqlDb, &postgresMigrate.Config{})
default:
// Should never happen at this point
log.Fatalf("unsupported database provider: %s", common.EnvConfig.DbProvider)
}
if err != nil {
log.Fatalf("failed to create migration driver: %v", err)
return nil, nil, fmt.Errorf("failed to get sql.DB: %w", err)
}
// Run migrations
if err := migrateDatabase(driver); err != nil {
log.Fatalf("failed to run migrations: %v", err)
err = utils.MigrateDatabase(ctx, sqlDb)
if err != nil {
return nil, nil, fmt.Errorf("failed to run migrations: %w", err)
}
return db
return db, pg, nil
}
func migrateDatabase(driver database.Driver) error {
// Use the embedded migrations
source, err := iofs.New(resources.FS, "migrations/"+string(common.EnvConfig.DbProvider))
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)
}
err = m.Up()
if err != nil && !errors.Is(err, migrate.ErrNoChange) {
return fmt.Errorf("failed to apply migrations: %w", err)
}
return 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
var onConnFn func(conn *sql.DB)
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")
}
if !strings.HasPrefix(common.EnvConfig.DbConnectionString, "file:") {
return nil, errors.New("invalid value for env var 'DB_CONNECTION_STRING': does not begin with 'file:'")
return nil, nil, errors.New("missing required env var 'DB_CONNECTION_STRING' for SQLite database")
}
sqliteutil.RegisterSqliteFunctions()
connString, 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 = sqlitekit.EnsureTempDir(filepath.Dir(dbPath), slog.Default())
if err != nil {
return nil, nil, err
}
if isMemoryDB {
// For in-memory SQLite databases, we must limit to 1 open connection at the same time, or they won't see the whole data
// The other workaround, of using shared caches, doesn't work well with multiple write transactions trying to happen at once
onConnFn = func(conn *sql.DB) {
conn.SetMaxOpenConns(1)
}
}
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,
Logger: getLogger(),
Logger: getGormLogger(),
})
if err == nil {
return db, 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 {
if pg != nil {
pg.Close()
}
return nil, nil, fmt.Errorf("failed to get database connection for onConnFn callback: %w", err)
}
onConnFn(conn)
}
return db, pg, nil
}
log.Printf("Attempt %d: Failed to initialize database. Retrying...", i)
// 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)
}
return nil, err
slog.Error("Failed to connect to database after 3 attempts", slog.String("provider", string(common.EnvConfig.DbProvider)), slog.Any("error", err))
if pg != nil {
pg.Close()
}
return nil, nil, err
}
// 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.
func parseSqliteConnectionString(connString string) (string, error) {
if !strings.HasPrefix(connString, "file:") {
connString = "file:" + connString
}
connStringUrl, err := url.Parse(connString)
if err != nil {
return "", fmt.Errorf("failed to parse SQLite connection string: %w", err)
}
// 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 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
}
}
connStringUrl.RawQuery = qs.Encode()
return connStringUrl.String(), nil
}
func getLogger() logger.Interface {
isProduction := common.EnvConfig.AppEnv == "production"
var logLevel logger.LogLevel
if isProduction {
logLevel = logger.Error
} else {
logLevel = logger.Info
}
return logger.New(
log.New(os.Stdout, "\r\n", log.LstdFlags),
logger.Config{
SlowThreshold: 200 * time.Millisecond,
LogLevel: logLevel,
IgnoreRecordNotFoundError: isProduction,
ParameterizedQueries: isProduction,
Colorful: !isProduction,
},
func getGormLogger() gormLogger.Interface {
loggerOpts := make([]slogGorm.Option, 0, 5)
loggerOpts = append(loggerOpts,
slogGorm.WithSlowThreshold(200*time.Millisecond),
slogGorm.WithErrorField("error"),
)
if common.EnvConfig.LogLevel == "debug" {
loggerOpts = append(loggerOpts,
slogGorm.SetLogLevel(slogGorm.DefaultLogType, slog.LevelDebug),
slogGorm.WithRecordNotFoundError(),
slogGorm.WithTraceAll(),
)
} else {
loggerOpts = append(loggerOpts,
slogGorm.SetLogLevel(slogGorm.DefaultLogType, slog.LevelWarn),
slogGorm.WithIgnoreTrace(),
)
}
return slogGorm.New(loggerOpts...)
}

View File

@@ -1,145 +0,0 @@
package bootstrap
import (
"net/url"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseSqliteConnectionString(t *testing.T) {
tests := []struct {
name string
input string
expected string
expectedError bool
}{
{
name: "basic file path",
input: "file:test.db",
expected: "file:test.db",
},
{
name: "adds file: prefix if missing",
input: "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",
},
{
name: "invalid URL format",
input: "file:invalid#$%^&*@test.db",
expectedError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := parseSqliteConnectionString(tt.input)
if tt.expectedError {
require.Error(t, err)
return
}
require.NoError(t, err)
// Parse both URLs to compare components independently
expectedURL, err := url.Parse(tt.expected)
require.NoError(t, err)
resultURL, err := url.Parse(result)
require.NoError(t, err)
// 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

@@ -3,7 +3,8 @@
package bootstrap
import (
"log"
"log/slog"
"os"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
@@ -16,9 +17,10 @@ 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 {
log.Fatalf("failed to initialize test service: %v", err)
slog.Error("Failed to initialize test service", slog.Any("error", err))
os.Exit(1)
return
}

View File

@@ -0,0 +1,215 @@
package bootstrap
import (
"context"
"fmt"
"log/slog"
"net/http"
"os"
"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"
"go.opentelemetry.io/contrib/exporters/autoexport"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
"go.opentelemetry.io/otel"
globallog "go.opentelemetry.io/otel/log/global"
"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"
"github.com/pocket-id/pocket-id/backend/internal/common"
)
func defaultResource() (*resource.Resource, error) {
return resource.Merge(
resource.Default(),
resource.NewSchemaless(
semconv.ServiceName(common.Name),
semconv.ServiceVersion(common.Version),
),
)
}
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([]servicerunner.Service, 0, 3)
httpClient = &http.Client{}
defaultTransport, ok := http.DefaultTransport.(*http.Transport)
if !ok {
// Indicates a development-time error
panic("Default transport is not of type *http.Transport")
}
httpClient.Transport = defaultTransport.Clone()
// Logging
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, resource, httpClient)
if err != nil {
return nil, nil, err
} else if tracingShutdownFn != nil {
shutdownFns = append(shutdownFns, tracingShutdownFn)
}
// Metrics
metricsShutdownFn, err := initOtelMetrics(ctx, resource)
if err != nil {
return nil, nil, err
} else if metricsShutdownFn != nil {
shutdownFns = append(shutdownFns, metricsShutdownFn)
}
return shutdownFns, httpClient, nil
}
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 nil, fmt.Errorf("failed to initialize OpenTelemetry log exporter: %w", err)
}
level, _ := sloggin.ParseLevel(common.EnvConfig.LogLevel)
// Create the console handler
var handler slog.Handler
if common.EnvConfig.LogJSON {
handler = slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: level,
})
} else {
handler = tint.NewHandler(os.Stdout, &tint.Options{
TimeFormat: time.Stamp,
Level: level,
NoColor: !isatty.IsTerminal(os.Stdout.Fd()),
})
}
// 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)
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
log := slog.New(handler).
With(slog.String("app", common.Name)).
With(slog.String("version", common.Version))
slog.SetDefault(log)
return shutdownFn, 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)
if err != nil {
return nil, fmt.Errorf("failed to initialize OpenTelemetry span exporter: %w", err)
}
tp := sdktrace.NewTracerProvider(
sdktrace.WithResource(resource),
sdktrace.WithBatcher(tr),
)
otel.SetTracerProvider(tp)
otel.SetTextMapPropagator(
propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{},
propagation.Baggage{},
),
)
shutdownFn = func(shutdownCtx context.Context) error { //nolint:contextcheck
tpCtx, tpCancel := context.WithTimeout(shutdownCtx, 10*time.Second)
defer tpCancel()
shutdownErr := tp.Shutdown(tpCtx)
if shutdownErr != nil {
return fmt.Errorf("failed to gracefully shut down traces exporter: %w", shutdownErr)
}
return nil
}
// Add tracing to the HTTP client
httpClient.Transport = otelhttp.NewTransport(httpClient.Transport)
return shutdownFn, 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)
if err != nil {
return nil, fmt.Errorf("failed to initialize OpenTelemetry metric reader: %w", err)
}
mp := metric.NewMeterProvider(
metric.WithResource(resource),
metric.WithReader(mr),
)
otel.SetMeterProvider(mp)
shutdownFn = func(shutdownCtx context.Context) error { //nolint:contextcheck
mpCtx, mpCancel := context.WithTimeout(shutdownCtx, 10*time.Second)
defer mpCancel()
shutdownErr := mp.Shutdown(mpCtx)
if shutdownErr != nil {
return fmt.Errorf("failed to gracefully shut down metrics exporter: %w", shutdownErr)
}
return nil
}
return shutdownFn, nil
}

View File

@@ -1,107 +0,0 @@
package bootstrap
import (
"context"
"fmt"
"net/http"
"time"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"go.opentelemetry.io/contrib/exporters/autoexport"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
"go.opentelemetry.io/otel"
metricnoop "go.opentelemetry.io/otel/metric/noop"
"go.opentelemetry.io/otel/propagation"
"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"
)
func defaultResource() (*resource.Resource, error) {
return resource.Merge(
resource.Default(),
resource.NewSchemaless(
semconv.ServiceName("pocket-id-backend"),
semconv.ServiceVersion(common.Version),
),
)
}
func initOtel(ctx context.Context, metrics, traces bool) (shutdownFns []utils.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)
httpClient = &http.Client{}
defaultTransport, ok := http.DefaultTransport.(*http.Transport)
if !ok {
// Indicates a development-time error
panic("Default transport is not of type *http.Transport")
}
httpClient.Transport = defaultTransport.Clone()
if traces {
tr, err := autoexport.NewSpanExporter(ctx)
if err != nil {
return nil, nil, fmt.Errorf("failed to initialize OpenTelemetry span exporter: %w", err)
}
tp := sdktrace.NewTracerProvider(
sdktrace.WithResource(resource),
sdktrace.WithBatcher(tr),
)
otel.SetTracerProvider(tp)
otel.SetTextMapPropagator(
propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{},
propagation.Baggage{},
),
)
shutdownFns = append(shutdownFns, func(shutdownCtx context.Context) error { //nolint:contextcheck
tpCtx, tpCancel := context.WithTimeout(shutdownCtx, 10*time.Second)
defer tpCancel()
shutdownErr := tp.Shutdown(tpCtx)
if shutdownErr != nil {
return fmt.Errorf("failed to gracefully shut down traces exporter: %w", shutdownErr)
}
return nil
})
httpClient.Transport = otelhttp.NewTransport(httpClient.Transport)
} else {
otel.SetTracerProvider(tracenoop.NewTracerProvider())
}
if metrics {
mr, err := autoexport.NewMetricReader(ctx)
if err != nil {
return nil, nil, fmt.Errorf("failed to initialize OpenTelemetry metric reader: %w", err)
}
mp := metric.NewMeterProvider(
metric.WithResource(resource),
metric.WithReader(mr),
)
otel.SetMeterProvider(mp)
shutdownFns = append(shutdownFns, func(shutdownCtx context.Context) error { //nolint:contextcheck
mpCtx, mpCancel := context.WithTimeout(shutdownCtx, 10*time.Second)
defer mpCancel()
shutdownErr := mp.Shutdown(mpCtx)
if shutdownErr != nil {
return fmt.Errorf("failed to gracefully shut down metrics exporter: %w", shutdownErr)
}
return nil
})
} else {
otel.SetMeterProvider(metricnoop.NewMeterProvider())
}
return shutdownFns, httpClient, nil
}

View File

@@ -2,193 +2,481 @@ package bootstrap
import (
"context"
"crypto/tls"
"errors"
"fmt"
"log"
"log/slog"
"net"
"net/http"
"os"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/pocket-id/pocket-id/backend/frontend"
"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 {
log.Fatalf("failed to init router: %v", err)
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) {
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)
err := configureEngine(r)
if err != nil {
return nil, err
}
registerGlobalMiddleware(r)
return r, nil
}
func setGinMode() {
// Set the appropriate Gin mode based on the environment
switch common.EnvConfig.AppEnv {
case "production":
case common.AppEnvProduction:
gin.SetMode(gin.ReleaseMode)
case "development":
case common.AppEnvDevelopment:
gin.SetMode(gin.DebugMode)
case "test":
case common.AppEnvTest:
gin.SetMode(gin.TestMode)
}
}
// do not log these URLs
func configureEngine(r *gin.Engine) error {
err := r.SetTrustedProxies(common.EnvConfig.TrustProxy)
if err != nil {
return fmt.Errorf("failed to configure trusted proxies: %w", err)
}
if common.EnvConfig.TrustedPlatform != "" {
r.TrustedPlatform = common.EnvConfig.TrustedPlatform
}
r.Use(otelgin.Middleware(
common.Name,
otelgin.WithFilter(shouldTraceRequest)),
)
return nil
}
// 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 fmt.Errorf("failed to register frontend: %w", err)
}
// Initialize middleware for specific routes
authMiddleware := middleware.NewAuthMiddleware(svc.apiKeyModule, svc.userService, svc.jwtService)
fileSizeLimitMiddleware := middleware.NewFileSizeLimitMiddleware()
rateLimitMiddleware := middleware.NewRateLimitMiddleware(rateLimitServices)
apiRateLimitMiddleware := rateLimitMiddleware.Add(middleware.RateLimitAPI)
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, authMiddleware, svc.versionService)
controller.NewScimController(apiGroup, authMiddleware, svc.scimService)
svc.userSignUpModule.RegisterRoutes(apiGroup,
authMiddleware.Add(),
rateLimitMiddleware.Add(middleware.RateLimitSignup),
)
optionalBrowserAuth := authMiddleware.WithAdminNotRequired().WithSuccessOptional().WithApiKeyAuthDisabled().Add()
browserAuth := authMiddleware.WithAdminNotRequired().WithApiKeyAuthDisabled().Add()
svc.oidcModule.RegisterRoutes(baseGroup, apiGroup, optionalBrowserAuth, browserAuth)
registerTestRoutes(apiGroup, db, svc)
controller.NewWellKnownController(baseGroup, svc.jwtService)
// These are not rate-limited.
controller.NewHealthzController(r)
// 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,
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)
}
r.ServeHTTP(w, req)
}),
}
}
func runServer(ctx context.Context, config *serverConfig) error {
slog.Info("Server listening", slog.String("addr", config.addr), slog.Bool("tls", config.tlsConfig != nil))
certWatcher, err := startCertWatcher(ctx, config.certProvider)
if err != nil {
return err
}
defer closeCertWatcher(certWatcher)
startHTTPServer(config)
notifySystemdReady()
<-ctx.Done()
// We do not pass the context because it's already been canceled
//nolint:contextcheck
return shutdownServer(config.server)
}
func startCertWatcher(ctx context.Context, certProvider *tlsCertProvider) (*fsnotify.Watcher, error) {
if certProvider == nil {
return nil, nil
}
certWatcher, err := fsnotify.NewWatcher()
if err != nil {
return nil, fmt.Errorf("failed to create certificate watcher: %w", err)
}
if err := certWatcher.Add(common.EnvConfig.TLSCertFile); err != nil {
certWatcher.Close()
return nil, fmt.Errorf("failed to watch TLS certificate: %w", err)
}
if err := certWatcher.Add(common.EnvConfig.TLSKeyFile); err != nil {
certWatcher.Close()
return nil, fmt.Errorf("failed to watch TLS key: %w", err)
}
go certProvider.StartWatching(ctx, certWatcher)
return certWatcher, nil
}
func closeCertWatcher(certWatcher *fsnotify.Watcher) {
if certWatcher != nil {
certWatcher.Close()
}
}
func startHTTPServer(config *serverConfig) {
go func() {
defer config.listener.Close()
listener := config.listener
if config.tlsConfig != nil {
listener = tls.NewListener(config.listener, config.tlsConfig)
}
srvErr := config.server.Serve(listener)
if srvErr != http.ErrServerClosed {
slog.Error("Error starting app server", "error", srvErr)
os.Exit(1)
}
}()
}
func notifySystemdReady() {
err := systemd.SdNotifyReady()
if err != nil {
// Log the error only
slog.Warn("Unable to notify systemd that the service is ready", "error", err)
}
}
func shutdownServer(srv *http.Server) error {
// Note we use the background context here as ctx has been canceled already
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
shutdownErr := srv.Shutdown(shutdownCtx) //nolint:contextcheck
shutdownCancel()
if shutdownErr != nil {
// Log the error only (could be context canceled)
slog.Warn("App server shutdown error", "error", shutdownErr)
}
return nil
}
func initLogger(r *gin.Engine) {
loggerSkipPathsPrefix := []string{
"GET /application-configuration/logo",
"GET /application-configuration/background-image",
"GET /application-configuration/favicon",
"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",
"HEAD /healthz",
}
r := gin.New()
r.Use(gin.LoggerWithConfig(gin.LoggerConfig{Skip: func(c *gin.Context) bool {
for _, prefix := range loggerSkipPathsPrefix {
if strings.HasPrefix(c.Request.Method+" "+c.Request.URL.String(), prefix) {
return true
r.Use(sloggin.SetLogger(
sloggin.WithLogger(func(_ *gin.Context, _ *slog.Logger) *slog.Logger {
return slog.Default()
}),
sloggin.WithSkipper(func(c *gin.Context) bool {
for _, prefix := range loggerSkipPathsPrefix {
if strings.HasPrefix(c.Request.Method+" "+c.Request.URL.String(), prefix) {
return true
}
}
}
return false
}}))
if !common.EnvConfig.TrustProxy {
_ = r.SetTrustedProxies(nil)
}
if common.EnvConfig.TracingEnabled {
r.Use(otelgin.Middleware("pocket-id-backend"))
}
rateLimitMiddleware := middleware.NewRateLimitMiddleware().Add(rate.Every(time.Second), 60)
// Setup global middleware
r.Use(middleware.NewCorsMiddleware().Add())
r.Use(middleware.NewErrorHandlerMiddleware().Add())
err := frontend.RegisterFrontend(r)
if errors.Is(err, frontend.ErrFrontendNotIncluded) {
log.Println("Frontend is not included in the build. Skipping frontend registration.")
} else if err != nil {
return nil, fmt.Errorf("failed to register frontend: %w", err)
}
// Initialize middleware for specific routes
authMiddleware := middleware.NewAuthMiddleware(svc.apiKeyService, svc.userService, svc.jwtService)
fileSizeLimitMiddleware := middleware.NewFileSizeLimitMiddleware()
// 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)
controller.NewAppConfigController(apiGroup, authMiddleware, svc.appConfigService, svc.emailService, svc.ldapService)
controller.NewAuditLogController(apiGroup, svc.auditLogService, authMiddleware)
controller.NewUserGroupController(apiGroup, authMiddleware, svc.userGroupService)
controller.NewCustomClaimController(apiGroup, authMiddleware, svc.customClaimService)
// Add test controller in non-production environments
if common.EnvConfig.AppEnv != "production" {
for _, f := range registerTestControllers {
f(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
controller.NewHealthzController(r)
// Set up the server
srv := &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
}
listener, err := net.Listen(network, addr)
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 {
log.Printf("Server listening on %s", 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 {
log.Fatalf("Error starting app server: %v", srvErr)
}
}()
// Notify systemd that we are ready
err = systemd.SdNotifyReady()
if err != nil {
// Log the error only
log.Printf("[WARN] Unable to notify systemd that the service is ready: %v", err)
}
// Block until the context is canceled
<-ctx.Done()
// Handle graceful shutdown
// Note we use the background context here as ctx has been canceled already
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
shutdownErr := srv.Shutdown(shutdownCtx) //nolint:contextcheck
shutdownCancel()
if shutdownErr != nil {
// Log the error only (could be context canceled)
log.Printf("[WARN] App server shutdown error: %v", shutdownErr)
}
return nil
}
return runFn, nil
return false
}),
))
}
// 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,31 +5,68 @@ import (
"fmt"
"net/http"
"github.com/italypaleale/francis/host/local"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/api"
"github.com/pocket-id/pocket-id/backend/internal/apikey"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"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/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
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
appConfigService *appconfig.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) (svc *services, err error) {
func initServices(
ctx context.Context,
db *gorm.DB,
instanceID string,
actors *local.Host,
httpClient *http.Client,
imageExtensions map[string]string,
fileStorage storage.FileStorage,
scheduler *job.Scheduler,
) (svc *services, err error) {
svc = &services{}
svc.appConfigService = service.NewAppConfigService(ctx, db)
// Init the app config service
svc.appConfigService, err = appconfig.NewService(ctx, actors, db)
if err != nil {
return nil, fmt.Errorf("failed to create app config service: %w", err)
}
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 {
@@ -38,19 +75,73 @@ func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client) (sv
svc.geoLiteService = service.NewGeoLiteService(httpClient)
svc.auditLogService = service.NewAuditLogService(db, svc.appConfigService, svc.emailService, svc.geoLiteService)
svc.jwtService = service.NewJwtService(db, svc.appConfigService)
svc.userService = service.NewUserService(db, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService)
svc.customClaimService = service.NewCustomClaimService(db)
svc.jwtService, err = service.NewJwtService(ctx, db, instanceID, svc.appConfigService)
if err != nil {
return nil, fmt.Errorf("failed to create JWT service: %w", err)
}
svc.oidcService, err = service.NewOidcService(ctx, db, svc.jwtService, svc.appConfigService, svc.auditLogService, svc.customClaimService)
svc.customClaimService = service.NewCustomClaimService(db)
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 module: %w", err)
}
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,
AllowInsecureCallbackURLs: common.EnvConfig.AllowInsecureCallbackURLs,
},
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.ldapService = service.NewLdapService(db, httpClient, svc.appConfigService, svc.userService, svc.userGroupService)
svc.apiKeyService = service.NewApiKeyService(db, svc.emailService)
svc.webauthnService = service.NewWebAuthnService(db, svc.jwtService, svc.auditLogService, svc.appConfigService)
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)
return svc, nil
}

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,186 @@
package cmds
import (
"context"
"errors"
"fmt"
"os"
"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/instanceid"
"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"
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
}
instanceID, err := instanceid.Load(cmd.Context(), db)
if err != nil {
return err
}
return encryptionKeyRotate(cmd.Context(), flags, db, instanceID, &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, instanceID string, 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)
}
}
// 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/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/instanceid"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
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)
instanceID, err := instanceid.Load(t.Context(), db)
require.NoError(t, err)
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,
}
err = encryptionKeyRotate(t.Context(), flags, db, instanceID, envConfig)
require.NoError(t, err)
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"
@@ -12,7 +13,7 @@ import (
"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/instanceid"
"github.com/pocket-id/pocket-id/backend/internal/utils"
jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk"
)
@@ -30,9 +31,17 @@ 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 := bootstrap.NewDatabase()
db, _, err := bootstrap.NewDatabase(cmd.Context())
if err != nil {
return err
}
return keyRotate(cmd.Context(), flags, db, &common.EnvConfig)
instanceID, err := instanceid.Load(cmd.Context(), db)
if err != nil {
return err
}
return keyRotate(cmd.Context(), flags, db, instanceID, &common.EnvConfig)
},
}
@@ -43,7 +52,7 @@ func init() {
rootCmd.AddCommand(keyRotateCmd)
}
func keyRotate(ctx context.Context, flags keyRotateFlags, db *gorm.DB, envConfig *common.EnvConfigSchema) error {
func keyRotate(ctx context.Context, flags keyRotateFlags, db *gorm.DB, instanceID string, envConfig *common.EnvConfigSchema) error {
// Validate the flags
switch strings.ToUpper(flags.Alg) {
case jwa.RS256().String(), jwa.RS384().String(), jwa.RS512().String(),
@@ -75,15 +84,12 @@ func keyRotate(ctx context.Context, flags keyRotateFlags, db *gorm.DB, envConfig
}
if !ok {
fmt.Println("Aborted")
return nil
os.Exit(1)
}
}
// Init the services we need
appConfigService := service.NewAppConfigService(ctx, db)
// Get the key provider
keyProvider, err := jwkutils.GetKeyProvider(db, envConfig, appConfigService.GetDbConfig().InstanceID.Value)
keyProvider, err := jwkutils.GetKeyProvider(db, envConfig, instanceID)
if err != nil {
return fmt.Errorf("failed to get key provider: %w", err)
}
@@ -95,7 +101,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,15 +1,13 @@
package cmds
import (
"os"
"path/filepath"
"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/service"
"github.com/pocket-id/pocket-id/backend/internal/instanceid"
jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk"
testingutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
@@ -69,93 +67,30 @@ 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 := service.NewAppConfigService(t.Context(), db)
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: "test-encryption-key-characters-long",
EncryptionKey: []byte("test-encryption-key-characters-long"),
}
// Create test database
db := testingutils.NewDatabaseForTest(t)
// Initialize app config service and create instance
appConfigService := service.NewAppConfigService(t.Context(), db)
instanceID := appConfigService.GetDbConfig().InstanceID.Value
// Load the instance ID
instanceID, err := instanceid.Load(t.Context(), db)
require.NoError(t, err)
// Get key provider
keyProvider, err := jwkutils.GetKeyProvider(db, envConfig, instanceID)
require.NoError(t, err)
// Run the key rotation
err = keyRotate(t.Context(), flags, db, envConfig)
err = keyRotate(t.Context(), flags, db, instanceID, envConfig)
if wantErr {
require.Error(t, err)
@@ -168,7 +103,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,11 +24,14 @@ var oneTimeAccessTokenCmd = &cobra.Command{
userArg := args[0]
// Connect to the database
db := bootstrap.NewDatabase()
db, _, err := bootstrap.NewDatabase(cmd.Context())
if err != nil {
return err
}
// Create the access token
var oneTimeAccessToken *model.OneTimeAccessToken
err := db.Transaction(func(tx *gorm.DB) error {
err = db.Transaction(func(tx *gorm.DB) error {
// Load the user to retrieve the user ID
var user model.User
queryCtx, queryCancel := context.WithTimeout(cmd.Context(), 10*time.Second)
@@ -48,7 +51,7 @@ var oneTimeAccessTokenCmd = &cobra.Command{
}
// Create a new access token that expires in 1 hour
oneTimeAccessToken, txErr = service.NewOneTimeAccessToken(user.ID, time.Now().Add(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

@@ -3,14 +3,22 @@ package common
import (
"errors"
"fmt"
"log"
"log/slog"
"net"
"net/url"
"os"
"reflect"
"strconv"
"strings"
"github.com/caarlos0/env/v11"
sloggin "github.com/gin-contrib/slog"
_ "github.com/joho/godotenv/autoload"
)
type AppEnv string
type DbProvider string
type TrustProxyConfig []string
const (
// TracerName should be passed to otel.Tracer, trace.SpanFromContext when creating custom spans.
@@ -20,35 +28,64 @@ 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 = "file:data/pocket-id.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(2500)&_txlock=immediate"
defaultSqliteConnString string = "data/pocket-id.db"
defaultFsUploadPath string = "data/uploads"
AppUrl string = "http://localhost:1411"
)
type EnvConfigSchema struct {
AppEnv string `env:"APP_ENV"`
AppURL string `env:"APP_URL"`
DbProvider DbProvider `env:"DB_PROVIDER"`
DbConnectionString string `env:"DB_CONNECTION_STRING"`
UploadPath string `env:"UPLOAD_PATH"`
KeysPath string `env:"KEYS_PATH"`
KeysStorage string `env:"KEYS_STORAGE"`
EncryptionKey string `env:"ENCRYPTION_KEY"`
EncryptionKeyFile string `env:"ENCRYPTION_KEY_FILE"`
Port string `env:"PORT"`
Host string `env:"HOST"`
UnixSocket string `env:"UNIX_SOCKET"`
UnixSocketMode string `env:"UNIX_SOCKET_MODE"`
MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY"`
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"`
TrustProxy bool `env:"TRUST_PROXY"`
AnalyticsDisabled bool `env:"ANALYTICS_DISABLED"`
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 TrustProxyConfig `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"`
AllowInsecureCallbackURLs bool `env:"ALLOW_INSECURE_CALLBACK_URLS"`
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()
@@ -56,78 +93,330 @@ var EnvConfig = defaultConfig()
func init() {
err := parseEnvConfig()
if err != nil {
log.Fatalf("Configuration error: %v", err)
slog.Error("Configuration error", slog.Any("error", err))
os.Exit(1)
}
}
func defaultConfig() EnvConfigSchema {
return EnvConfigSchema{
AppEnv: "production",
DbProvider: "sqlite",
DbConnectionString: "",
UploadPath: "data/uploads",
KeysPath: "data/keys",
KeysStorage: "", // "database" or "file"
EncryptionKey: "",
AppURL: "http://localhost:1411",
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,
AppEnv: AppEnvProduction,
LogLevel: "info",
DbProvider: "sqlite",
FileBackend: "filesystem",
AuditLogRetentionDays: 90,
AllowInsecureCallbackURLs: true, // TODO: Default to false in major v3
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 {
err := env.ParseWithOptions(&EnvConfig, env.Options{})
parsers := map[reflect.Type]env.ParserFunc{
reflect.TypeFor[[]byte](): func(value string) (any, error) {
return []byte(value), nil
},
}
err := env.ParseWithOptions(&EnvConfig, env.Options{
FuncMap: parsers,
})
if err != nil {
return fmt.Errorf("error parsing env config: %w", err)
}
// Validate the environment variables
switch EnvConfig.DbProvider {
case DbProviderSqlite:
if EnvConfig.DbConnectionString == "" {
EnvConfig.DbConnectionString = defaultSqliteConnString
}
case DbProviderPostgres:
if EnvConfig.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(EnvConfig.AppURL)
err = prepareEnvConfig(&EnvConfig)
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")
return fmt.Errorf("error preparing env config: %w", err)
}
switch EnvConfig.KeysStorage {
// KeysStorage defaults to "file" if empty
case "":
EnvConfig.KeysStorage = "file"
case "database":
// If KeysStorage is "database", a key must be specified
if EnvConfig.EncryptionKey == "" && EnvConfig.EncryptionKeyFile == "" {
return errors.New("ENCRYPTION_KEY or ENCRYPTION_KEY_FILE 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", EnvConfig.KeysStorage)
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
}
func shouldSkipEnvValidation(args []string) bool {
for _, arg := range args[1:] {
switch arg {
case "-h", "--help", "help", "version":
return true
}
}
return false
}
// prepareEnvConfig processes special options for EnvConfig fields
func prepareEnvConfig(config *EnvConfigSchema) error {
val := reflect.ValueOf(config).Elem()
typ := val.Type()
for i := 0; i < val.NumField(); i++ {
field := val.Field(i)
fieldType := typ.Field(i)
optionsTag := fieldType.Tag.Get("options")
options := strings.SplitSeq(optionsTag, ",")
for option := range options {
switch option {
case "toLower":
if field.Kind() == reflect.String {
field.SetString(strings.ToLower(field.String()))
}
case "file":
err := resolveFileBasedEnvVariable(field, fieldType)
if err != nil {
return err
}
case "trimTrailingSlash":
if field.Kind() == reflect.String {
field.SetString(strings.TrimRight(field.String(), "/"))
}
}
}
}
return nil
}
// resolveFileBasedEnvVariable checks if an environment variable with the suffix "_FILE" is set,
// reads the content of the file specified by that variable, and sets the corresponding field's value.
func resolveFileBasedEnvVariable(field reflect.Value, fieldType reflect.StructField) error {
// Only process string and []byte fields
isString := field.Kind() == reflect.String
isByteSlice := field.Kind() == reflect.Slice && field.Type().Elem().Kind() == reflect.Uint8
if !isString && !isByteSlice {
return nil
}
// Only process fields with the "env" tag
envTag := fieldType.Tag.Get("env")
if envTag == "" {
return nil
}
envVarName := envTag
if commaIndex := len(envTag); commaIndex > 0 {
envVarName = envTag[:commaIndex]
}
// If the file environment variable is not set, skip
envVarFileName := envVarName + "_FILE"
envVarFileValue := os.Getenv(envVarFileName)
if envVarFileValue == "" {
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)
}
if isString {
field.SetString(strings.TrimSpace(string(fileContent)))
} else {
field.SetBytes(fileContent)
}
return nil
}
func (a AppEnv) IsProduction() bool {
return a == AppEnvProduction
}
func (a AppEnv) IsTest() bool {
return a == AppEnvTest
}
func (config *TrustProxyConfig) UnmarshalText(text []byte) error {
value := strings.TrimSpace(string(text))
// Support boolean values for completely enabling or disabling trust proxy
enabled, err := strconv.ParseBool(value)
if err == nil {
if enabled {
*config = TrustProxyConfig{"0.0.0.0/0", "::/0"}
} else {
*config = nil
}
return nil
}
// Normalize and validate each explicit proxy before the server starts
proxies := strings.Split(value, ",")
for i, proxy := range proxies {
proxy = strings.TrimSpace(proxy)
proxies[i] = proxy
}
*config = proxies
return nil
}

View File

@@ -1,12 +1,27 @@
package common
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
"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
@@ -16,137 +31,87 @@ func TestParseEnvConfig(t *testing.T) {
t.Run("should parse valid SQLite config 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("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)
})
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("DB_CONNECTION_STRING", "") // Explicitly empty
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 default KEYS_STORAGE to 'file' when empty", func(t *testing.T) {
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("APP_URL", "http://localhost:3000")
t.Setenv("INTERNAL_APP_URL", "€://not-a-valid-url")
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()
err := parseAndValidateEnvConfig(t)
require.Error(t, err)
assert.ErrorContains(t, err, "ENCRYPTION_KEY or ENCRYPTION_KEY_FILE must be non-empty")
assert.ErrorContains(t, err, "INTERNAL_APP_URL is not a valid URL")
})
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) {
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("APP_URL", "http://localhost:3000")
t.Setenv("KEYS_STORAGE", "invalid")
t.Setenv("INTERNAL_APP_URL", "http://localhost:3000/path")
err := parseEnvConfig()
err := parseAndValidateEnvConfig(t)
require.Error(t, err)
assert.ErrorContains(t, err, "invalid value for KEYS_STORAGE")
assert.ErrorContains(t, err, "INTERNAL_APP_URL must not contain a path")
})
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")
@@ -154,35 +119,239 @@ func TestParseEnvConfig(t *testing.T) {
t.Setenv("TRACING_ENABLED", "false")
t.Setenv("TRUST_PROXY", "true")
t.Setenv("ANALYTICS_DISABLED", "false")
t.Setenv("ALLOW_INSECURE_CALLBACK_URLS", "false")
err := parseAndValidateEnvConfig(t)
require.NoError(t, err)
assert.True(t, EnvConfig.UiConfigDisabled)
assert.Equal(t, TrustProxyConfig{"0.0.0.0/0", "::/0"}, EnvConfig.TrustProxy)
assert.False(t, EnvConfig.AnalyticsDisabled)
assert.False(t, EnvConfig.AllowInsecureCallbackURLs)
})
t.Run("should parse trusted proxy IP addresses and CIDR ranges", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("TRUST_PROXY", "10.0.0.0/8, 192.168.1.10, ::1/128")
err := parseAndValidateEnvConfig(t)
require.NoError(t, err)
assert.Equal(t, TrustProxyConfig{"10.0.0.0/8", "192.168.1.10", "::1/128"}, EnvConfig.TrustProxy)
})
t.Run("should disable trusted proxies when set to false", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("TRUST_PROXY", "false")
err := parseAndValidateEnvConfig(t)
require.NoError(t, err)
assert.Nil(t, EnvConfig.TrustProxy)
})
t.Run("should allow insecure callback URLs by default", func(t *testing.T) {
assert.True(t, defaultConfig().AllowInsecureCallbackURLs)
})
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.True(t, EnvConfig.UiConfigDisabled)
assert.True(t, EnvConfig.MetricsEnabled)
assert.False(t, EnvConfig.TracingEnabled)
assert.True(t, EnvConfig.TrustProxy)
assert.False(t, EnvConfig.AnalyticsDisabled)
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", "127.0.0.1")
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)
assert.Equal(t, AppEnvProduction, EnvConfig.AppEnv) // lowercased
assert.Equal(t, "/custom/uploads", EnvConfig.UploadPath)
assert.Equal(t, "8080", EnvConfig.Port)
assert.Equal(t, "127.0.0.1", EnvConfig.Host)
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")
})
}
func TestPrepareEnvConfig_FileBasedAndToLower(t *testing.T) {
// Create temporary directory for test files
tempDir := t.TempDir()
// Create test files
encryptionKeyFile := tempDir + "/encryption_key.txt"
encryptionKeyContent := "test-encryption-key-123"
err := os.WriteFile(encryptionKeyFile, []byte(encryptionKeyContent), 0600)
require.NoError(t, err)
dbConnFile := tempDir + "/db_connection.txt"
dbConnContent := "postgres://user:pass@localhost/testdb" // #nosec G101 - test credential
err = os.WriteFile(dbConnFile, []byte(dbConnContent), 0600)
require.NoError(t, err)
binaryKeyFile := tempDir + "/binary_key.bin"
binaryKeyContent := []byte{0x01, 0x02, 0x03, 0x04}
err = os.WriteFile(binaryKeyFile, binaryKeyContent, 0600)
require.NoError(t, err)
t.Run("should process toLower and file options", func(t *testing.T) {
config := defaultConfig()
config.AppEnv = "STAGING"
config.Host = "LOCALHOST"
t.Setenv("ENCRYPTION_KEY_FILE", encryptionKeyFile)
t.Setenv("DB_CONNECTION_STRING_FILE", dbConnFile)
err := prepareEnvConfig(&config)
require.NoError(t, err)
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)
})
t.Run("should handle binary data correctly", func(t *testing.T) {
config := defaultConfig()
t.Setenv("ENCRYPTION_KEY_FILE", binaryKeyFile)
err := prepareEnvConfig(&config)
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,354 +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
}
func (e OidcInvalidDeviceCodeError) Error() string { return "invalid device code" }
func (e OidcInvalidDeviceCodeError) HttpStatusCode() int { return http.StatusBadRequest }
type OidcSlowDownError struct{}
type ReauthenticationRequiredError 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 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) 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) 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,20 @@
package common
import (
"errors"
)
// ErrUnsupportedActorMethod is returned by custom actors when the invoked method isn't supported
type ErrUnsupportedActorMethod struct {
Method string
}
func (e ErrUnsupportedActorMethod) Error() string {
return "method '" + e.Method + "' unsupported for actor invocation"
}
func (e ErrUnsupportedActorMethod) Is(target error) bool {
// Ignore the field method when checking if an error is of the type ErrUnsupportedActorMethod
_, ok := errors.AsType[ErrUnsupportedActorMethod](target)
return ok
}

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,5 +1,8 @@
package common
// Name is the name of the application
const Name = "pocket-id"
// Version contains the Pocket ID version.
//
// It can be set at build time using -ldflags.

View File

@@ -1,125 +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) {
userID := ctx.GetString("userID")
var sortedPaginationRequest utils.SortedPaginationRequest
if err := ctx.ShouldBindQuery(&sortedPaginationRequest); err != nil {
_ = ctx.Error(err)
return
}
apiKeys, pagination, err := c.apiKeyService.ListApiKeys(ctx.Request.Context(), userID, sortedPaginationRequest)
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

@@ -3,14 +3,14 @@ package controller
import (
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/middleware"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"github.com/pocket-id/pocket-id/backend/internal/tracing"
)
// NewAppConfigController creates a new controller for application configuration endpoints
@@ -20,7 +20,7 @@ import (
func NewAppConfigController(
group *gin.RouterGroup,
authMiddleware *middleware.AuthMiddleware,
appConfigService *service.AppConfigService,
appConfigService *appconfig.AppConfigService,
emailService *service.EmailService,
ldapService *service.LdapService,
) {
@@ -34,19 +34,12 @@ func NewAppConfigController(
group.GET("/application-configuration/all", authMiddleware.Add(), acc.listAllAppConfigHandler)
group.PUT("/application-configuration", authMiddleware.Add(), acc.updateAppConfigHandler)
group.GET("/application-configuration/logo", acc.getLogoHandler)
group.GET("/application-configuration/background-image", acc.getBackgroundImageHandler)
group.GET("/application-configuration/favicon", acc.getFaviconHandler)
group.PUT("/application-configuration/logo", authMiddleware.Add(), acc.updateLogoHandler)
group.PUT("/application-configuration/favicon", authMiddleware.Add(), acc.updateFaviconHandler)
group.PUT("/application-configuration/background-image", authMiddleware.Add(), acc.updateBackgroundImageHandler)
group.POST("/application-configuration/test-email", authMiddleware.Add(), acc.testEmailHandler)
group.POST("/application-configuration/sync-ldap", authMiddleware.Add(), acc.syncLdapHandler)
}
type AppConfigController struct {
appConfigService *service.AppConfigService
appConfigService *appconfig.AppConfigService
emailService *service.EmailService
ldapService *service.LdapService
}
@@ -58,7 +51,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)
@@ -75,6 +68,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)
}
@@ -85,7 +85,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)
@@ -129,147 +129,6 @@ func (acc *AppConfigController) updateAppConfigHandler(c *gin.Context) {
c.JSON(http.StatusOK, configVariablesDto)
}
// getLogoHandler godoc
// @Summary Get logo image
// @Description Get the logo image for the application
// @Tags Application Configuration
// @Param light query boolean false "Light mode logo (true) or dark mode logo (false)"
// @Produce image/png
// @Produce image/jpeg
// @Produce image/svg+xml
// @Success 200 {file} binary "Logo image"
// @Router /api/application-configuration/logo [get]
func (acc *AppConfigController) getLogoHandler(c *gin.Context) {
dbConfig := acc.appConfigService.GetDbConfig()
lightLogo, _ := strconv.ParseBool(c.DefaultQuery("light", "true"))
var imageName, imageType string
if lightLogo {
imageName = "logoLight"
imageType = dbConfig.LogoLightImageType.Value
} else {
imageName = "logoDark"
imageType = dbConfig.LogoDarkImageType.Value
}
acc.getImage(c, imageName, imageType)
}
// getFaviconHandler godoc
// @Summary Get favicon
// @Description Get the favicon for the application
// @Tags Application Configuration
// @Produce image/x-icon
// @Success 200 {file} binary "Favicon image"
// @Router /api/application-configuration/favicon [get]
func (acc *AppConfigController) getFaviconHandler(c *gin.Context) {
acc.getImage(c, "favicon", "ico")
}
// getBackgroundImageHandler godoc
// @Summary Get background image
// @Description Get the background image for the application
// @Tags Application Configuration
// @Produce image/png
// @Produce image/jpeg
// @Success 200 {file} binary "Background image"
// @Router /api/application-configuration/background-image [get]
func (acc *AppConfigController) getBackgroundImageHandler(c *gin.Context) {
imageType := acc.appConfigService.GetDbConfig().BackgroundImageType.Value
acc.getImage(c, "background", imageType)
}
// updateLogoHandler godoc
// @Summary Update logo
// @Description Update the application logo
// @Tags Application Configuration
// @Accept multipart/form-data
// @Param light query boolean false "Light mode logo (true) or dark mode logo (false)"
// @Param file formData file true "Logo image file"
// @Success 204 "No Content"
// @Router /api/application-configuration/logo [put]
func (acc *AppConfigController) updateLogoHandler(c *gin.Context) {
dbConfig := acc.appConfigService.GetDbConfig()
lightLogo, _ := strconv.ParseBool(c.DefaultQuery("light", "true"))
var imageName, imageType string
if lightLogo {
imageName = "logoLight"
imageType = dbConfig.LogoLightImageType.Value
} else {
imageName = "logoDark"
imageType = dbConfig.LogoDarkImageType.Value
}
acc.updateImage(c, imageName, imageType)
}
// updateFaviconHandler godoc
// @Summary Update favicon
// @Description Update the application favicon
// @Tags Application Configuration
// @Accept multipart/form-data
// @Param file formData file true "Favicon file (.ico)"
// @Success 204 "No Content"
// @Router /api/application-configuration/favicon [put]
func (acc *AppConfigController) updateFaviconHandler(c *gin.Context) {
file, err := c.FormFile("file")
if err != nil {
_ = c.Error(err)
return
}
fileType := utils.GetFileExtension(file.Filename)
if fileType != "ico" {
_ = c.Error(&common.WrongFileTypeError{ExpectedFileType: ".ico"})
return
}
acc.updateImage(c, "favicon", "ico")
}
// updateBackgroundImageHandler godoc
// @Summary Update background image
// @Description Update the application background image
// @Tags Application Configuration
// @Accept multipart/form-data
// @Param file formData file true "Background image file"
// @Success 204 "No Content"
// @Router /api/application-configuration/background-image [put]
func (acc *AppConfigController) updateBackgroundImageHandler(c *gin.Context) {
imageType := acc.appConfigService.GetDbConfig().BackgroundImageType.Value
acc.updateImage(c, "background", imageType)
}
// getImage is a helper function to serve image files
func (acc *AppConfigController) getImage(c *gin.Context, name string, imageType string) {
imagePath := common.EnvConfig.UploadPath + "/application-images/" + name + "." + imageType
mimeType := utils.GetImageMimeType(imageType)
c.Header("Content-Type", mimeType)
utils.SetCacheControlHeader(c, 15*time.Minute, 24*time.Hour)
c.File(imagePath)
}
// updateImage is a helper function to update image files
func (acc *AppConfigController) updateImage(c *gin.Context, imageName string, oldImageType string) {
file, err := c.FormFile("file")
if err != nil {
_ = c.Error(err)
return
}
err = acc.appConfigService.UpdateImage(c.Request.Context(), file, imageName, oldImageType)
if err != nil {
_ = c.Error(err)
return
}
c.Status(http.StatusNoContent)
}
// syncLdapHandler godoc
// @Summary Synchronize LDAP
// @Description Manually trigger LDAP synchronization

View File

@@ -0,0 +1,292 @@
package controller
import (
"net/http"
"slices"
"strconv"
"strings"
"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"
)
func NewAppImagesController(
group *gin.RouterGroup,
authMiddleware *middleware.AuthMiddleware,
appImagesService *service.AppImagesService,
) {
controller := &AppImagesController{
appImagesService: appImagesService,
}
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 {
appImagesService *service.AppImagesService
}
// getLogoHandler godoc
// @Summary Get logo image
// @Description Get the logo image for the application
// @Tags Application Images
// @Param light query boolean false "Light mode logo (true) or dark mode logo (false)"
// @Produce image/png
// @Produce image/jpeg
// @Produce image/svg+xml
// @Success 200 {file} binary "Logo image"
// @Router /api/application-images/logo [get]
func (c *AppImagesController) getLogoHandler(ctx *gin.Context) {
lightLogo, _ := strconv.ParseBool(ctx.DefaultQuery("light", "true"))
imageName := "logoLight"
if !lightLogo {
imageName = "logoDark"
}
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
// @Tags Application Images
// @Produce image/png
// @Produce image/jpeg
// @Success 200 {file} binary "Background image"
// @Router /api/application-images/background [get]
func (c *AppImagesController) getBackgroundImageHandler(ctx *gin.Context) {
c.getImage(ctx, "background")
}
// getFaviconHandler godoc
// @Summary Get favicon
// @Description Get the favicon for the application
// @Tags Application Images
// @Produce image/x-icon
// @Success 200 {file} binary "Favicon image"
// @Router /api/application-images/favicon [get]
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
// @Tags Application Images
// @Accept multipart/form-data
// @Param light query boolean false "Light mode logo (true) or dark mode logo (false)"
// @Param file formData file true "Logo image file"
// @Success 204 "No Content"
// @Router /api/application-images/logo [put]
func (c *AppImagesController) updateLogoHandler(ctx *gin.Context) {
file, err := ctx.FormFile("file")
if err != nil {
_ = ctx.Error(err)
return
}
lightLogo, _ := strconv.ParseBool(ctx.DefaultQuery("light", "true"))
imageName := "logoLight"
if !lightLogo {
imageName = "logoDark"
}
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
}
ctx.Status(http.StatusNoContent)
}
// updateBackgroundImageHandler godoc
// @Summary Update background image
// @Description Update the application background image
// @Tags Application Images
// @Accept multipart/form-data
// @Param file formData file true "Background image file"
// @Success 204 "No Content"
// @Router /api/application-images/background [put]
func (c *AppImagesController) updateBackgroundImageHandler(ctx *gin.Context) {
file, err := ctx.FormFile("file")
if err != nil {
_ = ctx.Error(err)
return
}
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
}
ctx.Status(http.StatusNoContent)
}
// updateFaviconHandler godoc
// @Summary Update favicon
// @Description Update the application favicon
// @Tags Application Images
// @Accept multipart/form-data
// @Param file formData file true "Favicon file (.svg/.png/.ico)"
// @Success 204 "No Content"
// @Router /api/application-images/favicon [put]
func (c *AppImagesController) updateFaviconHandler(ctx *gin.Context) {
file, err := ctx.FormFile("file")
if err != nil {
_ = ctx.Error(err)
return
}
fileType := utils.GetFileExtension(file.Filename)
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(ctx.Request.Context(), file, "favicon"); err != nil {
_ = ctx.Error(err)
return
}
ctx.Status(http.StatusNoContent)
}
func (c *AppImagesController) getImage(ctx *gin.Context, name string) {
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
}
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

@@ -41,18 +41,12 @@ type AuditLogController struct {
// @Success 200 {object} dto.Paginated[dto.AuditLogDto]
// @Router /api/audit-logs [get]
func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
var sortedPaginationRequest utils.SortedPaginationRequest
err := c.ShouldBindQuery(&sortedPaginationRequest)
if err != nil {
_ = c.Error(err)
return
}
listRequestOptions := utils.ParseListRequestOptions(c)
userID := c.GetString("userID")
// Fetch audit logs for the user
logs, pagination, err := alc.auditLogService.ListAuditLogsForUser(c.Request.Context(), userID, sortedPaginationRequest)
logs, pagination, err := alc.auditLogService.ListAuditLogsForUser(c.Request.Context(), userID, listRequestOptions)
if err != nil {
_ = c.Error(err)
return
@@ -69,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
}
@@ -86,26 +81,12 @@ func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
// @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")
// @Param filters[userId] query string false "Filter by user ID"
// @Param filters[event] query string false "Filter by event type"
// @Param filters[clientName] query string false "Filter by client name"
// @Param filters[location] query string false "Filter by location type (external or internal)"
// @Success 200 {object} dto.Paginated[dto.AuditLogDto]
// @Router /api/audit-logs/all [get]
func (alc *AuditLogController) listAllAuditLogsHandler(c *gin.Context) {
var sortedPaginationRequest utils.SortedPaginationRequest
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
_ = c.Error(err)
return
}
listRequestOptions := utils.ParseListRequestOptions(c)
var filters dto.AuditLogFilterDto
if err := c.ShouldBindQuery(&filters); err != nil {
_ = c.Error(err)
return
}
logs, pagination, err := alc.auditLogService.ListAllAuditLogs(c.Request.Context(), sortedPaginationRequest, filters)
logs, pagination, err := alc.auditLogService.ListAllAuditLogs(c.Request.Context(), listRequestOptions)
if err != nil {
_ = c.Error(err)
return
@@ -121,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,7 +41,12 @@ func (tc *TestController) resetAndSeedHandler(c *gin.Context) {
return
}
if err := tc.TestService.ResetApplicationImages(); err != nil {
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,8 @@
package controller
import (
"errors"
"log"
"net/http"
"net/url"
"strconv"
"strings"
"time"
@@ -15,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)
@@ -46,291 +35,24 @@ 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)
group.DELETE("/oidc/users/me/authorized-clients/:clientId", authMiddleware.WithAdminNotRequired().Add(), oc.revokeOwnClientAuthorizationHandler)
group.GET("/oidc/users/me/clients", authMiddleware.WithAdminNotRequired().Add(), oc.listOwnAccessibleClientsHandler)
group.GET("/oidc/clients/:id/scim-service-provider", authMiddleware.Add(), oc.getClientScimServiceProviderHandler)
group.GET("/oidc/users/me/clients", authMiddleware.WithAdminNotRequired().Add(), oc.listOwnAuthorizedClientsHandler)
group.GET("/oidc/users/:id/clients", authMiddleware.Add(), oc.listAuthorizedClientsHandler)
}
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
log.Printf("Error getting logout callback URL, the user has to confirm the logout manually: %v", 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
@@ -352,6 +74,7 @@ func (oc *OidcController) getClientMetaDataHandler(c *gin.Context) {
clientDto := dto.OidcClientMetaDataDto{}
err = dto.MapStruct(client, &clientDto)
if err == nil {
clientDto.HasDarkLogo = client.HasDarkLogo()
c.JSON(http.StatusOK, clientDto)
return
}
@@ -398,13 +121,9 @@ func (oc *OidcController) getClientHandler(c *gin.Context) {
// @Router /api/oidc/clients [get]
func (oc *OidcController) listClientsHandler(c *gin.Context) {
searchTerm := c.Query("search")
var sortedPaginationRequest utils.SortedPaginationRequest
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
_ = c.Error(err)
return
}
listRequestOptions := utils.ParseListRequestOptions(c)
clients, pagination, err := oc.oidcService.ListClients(c.Request.Context(), searchTerm, sortedPaginationRequest)
clients, pagination, err := oc.oidcService.ListClients(c.Request.Context(), searchTerm, listRequestOptions)
if err != nil {
_ = c.Error(err)
return
@@ -418,6 +137,7 @@ func (oc *OidcController) listClientsHandler(c *gin.Context) {
_ = c.Error(err)
return
}
clientDto.HasDarkLogo = client.HasDarkLogo()
clientDto.AllowedUserGroupsCount, err = oc.oidcService.GetAllowedGroupsCountOfClient(c, client.ID)
if err != nil {
_ = c.Error(err)
@@ -487,11 +207,11 @@ func (oc *OidcController) deleteClientHandler(c *gin.Context) {
// @Accept json
// @Produce json
// @Param id path string true "Client ID"
// @Param client body dto.OidcClientCreateDto true "Client information"
// @Param client body dto.OidcClientUpdateDto true "Client information"
// @Success 200 {object} dto.OidcClientWithAllowedUserGroupsDto "Updated client"
// @Router /api/oidc/clients/{id} [put]
func (oc *OidcController) updateClientHandler(c *gin.Context) {
var input dto.OidcClientCreateDto
var input dto.OidcClientUpdateDto
if err := c.ShouldBindJSON(&input); err != nil {
_ = c.Error(err)
return
@@ -538,19 +258,23 @@ func (oc *OidcController) createClientSecretHandler(c *gin.Context) {
// @Produce image/jpeg
// @Produce image/svg+xml
// @Param id path string true "Client ID"
// @Param light query boolean false "Light mode logo (true) or dark mode logo (false)"
// @Success 200 {file} binary "Logo image"
// @Router /api/oidc/clients/{id}/logo [get]
func (oc *OidcController) getClientLogoHandler(c *gin.Context) {
imagePath, mimeType, err := oc.oidcService.GetClientLogo(c.Request.Context(), c.Param("id"))
lightLogo, _ := strconv.ParseBool(c.DefaultQuery("light", "true"))
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
@@ -560,6 +284,7 @@ func (oc *OidcController) getClientLogoHandler(c *gin.Context) {
// @Accept multipart/form-data
// @Param id path string true "Client ID"
// @Param file formData file true "Logo image file (PNG, JPG, or SVG)"
// @Param light query boolean false "Light mode logo (true) or dark mode logo (false)"
// @Success 204 "No Content"
// @Router /api/oidc/clients/{id}/logo [post]
func (oc *OidcController) updateClientLogoHandler(c *gin.Context) {
@@ -569,7 +294,9 @@ func (oc *OidcController) updateClientLogoHandler(c *gin.Context) {
return
}
err = oc.oidcService.UpdateClientLogo(c.Request.Context(), c.Param("id"), file)
lightLogo, _ := strconv.ParseBool(c.DefaultQuery("light", "true"))
err = oc.oidcService.UpdateClientLogo(c.Request.Context(), c.Param("id"), file, lightLogo)
if err != nil {
_ = c.Error(err)
return
@@ -583,10 +310,19 @@ func (oc *OidcController) updateClientLogoHandler(c *gin.Context) {
// @Description Delete the logo for an OIDC client
// @Tags OIDC
// @Param id path string true "Client ID"
// @Param light query boolean false "Light mode logo (true) or dark mode logo (false)"
// @Success 204 "No Content"
// @Router /api/oidc/clients/{id}/logo [delete]
func (oc *OidcController) deleteClientLogoHandler(c *gin.Context) {
err := oc.oidcService.DeleteClientLogo(c.Request.Context(), c.Param("id"))
var err error
lightLogo, _ := strconv.ParseBool(c.DefaultQuery("light", "true"))
if lightLogo {
err = oc.oidcService.DeleteClientLogo(c.Request.Context(), c.Param("id"))
} else {
err = oc.oidcService.DeleteClientDarkLogo(c.Request.Context(), c.Param("id"))
}
if err != nil {
_ = c.Error(err)
return
@@ -623,31 +359,11 @@ func (oc *OidcController) updateAllowedUserGroupsHandler(c *gin.Context) {
_ = c.Error(err)
return
}
oidcClientDto.HasDarkLogo = oidcClient.HasDarkLogo()
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
@@ -657,7 +373,7 @@ func (oc *OidcController) deviceAuthorizationHandler(c *gin.Context) {
// @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.AuthorizedOidcClientDto]
// @Router /api/oidc/users/me/clients [get]
// @Router /api/oidc/users/me/authorized-clients [get]
func (oc *OidcController) listOwnAuthorizedClientsHandler(c *gin.Context) {
userID := c.GetString("userID")
oc.listAuthorizedClients(c, userID)
@@ -673,19 +389,16 @@ func (oc *OidcController) listOwnAuthorizedClientsHandler(c *gin.Context) {
// @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.AuthorizedOidcClientDto]
// @Router /api/oidc/users/{id}/clients [get]
// @Router /api/oidc/users/{id}/authorized-clients [get]
func (oc *OidcController) listAuthorizedClientsHandler(c *gin.Context) {
userID := c.Param("id")
oc.listAuthorizedClients(c, userID)
}
func (oc *OidcController) listAuthorizedClients(c *gin.Context, userID string) {
var sortedPaginationRequest utils.SortedPaginationRequest
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
_ = c.Error(err)
return
}
authorizedClients, pagination, err := oc.oidcService.ListAuthorizedClients(c.Request.Context(), userID, sortedPaginationRequest)
listRequestOptions := utils.ParseListRequestOptions(c)
authorizedClients, pagination, err := oc.oidcService.ListAuthorizedClients(c.Request.Context(), userID, listRequestOptions)
if err != nil {
_ = c.Error(err)
return
@@ -704,18 +417,19 @@ func (oc *OidcController) listAuthorizedClients(c *gin.Context, userID string) {
})
}
func (oc *OidcController) verifyDeviceCodeHandler(c *gin.Context) {
userCode := c.Query("code")
if userCode == "" {
_ = c.Error(&common.ValidationError{Message: "code is required"})
return
}
// revokeOwnClientAuthorizationHandler godoc
// @Summary Revoke authorization for an OIDC client
// @Description Revoke the authorization for a specific OIDC client for the current user
// @Tags OIDC
// @Param clientId path string true "Client ID to revoke authorization for"
// @Success 204 "No Content"
// @Router /api/oidc/users/me/authorized-clients/{clientId} [delete]
func (oc *OidcController) revokeOwnClientAuthorizationHandler(c *gin.Context) {
clientID := c.Param("clientId")
// Get IP address and user agent from the request context
ipAddress := c.ClientIP()
userAgent := c.Request.UserAgent()
userID := c.GetString("userID")
err := oc.oidcService.VerifyDeviceCode(c.Request.Context(), userCode, c.GetString("userID"), ipAddress, userAgent)
err := oc.oidcService.RevokeAuthorizedClient(c.Request.Context(), userID, clientID)
if err != nil {
_ = c.Error(err)
return
@@ -724,20 +438,31 @@ func (oc *OidcController) verifyDeviceCodeHandler(c *gin.Context) {
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
}
// listOwnAccessibleClientsHandler godoc
// @Summary List accessible OIDC clients for current user
// @Description Get a list of OIDC clients that the current user can access
// @Tags OIDC
// @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.AccessibleOidcClientDto]
// @Router /api/oidc/users/me/clients [get]
func (oc *OidcController) listOwnAccessibleClientsHandler(c *gin.Context) {
listRequestOptions := utils.ParseListRequestOptions(c)
deviceCodeInfo, err := oc.oidcService.GetDeviceCodeInfo(c.Request.Context(), userCode, c.GetString("userID"))
userID := c.GetString("userID")
clients, pagination, err := oc.oidcService.ListAccessibleOidcClients(c.Request.Context(), userID, listRequestOptions)
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, deviceCodeInfo)
c.JSON(http.StatusOK, dto.Paginated[dto.AccessibleOidcClientDto]{
Data: clients,
Pagination: pagination,
})
}
// getClientPreviewHandler godoc
@@ -771,7 +496,13 @@ func (oc *OidcController) getClientPreviewHandler(c *gin.Context) {
return
}
preview, err := oc.oidcService.GetClientPreview(c.Request.Context(), clientID, userID, scopes)
preview, err := oc.oidcService.GetClientPreview(
c.Request.Context(),
clientID,
userID,
strings.Split(scopes, " "),
c.GetString("authenticationMethod"))
if err != nil {
_ = c.Error(err)
return
@@ -779,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

@@ -1,9 +1,12 @@
package controller
import (
"fmt"
"net/http"
"time"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"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,17 +14,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
// 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 *appconfig.AppConfigService) {
uc := UserController{
userService: userService,
appConfigService: appConfigService,
userService: userService,
oneTimeAccessService: oneTimeAccessService,
webAuthnService: webAuthnService,
appConfigService: appConfigService,
}
group.GET("/users", authMiddleware.Add(), uc.listUsersHandler)
@@ -30,8 +37,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)
@@ -43,23 +52,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 *appconfig.AppConfigService
}
// getUserGroupsHandler godoc
@@ -67,7 +74,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")
@@ -77,7 +84,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
@@ -86,6 +93,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
@@ -99,13 +136,9 @@ func (uc *UserController) getUserGroupsHandler(c *gin.Context) {
// @Router /api/users [get]
func (uc *UserController) listUsersHandler(c *gin.Context) {
searchTerm := c.Query("search")
var sortedPaginationRequest utils.SortedPaginationRequest
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
_ = c.Error(err)
return
}
listRequestOptions := utils.ParseListRequestOptions(c)
users, pagination, err := uc.userService.ListUsers(c.Request.Context(), searchTerm, sortedPaginationRequest)
users, pagination, err := uc.userService.ListUsers(c.Request.Context(), searchTerm, listRequestOptions)
if err != nil {
_ = c.Error(err)
return
@@ -184,6 +217,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
@@ -285,7 +343,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
}
@@ -316,7 +374,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
}
@@ -326,15 +384,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 (
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, input.ExpiresAt)
if userID == "" {
_ = c.Error(&common.UserIdNotProvidedError{})
return
}
token, err := uc.oneTimeAccessService.CreateOneTimeAccessToken(c.Request.Context(), userID, ttl)
if err != nil {
_ = c.Error(err)
return
@@ -383,12 +460,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)
}
@@ -411,7 +489,11 @@ func (uc *UserController) RequestOneTimeAccessEmailAsAdminHandler(c *gin.Context
userID := c.Param("id")
err := uc.userService.RequestOneTimeAccessEmailAsAdmin(c.Request.Context(), userID, input.ExpiresAt)
ttl := input.TTL.Duration
if ttl <= 0 {
ttl = defaultOneTimeAccessTokenDuration
}
err := uc.oneTimeAccessService.RequestOneTimeAccessEmailAsAdmin(c.Request.Context(), userID, ttl)
if err != nil {
_ = c.Error(err)
return
@@ -428,53 +510,34 @@ 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())
cfg, err := appconfig.FromCtx(c.Request.Context())
if err != nil {
_ = c.Error(fmt.Errorf("error loading app configuration: %w", err))
return
}
loginCode := c.Param("token")
// reject invalid length login codes
if len(loginCode) != 6 && len(loginCode) != 16 {
_ = c.Error(&common.TokenInvalidOrExpiredError{})
return
}
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
}
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)
err = dto.MapStruct(user, &userDto)
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(uc.appConfigService.GetDbConfig().SessionDuration.AsDurationMinutes().Seconds())
maxAge := int(cfg.SessionDuration.AsDurationMinutes().Seconds())
cookie.AddAccessTokenCookie(c, maxAge, token)
c.JSON(http.StatusOK, userDto)
@@ -510,128 +573,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
}
signupToken, err := uc.userService.CreateSignupToken(c.Request.Context(), input.ExpiresAt, input.UsageLimit)
if err != nil {
_ = c.Error(err)
return
}
var tokenDto dto.SignupTokenDto
if err := dto.MapStruct(signupToken, &tokenDto); 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) {
var sortedPaginationRequest utils.SortedPaginationRequest
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
_ = c.Error(err)
return
}
tokens, pagination, err := uc.userService.ListSignupTokens(c.Request.Context(), sortedPaginationRequest)
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
@@ -673,7 +614,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
}
@@ -691,7 +632,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,33 +45,27 @@ 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) {
ctx := c.Request.Context()
searchTerm := c.Query("search")
var sortedPaginationRequest utils.SortedPaginationRequest
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
_ = c.Error(err)
return
}
listRequestOptions := utils.ParseListRequestOptions(c)
groups, pagination, err := ugc.UserGroupService.List(ctx, searchTerm, sortedPaginationRequest)
groups, pagination, err := ugc.UserGroupService.List(c, searchTerm, listRequestOptions)
if err != nil {
_ = c.Error(err)
return
}
// 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
}
groupDto.UserCount, err = ugc.UserGroupService.GetUserCountOfGroup(ctx, group.ID)
groupDto.UserCount, err = ugc.UserGroupService.GetUserCountOfGroup(c.Request.Context(), group.ID)
if err != nil {
_ = c.Error(err)
return
@@ -78,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,
})
@@ -91,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"))
@@ -100,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
@@ -116,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
@@ -131,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
@@ -148,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
@@ -163,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
@@ -198,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
@@ -213,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
@@ -221,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

@@ -0,0 +1,56 @@
package controller
import (
"net/http"
"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, 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 {
versionService *service.VersionService
}
// getLatestVersionHandler godoc
// @Summary Get latest available version of Pocket ID
// @Tags Version
// @Produce json
// @Success 200 {object} map[string]string "Latest version information"
// @Router /api/version/latest [get]
func (vc *VersionController) getLatestVersionHandler(c *gin.Context) {
tag, err := vc.versionService.GetLatestVersion(c.Request.Context())
if err != nil {
_ = c.Error(err)
return
}
utils.SetCacheControlHeader(c, 5*time.Minute, 15*time.Minute)
c.JSON(http.StatusOK, gin.H{
"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,173 +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.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)
}

View File

@@ -3,8 +3,9 @@ package controller
import (
"encoding/json"
"fmt"
"log"
"log/slog"
"net/http"
"os"
"github.com/gin-gonic/gin"
@@ -23,7 +24,9 @@ func NewWellKnownController(group *gin.RouterGroup, jwtService *service.JwtServi
var err error
wkc.oidcConfig, err = wkc.computeOIDCConfiguration()
if err != nil {
log.Fatalf("Failed to pre-compute OpenID Connect configuration document: %v", err)
slog.Error("Failed to pre-compute OpenID Connect configuration document", slog.Any("error", err))
os.Exit(1)
return
}
group.GET("/.well-known/jwks.json", wkc.jwksHandler)
@@ -64,6 +67,9 @@ func (wkc *WellKnownController) openIDConfigurationHandler(c *gin.Context) {
func (wkc *WellKnownController) computeOIDCConfiguration() ([]byte, error) {
appUrl := common.EnvConfig.AppURL
internalAppUrl := common.EnvConfig.InternalAppURL
alg, err := wkc.jwtService.GetKeyAlg()
if err != nil {
return nil, fmt.Errorf("failed to get key algorithm: %w", err)
@@ -71,19 +77,27 @@ func (wkc *WellKnownController) computeOIDCConfiguration() ([]byte, error) {
config := map[string]any{
"issuer": appUrl,
"authorization_endpoint": appUrl + "/authorize",
"token_endpoint": appUrl + "/api/oidc/token",
"userinfo_endpoint": appUrl + "/api/oidc/userinfo",
"token_endpoint": internalAppUrl + "/api/oidc/token",
"userinfo_endpoint": internalAppUrl + "/api/oidc/userinfo",
"end_session_endpoint": appUrl + "/api/oidc/end-session",
"introspection_endpoint": appUrl + "/api/oidc/introspect",
"introspection_endpoint": internalAppUrl + "/api/oidc/introspect",
"device_authorization_endpoint": appUrl + "/api/oidc/device/authorize",
"jwks_uri": appUrl + "/.well-known/jwks.json",
"grant_types_supported": []string{service.GrantTypeAuthorizationCode, service.GrantTypeRefreshToken, service.GrantTypeDeviceCode},
"scopes_supported": []string{"openid", "profile", "email", "groups"},
"claims_supported": []string{"sub", "given_name", "family_name", "name", "email", "email_verified", "preferred_username", "picture", "groups"},
"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", "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,11 +14,15 @@ 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"`
AllowUserSignups string `json:"allowUserSignups" binding:"required,oneof=disabled withToken open"`
SignupDefaultUserGroupIDs string `json:"signupDefaultUserGroupIDs" binding:"omitempty,json"`
SignupDefaultCustomClaims string `json:"signupDefaultCustomClaims" binding:"omitempty,json"`
AccentColor string `json:"accentColor"`
RequireUserEmail string `json:"requireUserEmail" binding:"required"`
SmtpHost string `json:"smtpHost"`
SmtpPort string `json:"smtpPort"`
SmtpFrom string `json:"smtpFrom" binding:"omitempty,email"`
@@ -39,14 +43,16 @@ type AppConfigUpdateDto struct {
LdapAttributeUserEmail string `json:"ldapAttributeUserEmail"`
LdapAttributeUserFirstName string `json:"ldapAttributeUserFirstName"`
LdapAttributeUserLastName string `json:"ldapAttributeUserLastName"`
LdapAttributeUserDisplayName string `json:"ldapAttributeUserDisplayName"`
LdapAttributeUserProfilePicture string `json:"ldapAttributeUserProfilePicture"`
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,19 +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"`
}
type AuditLogFilterDto struct {
UserID string `form:"filters[userId]"`
Event string `form:"filters[event]"`
ClientName string `form:"filters[clientName]"`
Location string `form:"filters[location]"`
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,

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