Compare commits

...

73 Commits

Author SHA1 Message Date
ItalyPaleAle
a1488565ea release: 1.6.4 2025-07-21 07:44:25 +02:00
Alessandro (Ale) Segala
35d5f887ce fix: migration fails on postgres (#762) 2025-07-20 22:36:22 -07:00
Kyle Mendell
4c76de45ed chore: remove labels from issue templates 2025-07-20 22:51:02 -05:00
Kyle Mendell
68fc9c0659 release: 1.6.3 2025-07-20 22:35:35 -05:00
Kyle Mendell
2952b15755 fix: show rename and delete buttons for passkeys without hovering over the row 2025-07-20 19:09:06 -05:00
Kyle Mendell
ef1d599662 fix: use user-agent for identifying known device signins 2025-07-20 19:02:17 -05:00
Kyle Mendell
4e49d3932a chore: upgrade dependencies (#752) 2025-07-14 23:36:36 -05:00
Elias Schneider
86d3c08494 chore(translations): update translations via Crowdin (#750) 2025-07-14 13:15:33 -05:00
Alessandro (Ale) Segala
7b4ccd1f30 fix: ensure user inputs are normalized (#724)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-07-13 16:15:57 +00:00
Kyle Mendell
f145903eb0 chore: use correct svelte 5 syntax for signup token modal 2025-07-11 22:53:01 -05:00
Kyle Mendell
d3bc1797b6 fix: use object-contain for images on oidc-client list 2025-07-11 22:46:40 -05:00
Kyle Mendell
db94f81937 chore: use issue types for new issues 2025-07-11 22:25:11 -05:00
Kyle Mendell
b03e91b653 fix: allow passkey names up to 50 characters 2025-07-11 22:10:59 -05:00
Kyle Mendell
505bdcb8ba release: 1.6.2 2025-07-09 16:56:34 -05:00
Kyle Mendell
f103a54790 fix: ensure confirmation dialog shows on top of other components 2025-07-09 16:50:01 -05:00
Alessandro (Ale) Segala
e1de593dcd fix: login failures on Postgres when IP is null (#737) 2025-07-09 08:45:07 -05:00
Elias Schneider
45f42772b1 chore(translations): update translations via Crowdin (#730) 2025-07-07 20:06:52 -05:00
XLion
98152640b1 chore(translations): Fix inconsistent punctuation marks for the language name of zh-TW (#731) 2025-07-07 12:54:45 +00:00
github-actions[bot]
04e235e805 chore: update AAGUIDs (#729)
Co-authored-by: stonith404 <58886915+stonith404@users.noreply.github.com>
2025-07-06 21:04:32 -05:00
Elias Schneider
ae737dddaa release: 1.6.1 2025-07-06 22:50:33 +02:00
Elias Schneider
f565c702e5 ci/cd: use latest-distroless tag for latest distroless images 2025-07-06 22:48:55 +02:00
Elias Schneider
f945b44bc9 release: 1.6.0 2025-07-06 20:19:45 +02:00
Elias Schneider
857b9cc864 refactor: run formatter 2025-07-06 15:32:19 +02:00
Elias Schneider
bf042563e9 feat: add support for OAuth 2.0 Authorization Server Issuer Identification 2025-07-06 15:29:26 +02:00
Elias Schneider
49f1ab2f75 fix: custom claims input suggestions flickering 2025-07-06 00:23:06 +02:00
Elias Schneider
e46f60ac8d fix: keep sidebar in settings sticky 2025-07-05 21:59:13 +02:00
Elias Schneider
5c9e504291 fix: show friendly name in user group selection 2025-07-05 21:58:56 +02:00
Alessandro (Ale) Segala
7fe83f8087 fix: actually fix linter issues (#720) 2025-07-04 21:14:44 -05:00
Alessandro (Ale) Segala
43f0114c57 fix: linter issues (#719) 2025-07-04 18:29:28 -05:00
Alessandro (Ale) Segala
1a41b05f60 feat: distroless container additional variant + healthcheck command (#716) 2025-07-04 12:26:01 -07:00
Elias Schneider
81315790a8 fix: support non UTF-8 LDAP IDs (#714) 2025-07-04 08:42:11 +02:00
Alessandro (Ale) Segala
8c8fc2304d feat: add "key-rotate" command (#709) 2025-07-03 22:23:24 +02:00
Elias Schneider
15ece0ab30 chore(translations): update translations via Crowdin (#712) 2025-07-03 13:47:27 -05:00
Alessandro (Ale) Segala
5550729120 feat: encrypt private keys saved on disk and in database (#682)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
2025-07-03 13:34:34 -05:00
Elias Schneider
9872608d61 fix: allow profile picture update even if "allow own account edit" enabled 2025-07-03 10:57:56 +02:00
Elias Schneider
be52660227 feat: enhance language selection message and add translation contribution link 2025-07-03 09:20:39 +02:00
Elias Schneider
237342e876 chore(translations): update translations via Crowdin (#707) 2025-07-02 13:45:10 +02:00
Elias Schneider
cfbfbc9753 chore(translations): update translations via Crowdin (#705) 2025-07-01 17:11:42 -05:00
Elias Schneider
aefb308536 fix: token introspection authentication not handled correctly (#704)
Co-authored-by: Alessandro (Ale) Segala <43508+ItalyPaleAle@users.noreply.github.com>
2025-07-01 21:14:07 +00:00
Alessandro (Ale) Segala
031181ad2a fix: auth fails when client IP is empty on Postgres (#695) 2025-06-30 14:04:30 +02:00
Elias Schneider
dbf3da41f3 chore(translations): update translations via Crowdin (#699)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
2025-06-29 13:04:15 -05:00
Kyle Mendell
3a2902789e chore: use correct team name for codeowners 2025-06-29 09:31:32 -05:00
Kyle Mendell
459a4fd727 chore: update CODEOWNERS to be global 2025-06-29 09:30:00 -05:00
Kyle Mendell
2ecc1abbad chore: add CODEOWNERS file 2025-06-29 09:28:08 -05:00
Kyle Mendell
92c57ada1a fix: app config forms not updating with latest values (#696) 2025-06-29 15:13:06 +02:00
Elias Schneider
fceb6fa7b4 fix: add missing error check in initial user setup 2025-06-29 15:10:39 +02:00
Alessandro (Ale) Segala
c290c027fb refactor: use github.com/jinzhu/copier for MapStruct (#698) 2025-06-29 15:01:10 +02:00
Elias Schneider
ca205a8c73 chore(translations): update translations via Crowdin (#697) 2025-06-29 01:01:39 -05:00
Elias Schneider
968cf0b307 chore(translations): update translations via Crowdin (#694) 2025-06-28 21:25:58 -05:00
Elias Schneider
fd8bee94a4 chore(translations): update translations via Crowdin (#692) 2025-06-28 15:26:17 +02:00
Manuel Rais
41ac1be082 chore(translations) : translate missing french values (#691) 2025-06-28 15:26:05 +02:00
Elias Schneider
dd9b1d26ea release: 1.5.0 2025-06-27 23:56:16 +02:00
Elias Schneider
4b829757b2 tests: fix e2e tests 2025-06-27 23:52:43 +02:00
Elias Schneider
b5b01cb6dd chore(translations): update translations via Crowdin (#688) 2025-06-27 23:42:32 +02:00
Elias Schneider
287314f016 feat: improve initial admin creation workflow 2025-06-27 23:41:05 +02:00
Elias Schneider
73e7e0b1c5 refactor: add formatter to Playwright tests 2025-06-27 23:33:26 +02:00
Elias Schneider
d070b9a778 fix: double double full stops for certain error messages 2025-06-27 22:43:31 +02:00
Elias Schneider
d976bf5965 fix: improve accent color picker disabled state 2025-06-27 22:38:21 +02:00
Elias Schneider
052ac008c3 fix: margin of user sign up description 2025-06-27 22:31:55 +02:00
Elias Schneider
57a2b2bc83 chore(translations): update translations via Crowdin (#687) 2025-06-27 22:24:36 +02:00
ElevenNotes
043f82ad79 fix: less noisy logging for certain GET requests (#681)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-06-27 22:24:22 +02:00
Elias Schneider
ba61cdba4e feat: redact sensitive app config variables if set with env variable 2025-06-27 22:22:28 +02:00
Kyle Mendell
dcd1ae96e0 feat: self-service user signup (#672)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-06-27 15:01:10 -05:00
Elias Schneider
1fdb058386 docs: clarify confusing user update logic 2025-06-27 17:20:51 +02:00
Elias Schneider
29cb5513a0 fix: users can't be updated by admin if self account editing is disabled 2025-06-27 17:15:26 +02:00
Elias Schneider
6db57d9f27 chore(translations): update translations via Crowdin (#683) 2025-06-26 19:01:16 +02:00
Elias Schneider
1a77bd9914 fix: error page flickering after sign out 2025-06-24 21:56:40 +02:00
Elias Schneider
350335711b chore(translations): update translations via Crowdin (#677) 2025-06-24 09:00:57 -05:00
Ryan Kaskel
988c425150 fix: remove duplicate request logging (#678) 2025-06-24 13:48:11 +00:00
Elias Schneider
23827ba1d1 release: 1.4.1 2025-06-22 21:30:07 +02:00
Elias Schneider
7d36bda769 fix: app not starting if UI config is disabled and Postgres is used 2025-06-22 21:21:14 +02:00
Manuel Rais
8c559ea067 chore(translations) : typo in french language (#669) 2025-06-22 18:58:59 +00:00
Elias Schneider
88832d4bc9 chore(translations): update translations via Crowdin (#663) 2025-06-20 11:11:42 +02:00
186 changed files with 14143 additions and 8735 deletions

1
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1 @@
* @pocket-id/maintainers

View File

@@ -1,7 +1,7 @@
name: "🐛 Bug Report"
description: "Report something that is not working as expected"
title: "🐛 Bug Report: "
labels: [bug]
type: 'Bug'
body:
- type: markdown
attributes:

View File

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

View File

@@ -1,7 +1,7 @@
name: "🌐 Language request"
description: "You want to contribute to a language that isn't on Crowdin yet?"
title: "🌐 Language Request: <language name in english>"
labels: [language-request]
type: 'Language Request'
body:
- type: input
id: language-name-native

View File

@@ -73,10 +73,24 @@ jobs:
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
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ env.DOCKER_IMAGE_NAME }}:next-distroless
file: Dockerfile-distroless
- name: Container image attestation
uses: actions/attest-build-provenance@v2
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

View File

@@ -29,14 +29,12 @@ jobs:
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}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
@@ -53,17 +51,26 @@ jobs:
type=semver,pattern={{version}},prefix=v
type=semver,pattern={{major}}.{{minor}},prefix=v
type=semver,pattern={{major}},prefix=v
- name: Docker metadata (distroless)
id: meta-distroless
uses: docker/metadata-action@v5
with:
images: |
${{ env.DOCKER_IMAGE_NAME }}
flavor: |
suffix=-distroless,onlatest=true
tags: |
type=semver,pattern={{version}},prefix=v
type=semver,pattern={{major}}.{{minor}},prefix=v
type=semver,pattern={{major}},prefix=v
- name: Install frontend dependencies
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
@@ -74,19 +81,32 @@ jobs:
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
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1 +1 @@
1.4.0
1.6.4

View File

@@ -1,3 +1,76 @@
## [](https://github.com/pocket-id/pocket-id/compare/v1.6.3...v) (2025-07-21)
### Bug Fixes
* migration fails on postgres ([#762](https://github.com/pocket-id/pocket-id/issues/762)) ([35d5f88](https://github.com/pocket-id/pocket-id/commit/35d5f887ce7c88933d7e4c2f0acd2aeedd18c214))
## [](https://github.com/pocket-id/pocket-id/compare/v1.6.2...v) (2025-07-21)
### Bug Fixes
* allow passkey names up to 50 characters ([b03e91b](https://github.com/pocket-id/pocket-id/commit/b03e91b6530c2393ad20ac49aa2cb2b4962651b2))
* ensure user inputs are normalized ([#724](https://github.com/pocket-id/pocket-id/issues/724)) ([7b4ccd1](https://github.com/pocket-id/pocket-id/commit/7b4ccd1f306f4882c52fe30133fcda114ef0d18b))
* show rename and delete buttons for passkeys without hovering over the row ([2952b15](https://github.com/pocket-id/pocket-id/commit/2952b1575542ecd0062fe740e2d6a3caad05190d))
* use object-contain for images on oidc-client list ([d3bc179](https://github.com/pocket-id/pocket-id/commit/d3bc1797b65ec8bc9201c55d06f3612093f3a873))
* use user-agent for identifying known device signins ([ef1d599](https://github.com/pocket-id/pocket-id/commit/ef1d5996624fc534190f80a26f2c48bbad206f49))
## [](https://github.com/pocket-id/pocket-id/compare/v1.6.1...v) (2025-07-09)
### Bug Fixes
* ensure confirmation dialog shows on top of other components ([f103a54](https://github.com/pocket-id/pocket-id/commit/f103a547904070c5b192e519c8b5a8fed9d80e96))
* login failures on Postgres when IP is null ([#737](https://github.com/pocket-id/pocket-id/issues/737)) ([e1de593](https://github.com/pocket-id/pocket-id/commit/e1de593dcd30b7b04da3b003455134992b702595))
## [](https://github.com/pocket-id/pocket-id/compare/v1.5.0...v) (2025-07-06)
### Features
* add "key-rotate" command ([#709](https://github.com/pocket-id/pocket-id/issues/709)) ([8c8fc23](https://github.com/pocket-id/pocket-id/commit/8c8fc2304d8f33c1fea54b1138b109f282e78b8b))
* add support for OAuth 2.0 Authorization Server Issuer Identification ([bf04256](https://github.com/pocket-id/pocket-id/commit/bf042563e997d57bb087705a5789fd72ffbed467))
* distroless container additional variant + healthcheck command ([#716](https://github.com/pocket-id/pocket-id/issues/716)) ([1a41b05](https://github.com/pocket-id/pocket-id/commit/1a41b05f60d487fff78703bec1d4e832f96fd071))
* encrypt private keys saved on disk and in database ([#682](https://github.com/pocket-id/pocket-id/issues/682)) ([5550729](https://github.com/pocket-id/pocket-id/commit/5550729120ac9f5e9361c7f9cf25b9075a33a94a))
* enhance language selection message and add translation contribution link ([be52660](https://github.com/pocket-id/pocket-id/commit/be526602273c1689cb4057ca96d4214e7f817d1d))
### Bug Fixes
* actually fix linter issues ([#720](https://github.com/pocket-id/pocket-id/issues/720)) ([7fe83f8](https://github.com/pocket-id/pocket-id/commit/7fe83f8087f033f957bb6e0eee5e0c159417e1cd))
* add missing error check in initial user setup ([fceb6fa](https://github.com/pocket-id/pocket-id/commit/fceb6fa7b4701a3645c4c2353bcd108b15d69ded))
* allow profile picture update even if "allow own account edit" enabled ([9872608](https://github.com/pocket-id/pocket-id/commit/9872608d61a486f7b775f314d9392e0620bcd891))
* app config forms not updating with latest values ([#696](https://github.com/pocket-id/pocket-id/issues/696)) ([92c57ad](https://github.com/pocket-id/pocket-id/commit/92c57ada1a11f76963e36ca0a81bca8f52dbc84e))
* auth fails when client IP is empty on Postgres ([#695](https://github.com/pocket-id/pocket-id/issues/695)) ([031181a](https://github.com/pocket-id/pocket-id/commit/031181ad2ae8fae94cc5793dd1c614e79476a766))
* custom claims input suggestions flickering ([49f1ab2](https://github.com/pocket-id/pocket-id/commit/49f1ab2f75df97d551fff5acbadcd55df74af617))
* keep sidebar in settings sticky ([e46f60a](https://github.com/pocket-id/pocket-id/commit/e46f60ac8d6944bcea54d0708af1950d98f66c3c))
* linter issues ([#719](https://github.com/pocket-id/pocket-id/issues/719)) ([43f0114](https://github.com/pocket-id/pocket-id/commit/43f0114c579f7b5b32b372e09f46bcb2a9d7796e))
* show friendly name in user group selection ([5c9e504](https://github.com/pocket-id/pocket-id/commit/5c9e504291b3bffe947bcbe907701806e301d1fe))
* support non UTF-8 LDAP IDs ([#714](https://github.com/pocket-id/pocket-id/issues/714)) ([8131579](https://github.com/pocket-id/pocket-id/commit/81315790a8aa601a2565a1b54807df1e68f06dc5))
* token introspection authentication not handled correctly ([#704](https://github.com/pocket-id/pocket-id/issues/704)) ([aefb308](https://github.com/pocket-id/pocket-id/commit/aefb30853677baf7ed29ac8b539e1aadf56e14a4))
## [](https://github.com/pocket-id/pocket-id/compare/v1.4.1...v) (2025-06-27)
### Features
* improve initial admin creation workflow ([287314f](https://github.com/pocket-id/pocket-id/commit/287314f01644e42ddb2ce1b1115bd14f2f0c1768))
* redact sensitive app config variables if set with env variable ([ba61cdb](https://github.com/pocket-id/pocket-id/commit/ba61cdba4eb3d5659f3ae6b6c21249985c0aa630))
* self-service user signup ([#672](https://github.com/pocket-id/pocket-id/issues/672)) ([dcd1ae9](https://github.com/pocket-id/pocket-id/commit/dcd1ae96e048115be34b0cce275054e990462ebf))
### Bug Fixes
* double double full stops for certain error messages ([d070b9a](https://github.com/pocket-id/pocket-id/commit/d070b9a778d7d1a51f2fa62d003f2331a96d6c91))
* error page flickering after sign out ([1a77bd9](https://github.com/pocket-id/pocket-id/commit/1a77bd9914ea01e445ff3d6e116c9ed3bcfbf153))
* improve accent color picker disabled state ([d976bf5](https://github.com/pocket-id/pocket-id/commit/d976bf5965eda10e3ecb71821c23e93e5d712a02))
* less noisy logging for certain GET requests ([#681](https://github.com/pocket-id/pocket-id/issues/681)) ([043f82a](https://github.com/pocket-id/pocket-id/commit/043f82ad794eb64a5550d8b80703114a055701d9))
* margin of user sign up description ([052ac00](https://github.com/pocket-id/pocket-id/commit/052ac008c3a8c910d1ce79ee99b2b2f75e4090f4))
* remove duplicate request logging ([#678](https://github.com/pocket-id/pocket-id/issues/678)) ([988c425](https://github.com/pocket-id/pocket-id/commit/988c425150556b32cff1d341a21fcc9c69d9aaf8))
* users can't be updated by admin if self account editing is disabled ([29cb551](https://github.com/pocket-id/pocket-id/commit/29cb5513a03d1a9571969c8a42deec9b2bdee037))
## [](https://github.com/pocket-id/pocket-id/compare/v1.4.0...v) (2025-06-22)
### Bug Fixes
* app not starting if UI config is disabled and Postgres is used ([7d36bda](https://github.com/pocket-id/pocket-id/commit/7d36bda769e25497dec6b76206a4f7e151b0bd72))
## [](https://github.com/pocket-id/pocket-id/compare/v1.3.1...v) (2025-06-19)
### Features

View File

@@ -48,5 +48,7 @@ RUN chmod +x /app/pocket-id && \
EXPOSE 1411
ENV APP_ENV=production
HEALTHCHECK --interval=90s --timeout=5s --start-period=10s --retries=3 CMD [ "/app/pocket-id", "healthcheck" ]
ENTRYPOINT ["sh", "/app/docker/entrypoint.sh"]
CMD ["/app/pocket-id"]

18
Dockerfile-distroless Normal file
View File

@@ -0,0 +1,18 @@
# This Dockerfile embeds a pre-built binary for the given Linux architecture
# Binaries must be built using "./scripts/development/build-binaries.sh --docker-only"
FROM gcr.io/distroless/static-debian12:nonroot
# TARGETARCH can be "amd64" or "arm64"
ARG TARGETARCH
WORKDIR /app
COPY ./backend/.bin/pocket-id-linux-${TARGETARCH} /app/pocket-id
EXPOSE 1411
ENV APP_ENV=production
HEALTHCHECK --interval=90s --timeout=5s --start-period=10s --retries=3 CMD [ "/app/pocket-id", "healthcheck" ]
CMD ["/app/pocket-id"]

View File

@@ -1,5 +1,5 @@
# This Dockerfile embeds a pre-built binary for the given Linux architecture
# Binaries must be built using ""./scripts/development/build-binaries.sh --docker-only"
# Binaries must be built using "./scripts/development/build-binaries.sh --docker-only"
FROM alpine
@@ -16,5 +16,7 @@ COPY ./scripts/docker /app/docker
EXPOSE 1411
ENV APP_ENV=production
HEALTHCHECK --interval=90s --timeout=5s --start-period=10s --retries=3 CMD [ "/app/pocket-id", "healthcheck" ]
ENTRYPOINT ["/app/docker/entrypoint.sh"]
CMD ["/app/pocket-id"]

View File

@@ -1,15 +1,9 @@
package main
import (
"flag"
"fmt"
"log"
_ "time/tzdata"
"github.com/pocket-id/pocket-id/backend/internal/bootstrap"
"github.com/pocket-id/pocket-id/backend/internal/cmds"
"github.com/pocket-id/pocket-id/backend/internal/common"
)
// @title Pocket ID API
@@ -17,27 +11,5 @@ import (
// @description.markdown
func main() {
// Get the command
// By default, this starts the server
var cmd string
flag.Parse()
args := flag.Args()
if len(args) > 0 {
cmd = args[0]
}
var err error
switch cmd {
case "version":
fmt.Println("pocket-id " + common.Version)
case "one-time-access-token":
err = cmds.OneTimeAccessToken(args)
default:
// Start the server
err = bootstrap.Bootstrap()
}
if err != nil {
log.Fatal(err.Error())
}
cmds.Execute()
}

View File

@@ -11,6 +11,7 @@ require (
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
@@ -19,11 +20,13 @@ require (
github.com/golang-migrate/migrate/v4 v4.18.2
github.com/google/uuid v1.6.0
github.com/hashicorp/go-uuid v1.0.3
github.com/jinzhu/copier v0.4.0
github.com/joho/godotenv v1.5.1
github.com/lestrrat-go/httprc/v3 v3.0.0-beta2
github.com/lestrrat-go/jwx/v3 v3.0.1
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
@@ -33,8 +36,9 @@ require (
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.37.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
@@ -55,7 +59,6 @@ require (
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/glebarez/go-sqlite v1.21.2 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
@@ -68,6 +71,7 @@ require (
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/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.7.2 // indirect
@@ -98,6 +102,7 @@ require (
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/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/x448/float16 v0.8.4 // indirect
@@ -121,16 +126,15 @@ require (
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.14.0 // indirect
golang.org/x/sync v0.15.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.24.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
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.65.6 // indirect
modernc.org/libc v1.65.10 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.10.0 // indirect
modernc.org/sqlite v1.37.0 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.38.0 // indirect
)

View File

@@ -24,6 +24,7 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -120,6 +121,8 @@ github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@@ -140,6 +143,8 @@ github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh6
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8=
github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
@@ -225,8 +230,13 @@ github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -309,8 +319,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI=
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
@@ -321,8 +331,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
@@ -343,8 +353,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -377,8 +387,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -413,22 +423,22 @@ modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s=
modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8=
modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/fileutil v1.3.3 h1:3qaU+7f7xxTUmvU1pJTZiDLAIoJVdUSSauJNHg9yXoA=
modernc.org/fileutil v1.3.3/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/libc v1.65.6 h1:OhJUhmuJ6MVZdqL5qmnd0/my46DKGFhSX4WOR7ijfyE=
modernc.org/libc v1.65.6/go.mod h1:MOiGAM9lrMBT9L8xT1nO41qYl5eg9gCp9/kWhz5L7WA=
modernc.org/libc v1.65.10 h1:ZwEk8+jhW7qBjHIT+wd0d9VjitRyQef9BnzlzGwMODc=
modernc.org/libc v1.65.10/go.mod h1:StFvYpx7i/mXtBAfVOjaU0PWZOvIRoZSgXhrwXzr8Po=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.10.0 h1:fzumd51yQ1DxcOxSO+S6X7+QTuVU+n8/Aj7swYjFfC4=
modernc.org/memory v1.10.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=
modernc.org/sqlite v1.38.0 h1:+4OrfPQ8pxHKuWG4md1JpR/EYAh3Md7TdejuuzE7EUI=
modernc.org/sqlite v1.38.0/go.mod h1:1Bj+yES4SVvBZ4cBOpVZ6QgesMCKpJZDq0nxYzOpmNE=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View File

@@ -11,13 +11,9 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/job"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"github.com/pocket-id/pocket-id/backend/internal/utils/signals"
)
func Bootstrap() error {
// Get a context that is canceled when the application is stopping
ctx := signals.SignalContext(context.Background())
func Bootstrap(ctx context.Context) error {
initApplicationImages()
// Initialize the tracer and metrics exporter
@@ -59,11 +55,12 @@ func Bootstrap() error {
// 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)
Run(shutdownCtx) //nolint:contextcheck
if err != nil {
log.Printf("Error shutting down services: %v", err)
}

View File

@@ -20,6 +20,7 @@ import (
"gorm.io/gorm/logger"
"github.com/pocket-id/pocket-id/backend/internal/common"
sqliteutil "github.com/pocket-id/pocket-id/backend/internal/utils/sqlite"
"github.com/pocket-id/pocket-id/backend/resources"
)
@@ -88,6 +89,7 @@ func connectDatabase() (db *gorm.DB, err error) {
if !strings.HasPrefix(common.EnvConfig.DbConnectionString, "file:") {
return nil, errors.New("invalid value for env var 'DB_CONNECTION_STRING': does not begin with 'file:'")
}
sqliteutil.RegisterSqliteFunctions()
connString, err := parseSqliteConnectionString(common.EnvConfig.DbConnectionString)
if err != nil {
return nil, err

View File

@@ -9,6 +9,7 @@ import (
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/pocket-id/pocket-id/backend/frontend"
@@ -47,8 +48,26 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
gin.SetMode(gin.TestMode)
}
r := gin.Default()
r.Use(gin.Logger())
// do not log these URLs
loggerSkipPathsPrefix := []string{
"GET /application-configuration/logo",
"GET /application-configuration/background-image",
"GET /application-configuration/favicon",
"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
}
}
return false
}}))
if !common.EnvConfig.TrustProxy {
_ = r.SetTrustedProxies(nil)

View File

@@ -38,7 +38,7 @@ 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(svc.appConfigService)
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)

View File

@@ -0,0 +1,83 @@
package cmds
import (
"context"
"log/slog"
"net/http"
"os"
"time"
"github.com/spf13/cobra"
"github.com/pocket-id/pocket-id/backend/internal/common"
)
type healthcheckFlags struct {
Endpoint string
Verbose bool
}
func init() {
var flags healthcheckFlags
healthcheckCmd := &cobra.Command{
Use: "healthcheck",
Short: "Performs a healthcheck of a running Pocket ID instance",
Run: func(cmd *cobra.Command, args []string) {
start := time.Now()
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 err != nil {
slog.ErrorContext(ctx,
"Failed to create request object",
"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,
"ms", time.Since(start).Milliseconds(),
)
}
},
}
healthcheckCmd.Flags().StringVarP(&flags.Endpoint, "endpoint", "e", "http://localhost:"+common.EnvConfig.Port, "Endpoint for Pocket ID")
healthcheckCmd.Flags().BoolVarP(&flags.Verbose, "verbose", "v", false, "Enable verbose mode")
rootCmd.AddCommand(healthcheckCmd)
}

View File

@@ -0,0 +1,107 @@
package cmds
import (
"context"
"errors"
"fmt"
"strings"
"github.com/lestrrat-go/jwx/v3/jwa"
"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"
jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk"
)
type keyRotateFlags struct {
Alg string
Crv string
Yes bool
}
func init() {
var flags keyRotateFlags
keyRotateCmd := &cobra.Command{
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()
return keyRotate(cmd.Context(), flags, db, &common.EnvConfig)
},
}
keyRotateCmd.Flags().StringVarP(&flags.Alg, "alg", "a", "RS256", "Key algorithm. Supported values: RS256, RS384, RS512, ES256, ES384, ES512, EdDSA")
keyRotateCmd.Flags().StringVarP(&flags.Crv, "crv", "c", "", "Curve name when using EdDSA keys. Supported values: Ed25519")
keyRotateCmd.Flags().BoolVarP(&flags.Yes, "yes", "y", false, "Do not prompt for confirmation")
rootCmd.AddCommand(keyRotateCmd)
}
func keyRotate(ctx context.Context, flags keyRotateFlags, db *gorm.DB, envConfig *common.EnvConfigSchema) error {
// Validate the flags
switch strings.ToUpper(flags.Alg) {
case jwa.RS256().String(), jwa.RS384().String(), jwa.RS512().String(),
jwa.ES256().String(), jwa.ES384().String(), jwa.ES512().String():
// All good, but uppercase it for consistency
flags.Alg = strings.ToUpper(flags.Alg)
case strings.ToUpper(jwa.EdDSA().String()):
// Ensure Crv is set and valid
switch strings.ToUpper(flags.Crv) {
case strings.ToUpper(jwa.Ed25519().String()):
// All good, but ensure consistency in casing
flags.Crv = jwa.Ed25519().String()
case "":
return errors.New("a curve name is required when algorithm is EdDSA")
default:
return errors.New("unsupported EdDSA curve; supported values: Ed25519")
}
case "":
return errors.New("key algorithm is required")
default:
return errors.New("unsupported key algorithm; supported values: RS256, RS384, RS512, ES256, ES384, ES512, EdDSA")
}
if !flags.Yes {
fmt.Println("WARNING: Rotating the private key will invalidate all existing tokens. Both pocket-id and all client applications will likely need to be restarted.")
ok, err := utils.PromptForConfirmation("Confirm")
if err != nil {
return err
}
if !ok {
fmt.Println("Aborted")
return nil
}
}
// Init the services we need
appConfigService := service.NewAppConfigService(ctx, db)
// Get the key provider
keyProvider, err := jwkutils.GetKeyProvider(db, envConfig, appConfigService.GetDbConfig().InstanceID.Value)
if err != nil {
return fmt.Errorf("failed to get key provider: %w", err)
}
// Generate a new key
key, err := jwkutils.GenerateKey(flags.Alg, flags.Crv)
if err != nil {
return fmt.Errorf("failed to generate key: %w", err)
}
// Save the key
err = keyProvider.SaveKey(key)
if err != nil {
return fmt.Errorf("failed to store new key: %w", err)
}
fmt.Println("Key rotated successfully")
fmt.Println("Note: if pocket-id is running, you will need to restart it for the new key to be loaded")
return nil
}

View File

@@ -0,0 +1,214 @@
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"
jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk"
testingutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
func TestKeyRotate(t *testing.T) {
tests := []struct {
name string
flags keyRotateFlags
wantErr bool
errMsg string
}{
{
name: "valid RS256",
flags: keyRotateFlags{
Alg: "RS256",
Yes: true,
},
wantErr: false,
},
{
name: "valid EdDSA with Ed25519",
flags: keyRotateFlags{
Alg: "EdDSA",
Crv: "Ed25519",
Yes: true,
},
wantErr: false,
},
{
name: "invalid algorithm",
flags: keyRotateFlags{
Alg: "INVALID",
Yes: true,
},
wantErr: true,
errMsg: "unsupported key algorithm",
},
{
name: "EdDSA without curve",
flags: keyRotateFlags{
Alg: "EdDSA",
Yes: true,
},
wantErr: true,
errMsg: "a curve name is required when algorithm is EdDSA",
},
{
name: "empty algorithm",
flags: keyRotateFlags{
Alg: "",
Yes: true,
},
wantErr: true,
errMsg: "key algorithm is required",
},
}
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)
})
})
}
}
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",
}
// 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
// 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)
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 TestKeyRotateMultipleAlgorithms(t *testing.T) {
algorithms := []struct {
alg string
crv string
}{
{"RS256", ""},
{"RS384", ""},
// Skip RSA-4096 key generation test as it can take a long time
// {"RS512", ""},
{"ES256", ""},
{"ES384", ""},
{"ES512", ""},
{"EdDSA", "Ed25519"},
}
for _, algo := range algorithms {
t.Run(algo.alg, func(t *testing.T) {
// Test with database storage for all algorithms
testKeyRotateWithDatabaseStorage(t, keyRotateFlags{
Alg: algo.alg,
Crv: algo.crv,
Yes: true,
}, false, "")
})
}
}

View File

@@ -6,77 +6,77 @@ import (
"fmt"
"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/model"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/utils/signals"
)
// OneTimeAccessToken creates a one-time access token for the given user
// Args must contain the username or email of the user
func OneTimeAccessToken(args []string) error {
// Get a context that is canceled when the application is stopping
ctx := signals.SignalContext(context.Background())
var oneTimeAccessTokenCmd = &cobra.Command{
Use: "one-time-access-token [username or email]",
Short: "Generates a one-time access token for the given user",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
// Get the username or email of the user
userArg := args[0]
// Get the username or email of the user
// Note length is 2 because the first argument is always the command (one-time-access-token)
if len(args) != 2 {
return errors.New("missing username or email of user; usage: one-time-access-token <username or email>")
}
userArg := args[1]
// Connect to the database
db := bootstrap.NewDatabase()
// Connect to the database
db := bootstrap.NewDatabase()
// Create the access token
var oneTimeAccessToken *model.OneTimeAccessToken
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)
defer queryCancel()
txErr := tx.
WithContext(queryCtx).
Where("username = ? OR email = ?", userArg, userArg).
First(&user).
Error
switch {
case errors.Is(txErr, gorm.ErrRecordNotFound):
return errors.New("user not found")
case txErr != nil:
return fmt.Errorf("failed to query for user: %w", txErr)
case user.ID == "":
return errors.New("invalid user loaded: ID is empty")
}
// Create the access token
var oneTimeAccessToken *model.OneTimeAccessToken
err := db.Transaction(func(tx *gorm.DB) error {
// Load the user to retrieve the user ID
var user model.User
queryCtx, queryCancel := context.WithTimeout(ctx, 10*time.Second)
defer queryCancel()
txErr := tx.
WithContext(queryCtx).
Where("username = ? OR email = ?", userArg, userArg).
First(&user).
Error
switch {
case errors.Is(txErr, gorm.ErrRecordNotFound):
return errors.New("user not found")
case txErr != nil:
return fmt.Errorf("failed to query for user: %w", txErr)
case user.ID == "":
return errors.New("invalid user loaded: ID is empty")
// Create a new access token that expires in 1 hour
oneTimeAccessToken, txErr = service.NewOneTimeAccessToken(user.ID, time.Now().Add(time.Hour))
if txErr != nil {
return fmt.Errorf("failed to generate access token: %w", txErr)
}
queryCtx, queryCancel = context.WithTimeout(cmd.Context(), 10*time.Second)
defer queryCancel()
txErr = tx.
WithContext(queryCtx).
Create(oneTimeAccessToken).
Error
if txErr != nil {
return fmt.Errorf("failed to save access token: %w", txErr)
}
return nil
})
if err != nil {
return err
}
// Create a new access token that expires in 1 hour
oneTimeAccessToken, txErr = service.NewOneTimeAccessToken(user.ID, time.Now().Add(time.Hour))
if txErr != nil {
return fmt.Errorf("failed to generate access token: %w", txErr)
}
queryCtx, queryCancel = context.WithTimeout(ctx, 10*time.Second)
defer queryCancel()
txErr = tx.
WithContext(queryCtx).
Create(oneTimeAccessToken).
Error
if txErr != nil {
return fmt.Errorf("failed to save access token: %w", txErr)
}
// Print the result
fmt.Printf(`A one-time access token valid for 1 hour has been created for "%s".`+"\n", userArg)
fmt.Printf("Use the following URL to sign in once: %s/lc/%s\n", common.EnvConfig.AppURL, oneTimeAccessToken.Token)
return nil
})
if err != nil {
return err
}
// Print the result
fmt.Printf(`A one-time access token valid for 1 hour has been created for "%s".`+"\n", userArg)
fmt.Printf("Use the following URL to sign in once: %s/lc/%s\n", common.EnvConfig.AppURL, oneTimeAccessToken.Token)
return nil
},
}
func init() {
rootCmd.AddCommand(oneTimeAccessTokenCmd)
}

View File

@@ -0,0 +1,36 @@
package cmds
import (
"context"
"log/slog"
"os"
"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.",
Run: func(cmd *cobra.Command, args []string) {
// Start the server
err := bootstrap.Bootstrap(cmd.Context())
if err != nil {
slog.Error("Failed to run pocket-id", "error", err)
os.Exit(1)
}
},
}
func Execute() {
// Get a context that is canceled when the application is stopping
ctx := signals.SignalContext(context.Background())
err := rootCmd.ExecuteContext(ctx)
if err != nil {
os.Exit(1)
}
}

View File

@@ -0,0 +1,19 @@
package cmds
import (
"fmt"
"github.com/spf13/cobra"
"github.com/pocket-id/pocket-id/backend/internal/common"
)
func init() {
rootCmd.AddCommand(&cobra.Command{
Use: "version",
Short: "Print the version number",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("pocket-id " + common.Version)
},
})
}

View File

@@ -1,6 +1,8 @@
package common
import (
"errors"
"fmt"
"log"
"net/url"
@@ -18,9 +20,10 @@ const (
)
const (
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"
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"
)
type EnvConfigSchema struct {
@@ -30,6 +33,9 @@ type EnvConfigSchema struct {
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"`
@@ -45,52 +51,83 @@ type EnvConfigSchema struct {
AnalyticsDisabled bool `env:"ANALYTICS_DISABLED"`
}
var EnvConfig = &EnvConfigSchema{
AppEnv: "production",
DbProvider: "sqlite",
DbConnectionString: "file:data/pocket-id.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(2500)&_txlock=immediate",
UploadPath: "data/uploads",
KeysPath: "data/keys",
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,
}
var EnvConfig = defaultConfig()
func init() {
if err := env.ParseWithOptions(EnvConfig, env.Options{}); err != nil {
log.Fatal(err)
err := parseEnvConfig()
if err != nil {
log.Fatalf("Configuration error: %v", err)
}
}
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,
}
}
func parseEnvConfig() error {
err := env.ParseWithOptions(&EnvConfig, env.Options{})
if err != nil {
return fmt.Errorf("error parsing env config: %w", err)
}
// Validate the environment variables
switch EnvConfig.DbProvider {
case DbProviderSqlite:
if EnvConfig.DbConnectionString == "" {
log.Fatal("Missing required env var 'DB_CONNECTION_STRING' for SQLite database")
EnvConfig.DbConnectionString = defaultSqliteConnString
}
case DbProviderPostgres:
if EnvConfig.DbConnectionString == "" {
log.Fatal("Missing required env var 'DB_CONNECTION_STRING' for Postgres database")
return errors.New("missing required env var 'DB_CONNECTION_STRING' for Postgres database")
}
default:
log.Fatal("Invalid DB_PROVIDER value. Must be 'sqlite' or 'postgres'")
return errors.New("invalid DB_PROVIDER value. Must be 'sqlite' or 'postgres'")
}
parsedAppUrl, err := url.Parse(EnvConfig.AppURL)
if err != nil {
log.Fatal("APP_URL is not a valid URL")
return errors.New("APP_URL is not a valid URL")
}
if parsedAppUrl.Path != "" {
log.Fatal("APP_URL must not contain a path")
return errors.New("APP_URL must not contain a path")
}
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
}

View File

@@ -0,0 +1,188 @@
package common
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseEnvConfig(t *testing.T) {
// Store original config to restore later
originalConfig := EnvConfig
t.Cleanup(func() {
EnvConfig = originalConfig
})
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")
err := parseEnvConfig()
require.NoError(t, err)
assert.Equal(t, DbProviderSqlite, EnvConfig.DbProvider)
})
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()
require.NoError(t, err)
assert.Equal(t, DbProviderPostgres, EnvConfig.DbProvider)
})
t.Run("should fail with invalid DB_PROVIDER", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "invalid")
t.Setenv("DB_CONNECTION_STRING", "test")
t.Setenv("APP_URL", "http://localhost:3000")
err := parseEnvConfig()
require.Error(t, err)
assert.ErrorContains(t, err, "invalid DB_PROVIDER value")
})
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()
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()
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()
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) {
EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "sqlite")
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("APP_URL", "http://localhost:3000")
err := parseEnvConfig()
require.NoError(t, err)
assert.Equal(t, "file", EnvConfig.KeysStorage)
})
t.Run("should fail when KEYS_STORAGE is 'database' but no encryption key", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "sqlite")
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("APP_URL", "http://localhost:3000")
t.Setenv("KEYS_STORAGE", "database")
err := parseEnvConfig()
require.Error(t, err)
assert.ErrorContains(t, err, "ENCRYPTION_KEY or ENCRYPTION_KEY_FILE must be non-empty")
})
t.Run("should accept valid KEYS_STORAGE values", func(t *testing.T) {
validStorageTypes := []string{"file", "database"}
for _, storage := range validStorageTypes {
EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "sqlite")
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("APP_URL", "http://localhost:3000")
t.Setenv("KEYS_STORAGE", storage)
if storage == "database" {
t.Setenv("ENCRYPTION_KEY", "test-key")
}
err := parseEnvConfig()
require.NoError(t, err)
assert.Equal(t, storage, EnvConfig.KeysStorage)
}
})
t.Run("should fail with invalid KEYS_STORAGE value", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "sqlite")
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("APP_URL", "http://localhost:3000")
t.Setenv("KEYS_STORAGE", "invalid")
err := parseEnvConfig()
require.Error(t, err)
assert.ErrorContains(t, err, "invalid value for KEYS_STORAGE")
})
t.Run("should parse boolean environment variables correctly", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "sqlite")
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("APP_URL", "http://localhost:3000")
t.Setenv("UI_CONFIG_DISABLED", "true")
t.Setenv("METRICS_ENABLED", "true")
t.Setenv("TRACING_ENABLED", "false")
t.Setenv("TRUST_PROXY", "true")
t.Setenv("ANALYTICS_DISABLED", "false")
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)
})
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("UPLOAD_PATH", "/custom/uploads")
t.Setenv("KEYS_PATH", "/custom/keys")
t.Setenv("PORT", "8080")
t.Setenv("HOST", "127.0.0.1")
t.Setenv("UNIX_SOCKET", "/tmp/app.sock")
t.Setenv("MAXMIND_LICENSE_KEY", "test-license")
t.Setenv("GEOLITE_DB_PATH", "/custom/geolite.mmdb")
err := parseEnvConfig()
require.NoError(t, err)
assert.Equal(t, "staging", EnvConfig.AppEnv)
assert.Equal(t, "/custom/uploads", EnvConfig.UploadPath)
assert.Equal(t, "8080", EnvConfig.Port)
assert.Equal(t, "127.0.0.1", EnvConfig.Host)
})
}

View File

@@ -349,3 +349,13 @@ func (e *OidcAuthorizationPendingError) Error() string {
func (e *OidcAuthorizationPendingError) HttpStatusCode() int {
return http.StatusBadRequest
}
type OpenSignupDisabledError struct{}
func (e *OpenSignupDisabledError) Error() string {
return "Open user signup is not enabled"
}
func (e *OpenSignupDisabledError) HttpStatusCode() int {
return http.StatusForbidden
}

View File

@@ -82,7 +82,7 @@ func (c *ApiKeyController) createApiKeyHandler(ctx *gin.Context) {
userID := ctx.GetString("userID")
var input dto.ApiKeyCreateDto
if err := ctx.ShouldBindJSON(&input); err != nil {
if err := dto.ShouldBindWithNormalizedJSON(ctx, &input); err != nil {
_ = ctx.Error(err)
return
}

View File

@@ -109,7 +109,7 @@ func (acc *AppConfigController) listAllAppConfigHandler(c *gin.Context) {
// @Router /api/application-configuration [put]
func (acc *AppConfigController) updateAppConfigHandler(c *gin.Context) {
var input dto.AppConfigUpdateDto
if err := c.ShouldBindJSON(&input); err != nil {
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
}

View File

@@ -59,7 +59,7 @@ func (ccc *CustomClaimController) getSuggestionsHandler(c *gin.Context) {
func (ccc *CustomClaimController) UpdateCustomClaimsForUserHandler(c *gin.Context) {
var input []dto.CustomClaimCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
}
@@ -93,7 +93,7 @@ func (ccc *CustomClaimController) UpdateCustomClaimsForUserHandler(c *gin.Contex
func (ccc *CustomClaimController) UpdateCustomClaimsForUserGroupHandler(c *gin.Context) {
var input []dto.CustomClaimCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
}

View File

@@ -33,6 +33,7 @@ func (tc *TestController) resetAndSeedHandler(c *gin.Context) {
}
skipLdap := c.Query("skip-ldap") == "true"
skipSeed := c.Query("skip-seed") == "true"
if err := tc.TestService.ResetDatabase(); err != nil {
_ = c.Error(err)
@@ -44,9 +45,11 @@ func (tc *TestController) resetAndSeedHandler(c *gin.Context) {
return
}
if err := tc.TestService.SeedDatabase(baseURL); err != nil {
_ = c.Error(err)
return
if !skipSeed {
if err := tc.TestService.SeedDatabase(baseURL); err != nil {
_ = c.Error(err)
return
}
}
if err := tc.TestService.ResetAppConfig(c.Request.Context()); err != nil {

View File

@@ -89,6 +89,7 @@ func (oc *OidcController) authorizeHandler(c *gin.Context) {
response := dto.AuthorizeOidcClientResponseDto{
Code: code,
CallbackURL: callbackURL,
Issuer: common.EnvConfig.AppURL,
}
c.JSON(http.StatusOK, response)

View File

@@ -44,11 +44,17 @@ func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
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-token/setup", uc.getSetupAccessTokenHandler)
group.POST("/one-time-access-email", rateLimitMiddleware.Add(rate.Every(10*time.Minute), 3), 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)
}
type UserController struct {
@@ -187,7 +193,7 @@ func (uc *UserController) deleteUserHandler(c *gin.Context) {
// @Router /api/users [post]
func (uc *UserController) createUserHandler(c *gin.Context) {
var input dto.UserCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
}
@@ -372,7 +378,7 @@ func (uc *UserController) createAdminOneTimeAccessTokenHandler(c *gin.Context) {
// @Router /api/one-time-access-email [post]
func (uc *UserController) RequestOneTimeAccessEmailAsUnauthenticatedUserHandler(c *gin.Context) {
var input dto.OneTimeAccessEmailAsUnauthenticatedUserDto
if err := c.ShouldBindJSON(&input); err != nil {
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
}
@@ -440,14 +446,23 @@ func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) {
c.JSON(http.StatusOK, userDto)
}
// getSetupAccessTokenHandler godoc
// @Summary Setup initial admin
// @Description Generate setup access token for initial admin user configuration
// 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/one-time-access-token/setup [post]
func (uc *UserController) getSetupAccessTokenHandler(c *gin.Context) {
user, token, err := uc.userService.SetupInitialAdmin(c.Request.Context())
// @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)
if err != nil {
_ = c.Error(err)
return
@@ -495,10 +510,132 @@ 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
if err := c.ShouldBindJSON(&input); err != nil {
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
}

View File

@@ -120,7 +120,7 @@ func (ugc *UserGroupController) get(c *gin.Context) {
// @Router /api/user-groups [post]
func (ugc *UserGroupController) create(c *gin.Context) {
var input dto.UserGroupCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
}
@@ -152,7 +152,7 @@ func (ugc *UserGroupController) create(c *gin.Context) {
// @Router /api/user-groups/{id} [put]
func (ugc *UserGroupController) update(c *gin.Context) {
var input dto.UserGroupCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
}

View File

@@ -69,20 +69,21 @@ func (wkc *WellKnownController) computeOIDCConfiguration() ([]byte, error) {
return nil, fmt.Errorf("failed to get key algorithm: %w", err)
}
config := map[string]any{
"issuer": appUrl,
"authorization_endpoint": appUrl + "/authorize",
"token_endpoint": appUrl + "/api/oidc/token",
"userinfo_endpoint": appUrl + "/api/oidc/userinfo",
"end_session_endpoint": appUrl + "/api/oidc/end-session",
"introspection_endpoint": appUrl + "/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"},
"response_types_supported": []string{"code", "id_token"},
"subject_types_supported": []string{"public"},
"id_token_signing_alg_values_supported": []string{alg.String()},
"issuer": appUrl,
"authorization_endpoint": appUrl + "/authorize",
"token_endpoint": appUrl + "/api/oidc/token",
"userinfo_endpoint": appUrl + "/api/oidc/userinfo",
"end_session_endpoint": appUrl + "/api/oidc/end-session",
"introspection_endpoint": appUrl + "/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"},
"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,
}
return json.Marshal(config)
}

View File

@@ -5,8 +5,8 @@ import (
)
type ApiKeyCreateDto struct {
Name string `json:"name" binding:"required,min=3,max=50"`
Description string `json:"description"`
Name string `json:"name" binding:"required,min=3,max=50" unorm:"nfc"`
Description string `json:"description" unorm:"nfc"`
ExpiresAt datatype.DateTime `json:"expiresAt" binding:"required"`
}

View File

@@ -12,11 +12,12 @@ type AppConfigVariableDto struct {
}
type AppConfigUpdateDto struct {
AppName string `json:"appName" binding:"required,min=1,max=30"`
AppName string `json:"appName" binding:"required,min=1,max=30" unorm:"nfc"`
SessionDuration string `json:"sessionDuration" 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"`
AccentColor string `json:"accentColor"`
SmtpHost string `json:"smtpHost"`
SmtpPort string `json:"smtpPort"`

View File

@@ -1,7 +1,6 @@
package dto
import (
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
)
@@ -9,14 +8,14 @@ type AuditLogDto struct {
ID string `json:"id"`
CreatedAt datatype.DateTime `json:"createdAt"`
Event model.AuditLogEvent `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 model.AuditLogData `json:"data"`
Event string `json:"event"`
IpAddress string `json:"ipAddress"`
Country string `json:"country"`
City string `json:"city"`
Device string `json:"device"`
UserID string `json:"userID"`
Username string `json:"username"`
Data map[string]string `json:"data"`
}
type AuditLogFilterDto struct {

View File

@@ -6,6 +6,6 @@ type CustomClaimDto struct {
}
type CustomClaimCreateDto struct {
Key string `json:"key" binding:"required"`
Value string `json:"value" binding:"required"`
Key string `json:"key" binding:"required" unorm:"nfc"`
Value string `json:"value" binding:"required" unorm:"nfc"`
}

View File

@@ -1,162 +1,27 @@
package dto
import (
"errors"
"reflect"
"time"
"fmt"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/jinzhu/copier"
)
// MapStructList maps a list of source structs to a list of destination structs
func MapStructList[S any, D any](source []S, destination *[]D) error {
*destination = make([]D, 0, len(source))
func MapStructList[S any, D any](source []S, destination *[]D) (err error) {
*destination = make([]D, len(source))
for _, item := range source {
var destItem D
if err := MapStruct(item, &destItem); err != nil {
return err
for i, item := range source {
err = MapStruct(item, &((*destination)[i]))
if err != nil {
return fmt.Errorf("failed to map field %d: %w", i, err)
}
*destination = append(*destination, destItem)
}
return nil
}
// MapStruct maps a source struct to a destination struct
func MapStruct[S any, D any](source S, destination *D) error {
// Ensure destination is a non-nil pointer
destValue := reflect.ValueOf(destination)
if destValue.Kind() != reflect.Ptr || destValue.IsNil() {
return errors.New("destination must be a non-nil pointer to a struct")
}
// Ensure source is a struct
sourceValue := reflect.ValueOf(source)
if sourceValue.Kind() != reflect.Struct {
return errors.New("source must be a struct")
}
return mapStructInternal(sourceValue, destValue.Elem())
}
func mapStructInternal(sourceVal reflect.Value, destVal reflect.Value) error {
for i := 0; i < destVal.NumField(); i++ {
destField := destVal.Field(i)
destFieldType := destVal.Type().Field(i)
if destFieldType.Anonymous {
if err := mapStructInternal(sourceVal, destField); err != nil {
return err
}
continue
}
sourceField := sourceVal.FieldByName(destFieldType.Name)
if sourceField.IsValid() && destField.CanSet() {
if err := mapField(sourceField, destField); err != nil {
return err
}
}
}
return nil
}
//nolint:gocognit
func mapField(sourceField reflect.Value, destField reflect.Value) error {
// Handle pointer to struct in source
if sourceField.Kind() == reflect.Ptr && !sourceField.IsNil() {
switch {
case sourceField.Elem().Kind() == reflect.Struct:
switch {
case destField.Kind() == reflect.Struct:
// Map from pointer to struct -> struct
return mapStructInternal(sourceField.Elem(), destField)
case destField.Kind() == reflect.Ptr && destField.CanSet():
// Map from pointer to struct -> pointer to struct
if destField.IsNil() {
destField.Set(reflect.New(destField.Type().Elem()))
}
return mapStructInternal(sourceField.Elem(), destField.Elem())
}
case destField.Kind() == reflect.Ptr &&
destField.CanSet() &&
sourceField.Elem().Type().AssignableTo(destField.Type().Elem()):
// Handle primitive pointer types (e.g., *string to *string)
if destField.IsNil() {
destField.Set(reflect.New(destField.Type().Elem()))
}
destField.Elem().Set(sourceField.Elem())
return nil
case destField.Kind() != reflect.Ptr &&
destField.CanSet() &&
sourceField.Elem().Type().AssignableTo(destField.Type()):
// Handle *T to T conversion for primitive types
destField.Set(sourceField.Elem())
return nil
}
}
// Handle pointer to struct in destination
if destField.Kind() == reflect.Ptr && destField.CanSet() {
switch {
case sourceField.Kind() == reflect.Struct:
// Map from struct -> pointer to struct
if destField.IsNil() {
destField.Set(reflect.New(destField.Type().Elem()))
}
return mapStructInternal(sourceField, destField.Elem())
case !sourceField.IsZero() && sourceField.Type().AssignableTo(destField.Type().Elem()):
// Handle T to *T conversion for primitive types
if destField.IsNil() {
destField.Set(reflect.New(destField.Type().Elem()))
}
destField.Elem().Set(sourceField)
return nil
}
}
switch {
case sourceField.Type() == destField.Type():
destField.Set(sourceField)
case sourceField.Kind() == reflect.Slice && destField.Kind() == reflect.Slice:
return mapSlice(sourceField, destField)
case sourceField.Kind() == reflect.Struct && destField.Kind() == reflect.Struct:
return mapStructInternal(sourceField, destField)
default:
return mapSpecialTypes(sourceField, destField)
}
return nil
}
func mapSlice(sourceField reflect.Value, destField reflect.Value) error {
if sourceField.Type().Elem() == destField.Type().Elem() {
newSlice := reflect.MakeSlice(destField.Type(), sourceField.Len(), sourceField.Cap())
for j := 0; j < sourceField.Len(); j++ {
newSlice.Index(j).Set(sourceField.Index(j))
}
destField.Set(newSlice)
} else if sourceField.Type().Elem().Kind() == reflect.Struct && destField.Type().Elem().Kind() == reflect.Struct {
newSlice := reflect.MakeSlice(destField.Type(), sourceField.Len(), sourceField.Cap())
for j := 0; j < sourceField.Len(); j++ {
sourceElem := sourceField.Index(j)
destElem := reflect.New(destField.Type().Elem()).Elem()
if err := mapStructInternal(sourceElem, destElem); err != nil {
return err
}
newSlice.Index(j).Set(destElem)
}
destField.Set(newSlice)
}
return nil
}
func mapSpecialTypes(sourceField reflect.Value, destField reflect.Value) error {
if _, ok := sourceField.Interface().(datatype.DateTime); ok {
if sourceField.Type() == reflect.TypeOf(datatype.DateTime{}) && destField.Type() == reflect.TypeOf(time.Time{}) {
dateValue := sourceField.Interface().(datatype.DateTime)
destField.Set(reflect.ValueOf(dateValue.ToTime()))
}
}
return nil
func MapStruct(source any, destination any) error {
return copier.CopyWithOption(destination, source, copier.Option{
DeepCopy: true,
})
}

View File

@@ -0,0 +1,197 @@
package dto
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"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 {
AString string
AStringPtr *string
ABool bool
ABoolPtr *bool
ACustomDateTime datatype.DateTime
ACustomDateTimePtr *datatype.DateTime
ANilStringPtr *string
ASlice []string
AMap map[string]int
AStruct embeddedStruct
AStructPtr *embeddedStruct
StringPtrToString *string
EmptyStringPtrToString *string
NilStringPtrToString *string
IntToInt64 int
AuditLogEventToString model.AuditLogEvent
}
type destStruct struct {
AString string
AStringPtr *string
ABool bool
ABoolPtr *bool
ACustomDateTime datatype.DateTime
ACustomDateTimePtr *datatype.DateTime
ANilStringPtr *string
ASlice []string
AMap map[string]int
AStruct embeddedStruct
AStructPtr *embeddedStruct
StringPtrToString string
EmptyStringPtrToString string
NilStringPtrToString string
IntToInt64 int64
AuditLogEventToString string
}
type embeddedStruct struct {
Foo string
Bar int64
}
func TestMapStruct(t *testing.T) {
src := sourceStruct{
AString: "abcd",
AStringPtr: utils.Ptr("xyz"),
ABool: true,
ABoolPtr: utils.Ptr(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))),
ANilStringPtr: nil,
ASlice: []string{"a", "b", "c"},
AMap: map[string]int{
"a": 1,
"b": 2,
},
AStruct: embeddedStruct{
Foo: "bar",
Bar: 42,
},
AStructPtr: &embeddedStruct{
Foo: "quo",
Bar: 111,
},
StringPtrToString: utils.Ptr("foobar"),
EmptyStringPtrToString: utils.Ptr(""),
NilStringPtrToString: nil,
IntToInt64: 99,
AuditLogEventToString: model.AuditLogEventAccountCreated,
}
var dst destStruct
err := MapStruct(src, &dst)
require.NoError(t, err)
assert.Equal(t, src.AString, dst.AString)
_ = assert.NotNil(t, src.AStringPtr) &&
assert.Equal(t, *src.AStringPtr, *dst.AStringPtr)
assert.Equal(t, src.ABool, dst.ABool)
_ = assert.NotNil(t, src.ABoolPtr) &&
assert.Equal(t, *src.ABoolPtr, *dst.ABoolPtr)
assert.Equal(t, src.ACustomDateTime, dst.ACustomDateTime)
_ = assert.NotNil(t, src.ACustomDateTimePtr) &&
assert.Equal(t, *src.ACustomDateTimePtr, *dst.ACustomDateTimePtr)
assert.Nil(t, dst.ANilStringPtr)
assert.Equal(t, src.ASlice, dst.ASlice)
assert.Equal(t, src.AMap, dst.AMap)
assert.Equal(t, "bar", dst.AStruct.Foo)
assert.Equal(t, int64(42), dst.AStruct.Bar)
_ = assert.NotNil(t, src.AStructPtr) &&
assert.Equal(t, "quo", dst.AStructPtr.Foo) &&
assert.Equal(t, int64(111), dst.AStructPtr.Bar)
assert.Equal(t, "foobar", dst.StringPtrToString)
assert.Empty(t, dst.EmptyStringPtrToString)
assert.Empty(t, dst.NilStringPtrToString)
assert.Equal(t, int64(99), dst.IntToInt64)
assert.Equal(t, "ACCOUNT_CREATED", dst.AuditLogEventToString)
}
func TestMapStructList(t *testing.T) {
sources := []sourceStruct{
{
AString: "first",
AStringPtr: utils.Ptr("one"),
ABool: true,
ABoolPtr: utils.Ptr(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))),
ASlice: []string{"a", "b"},
AMap: map[string]int{
"a": 1,
"b": 2,
},
AStruct: embeddedStruct{
Foo: "first_struct",
Bar: 10,
},
IntToInt64: 10,
},
{
AString: "second",
AStringPtr: utils.Ptr("two"),
ABool: false,
ABoolPtr: utils.Ptr(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))),
ASlice: []string{"c", "d", "e"},
AMap: map[string]int{
"c": 3,
"d": 4,
},
AStruct: embeddedStruct{
Foo: "second_struct",
Bar: 20,
},
IntToInt64: 20,
},
}
var destinations []destStruct
err := MapStructList(sources, &destinations)
require.NoError(t, err)
require.Len(t, destinations, 2)
// Verify first element
assert.Equal(t, "first", destinations[0].AString)
assert.Equal(t, "one", *destinations[0].AStringPtr)
assert.True(t, destinations[0].ABool)
assert.False(t, *destinations[0].ABoolPtr)
assert.Equal(t, datatype.DateTime(time.Date(2025, 1, 2, 3, 4, 5, 0, time.UTC)), destinations[0].ACustomDateTime)
assert.Equal(t, datatype.DateTime(time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC)), *destinations[0].ACustomDateTimePtr)
assert.Equal(t, []string{"a", "b"}, destinations[0].ASlice)
assert.Equal(t, map[string]int{"a": 1, "b": 2}, destinations[0].AMap)
assert.Equal(t, "first_struct", destinations[0].AStruct.Foo)
assert.Equal(t, int64(10), destinations[0].AStruct.Bar)
assert.Equal(t, int64(10), destinations[0].IntToInt64)
// Verify second element
assert.Equal(t, "second", destinations[1].AString)
assert.Equal(t, "two", *destinations[1].AStringPtr)
assert.False(t, destinations[1].ABool)
assert.True(t, *destinations[1].ABoolPtr)
assert.Equal(t, datatype.DateTime(time.Date(2026, 6, 7, 8, 9, 10, 0, time.UTC)), destinations[1].ACustomDateTime)
assert.Equal(t, datatype.DateTime(time.Date(2023, 6, 7, 8, 9, 10, 0, time.UTC)), *destinations[1].ACustomDateTimePtr)
assert.Equal(t, []string{"c", "d", "e"}, destinations[1].ASlice)
assert.Equal(t, map[string]int{"c": 3, "d": 4}, destinations[1].AMap)
assert.Equal(t, "second_struct", destinations[1].AStruct.Foo)
assert.Equal(t, int64(20), destinations[1].AStruct.Bar)
assert.Equal(t, int64(20), destinations[1].IntToInt64)
}
func TestMapStructList_EmptySource(t *testing.T) {
var sources []sourceStruct
var destinations []destStruct
err := MapStructList(sources, &destinations)
require.NoError(t, err)
assert.Empty(t, destinations)
}

View File

@@ -0,0 +1,94 @@
package dto
import (
"net/http"
"reflect"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"golang.org/x/text/unicode/norm"
)
// Normalize iterates through an object and performs Unicode normalization on all string fields with the `unorm` tag.
func Normalize(obj any) {
v := reflect.ValueOf(obj)
if v.Kind() != reflect.Ptr || v.IsNil() {
return
}
v = v.Elem()
// Handle case where obj is a slice of models
if v.Kind() == reflect.Slice {
for i := 0; i < v.Len(); i++ {
elem := v.Index(i)
if elem.Kind() == reflect.Ptr && !elem.IsNil() && elem.Elem().Kind() == reflect.Struct {
Normalize(elem.Interface())
} else if elem.Kind() == reflect.Struct && elem.CanAddr() {
Normalize(elem.Addr().Interface())
}
}
return
}
if v.Kind() != reflect.Struct {
return
}
// Iterate through all fields looking for those with the "unorm" tag
t := v.Type()
loop:
for i := range t.NumField() {
field := t.Field(i)
unormTag := field.Tag.Get("unorm")
if unormTag == "" {
continue
}
fv := v.Field(i)
if !fv.CanSet() || fv.Kind() != reflect.String {
continue
}
var form norm.Form
switch unormTag {
case "nfc":
form = norm.NFC
case "nfkc":
form = norm.NFKC
case "nfd":
form = norm.NFD
case "nfkd":
form = norm.NFKD
default:
continue loop
}
val := fv.String()
val = form.String(val)
fv.SetString(val)
}
}
func ShouldBindWithNormalizedJSON(ctx *gin.Context, obj any) error {
return ctx.ShouldBindWith(obj, binding.JSON)
}
type NormalizerJSONBinding struct{}
func (NormalizerJSONBinding) Name() string {
return "json"
}
func (NormalizerJSONBinding) Bind(req *http.Request, obj any) error {
// Use the default JSON binder
err := binding.JSON.Bind(req, obj)
if err != nil {
return err
}
// Perform normalization
Normalize(obj)
return nil
}

View File

@@ -0,0 +1,84 @@
package dto
import (
"testing"
"github.com/stretchr/testify/assert"
"golang.org/x/text/unicode/norm"
)
type testDto struct {
Name string `unorm:"nfc"`
Description string `unorm:"nfd"`
Other string
BadForm string `unorm:"bad"`
}
func TestNormalize(t *testing.T) {
input := testDto{
// Is in NFC form already
Name: norm.NFC.String("Café"),
// NFC form will be normalized to NFD
Description: norm.NFC.String("vërø"),
// Should be unchanged
Other: "NöTag",
// Should be unchanged
BadForm: "BåD",
}
Normalize(&input)
assert.Equal(t, norm.NFC.String("Café"), input.Name)
assert.Equal(t, norm.NFD.String("vërø"), input.Description)
assert.Equal(t, "NöTag", input.Other)
assert.Equal(t, "BåD", input.BadForm)
}
func TestNormalizeSlice(t *testing.T) {
obj1 := testDto{
Name: norm.NFC.String("Café1"),
Description: norm.NFC.String("vërø1"),
Other: "NöTag1",
BadForm: "BåD1",
}
obj2 := testDto{
Name: norm.NFD.String("Résumé2"),
Description: norm.NFD.String("accéléré2"),
Other: "NöTag2",
BadForm: "BåD2",
}
t.Run("slice of structs", func(t *testing.T) {
slice := []testDto{obj1, obj2}
Normalize(&slice)
// Verify first element
assert.Equal(t, norm.NFC.String("Café1"), slice[0].Name)
assert.Equal(t, norm.NFD.String("vërø1"), slice[0].Description)
assert.Equal(t, "NöTag1", slice[0].Other)
assert.Equal(t, "BåD1", slice[0].BadForm)
// Verify second element
assert.Equal(t, norm.NFC.String("Résumé2"), slice[1].Name)
assert.Equal(t, norm.NFD.String("accéléré2"), slice[1].Description)
assert.Equal(t, "NöTag2", slice[1].Other)
assert.Equal(t, "BåD2", slice[1].BadForm)
})
t.Run("slice of pointers to structs", func(t *testing.T) {
slice := []*testDto{&obj1, &obj2}
Normalize(&slice)
// Verify first element
assert.Equal(t, norm.NFC.String("Café1"), slice[0].Name)
assert.Equal(t, norm.NFD.String("vërø1"), slice[0].Description)
assert.Equal(t, "NöTag1", slice[0].Other)
assert.Equal(t, "BåD1", slice[0].BadForm)
// Verify second element
assert.Equal(t, norm.NFC.String("Résumé2"), slice[1].Name)
assert.Equal(t, norm.NFD.String("accéléré2"), slice[1].Description)
assert.Equal(t, "NöTag2", slice[1].Other)
assert.Equal(t, "BåD2", slice[1].BadForm)
})
}

View File

@@ -26,7 +26,7 @@ type OidcClientWithAllowedGroupsCountDto struct {
}
type OidcClientCreateDto struct {
Name string `json:"name" binding:"required,max=50"`
Name string `json:"name" binding:"required,max=50" unorm:"nfc"`
CallbackURLs []string `json:"callbackURLs"`
LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
IsPublic bool `json:"isPublic"`
@@ -57,6 +57,7 @@ type AuthorizeOidcClientRequestDto struct {
type AuthorizeOidcClientResponseDto struct {
Code string `json:"code"`
CallbackURL string `json:"callbackURL"`
Issuer string `json:"issuer"`
}
type AuthorizationRequiredDto struct {
@@ -149,7 +150,7 @@ type AuthorizedOidcClientDto struct {
}
type OidcClientPreviewDto struct {
IdToken map[string]interface{} `json:"idToken"`
AccessToken map[string]interface{} `json:"accessToken"`
UserInfo map[string]interface{} `json:"userInfo"`
IdToken map[string]any `json:"idToken"`
AccessToken map[string]any `json:"accessToken"`
UserInfo map[string]any `json:"userInfo"`
}

View File

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

View File

@@ -1,6 +1,8 @@
package dto
import "time"
import (
"time"
)
type UserDto struct {
ID string `json:"id"`
@@ -17,10 +19,10 @@ type UserDto struct {
}
type UserCreateDto struct {
Username string `json:"username" binding:"required,username,min=2,max=50"`
Email string `json:"email" binding:"required,email"`
FirstName string `json:"firstName" binding:"required,min=1,max=50"`
LastName string `json:"lastName" binding:"max=50"`
Username string `json:"username" binding:"required,username,min=2,max=50" unorm:"nfc"`
Email string `json:"email" binding:"required,email" unorm:"nfc"`
FirstName string `json:"firstName" binding:"required,min=1,max=50" unorm:"nfc"`
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
IsAdmin bool `json:"isAdmin"`
Locale *string `json:"locale"`
Disabled bool `json:"disabled"`
@@ -33,7 +35,7 @@ type OneTimeAccessTokenCreateDto struct {
}
type OneTimeAccessEmailAsUnauthenticatedUserDto struct {
Email string `json:"email" binding:"required,email"`
Email string `json:"email" binding:"required,email" unorm:"nfc"`
RedirectPath string `json:"redirectPath"`
}
@@ -44,3 +46,11 @@ type OneTimeAccessEmailAsAdminDto struct {
type UserUpdateUserGroupDto struct {
UserGroupIds []string `json:"userGroupIds" binding:"required"`
}
type SignUpDto struct {
Username string `json:"username" binding:"required,username,min=2,max=50" unorm:"nfc"`
Email string `json:"email" binding:"required,email" unorm:"nfc"`
FirstName string `json:"firstName" binding:"required,min=1,max=50" unorm:"nfc"`
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
Token string `json:"token"`
}

View File

@@ -34,8 +34,8 @@ type UserGroupDtoWithUserCount struct {
}
type UserGroupCreateDto struct {
FriendlyName string `json:"friendlyName" binding:"required,min=2,max=50"`
Name string `json:"name" binding:"required,min=2,max=255"`
FriendlyName string `json:"friendlyName" binding:"required,min=2,max=50" unorm:"nfc"`
Name string `json:"name" binding:"required,min=2,max=255" unorm:"nfc"`
LdapID string `json:"-"`
}

View File

@@ -8,13 +8,13 @@ import (
"github.com/go-playground/validator/v10"
)
// [a-zA-Z0-9] : The username must start with an alphanumeric character
// [a-zA-Z0-9_.@-]* : The rest of the username can contain alphanumeric characters, dots, underscores, hyphens, and "@" symbols
// [a-zA-Z0-9]$ : The username must end with an alphanumeric character
var validateUsernameRegex = regexp.MustCompile("^[a-zA-Z0-9][a-zA-Z0-9_.@-]*[a-zA-Z0-9]$")
var validateUsername validator.Func = func(fl validator.FieldLevel) bool {
// [a-zA-Z0-9] : The username must start with an alphanumeric character
// [a-zA-Z0-9_.@-]* : The rest of the username can contain alphanumeric characters, dots, underscores, hyphens, and "@" symbols
// [a-zA-Z0-9]$ : The username must end with an alphanumeric character
regex := "^[a-zA-Z0-9][a-zA-Z0-9_.@-]*[a-zA-Z0-9]$"
matched, _ := regexp.MatchString(regex, fl.Field().String())
return matched
return validateUsernameRegex.MatchString(fl.Field().String())
}
func init() {

View File

@@ -19,5 +19,5 @@ type WebauthnCredentialDto struct {
}
type WebauthnCredentialUpdateDto struct {
Name string `json:"name" binding:"required,min=1,max=30"`
Name string `json:"name" binding:"required,min=1,max=50"`
}

View File

@@ -22,6 +22,7 @@ func (s *Scheduler) RegisterDbCleanupJobs(ctx context.Context, db *gorm.DB) erro
return errors.Join(
s.registerJob(ctx, "ClearWebauthnSessions", def, jobs.clearWebauthnSessions, true),
s.registerJob(ctx, "ClearOneTimeAccessTokens", def, jobs.clearOneTimeAccessTokens, true),
s.registerJob(ctx, "ClearSignupTokens", def, jobs.clearSignupTokens, true),
s.registerJob(ctx, "ClearOidcAuthorizationCodes", def, jobs.clearOidcAuthorizationCodes, true),
s.registerJob(ctx, "ClearOidcRefreshTokens", def, jobs.clearOidcRefreshTokens, true),
s.registerJob(ctx, "ClearAuditLogs", def, jobs.clearAuditLogs, true),
@@ -60,6 +61,21 @@ func (j *DbCleanupJobs) clearOneTimeAccessTokens(ctx context.Context) error {
return nil
}
// ClearSignupTokens deletes signup tokens that have expired
func (j *DbCleanupJobs) clearSignupTokens(ctx context.Context) error {
// Delete tokens that are expired OR have reached their usage limit
st := j.db.
WithContext(ctx).
Delete(&model.SignupToken{}, "expires_at < ?", datatype.DateTime(time.Now()))
if st.Error != nil {
return fmt.Errorf("failed to clean expired tokens: %w", st.Error)
}
slog.InfoContext(ctx, "Cleaned expired tokens", slog.Int64("count", st.RowsAffected))
return nil
}
// ClearOidcAuthorizationCodes deletes OIDC authorization codes that have expired
func (j *DbCleanupJobs) clearOidcAuthorizationCodes(ctx context.Context) error {
st := j.db.

View File

@@ -29,7 +29,7 @@ func (m *RateLimitMiddleware) Add(limit rate.Limit, burst int) gin.HandlerFunc {
// Skip rate limiting for localhost and test environment
// If the client ip is localhost the request comes from the frontend
if ip == "127.0.0.1" || ip == "::1" || common.EnvConfig.AppEnv == "test" {
if ip == "" || ip == "127.0.0.1" || ip == "::1" || common.EnvConfig.AppEnv == "test" {
c.Next()
return
}

View File

@@ -8,6 +8,8 @@ import (
"strconv"
"strings"
"time"
"github.com/pocket-id/pocket-id/backend/internal/common"
)
type AppConfigVariable struct {
@@ -38,6 +40,7 @@ type AppConfig struct {
AccentColor AppConfigVariable `key:"accentColor,public"` // Public
DisableAnimations AppConfigVariable `key:"disableAnimations,public"` // Public
AllowOwnAccountEdit AppConfigVariable `key:"allowOwnAccountEdit,public"` // Public
AllowUserSignups AppConfigVariable `key:"allowUserSignups,public"` // Public
// Internal
BackgroundImageType AppConfigVariable `key:"backgroundImageType,internal"` // Internal
LogoLightImageType AppConfigVariable `key:"logoLightImageType,internal"` // Internal
@@ -48,7 +51,7 @@ type AppConfig struct {
SmtpPort AppConfigVariable `key:"smtpPort"`
SmtpFrom AppConfigVariable `key:"smtpFrom"`
SmtpUser AppConfigVariable `key:"smtpUser"`
SmtpPassword AppConfigVariable `key:"smtpPassword"`
SmtpPassword AppConfigVariable `key:"smtpPassword,sensitive"`
SmtpTls AppConfigVariable `key:"smtpTls"`
SmtpSkipCertVerify AppConfigVariable `key:"smtpSkipCertVerify"`
EmailLoginNotificationEnabled AppConfigVariable `key:"emailLoginNotificationEnabled"`
@@ -59,7 +62,7 @@ type AppConfig struct {
LdapEnabled AppConfigVariable `key:"ldapEnabled,public"` // Public
LdapUrl AppConfigVariable `key:"ldapUrl"`
LdapBindDn AppConfigVariable `key:"ldapBindDn"`
LdapBindPassword AppConfigVariable `key:"ldapBindPassword"`
LdapBindPassword AppConfigVariable `key:"ldapBindPassword,sensitive"`
LdapBase AppConfigVariable `key:"ldapBase"`
LdapUserSearchFilter AppConfigVariable `key:"ldapUserSearchFilter"`
LdapUserGroupSearchFilter AppConfigVariable `key:"ldapUserGroupSearchFilter"`
@@ -77,7 +80,7 @@ type AppConfig struct {
LdapSoftDeleteUsers AppConfigVariable `key:"ldapSoftDeleteUsers"`
}
func (c *AppConfig) ToAppConfigVariableSlice(showAll bool) []AppConfigVariable {
func (c *AppConfig) ToAppConfigVariableSlice(showAll bool, redactSensitiveValues bool) []AppConfigVariable {
// Use reflection to iterate through all fields
cfgValue := reflect.ValueOf(c).Elem()
cfgType := cfgValue.Type()
@@ -97,11 +100,16 @@ func (c *AppConfig) ToAppConfigVariableSlice(showAll bool) []AppConfigVariable {
continue
}
fieldValue := cfgValue.Field(i)
value := cfgValue.Field(i).FieldByName("Value").String()
// Redact sensitive values if the value isn't empty, the UI config is disabled, and redactSensitiveValues is true
if value != "" && common.EnvConfig.UiConfigDisabled && redactSensitiveValues && attrs == "sensitive" {
value = "XXXXXXXXXX"
}
appConfigVariable := AppConfigVariable{
Key: key,
Value: fieldValue.FieldByName("Value").String(),
Value: value,
}
res = append(res, appConfigVariable)

View File

@@ -10,7 +10,7 @@ type AuditLog struct {
Base
Event AuditLogEvent `sortable:"true"`
IpAddress string `sortable:"true"`
IpAddress *string `sortable:"true"`
Country string `sortable:"true"`
City string `sortable:"true"`
UserAgent string `sortable:"true"`
@@ -28,6 +28,7 @@ type AuditLogEvent string //nolint:recvcheck
const (
AuditLogEventSignIn AuditLogEvent = "SIGN_IN"
AuditLogEventOneTimeAccessTokenSignIn AuditLogEvent = "TOKEN_SIGN_IN"
AuditLogEventAccountCreated AuditLogEvent = "ACCOUNT_CREATED"
AuditLogEventClientAuthorization AuditLogEvent = "CLIENT_AUTHORIZATION"
AuditLogEventNewClientAuthorization AuditLogEvent = "NEW_CLIENT_AUTHORIZATION"
AuditLogEventDeviceCodeAuthorization AuditLogEvent = "DEVICE_CODE_AUTHORIZATION"

View File

@@ -0,0 +1,11 @@
package model
type KV struct {
Key string `gorm:"primaryKey;not null"`
Value *string
}
// TableName overrides the table name used by KV to `kv`
func (KV) TableName() string {
return "kv"
}

View File

@@ -0,0 +1,28 @@
package model
import (
"time"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
)
type SignupToken struct {
Base
Token string `json:"token"`
ExpiresAt datatype.DateTime `json:"expiresAt" sortable:"true"`
UsageLimit int `json:"usageLimit" sortable:"true"`
UsageCount int `json:"usageCount" sortable:"true"`
}
func (st *SignupToken) IsExpired() bool {
return time.Time(st.ExpiresAt).Before(time.Now())
}
func (st *SignupToken) IsUsageLimitReached() bool {
return st.UsageCount >= st.UsageLimit
}
func (st *SignupToken) IsValid() bool {
return !st.IsExpired() && !st.IsUsageLimitReached()
}

View File

@@ -68,6 +68,7 @@ func (s *AppConfigService) getDefaultDbConfig() *model.AppConfig {
EmailsVerified: model.AppConfigVariable{Value: "false"},
DisableAnimations: model.AppConfigVariable{Value: "false"},
AllowOwnAccountEdit: model.AppConfigVariable{Value: "true"},
AllowUserSignups: model.AppConfigVariable{Value: "disabled"},
AccentColor: model.AppConfigVariable{Value: "default"},
// Internal
BackgroundImageType: model.AppConfigVariable{Value: "jpg"},
@@ -233,7 +234,7 @@ func (s *AppConfigService) UpdateAppConfig(ctx context.Context, input dto.AppCon
s.dbConfig.Store(cfg)
// Return the updated config
res := cfg.ToAppConfigVariableSlice(true)
res := cfg.ToAppConfigVariableSlice(true, false)
return res, nil
}
@@ -318,7 +319,7 @@ func (s *AppConfigService) UpdateAppConfigValues(ctx context.Context, keysAndVal
}
func (s *AppConfigService) ListAppConfig(showAll bool) []model.AppConfigVariable {
return s.GetDbConfig().ToAppConfigVariableSlice(showAll)
return s.GetDbConfig().ToAppConfigVariableSlice(showAll, true)
}
func (s *AppConfigService) UpdateImage(ctx context.Context, uploadedFile *multipart.FileHeader, imageName string, oldImageType string) (err error) {
@@ -369,7 +370,7 @@ func (s *AppConfigService) LoadDbConfig(ctx context.Context) (err error) {
func (s *AppConfigService) loadDbConfigInternal(ctx context.Context, tx *gorm.DB) (*model.AppConfig, error) {
// If the UI config is disabled, only load from the env
if common.EnvConfig.UiConfigDisabled {
dest, err := s.loadDbConfigFromEnv(ctx, s.db)
dest, err := s.loadDbConfigFromEnv(ctx, tx)
return dest, err
}

View File

@@ -4,10 +4,12 @@ import (
"sync/atomic"
"testing"
"github.com/stretchr/testify/require"
"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/stretchr/testify/require"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
// NewTestAppConfigService is a function used by tests to create AppConfigService objects with pre-defined configuration values
@@ -22,7 +24,7 @@ func NewTestAppConfigService(config *model.AppConfig) *AppConfigService {
func TestLoadDbConfig(t *testing.T) {
t.Run("empty config table", func(t *testing.T) {
db := newDatabaseForTest(t)
db := testutils.NewDatabaseForTest(t)
service := &AppConfigService{
db: db,
}
@@ -36,7 +38,7 @@ func TestLoadDbConfig(t *testing.T) {
})
t.Run("loads value from config table", func(t *testing.T) {
db := newDatabaseForTest(t)
db := testutils.NewDatabaseForTest(t)
// Populate the config table with some initial values
err := db.
@@ -66,7 +68,7 @@ func TestLoadDbConfig(t *testing.T) {
})
t.Run("ignores unknown config keys", func(t *testing.T) {
db := newDatabaseForTest(t)
db := testutils.NewDatabaseForTest(t)
// Add an entry with a key that doesn't exist in the config struct
err := db.Create([]model.AppConfigVariable{
@@ -87,7 +89,7 @@ func TestLoadDbConfig(t *testing.T) {
})
t.Run("loading config multiple times", func(t *testing.T) {
db := newDatabaseForTest(t)
db := testutils.NewDatabaseForTest(t)
// Initial state
err := db.Create([]model.AppConfigVariable{
@@ -129,7 +131,7 @@ func TestLoadDbConfig(t *testing.T) {
common.EnvConfig.UiConfigDisabled = true
// Create database with config that should be ignored
db := newDatabaseForTest(t)
db := testutils.NewDatabaseForTest(t)
err := db.Create([]model.AppConfigVariable{
{Key: "appName", Value: "DB App"},
{Key: "sessionDuration", Value: "120"},
@@ -165,7 +167,7 @@ func TestLoadDbConfig(t *testing.T) {
common.EnvConfig.UiConfigDisabled = false
// Create database with config values that should take precedence
db := newDatabaseForTest(t)
db := testutils.NewDatabaseForTest(t)
err := db.Create([]model.AppConfigVariable{
{Key: "appName", Value: "DB App"},
{Key: "sessionDuration", Value: "120"},
@@ -189,7 +191,7 @@ func TestLoadDbConfig(t *testing.T) {
func TestUpdateAppConfigValues(t *testing.T) {
t.Run("update single value", func(t *testing.T) {
db := newDatabaseForTest(t)
db := testutils.NewDatabaseForTest(t)
// Create a service with default config
service := &AppConfigService{
@@ -214,7 +216,7 @@ func TestUpdateAppConfigValues(t *testing.T) {
})
t.Run("update multiple values", func(t *testing.T) {
db := newDatabaseForTest(t)
db := testutils.NewDatabaseForTest(t)
// Create a service with default config
service := &AppConfigService{
@@ -258,7 +260,7 @@ func TestUpdateAppConfigValues(t *testing.T) {
})
t.Run("empty value resets to default", func(t *testing.T) {
db := newDatabaseForTest(t)
db := testutils.NewDatabaseForTest(t)
// Create a service with default config
service := &AppConfigService{
@@ -279,7 +281,7 @@ func TestUpdateAppConfigValues(t *testing.T) {
})
t.Run("error with odd number of arguments", func(t *testing.T) {
db := newDatabaseForTest(t)
db := testutils.NewDatabaseForTest(t)
// Create a service with default config
service := &AppConfigService{
@@ -295,7 +297,7 @@ func TestUpdateAppConfigValues(t *testing.T) {
})
t.Run("error with invalid key", func(t *testing.T) {
db := newDatabaseForTest(t)
db := testutils.NewDatabaseForTest(t)
// Create a service with default config
service := &AppConfigService{
@@ -313,7 +315,7 @@ func TestUpdateAppConfigValues(t *testing.T) {
func TestUpdateAppConfig(t *testing.T) {
t.Run("updates configuration values from DTO", func(t *testing.T) {
db := newDatabaseForTest(t)
db := testutils.NewDatabaseForTest(t)
// Create a service with default config
service := &AppConfigService{
@@ -386,7 +388,7 @@ func TestUpdateAppConfig(t *testing.T) {
})
t.Run("empty values reset to defaults", func(t *testing.T) {
db := newDatabaseForTest(t)
db := testutils.NewDatabaseForTest(t)
// Create a service with default config and modify some values
service := &AppConfigService{
@@ -451,7 +453,7 @@ func TestUpdateAppConfig(t *testing.T) {
// Disable UI config
common.EnvConfig.UiConfigDisabled = true
db := newDatabaseForTest(t)
db := testutils.NewDatabaseForTest(t)
service := &AppConfigService{
db: db,
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"log"
"log/slog"
userAgentParser "github.com/mileusna/useragent"
"github.com/pocket-id/pocket-id/backend/internal/dto"
@@ -25,15 +26,15 @@ func NewAuditLogService(db *gorm.DB, appConfigService *AppConfigService, emailSe
}
// Create creates a new audit log entry in the database
func (s *AuditLogService) Create(ctx context.Context, event model.AuditLogEvent, ipAddress, userAgent, userID string, data model.AuditLogData, tx *gorm.DB) model.AuditLog {
func (s *AuditLogService) Create(ctx context.Context, event model.AuditLogEvent, ipAddress, userAgent, userID string, data model.AuditLogData, tx *gorm.DB) (model.AuditLog, bool) {
country, city, err := s.geoliteService.GetLocationByIP(ipAddress)
if err != nil {
log.Printf("Failed to get IP location: %v", err)
// Log the error but don't interrupt the operation
slog.Warn("Failed to get IP location", "error", err)
}
auditLog := model.AuditLog{
Event: event,
IpAddress: ipAddress,
Country: country,
City: city,
UserAgent: userAgent,
@@ -41,33 +42,47 @@ func (s *AuditLogService) Create(ctx context.Context, event model.AuditLogEvent,
Data: data,
}
if ipAddress != "" {
// Only set ipAddress if not empty, because on Postgres we use INET columns that don't allow non-null empty values
auditLog.IpAddress = &ipAddress
}
// Save the audit log in the database
err = tx.
WithContext(ctx).
Create(&auditLog).
Error
if err != nil {
log.Printf("Failed to create audit log: %v", err)
return model.AuditLog{}
slog.Error("Failed to create audit log", "error", err)
return model.AuditLog{}, false
}
return auditLog
return auditLog, true
}
// CreateNewSignInWithEmail creates a new audit log entry in the database and sends an email if the device hasn't been used before
func (s *AuditLogService) CreateNewSignInWithEmail(ctx context.Context, ipAddress, userAgent, userID string, tx *gorm.DB) model.AuditLog {
createdAuditLog := s.Create(ctx, model.AuditLogEventSignIn, ipAddress, userAgent, userID, model.AuditLogData{}, tx)
createdAuditLog, ok := s.Create(ctx, model.AuditLogEventSignIn, ipAddress, userAgent, userID, model.AuditLogData{}, tx)
if !ok {
// At this point the transaction has been canceled already, and error has been logged
return createdAuditLog
}
// Count the number of times the user has logged in from the same device
var count int64
err := tx.
stmt := tx.
WithContext(ctx).
Model(&model.AuditLog{}).
Where("user_id = ? AND ip_address = ? AND user_agent = ?", userID, ipAddress, userAgent).
Count(&count).
Error
Where("user_id = ? AND user_agent = ?", userID, userAgent)
if ipAddress == "" {
// An empty IP address is stored as NULL in the database
stmt = stmt.Where("ip_address IS NULL")
} else {
stmt = stmt.Where("ip_address = ?", ipAddress)
}
err := stmt.Count(&count).Error
if err != nil {
log.Printf("Failed to count audit logs: %v\n", err)
log.Printf("Failed to count audit logs: %v", err)
return createdAuditLog
}

View File

@@ -17,6 +17,7 @@ import (
"github.com/fxamacker/cbor/v2"
"github.com/go-webauthn/webauthn/protocol"
"github.com/lestrrat-go/jwx/v3/jwa"
"github.com/lestrrat-go/jwx/v3/jwk"
"github.com/lestrrat-go/jwx/v3/jwt"
"gorm.io/gorm"
@@ -25,6 +26,7 @@ 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"
jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk"
"github.com/pocket-id/pocket-id/backend/resources"
)
@@ -60,7 +62,7 @@ func (s *TestService) initExternalIdP() error {
return fmt.Errorf("failed to generate private key: %w", err)
}
s.externalIdPKey, err = utils.ImportRawKey(rawKey)
s.externalIdPKey, err = jwkutils.ImportRawKey(rawKey, jwa.ES256().String(), "")
if err != nil {
return fmt.Errorf("failed to import private key: %w", err)
}
@@ -310,6 +312,50 @@ func (s *TestService) SeedDatabase(baseURL string) error {
return err
}
signupTokens := []model.SignupToken{
{
Base: model.Base{
ID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
},
Token: "VALID1234567890A",
ExpiresAt: datatype.DateTime(time.Now().Add(24 * time.Hour)),
UsageLimit: 1,
UsageCount: 0,
},
{
Base: model.Base{
ID: "b2c3d4e5-f6g7-8901-bcde-f12345678901",
},
Token: "PARTIAL567890ABC",
ExpiresAt: datatype.DateTime(time.Now().Add(7 * 24 * time.Hour)),
UsageLimit: 5,
UsageCount: 2,
},
{
Base: model.Base{
ID: "c3d4e5f6-g7h8-9012-cdef-123456789012",
},
Token: "EXPIRED34567890B",
ExpiresAt: datatype.DateTime(time.Now().Add(-24 * time.Hour)), // Expired
UsageLimit: 3,
UsageCount: 1,
},
{
Base: model.Base{
ID: "d4e5f6g7-h8i9-0123-def0-234567890123",
},
Token: "FULLYUSED567890C",
ExpiresAt: datatype.DateTime(time.Now().Add(24 * time.Hour)),
UsageLimit: 1,
UsageCount: 1, // Usage limit reached
},
}
for _, token := range signupTokens {
if err := tx.Create(&token).Error; err != nil {
return err
}
}
return nil
})

View File

@@ -122,6 +122,10 @@ func (s *GeoLiteService) DisableUpdater() bool {
// GetLocationByIP returns the country and city of the given IP address.
func (s *GeoLiteService) GetLocationByIP(ipAddress string) (country, city string, err error) {
if ipAddress == "" {
return "", "", nil
}
// Check the IP address against known private IP ranges
if ip := net.ParseIP(ipAddress); ip != nil {
// Check IPv6 local ranges first
@@ -147,6 +151,11 @@ func (s *GeoLiteService) GetLocationByIP(ipAddress string) (country, city string
}
}
addr, err := netip.ParseAddr(ipAddress)
if err != nil {
return "", "", fmt.Errorf("failed to parse IP address: %w", err)
}
// Race condition between reading and writing the database.
s.mutex.RLock()
defer s.mutex.RUnlock()
@@ -157,11 +166,6 @@ func (s *GeoLiteService) GetLocationByIP(ipAddress string) (country, city string
}
defer db.Close()
addr, err := netip.ParseAddr(ipAddress)
if err != nil {
return "", "", fmt.Errorf("failed to parse IP address: %w", err)
}
var record struct {
City struct {
Names map[string]string `maxminddb:"names"`

View File

@@ -6,6 +6,8 @@ import (
"testing"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGeoLiteService_IPv6LocalRanges(t *testing.T) {
@@ -80,15 +82,9 @@ func TestGeoLiteService_IPv6LocalRanges(t *testing.T) {
t.Errorf("Expected error or internal network classification for external IP")
}
} else {
if err != nil {
t.Errorf("Expected no error for local IP, got: %v", err)
}
if country != tt.expectedCountry {
t.Errorf("Expected country %s, got %s", tt.expectedCountry, country)
}
if city != tt.expectedCity {
t.Errorf("Expected city %s, got %s", tt.expectedCity, city)
}
require.NoError(t, err)
assert.Equal(t, tt.expectedCountry, country)
assert.Equal(t, tt.expectedCity, city)
}
})
}
@@ -148,9 +144,7 @@ func TestGeoLiteService_isLocalIPv6(t *testing.T) {
}
result := service.isLocalIPv6(ip)
if result != tt.expected {
t.Errorf("Expected %v, got %v for IP %s", tt.expected, result, tt.testIP)
}
assert.Equal(t, tt.expected, result)
})
}
}
@@ -214,18 +208,13 @@ func TestGeoLiteService_initializeIPv6LocalRanges(t *testing.T) {
err := service.initializeIPv6LocalRanges()
if tt.expectError && err == nil {
t.Errorf("Expected error but got none")
}
if !tt.expectError && err != nil {
t.Errorf("Expected no error but got: %v", err)
if tt.expectError {
require.Error(t, err)
} else {
require.NoError(t, err)
}
rangeCount := len(service.localIPv6Ranges)
if rangeCount != tt.expectCount {
t.Errorf("Expected %d ranges, got %d", tt.expectCount, rangeCount)
}
assert.Len(t, service.localIPv6Ranges, tt.expectCount)
})
}
}

View File

@@ -2,23 +2,20 @@ package service
import (
"context"
"crypto/rand"
"crypto/rsa"
"encoding/json"
"errors"
"fmt"
"log"
"os"
"path/filepath"
"time"
"github.com/lestrrat-go/jwx/v3/jwa"
"github.com/lestrrat-go/jwx/v3/jwk"
"github.com/lestrrat-go/jwx/v3/jwt"
"gorm.io/gorm"
"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/utils"
jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk"
)
const (
@@ -26,8 +23,9 @@ const (
// This is a JSON file containing a key encoded as JWK
PrivateKeyFile = "jwt_private_key.json"
// RsaKeySize is the size, in bits, of the RSA key to generate if none is found
RsaKeySize = 2048
// PrivateKeyFileEncrypted is the path in the data/keys folder where the encrypted key is stored
// This is a encrypted JSON file containing a key encoded as JWK
PrivateKeyFileEncrypted = "jwt_private_key.json.enc"
// KeyUsageSigning is the usage for the private keys, for the "use" property
KeyUsageSigning = "sig"
@@ -59,58 +57,74 @@ const (
)
type JwtService struct {
envConfig *common.EnvConfigSchema
privateKey jwk.Key
keyId string
appConfigService *AppConfigService
jwksEncoded []byte
}
func NewJwtService(appConfigService *AppConfigService) *JwtService {
func NewJwtService(db *gorm.DB, appConfigService *AppConfigService) *JwtService {
service := &JwtService{}
// Ensure keys are generated or loaded
if err := service.init(appConfigService, common.EnvConfig.KeysPath); err != nil {
err := service.init(db, appConfigService, &common.EnvConfig)
if err != nil {
log.Fatalf("Failed to initialize jwt service: %v", err)
}
return service
}
func (s *JwtService) init(appConfigService *AppConfigService, keysPath string) error {
func (s *JwtService) init(db *gorm.DB, appConfigService *AppConfigService, envConfig *common.EnvConfigSchema) (err error) {
s.appConfigService = appConfigService
s.envConfig = envConfig
// Ensure keys are generated or loaded
return s.loadOrGenerateKey(keysPath)
return s.loadOrGenerateKey(db)
}
// loadOrGenerateKey loads the private key from the given path or generates it if not existing.
func (s *JwtService) loadOrGenerateKey(keysPath string) error {
var key jwk.Key
// First, check if we have a JWK file
// If we do, then we just load that
jwkPath := filepath.Join(keysPath, PrivateKeyFile)
ok, err := utils.FileExists(jwkPath)
func (s *JwtService) loadOrGenerateKey(db *gorm.DB) error {
// Get the key provider
keyProvider, err := jwkutils.GetKeyProvider(db, s.envConfig, s.appConfigService.GetDbConfig().InstanceID.Value)
if err != nil {
return fmt.Errorf("failed to check if private key file (JWK) exists at path '%s': %w", jwkPath, err)
return fmt.Errorf("failed to get key provider: %w", err)
}
if ok {
key, err = s.loadKeyJWK(jwkPath)
if err != nil {
return fmt.Errorf("failed to load private key file (JWK) at path '%s': %w", jwkPath, err)
}
// Set the key, and we are done
// Try loading a key
key, err := keyProvider.LoadKey()
if err != nil {
return fmt.Errorf("failed to load key (provider type '%s'): %w", s.envConfig.KeysStorage, err)
}
// If we have a key, store it in the object and we're done
if key != nil {
err = s.SetKey(key)
if err != nil {
return fmt.Errorf("failed to set private key: %w", err)
}
return nil
}
// If we are here, we need to generate a new key
key, err = s.generateNewRSAKey()
err = s.generateKey()
if err != nil {
return fmt.Errorf("failed to generate key: %w", err)
}
// Save the newly-generated key
err = keyProvider.SaveKey(s.privateKey)
if err != nil {
return fmt.Errorf("failed to save private key (provider type '%s'): %w", s.envConfig.KeysStorage, err)
}
return nil
}
// generateKey generates a new key and stores it in the object
func (s *JwtService) generateKey() error {
// Default is to generate RS256 (RSA-2048) keys
key, err := jwkutils.GenerateKey(jwa.RS256().String(), "")
if err != nil {
return fmt.Errorf("failed to generate new private key: %w", err)
}
@@ -121,12 +135,6 @@ func (s *JwtService) loadOrGenerateKey(keysPath string) error {
return fmt.Errorf("failed to set private key: %w", err)
}
// Save the key as JWK
err = SaveKeyJWK(s.privateKey, jwkPath)
if err != nil {
return fmt.Errorf("failed to save private key file at path '%s': %w", jwkPath, err)
}
return nil
}
@@ -192,13 +200,13 @@ func (s *JwtService) GenerateAccessToken(user model.User) (string, error) {
Subject(user.ID).
Expiration(now.Add(s.appConfigService.GetDbConfig().SessionDuration.AsDurationMinutes())).
IssuedAt(now).
Issuer(common.EnvConfig.AppURL).
Issuer(s.envConfig.AppURL).
Build()
if err != nil {
return "", fmt.Errorf("failed to build token: %w", err)
}
err = SetAudienceString(token, common.EnvConfig.AppURL)
err = SetAudienceString(token, s.envConfig.AppURL)
if err != nil {
return "", fmt.Errorf("failed to set 'aud' claim in token: %w", err)
}
@@ -229,8 +237,8 @@ func (s *JwtService) VerifyAccessToken(tokenString string) (jwt.Token, error) {
jwt.WithValidate(true),
jwt.WithKey(alg, s.privateKey),
jwt.WithAcceptableSkew(clockSkew),
jwt.WithAudience(common.EnvConfig.AppURL),
jwt.WithIssuer(common.EnvConfig.AppURL),
jwt.WithAudience(s.envConfig.AppURL),
jwt.WithIssuer(s.envConfig.AppURL),
jwt.WithValidator(TokenTypeValidator(AccessTokenJWTType)),
)
if err != nil {
@@ -246,7 +254,7 @@ func (s *JwtService) BuildIDToken(userClaims map[string]any, clientID string, no
token, err := jwt.NewBuilder().
Expiration(now.Add(1 * time.Hour)).
IssuedAt(now).
Issuer(common.EnvConfig.AppURL).
Issuer(s.envConfig.AppURL).
Build()
if err != nil {
return nil, fmt.Errorf("failed to build token: %w", err)
@@ -305,7 +313,7 @@ func (s *JwtService) VerifyIdToken(tokenString string, acceptExpiredTokens bool)
jwt.WithValidate(true),
jwt.WithKey(alg, s.privateKey),
jwt.WithAcceptableSkew(clockSkew),
jwt.WithIssuer(common.EnvConfig.AppURL),
jwt.WithIssuer(s.envConfig.AppURL),
jwt.WithValidator(TokenTypeValidator(IDTokenJWTType)),
)
@@ -335,7 +343,7 @@ func (s *JwtService) BuildOAuthAccessToken(user model.User, clientID string) (jw
Subject(user.ID).
Expiration(now.Add(1 * time.Hour)).
IssuedAt(now).
Issuer(common.EnvConfig.AppURL).
Issuer(s.envConfig.AppURL).
Build()
if err != nil {
return nil, fmt.Errorf("failed to build token: %w", err)
@@ -377,7 +385,7 @@ func (s *JwtService) VerifyOAuthAccessToken(tokenString string) (jwt.Token, erro
jwt.WithValidate(true),
jwt.WithKey(alg, s.privateKey),
jwt.WithAcceptableSkew(clockSkew),
jwt.WithIssuer(common.EnvConfig.AppURL),
jwt.WithIssuer(s.envConfig.AppURL),
jwt.WithValidator(TokenTypeValidator(OAuthAccessTokenJWTType)),
)
if err != nil {
@@ -393,7 +401,7 @@ func (s *JwtService) GenerateOAuthRefreshToken(userID string, clientID string, r
Subject(userID).
Expiration(now.Add(RefreshTokenDuration)).
IssuedAt(now).
Issuer(common.EnvConfig.AppURL).
Issuer(s.envConfig.AppURL).
Build()
if err != nil {
return "", fmt.Errorf("failed to build token: %w", err)
@@ -430,7 +438,7 @@ func (s *JwtService) VerifyOAuthRefreshToken(tokenString string) (userID, client
jwt.WithValidate(true),
jwt.WithKey(alg, s.privateKey),
jwt.WithAcceptableSkew(clockSkew),
jwt.WithIssuer(common.EnvConfig.AppURL),
jwt.WithIssuer(s.envConfig.AppURL),
jwt.WithValidator(TokenTypeValidator(OAuthRefreshTokenJWTType)),
)
if err != nil {
@@ -488,7 +496,7 @@ func (s *JwtService) GetPublicJWK() (jwk.Key, error) {
return nil, fmt.Errorf("failed to get public key: %w", err)
}
utils.EnsureAlgInKey(pubKey)
jwkutils.EnsureAlgInKey(pubKey, "", "")
return pubKey, nil
}
@@ -517,56 +525,6 @@ func (s *JwtService) GetKeyAlg() (jwa.KeyAlgorithm, error) {
return alg, nil
}
func (s *JwtService) loadKeyJWK(path string) (jwk.Key, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read key data: %w", err)
}
key, err := jwk.ParseKey(data)
if err != nil {
return nil, fmt.Errorf("failed to parse key: %w", err)
}
return key, nil
}
func (s *JwtService) generateNewRSAKey() (jwk.Key, error) {
// We generate RSA keys only
rawKey, err := rsa.GenerateKey(rand.Reader, RsaKeySize)
if err != nil {
return nil, fmt.Errorf("failed to generate RSA private key: %w", err)
}
// Import the raw key
return utils.ImportRawKey(rawKey)
}
// SaveKeyJWK saves a JWK to a file
func SaveKeyJWK(key jwk.Key, path string) error {
dir := filepath.Dir(path)
err := os.MkdirAll(dir, 0700)
if err != nil {
return fmt.Errorf("failed to create directory '%s' for key file: %w", dir, err)
}
keyFile, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return fmt.Errorf("failed to create key file: %w", err)
}
defer keyFile.Close()
// Write the JSON file to disk
enc := json.NewEncoder(keyFile)
enc.SetEscapeHTML(false)
err = enc.Encode(key)
if err != nil {
return fmt.Errorf("failed to write key file: %w", err)
}
return nil
}
// GetIsAdmin returns the value of the "isAdmin" claim in the token
func GetIsAdmin(token jwt.Token) (bool, error) {
if !token.Has(IsAdminClaim) {

View File

@@ -21,7 +21,7 @@ import (
"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/utils"
jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk"
)
func TestJwtService_Init(t *testing.T) {
@@ -33,9 +33,16 @@ func TestJwtService_Init(t *testing.T) {
// Create a temporary directory for the test
tempDir := t.TempDir()
// Setup the environment variable required by the token verification
mockEnvConfig := &common.EnvConfigSchema{
AppURL: "https://test.example.com",
KeysStorage: "file",
KeysPath: tempDir,
}
// Initialize the JWT service
service := &JwtService{}
err := service.init(mockConfig, tempDir)
err := service.init(nil, mockConfig, mockEnvConfig)
require.NoError(t, err, "Failed to initialize JWT service")
// Verify the private key was set
@@ -66,9 +73,16 @@ func TestJwtService_Init(t *testing.T) {
// Create a temporary directory for the test
tempDir := t.TempDir()
// Setup the environment variable required by the token verification
mockEnvConfig := &common.EnvConfigSchema{
AppURL: "https://test.example.com",
KeysStorage: "file",
KeysPath: tempDir,
}
// First create a service to generate a key
firstService := &JwtService{}
err := firstService.init(mockConfig, tempDir)
err := firstService.init(nil, mockConfig, mockEnvConfig)
require.NoError(t, err)
// Get the key ID of the first service
@@ -77,7 +91,7 @@ func TestJwtService_Init(t *testing.T) {
// Now create a new service that should load the existing key
secondService := &JwtService{}
err = secondService.init(mockConfig, tempDir)
err = secondService.init(nil, mockConfig, mockEnvConfig)
require.NoError(t, err)
// Verify the loaded key has the same ID as the original
@@ -90,12 +104,19 @@ func TestJwtService_Init(t *testing.T) {
// Create a temporary directory for the test
tempDir := t.TempDir()
// Setup the environment variable required by the token verification
mockEnvConfig := &common.EnvConfigSchema{
AppURL: "https://test.example.com",
KeysStorage: "file",
KeysPath: tempDir,
}
// Create a new JWK and save it to disk
origKeyID := createECDSAKeyJWK(t, tempDir)
// Now create a new service that should load the existing key
svc := &JwtService{}
err := svc.init(mockConfig, tempDir)
err := svc.init(nil, mockConfig, mockEnvConfig)
require.NoError(t, err)
// Ensure loaded key has the right algorithm
@@ -113,12 +134,19 @@ func TestJwtService_Init(t *testing.T) {
// Create a temporary directory for the test
tempDir := t.TempDir()
// Setup the environment variable required by the token verification
mockEnvConfig := &common.EnvConfigSchema{
AppURL: "https://test.example.com",
KeysStorage: "file",
KeysPath: tempDir,
}
// Create a new JWK and save it to disk
origKeyID := createEdDSAKeyJWK(t, tempDir)
// Now create a new service that should load the existing key
svc := &JwtService{}
err := svc.init(mockConfig, tempDir)
err := svc.init(nil, mockConfig, mockEnvConfig)
require.NoError(t, err)
// Ensure loaded key has the right algorithm and curve
@@ -147,9 +175,16 @@ func TestJwtService_GetPublicJWK(t *testing.T) {
// Create a temporary directory for the test
tempDir := t.TempDir()
// Setup the environment variable required by the token verification
mockEnvConfig := &common.EnvConfigSchema{
AppURL: "https://test.example.com",
KeysStorage: "file",
KeysPath: tempDir,
}
// Create a JWT service with initialized key
service := &JwtService{}
err := service.init(mockConfig, tempDir)
err := service.init(nil, mockConfig, mockEnvConfig)
require.NoError(t, err, "Failed to initialize JWT service")
// Get the JWK (public key)
@@ -178,12 +213,19 @@ func TestJwtService_GetPublicJWK(t *testing.T) {
// Create a temporary directory for the test
tempDir := t.TempDir()
// Setup the environment variable required by the token verification
mockEnvConfig := &common.EnvConfigSchema{
AppURL: "https://test.example.com",
KeysStorage: "file",
KeysPath: tempDir,
}
// Create an ECDSA key and save it as JWK
originalKeyID := createECDSAKeyJWK(t, tempDir)
// Create a JWT service that loads the ECDSA key
service := &JwtService{}
err := service.init(mockConfig, tempDir)
err := service.init(nil, mockConfig, mockEnvConfig)
require.NoError(t, err, "Failed to initialize JWT service")
// Get the JWK (public key)
@@ -216,12 +258,19 @@ func TestJwtService_GetPublicJWK(t *testing.T) {
// Create a temporary directory for the test
tempDir := t.TempDir()
// Setup the environment variable required by the token verification
mockEnvConfig := &common.EnvConfigSchema{
AppURL: "https://test.example.com",
KeysStorage: "file",
KeysPath: tempDir,
}
// Create an EdDSA key and save it as JWK
originalKeyID := createEdDSAKeyJWK(t, tempDir)
// Create a JWT service that loads the EdDSA key
service := &JwtService{}
err := service.init(mockConfig, tempDir)
err := service.init(nil, mockConfig, mockEnvConfig)
require.NoError(t, err, "Failed to initialize JWT service")
// Get the JWK (public key)
@@ -276,16 +325,16 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
})
// Setup the environment variable required by the token verification
originalAppURL := common.EnvConfig.AppURL
common.EnvConfig.AppURL = "https://test.example.com"
defer func() {
common.EnvConfig.AppURL = originalAppURL
}()
mockEnvConfig := &common.EnvConfigSchema{
AppURL: "https://test.example.com",
KeysStorage: "file",
KeysPath: tempDir,
}
t.Run("generates token for regular user", func(t *testing.T) {
// Create a JWT service
service := &JwtService{}
err := service.init(mockConfig, tempDir)
err := service.init(nil, mockConfig, mockEnvConfig)
require.NoError(t, err, "Failed to initialize JWT service")
// Create a test user
@@ -328,7 +377,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
t.Run("generates token for admin user", func(t *testing.T) {
// Create a JWT service
service := &JwtService{}
err := service.init(mockConfig, tempDir)
err := service.init(nil, mockConfig, mockEnvConfig)
require.NoError(t, err, "Failed to initialize JWT service")
// Create a test admin user
@@ -364,7 +413,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
})
service := &JwtService{}
err := service.init(customMockConfig, tempDir)
err := service.init(nil, customMockConfig, mockEnvConfig)
require.NoError(t, err, "Failed to initialize JWT service")
// Create a test user
@@ -399,7 +448,10 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
// Create a JWT service that loads the key
service := &JwtService{}
err := service.init(mockConfig, tempDir)
err := service.init(nil, mockConfig, &common.EnvConfigSchema{
KeysStorage: "file",
KeysPath: tempDir,
})
require.NoError(t, err, "Failed to initialize JWT service")
// Verify it loaded the right key
@@ -453,7 +505,10 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
// Create a JWT service that loads the key
service := &JwtService{}
err := service.init(mockConfig, tempDir)
err := service.init(nil, mockConfig, &common.EnvConfigSchema{
KeysStorage: "file",
KeysPath: tempDir,
})
require.NoError(t, err, "Failed to initialize JWT service")
// Verify it loaded the right key
@@ -507,7 +562,10 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
// Create a JWT service that loads the key
service := &JwtService{}
err := service.init(mockConfig, tempDir)
err := service.init(nil, mockConfig, &common.EnvConfigSchema{
KeysStorage: "file",
KeysPath: tempDir,
})
require.NoError(t, err, "Failed to initialize JWT service")
// Verify it loaded the right key
@@ -563,16 +621,16 @@ func TestGenerateVerifyIdToken(t *testing.T) {
})
// Setup the environment variable required by the token verification
originalAppURL := common.EnvConfig.AppURL
common.EnvConfig.AppURL = "https://test.example.com"
defer func() {
common.EnvConfig.AppURL = originalAppURL
}()
mockEnvConfig := &common.EnvConfigSchema{
AppURL: "https://test.example.com",
KeysStorage: "file",
KeysPath: tempDir,
}
t.Run("generates and verifies ID token with standard claims", func(t *testing.T) {
// Create a JWT service
service := &JwtService{}
err := service.init(mockConfig, tempDir)
err := service.init(nil, mockConfig, mockEnvConfig)
require.NoError(t, err, "Failed to initialize JWT service")
// Create test claims
@@ -601,7 +659,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
assert.Equal(t, []string{clientID}, audience, "Audience should contain the client ID")
issuer, ok := claims.Issuer()
_ = assert.True(t, ok, "Issuer not found in token") &&
assert.Equal(t, common.EnvConfig.AppURL, issuer, "Issuer should match app URL")
assert.Equal(t, service.envConfig.AppURL, issuer, "Issuer should match app URL")
// Check token expiration time is approximately 1 hour from now
expectedExp := time.Now().Add(1 * time.Hour)
@@ -614,7 +672,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
t.Run("can accept expired tokens if told so", func(t *testing.T) {
// Create a JWT service
service := &JwtService{}
err := service.init(mockConfig, tempDir)
err := service.init(nil, mockConfig, mockEnvConfig)
require.NoError(t, err, "Failed to initialize JWT service")
// Create test claims
@@ -628,7 +686,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
// Create a token that's already expired
token, err := jwt.NewBuilder().
Subject(userClaims["sub"].(string)).
Issuer(common.EnvConfig.AppURL).
Issuer(service.envConfig.AppURL).
Audience([]string{clientID}).
IssuedAt(time.Now().Add(-2 * time.Hour)).
Expiration(time.Now().Add(-1 * time.Hour)). // Expired 1 hour ago
@@ -666,13 +724,13 @@ func TestGenerateVerifyIdToken(t *testing.T) {
assert.Equal(t, userClaims["sub"], subject, "Token subject should match user ID")
issuer, ok := claims.Issuer()
_ = assert.True(t, ok, "Issuer not found in token") &&
assert.Equal(t, common.EnvConfig.AppURL, issuer, "Issuer should match app URL")
assert.Equal(t, service.envConfig.AppURL, issuer, "Issuer should match app URL")
})
t.Run("generates and verifies ID token with nonce", func(t *testing.T) {
// Create a JWT service
service := &JwtService{}
err := service.init(mockConfig, tempDir)
err := service.init(nil, mockConfig, mockEnvConfig)
require.NoError(t, err, "Failed to initialize JWT service")
// Create test claims with nonce
@@ -703,7 +761,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
t.Run("fails verification with incorrect issuer", func(t *testing.T) {
// Create a JWT service
service := &JwtService{}
err := service.init(mockConfig, tempDir)
err := service.init(nil, mockConfig, mockEnvConfig)
require.NoError(t, err, "Failed to initialize JWT service")
// Generate a token with standard claims
@@ -714,7 +772,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
require.NoError(t, err, "Failed to generate ID token")
// Temporarily change the app URL to simulate wrong issuer
common.EnvConfig.AppURL = "https://wrong-issuer.com"
service.envConfig.AppURL = "https://wrong-issuer.com"
// Verify should fail due to issuer mismatch
_, err = service.VerifyIdToken(tokenString, false)
@@ -731,7 +789,10 @@ func TestGenerateVerifyIdToken(t *testing.T) {
// Create a JWT service that loads the key
service := &JwtService{}
err := service.init(mockConfig, tempDir)
err := service.init(nil, mockConfig, &common.EnvConfigSchema{
KeysStorage: "file",
KeysPath: tempDir,
})
require.NoError(t, err, "Failed to initialize JWT service")
// Verify it loaded the right key
@@ -762,7 +823,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
assert.Equal(t, "eddsauser456", subject, "Token subject should match user ID")
issuer, ok := claims.Issuer()
_ = assert.True(t, ok, "Issuer not found in token") &&
assert.Equal(t, common.EnvConfig.AppURL, issuer, "Issuer should match app URL")
assert.Equal(t, service.envConfig.AppURL, issuer, "Issuer should match app URL")
// Verify the key type is OKP
publicKey, err := service.GetPublicJWK()
@@ -784,7 +845,10 @@ func TestGenerateVerifyIdToken(t *testing.T) {
// Create a JWT service that loads the key
service := &JwtService{}
err := service.init(mockConfig, tempDir)
err := service.init(nil, mockConfig, &common.EnvConfigSchema{
KeysStorage: "file",
KeysPath: tempDir,
})
require.NoError(t, err, "Failed to initialize JWT service")
// Verify it loaded the right key
@@ -795,7 +859,6 @@ func TestGenerateVerifyIdToken(t *testing.T) {
// Create test claims
userClaims := map[string]interface{}{
"sub": "ecdsauser456",
"name": "ECDSA User",
"email": "ecdsauser@example.com",
}
const clientID = "ecdsa-client-123"
@@ -815,7 +878,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
assert.Equal(t, "ecdsauser456", subject, "Token subject should match user ID")
issuer, ok := claims.Issuer()
_ = assert.True(t, ok, "Issuer not found in token") &&
assert.Equal(t, common.EnvConfig.AppURL, issuer, "Issuer should match app URL")
assert.Equal(t, service.envConfig.AppURL, issuer, "Issuer should match app URL")
// Verify the key type is EC
publicKey, err := service.GetPublicJWK()
@@ -837,7 +900,10 @@ func TestGenerateVerifyIdToken(t *testing.T) {
// Create a JWT service that loads the key
service := &JwtService{}
err := service.init(mockConfig, tempDir)
err := service.init(nil, mockConfig, &common.EnvConfigSchema{
KeysStorage: "file",
KeysPath: tempDir,
})
require.NoError(t, err, "Failed to initialize JWT service")
// Verify it loaded the right key
@@ -868,17 +934,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
assert.Equal(t, "rsauser456", subject, "Token subject should match user ID")
issuer, ok := claims.Issuer()
_ = assert.True(t, ok, "Issuer not found in token") &&
assert.Equal(t, common.EnvConfig.AppURL, issuer, "Issuer should match app URL")
// Verify the key type is RSA
publicKey, err := service.GetPublicJWK()
require.NoError(t, err)
assert.Equal(t, jwa.RSA().String(), publicKey.KeyType().String(), "Key type should be RSA")
// Verify the algorithm is RS256
alg, ok := publicKey.Algorithm()
require.True(t, ok)
assert.Equal(t, jwa.RS256().String(), alg.String(), "Algorithm should be RS256")
assert.Equal(t, service.envConfig.AppURL, issuer, "Issuer should match app URL")
})
}
@@ -892,16 +948,16 @@ func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
})
// Setup the environment variable required by the token verification
originalAppURL := common.EnvConfig.AppURL
common.EnvConfig.AppURL = "https://test.example.com"
defer func() {
common.EnvConfig.AppURL = originalAppURL
}()
mockEnvConfig := &common.EnvConfigSchema{
AppURL: "https://test.example.com",
KeysStorage: "file",
KeysPath: tempDir,
}
t.Run("generates and verifies OAuth access token with standard claims", func(t *testing.T) {
// Create a JWT service
service := &JwtService{}
err := service.init(mockConfig, tempDir)
err := service.init(nil, mockConfig, mockEnvConfig)
require.NoError(t, err, "Failed to initialize JWT service")
// Create a test user
@@ -931,7 +987,7 @@ func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
assert.Equal(t, []string{clientID}, audience, "Audience should contain the client ID")
issuer, ok := claims.Issuer()
_ = assert.True(t, ok, "Issuer not found in token") &&
assert.Equal(t, common.EnvConfig.AppURL, issuer, "Issuer should match app URL")
assert.Equal(t, service.envConfig.AppURL, issuer, "Issuer should match app URL")
// Check token expiration time is approximately 1 hour from now
expectedExp := time.Now().Add(1 * time.Hour)
@@ -944,7 +1000,7 @@ func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
t.Run("fails verification for expired token", func(t *testing.T) {
// Create a JWT service with a mock function to generate an expired token
service := &JwtService{}
err := service.init(mockConfig, tempDir)
err := service.init(nil, mockConfig, mockEnvConfig)
require.NoError(t, err, "Failed to initialize JWT service")
// Create a test user
@@ -961,7 +1017,7 @@ func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
Expiration(time.Now().Add(-1 * time.Hour)). // Expired 1 hour ago
IssuedAt(time.Now().Add(-2 * time.Hour)).
Audience([]string{clientID}).
Issuer(common.EnvConfig.AppURL).
Issuer(service.envConfig.AppURL).
Build()
require.NoError(t, err, "Failed to build token")
@@ -980,11 +1036,17 @@ func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
t.Run("fails verification with invalid signature", func(t *testing.T) {
// Create two JWT services with different keys
service1 := &JwtService{}
err := service1.init(mockConfig, t.TempDir()) // Use a different temp dir
err := service1.init(nil, mockConfig, &common.EnvConfigSchema{
KeysStorage: "file",
KeysPath: t.TempDir(), // Use a different temp dir
})
require.NoError(t, err, "Failed to initialize first JWT service")
service2 := &JwtService{}
err = service2.init(mockConfig, t.TempDir()) // Use a different temp dir
err = service2.init(nil, mockConfig, &common.EnvConfigSchema{
KeysStorage: "file",
KeysPath: t.TempDir(), // Use a different temp dir
})
require.NoError(t, err, "Failed to initialize second JWT service")
// Create a test user
@@ -1014,7 +1076,10 @@ func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
// Create a JWT service that loads the key
service := &JwtService{}
err := service.init(mockConfig, tempDir)
err := service.init(nil, mockConfig, &common.EnvConfigSchema{
KeysStorage: "file",
KeysPath: tempDir,
})
require.NoError(t, err, "Failed to initialize JWT service")
// Verify it loaded the right key
@@ -1068,7 +1133,10 @@ func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
// Create a JWT service that loads the key
service := &JwtService{}
err := service.init(mockConfig, tempDir)
err := service.init(nil, mockConfig, &common.EnvConfigSchema{
KeysStorage: "file",
KeysPath: tempDir,
})
require.NoError(t, err, "Failed to initialize JWT service")
// Verify it loaded the right key
@@ -1122,7 +1190,10 @@ func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
// Create a JWT service that loads the key
service := &JwtService{}
err := service.init(mockConfig, tempDir)
err := service.init(nil, mockConfig, &common.EnvConfigSchema{
KeysStorage: "file",
KeysPath: tempDir,
})
require.NoError(t, err, "Failed to initialize JWT service")
// Verify it loaded the right key
@@ -1176,16 +1247,16 @@ func TestGenerateVerifyOAuthRefreshToken(t *testing.T) {
mockConfig := NewTestAppConfigService(&model.AppConfig{})
// Setup the environment variable required by the token verification
originalAppURL := common.EnvConfig.AppURL
common.EnvConfig.AppURL = "https://test.example.com"
defer func() {
common.EnvConfig.AppURL = originalAppURL
}()
mockEnvConfig := &common.EnvConfigSchema{
AppURL: "https://test.example.com",
KeysStorage: "file",
KeysPath: tempDir,
}
t.Run("generates and verifies refresh token", func(t *testing.T) {
// Create a JWT service
service := &JwtService{}
err := service.init(mockConfig, tempDir)
err := service.init(nil, mockConfig, mockEnvConfig)
require.NoError(t, err, "Failed to initialize JWT service")
// Create a test user
@@ -1211,7 +1282,7 @@ func TestGenerateVerifyOAuthRefreshToken(t *testing.T) {
t.Run("fails verification for expired token", func(t *testing.T) {
// Create a JWT service
service := &JwtService{}
err := service.init(mockConfig, tempDir)
err := service.init(nil, mockConfig, mockEnvConfig)
require.NoError(t, err, "Failed to initialize JWT service")
// Generate a token using JWT directly to create an expired token
@@ -1220,7 +1291,7 @@ func TestGenerateVerifyOAuthRefreshToken(t *testing.T) {
Expiration(time.Now().Add(-1 * time.Hour)). // Expired 1 hour ago
IssuedAt(time.Now().Add(-2 * time.Hour)).
Audience([]string{"client123"}).
Issuer(common.EnvConfig.AppURL).
Issuer(service.envConfig.AppURL).
Build()
require.NoError(t, err, "Failed to build token")
@@ -1236,11 +1307,17 @@ func TestGenerateVerifyOAuthRefreshToken(t *testing.T) {
t.Run("fails verification with invalid signature", func(t *testing.T) {
// Create two JWT services with different keys
service1 := &JwtService{}
err := service1.init(mockConfig, t.TempDir())
err := service1.init(nil, mockConfig, &common.EnvConfigSchema{
KeysStorage: "file",
KeysPath: t.TempDir(), // Use a different temp dir
})
require.NoError(t, err, "Failed to initialize first JWT service")
service2 := &JwtService{}
err = service2.init(mockConfig, t.TempDir())
err = service2.init(nil, mockConfig, &common.EnvConfigSchema{
KeysStorage: "file",
KeysPath: t.TempDir(), // Use a different temp dir
})
require.NoError(t, err, "Failed to initialize second JWT service")
// Generate a token with the first service
@@ -1308,7 +1385,10 @@ func TestGetTokenType(t *testing.T) {
// Initialize the JWT service
mockConfig := NewTestAppConfigService(&model.AppConfig{})
service := &JwtService{}
err := service.init(mockConfig, tempDir)
err := service.init(nil, mockConfig, &common.EnvConfigSchema{
KeysStorage: "file",
KeysPath: tempDir,
})
require.NoError(t, err, "Failed to initialize JWT service")
buildTokenForType := func(t *testing.T, typ string, setClaimsFn func(b *jwt.Builder)) string {
@@ -1402,10 +1482,19 @@ func TestGetTokenType(t *testing.T) {
func importKey(t *testing.T, privateKeyRaw any, path string) string {
t.Helper()
privateKey, err := utils.ImportRawKey(privateKeyRaw)
privateKey, err := jwkutils.ImportRawKey(privateKeyRaw, "", "")
require.NoError(t, err, "Failed to import private key")
err = SaveKeyJWK(privateKey, filepath.Join(path, PrivateKeyFile))
keyProvider := &jwkutils.KeyProviderFile{}
err = keyProvider.Init(jwkutils.KeyProviderOpts{
EnvConfig: &common.EnvConfigSchema{
KeysStorage: "file",
KeysPath: path,
},
})
require.NoError(t, err, "Failed to init file key provider")
err = keyProvider.SaveKey(privateKey)
require.NoError(t, err, "Failed to save key")
kid, _ := privateKey.KeyID()

View File

@@ -13,12 +13,16 @@ import (
"net/url"
"strings"
"time"
"unicode/utf8"
"github.com/go-ldap/ldap/v3"
"github.com/google/uuid"
"golang.org/x/text/unicode/norm"
"gorm.io/gorm"
"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"
"gorm.io/gorm"
)
type LdapService struct {
@@ -122,7 +126,7 @@ func (s *LdapService) SyncGroups(ctx context.Context, tx *gorm.DB, client *ldap.
ldapGroupIDs := make(map[string]struct{}, len(result.Entries))
for _, value := range result.Entries {
ldapId := value.GetAttributeValue(dbConfig.LdapAttributeGroupUniqueIdentifier.Value)
ldapId := convertLdapIdToString(value.GetAttributeValue(dbConfig.LdapAttributeGroupUniqueIdentifier.Value))
// Skip groups without a valid LDAP ID
if ldapId == "" {
@@ -178,7 +182,7 @@ func (s *LdapService) SyncGroups(ctx context.Context, tx *gorm.DB, client *ldap.
var databaseUser model.User
err = tx.
WithContext(ctx).
Where("username = ? AND ldap_id IS NOT NULL", username).
Where("username = ? AND ldap_id IS NOT NULL", norm.NFC.String(username)).
First(&databaseUser).
Error
if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -194,8 +198,9 @@ func (s *LdapService) SyncGroups(ctx context.Context, tx *gorm.DB, client *ldap.
syncGroup := dto.UserGroupCreateDto{
Name: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.Value),
FriendlyName: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.Value),
LdapID: value.GetAttributeValue(dbConfig.LdapAttributeGroupUniqueIdentifier.Value),
LdapID: ldapId,
}
dto.Normalize(syncGroup)
if databaseGroup.ID == "" {
newGroup, err := s.groupService.createInternal(ctx, syncGroup, tx)
@@ -286,7 +291,7 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
ldapUserIDs := make(map[string]struct{}, len(result.Entries))
for _, value := range result.Entries {
ldapId := value.GetAttributeValue(dbConfig.LdapAttributeUserUniqueIdentifier.Value)
ldapId := convertLdapIdToString(value.GetAttributeValue(dbConfig.LdapAttributeUserUniqueIdentifier.Value))
// Skip users without a valid LDAP ID
if ldapId == "" {
@@ -306,7 +311,6 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
// If a user is found (even if disabled), enable them since they're now back in LDAP
if databaseUser.ID != "" && databaseUser.Disabled {
// Use the transaction instead of the direct context
err = tx.
WithContext(ctx).
Model(&model.User{}).
@@ -315,7 +319,7 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
Error
if err != nil {
log.Printf("Failed to enable user %s: %v", databaseUser.Username, err)
return fmt.Errorf("failed to enable user %s: %w", databaseUser.Username, err)
}
}
@@ -341,6 +345,7 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
IsAdmin: isAdmin,
LdapID: ldapId,
}
dto.Normalize(newUser)
if databaseUser.ID == "" {
_, err = s.userService.createUserInternal(ctx, newUser, true, tx)
@@ -468,3 +473,21 @@ func getDNProperty(property string, str string) string {
// CN not found, return an empty string
return ""
}
// convertLdapIdToString converts LDAP IDs to valid UTF-8 strings.
// LDAP servers may return binary UUIDs (16 bytes) or other non-UTF-8 data.
func convertLdapIdToString(ldapId string) string {
if utf8.ValidString(ldapId) {
return norm.NFC.String(ldapId)
}
// Try to parse as binary UUID (16 bytes)
if len(ldapId) == 16 {
if parsedUUID, err := uuid.FromBytes([]byte(ldapId)); err == nil {
return parsedUUID.String()
}
}
// As a last resort, encode as base64 to make it UTF-8 safe
return base64.StdEncoding.EncodeToString([]byte(ldapId))
}

View File

@@ -71,3 +71,36 @@ func TestGetDNProperty(t *testing.T) {
})
}
}
func TestConvertLdapIdToString(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "valid UTF-8 string",
input: "simple-utf8-id",
expected: "simple-utf8-id",
},
{
name: "binary UUID (16 bytes)",
input: string([]byte{0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0, 0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf1}),
expected: "12345678-9abc-def0-1234-56789abcdef1",
},
{
name: "non-UTF8, non-UUID returns base64",
input: string([]byte{0xff, 0xfe, 0xfd, 0xfc}),
expected: "//79/A==",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := convertLdapIdToString(tt.input)
if got != tt.expected {
t.Errorf("Expected %q, got %q", tt.expected, got)
}
})
}
}

View File

@@ -255,7 +255,7 @@ func (s *OidcService) createTokenFromDeviceCode(ctx context.Context, input dto.O
tx.Rollback()
}()
_, err := s.verifyClientCredentialsInternal(ctx, tx, clientAuthCredentialsFromCreateTokensDto(&input))
_, err := s.verifyClientCredentialsInternal(ctx, tx, clientAuthCredentialsFromCreateTokensDto(&input), true)
if err != nil {
return CreatedTokens{}, err
}
@@ -336,7 +336,7 @@ func (s *OidcService) createTokenFromAuthorizationCode(ctx context.Context, inpu
tx.Rollback()
}()
client, err := s.verifyClientCredentialsInternal(ctx, tx, clientAuthCredentialsFromCreateTokensDto(&input))
client, err := s.verifyClientCredentialsInternal(ctx, tx, clientAuthCredentialsFromCreateTokensDto(&input), true)
if err != nil {
return CreatedTokens{}, err
}
@@ -420,7 +420,7 @@ func (s *OidcService) createTokenFromRefreshToken(ctx context.Context, input dto
tx.Rollback()
}()
client, err := s.verifyClientCredentialsInternal(ctx, tx, clientAuthCredentialsFromCreateTokensDto(&input))
client, err := s.verifyClientCredentialsInternal(ctx, tx, clientAuthCredentialsFromCreateTokensDto(&input), true)
if err != nil {
return CreatedTokens{}, err
}
@@ -490,6 +490,11 @@ func (s *OidcService) createTokenFromRefreshToken(ctx context.Context, input dto
}
func (s *OidcService) IntrospectToken(ctx context.Context, creds ClientAuthCredentials, tokenString string) (introspectDto dto.OidcIntrospectionResponseDto, err error) {
client, err := s.verifyClientCredentialsInternal(ctx, s.db, creds, false)
if err != nil {
return introspectDto, err
}
// Get the type of the token and the client ID
tokenType, token, err := s.jwtService.GetTokenType(tokenString)
if err != nil {
@@ -498,24 +503,16 @@ func (s *OidcService) IntrospectToken(ctx context.Context, creds ClientAuthCrede
return introspectDto, nil //nolint:nilerr
}
// If we don't have a client ID, get it from the token
// Otherwise, we need to make sure that the client ID passed as credential matches
// Get the audience from the token
tokenAudiences, _ := token.Audience()
if len(tokenAudiences) != 1 || tokenAudiences[0] == "" {
// We just treat the token as invalid
introspectDto.Active = false
return introspectDto, nil
}
if creds.ClientID == "" {
creds.ClientID = tokenAudiences[0]
} else if creds.ClientID != tokenAudiences[0] {
return introspectDto, &common.OidcMissingClientCredentialsError{}
}
// Verify the credentials for the call
client, err := s.verifyClientCredentialsInternal(ctx, s.db, creds)
if err != nil {
return introspectDto, err
// Audience must match the client ID
if client.ID != tokenAudiences[0] {
return introspectDto, &common.OidcMissingClientCredentialsError{}
}
// Introspect the token
@@ -1137,7 +1134,7 @@ func (s *OidcService) CreateDeviceAuthorization(ctx context.Context, input dto.O
ClientSecret: input.ClientSecret,
ClientAssertionType: input.ClientAssertionType,
ClientAssertion: input.ClientAssertion,
})
}, true)
if err != nil {
return nil, err
}
@@ -1385,24 +1382,39 @@ func clientAuthCredentialsFromCreateTokensDto(d *dto.OidcCreateTokensDto) Client
}
}
func (s *OidcService) verifyClientCredentialsInternal(ctx context.Context, tx *gorm.DB, input ClientAuthCredentials) (*model.OidcClient, error) {
// First, ensure we have a valid client ID
if input.ClientID == "" {
func (s *OidcService) verifyClientCredentialsInternal(ctx context.Context, tx *gorm.DB, input ClientAuthCredentials, allowPublicClientsWithoutAuth bool) (client *model.OidcClient, err error) {
isClientAssertion := input.ClientAssertionType == ClientAssertionTypeJWTBearer && input.ClientAssertion != ""
// Determine the client ID based on the authentication method
var clientID string
switch {
case isClientAssertion:
// Extract client ID from the JWT assertion's 'sub' claim
clientID, err = s.extractClientIDFromAssertion(input.ClientAssertion)
if err != nil {
slog.Error("Failed to extract client ID from assertion", "error", err)
return nil, &common.OidcClientAssertionInvalidError{}
}
case input.ClientID != "":
// Use the provided client ID for other authentication methods
clientID = input.ClientID
default:
return nil, &common.OidcMissingClientCredentialsError{}
}
// Load the OIDC client's configuration
var client model.OidcClient
err := tx.
err = tx.
WithContext(ctx).
First(&client, "id = ?", input.ClientID).
First(&client, "id = ?", clientID).
Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) && isClientAssertion {
return nil, &common.OidcClientAssertionInvalidError{}
}
return nil, err
}
// We have 3 options
// If credentials are provided, we validate them; otherwise, we can continue without credentials for public clients only
// Validate credentials based on the authentication method
switch {
// First, if we have a client secret, we validate it
case input.ClientSecret != "":
@@ -1410,21 +1422,21 @@ func (s *OidcService) verifyClientCredentialsInternal(ctx context.Context, tx *g
if err != nil {
return nil, &common.OidcClientSecretInvalidError{}
}
return &client, nil
return client, nil
// Next, check if we want to use client assertions from federated identities
case input.ClientAssertionType == ClientAssertionTypeJWTBearer && input.ClientAssertion != "":
err = s.verifyClientAssertionFromFederatedIdentities(ctx, &client, input)
case isClientAssertion:
err = s.verifyClientAssertionFromFederatedIdentities(ctx, client, input)
if err != nil {
log.Printf("Invalid assertion for client '%s': %v", client.ID, err)
return nil, &common.OidcClientAssertionInvalidError{}
}
return &client, nil
return client, nil
// There's no credentials
// This is allowed only if the client is public
case client.IsPublic:
return &client, nil
case client.IsPublic && allowPublicClientsWithoutAuth:
return client, nil
// If we're here, we have no credentials AND the client is not public, so credentials are required
default:
@@ -1523,6 +1535,23 @@ func (s *OidcService) verifyClientAssertionFromFederatedIdentities(ctx context.C
return nil
}
// extractClientIDFromAssertion extracts the client_id from the JWT assertion's 'sub' claim
func (s *OidcService) extractClientIDFromAssertion(assertion string) (string, error) {
// Parse the JWT without verification first to get the claims
insecureToken, err := jwt.ParseInsecure([]byte(assertion))
if err != nil {
return "", fmt.Errorf("failed to parse JWT assertion: %w", err)
}
// Extract the subject claim which must be the client_id according to RFC 7523
sub, ok := insecureToken.Subject()
if !ok || sub == "" {
return "", fmt.Errorf("missing or invalid 'sub' claim in JWT assertion")
}
return sub, nil
}
func (s *OidcService) GetClientPreview(ctx context.Context, clientID string, userID string, scopes string) (*dto.OidcClientPreviewDto, error) {
tx := s.db.Begin()
defer func() {

View File

@@ -18,6 +18,7 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
// generateTestECDSAKey creates an ECDSA key for testing
@@ -62,12 +63,12 @@ func TestOidcService_jwkSetForURL(t *testing.T) {
)
mockResponses := map[string]*http.Response{
//nolint:bodyclose
url1: NewMockResponse(http.StatusOK, string(jwkSetJSON1)),
url1: testutils.NewMockResponse(http.StatusOK, string(jwkSetJSON1)),
//nolint:bodyclose
url2: NewMockResponse(http.StatusOK, string(jwkSetJSON2)),
url2: testutils.NewMockResponse(http.StatusOK, string(jwkSetJSON2)),
}
httpClient := &http.Client{
Transport: &MockRoundTripper{
Transport: &testutils.MockRoundTripper{
Responses: mockResponses,
},
}
@@ -134,13 +135,12 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
const (
federatedClientIssuer = "https://external-idp.com"
federatedClientAudience = "https://pocket-id.com"
federatedClientSubject = "123456abcdef"
federatedClientIssuerDefaults = "https://external-idp-defaults.com/"
)
var err error
// Create a test database
db := newDatabaseForTest(t)
db := testutils.NewDatabaseForTest(t)
// Create two JWKs for testing
privateJWK, jwkSetJSON := generateTestECDSAKey(t)
@@ -150,12 +150,12 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
// Create a mock HTTP client with custom transport to return the JWKS
httpClient := &http.Client{
Transport: &MockRoundTripper{
Transport: &testutils.MockRoundTripper{
Responses: map[string]*http.Response{
//nolint:bodyclose
federatedClientIssuer + "/jwks.json": NewMockResponse(http.StatusOK, string(jwkSetJSON)),
federatedClientIssuer + "/jwks.json": testutils.NewMockResponse(http.StatusOK, string(jwkSetJSON)),
//nolint:bodyclose
federatedClientIssuerDefaults + ".well-known/jwks.json": NewMockResponse(http.StatusOK, string(jwkSetJSONDefaults)),
federatedClientIssuerDefaults + ".well-known/jwks.json": testutils.NewMockResponse(http.StatusOK, string(jwkSetJSONDefaults)),
},
},
}
@@ -192,18 +192,24 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
federatedClient, err := s.CreateClient(t.Context(), dto.OidcClientCreateDto{
Name: "Federated Client",
CallbackURLs: []string{"https://example.com/callback"},
}, "test-user-id")
require.NoError(t, err)
federatedClient, err = s.UpdateClient(t.Context(), federatedClient.ID, dto.OidcClientCreateDto{
Name: federatedClient.Name,
CallbackURLs: federatedClient.CallbackURLs,
Credentials: dto.OidcClientCredentialsDto{
FederatedIdentities: []dto.OidcClientFederatedIdentityDto{
{
Issuer: federatedClientIssuer,
Audience: federatedClientAudience,
Subject: federatedClientSubject,
Subject: federatedClient.ID,
JWKS: federatedClientIssuer + "/jwks.json",
},
{Issuer: federatedClientIssuerDefaults},
},
},
}, "test-user-id")
})
require.NoError(t, err)
// Test cases for confidential client (using client secret)
@@ -213,7 +219,7 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
client, err := s.verifyClientCredentialsInternal(t.Context(), s.db, ClientAuthCredentials{
ClientID: confidentialClient.ID,
ClientSecret: confidentialSecret,
})
}, true)
require.NoError(t, err)
require.NotNil(t, client)
assert.Equal(t, confidentialClient.ID, client.ID)
@@ -224,7 +230,7 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
client, err := s.verifyClientCredentialsInternal(t.Context(), s.db, ClientAuthCredentials{
ClientID: confidentialClient.ID,
ClientSecret: "invalid-secret",
})
}, true)
require.Error(t, err)
require.ErrorIs(t, err, &common.OidcClientSecretInvalidError{})
assert.Nil(t, client)
@@ -234,7 +240,7 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
// Test with missing client secret
client, err := s.verifyClientCredentialsInternal(t.Context(), s.db, ClientAuthCredentials{
ClientID: confidentialClient.ID,
})
}, true)
require.Error(t, err)
require.ErrorIs(t, err, &common.OidcMissingClientCredentialsError{})
assert.Nil(t, client)
@@ -247,11 +253,21 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
// Public clients don't require client secret
client, err := s.verifyClientCredentialsInternal(t.Context(), s.db, ClientAuthCredentials{
ClientID: publicClient.ID,
})
}, true)
require.NoError(t, err)
require.NotNil(t, client)
assert.Equal(t, publicClient.ID, client.ID)
})
t.Run("Fails with no credentials if allowPublicClientsWithoutAuth is false", func(t *testing.T) {
// Public clients don't require client secret
client, err := s.verifyClientCredentialsInternal(t.Context(), s.db, ClientAuthCredentials{
ClientID: publicClient.ID,
}, false)
require.Error(t, err)
require.ErrorIs(t, err, &common.OidcMissingClientCredentialsError{})
assert.Nil(t, client)
})
})
// Test cases for federated client using JWT assertion
@@ -261,7 +277,7 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
token, err := jwt.NewBuilder().
Issuer(federatedClientIssuer).
Audience([]string{federatedClientAudience}).
Subject(federatedClientSubject).
Subject(federatedClient.ID).
IssuedAt(time.Now()).
Expiration(time.Now().Add(10 * time.Minute)).
Build()
@@ -274,7 +290,7 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
ClientID: federatedClient.ID,
ClientAssertionType: ClientAssertionTypeJWTBearer,
ClientAssertion: string(signedToken),
})
}, true)
require.NoError(t, err)
require.NotNil(t, client)
assert.Equal(t, federatedClient.ID, client.ID)
@@ -286,7 +302,7 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
ClientID: federatedClient.ID,
ClientAssertionType: ClientAssertionTypeJWTBearer,
ClientAssertion: "invalid.jwt.token",
})
}, true)
require.Error(t, err)
require.ErrorIs(t, err, &common.OidcClientAssertionInvalidError{})
assert.Nil(t, client)
@@ -298,7 +314,7 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
builder := jwt.NewBuilder().
Issuer(federatedClientIssuer).
Audience([]string{federatedClientAudience}).
Subject(federatedClientSubject).
Subject(federatedClient.ID).
IssuedAt(time.Now()).
Expiration(time.Now().Add(10 * time.Minute))
@@ -315,7 +331,7 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
ClientID: federatedClient.ID,
ClientAssertionType: ClientAssertionTypeJWTBearer,
ClientAssertion: string(signedToken),
})
}, true)
require.Error(t, err)
require.ErrorIs(t, err, &common.OidcClientAssertionInvalidError{})
require.Nil(t, client)
@@ -356,7 +372,7 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
ClientID: federatedClient.ID,
ClientAssertionType: ClientAssertionTypeJWTBearer,
ClientAssertion: string(signedToken),
})
}, true)
require.NoError(t, err)
require.NotNil(t, client)
assert.Equal(t, federatedClient.ID, client.ID)

View File

@@ -296,15 +296,21 @@ func (s *UserService) updateUserInternal(ctx context.Context, userID string, upd
isLdapUser := user.LdapID != nil && s.appConfigService.GetDbConfig().LdapEnabled.IsTrue()
allowOwnAccountEdit := s.appConfigService.GetDbConfig().AllowOwnAccountEdit.IsTrue()
// For LDAP users or if own account editing is not allowed, only allow updating the locale unless it's an LDAP sync
if !isLdapSync && (isLdapUser || (!allowOwnAccountEdit && !updateOwnUser)) {
if !isLdapSync && (isLdapUser || (!allowOwnAccountEdit && updateOwnUser)) {
// Restricted update: Only locale can be changed when:
// - User is from LDAP, OR
// - User is editing their own account but global setting disallows self-editing
// (Exception: LDAP sync operations can update everything)
user.Locale = updatedUser.Locale
} else {
// Full update: Allow updating all personal fields
user.FirstName = updatedUser.FirstName
user.LastName = updatedUser.LastName
user.Email = updatedUser.Email
user.Username = updatedUser.Username
user.Locale = updatedUser.Locale
// Admin-only fields: Only allow updates when not updating own account
if !updateOwnUser {
user.IsAdmin = updatedUser.IsAdmin
user.Disabled = updatedUser.Disabled
@@ -463,9 +469,7 @@ func (s *UserService) ExchangeOneTimeAccessToken(ctx context.Context, token stri
return model.User{}, "", err
}
if ipAddress != "" && userAgent != "" {
s.auditLogService.Create(ctx, model.AuditLogEventOneTimeAccessTokenSignIn, ipAddress, userAgent, oneTimeAccessToken.User.ID, model.AuditLogData{}, tx)
}
s.auditLogService.Create(ctx, model.AuditLogEventOneTimeAccessTokenSignIn, ipAddress, userAgent, oneTimeAccessToken.User.ID, model.AuditLogData{}, tx)
err = tx.Commit().Error
if err != nil {
@@ -523,7 +527,7 @@ func (s *UserService) UpdateUserGroups(ctx context.Context, id string, userGroup
return user, nil
}
func (s *UserService) SetupInitialAdmin(ctx context.Context) (model.User, string, error) {
func (s *UserService) SignUpInitialAdmin(ctx context.Context, signUpData dto.SignUpDto) (model.User, string, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
@@ -533,26 +537,23 @@ func (s *UserService) SetupInitialAdmin(ctx context.Context) (model.User, string
if err := tx.WithContext(ctx).Model(&model.User{}).Count(&userCount).Error; err != nil {
return model.User{}, "", err
}
if userCount > 1 {
if userCount != 0 {
return model.User{}, "", &common.SetupAlreadyCompletedError{}
}
user := model.User{
FirstName: "Admin",
LastName: "Admin",
Username: "admin",
Email: "admin@admin.com",
userToCreate := dto.UserCreateDto{
FirstName: signUpData.FirstName,
LastName: signUpData.LastName,
Username: signUpData.Username,
Email: signUpData.Email,
IsAdmin: true,
}
if err := tx.WithContext(ctx).Model(&model.User{}).Preload("Credentials").FirstOrCreate(&user).Error; err != nil {
user, err := s.createUserInternal(ctx, userToCreate, false, tx)
if err != nil {
return model.User{}, "", err
}
if len(user.Credentials) > 0 {
return model.User{}, "", &common.SetupAlreadyCompletedError{}
}
token, err := s.jwtService.GenerateAccessToken(user)
if err != nil {
return model.User{}, "", err
@@ -630,6 +631,110 @@ func (s *UserService) disableUserInternal(ctx context.Context, userID string, tx
Error
}
func (s *UserService) CreateSignupToken(ctx context.Context, expiresAt time.Time, usageLimit int) (model.SignupToken, error) {
return s.createSignupTokenInternal(ctx, expiresAt, usageLimit, s.db)
}
func (s *UserService) createSignupTokenInternal(ctx context.Context, expiresAt time.Time, usageLimit int, tx *gorm.DB) (model.SignupToken, error) {
signupToken, err := NewSignupToken(expiresAt, usageLimit)
if err != nil {
return model.SignupToken{}, err
}
if err := tx.WithContext(ctx).Create(signupToken).Error; err != nil {
return model.SignupToken{}, err
}
return *signupToken, nil
}
func (s *UserService) SignUp(ctx context.Context, signupData dto.SignUpDto, ipAddress, userAgent string) (model.User, string, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
tokenProvided := signupData.Token != ""
config := s.appConfigService.GetDbConfig()
if config.AllowUserSignups.Value != "open" && !tokenProvided {
return model.User{}, "", &common.OpenSignupDisabledError{}
}
var signupToken model.SignupToken
if tokenProvided {
err := tx.
WithContext(ctx).
Where("token = ?", signupData.Token).
First(&signupToken).
Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return model.User{}, "", &common.TokenInvalidOrExpiredError{}
}
return model.User{}, "", err
}
if !signupToken.IsValid() {
return model.User{}, "", &common.TokenInvalidOrExpiredError{}
}
}
userToCreate := dto.UserCreateDto{
Username: signupData.Username,
Email: signupData.Email,
FirstName: signupData.FirstName,
LastName: signupData.LastName,
}
user, err := s.createUserInternal(ctx, userToCreate, false, tx)
if err != nil {
return model.User{}, "", err
}
accessToken, err := s.jwtService.GenerateAccessToken(user)
if err != nil {
return model.User{}, "", err
}
if tokenProvided {
s.auditLogService.Create(ctx, model.AuditLogEventAccountCreated, ipAddress, userAgent, user.ID, model.AuditLogData{
"signupToken": signupToken.Token,
}, tx)
signupToken.UsageCount++
err = tx.WithContext(ctx).Save(&signupToken).Error
if err != nil {
return model.User{}, "", err
}
} else {
s.auditLogService.Create(ctx, model.AuditLogEventAccountCreated, ipAddress, userAgent, user.ID, model.AuditLogData{
"method": "open_signup",
}, tx)
}
err = tx.Commit().Error
if err != nil {
return model.User{}, "", err
}
return user, accessToken, nil
}
func (s *UserService) ListSignupTokens(ctx context.Context, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.SignupToken, utils.PaginationResponse, error) {
var tokens []model.SignupToken
query := s.db.WithContext(ctx).Model(&model.SignupToken{})
pagination, err := utils.PaginateAndSort(sortedPaginationRequest, query, &tokens)
return tokens, pagination, err
}
func (s *UserService) DeleteSignupToken(ctx context.Context, tokenID string) error {
return s.db.WithContext(ctx).Delete(&model.SignupToken{}, "id = ?", tokenID).Error
}
func NewOneTimeAccessToken(userID string, expiresAt time.Time) (*model.OneTimeAccessToken, error) {
// If expires at is less than 15 minutes, use a 6-character token instead of 16
tokenLength := 16
@@ -650,3 +755,20 @@ func NewOneTimeAccessToken(userID string, expiresAt time.Time) (*model.OneTimeAc
return o, nil
}
func NewSignupToken(expiresAt time.Time, usageLimit int) (*model.SignupToken, error) {
// Generate a random token
randomString, err := utils.GenerateRandomAlphanumericString(16)
if err != nil {
return nil, err
}
token := &model.SignupToken{
Token: randomString,
ExpiresAt: datatype.DateTime(expiresAt),
UsageLimit: usageLimit,
UsageCount: 0,
}
return token, nil
}

View File

@@ -0,0 +1,24 @@
package utils
import (
"bufio"
"fmt"
"os"
"strings"
)
// PromptForConfirmation prompts the user to answer "y" in the terminal
func PromptForConfirmation(prompt string) (bool, error) {
fmt.Print(prompt + " [y/N]: ")
reader := bufio.NewReader(os.Stdin)
r, err := reader.ReadString('\n')
if err != nil {
return false, fmt.Errorf("failed to read response: %w", err)
}
r = strings.TrimSpace(strings.ToLower(r))
ok := r == "yes" || r == "y"
return ok, nil
}

View File

@@ -0,0 +1,69 @@
package crypto
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"errors"
"fmt"
"io"
)
// ErrDecrypt is returned by Decrypt when the operation failed for any reason
var ErrDecrypt = errors.New("failed to decrypt data")
// Encrypt a byte slice using AES-GCM and a random nonce
// Important: do not encrypt more than ~4 billion messages with the same key!
func Encrypt(key []byte, plaintext []byte, associatedData []byte) (ciphertext []byte, err error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("failed to create block cipher: %w", err)
}
aead, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("failed to create AEAD cipher: %w", err)
}
// Generate a random nonce
nonce := make([]byte, aead.NonceSize())
_, err = io.ReadFull(rand.Reader, nonce)
if err != nil {
return nil, fmt.Errorf("failed to generate random nonce: %w", err)
}
// Allocate the slice for the result, with additional space for the nonce and overhead
ciphertext = make([]byte, 0, len(plaintext)+aead.NonceSize()+aead.Overhead())
ciphertext = append(ciphertext, nonce...)
// Encrypt the plaintext
// Tag is automatically added at the end
ciphertext = aead.Seal(ciphertext, nonce, plaintext, associatedData)
return ciphertext, nil
}
// Decrypt a byte slice using AES-GCM
func Decrypt(key []byte, ciphertext []byte, associatedData []byte) (plaintext []byte, err error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("failed to create block cipher: %w", err)
}
aead, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("failed to create AEAD cipher: %w", err)
}
// Extract the nonce
if len(ciphertext) < (aead.NonceSize() + aead.Overhead()) {
return nil, ErrDecrypt
}
// Decrypt the data
plaintext, err = aead.Open(nil, ciphertext[:aead.NonceSize()], ciphertext[aead.NonceSize():], associatedData)
if err != nil {
// Note: we do not return the exact error here, to avoid disclosing information
return nil, ErrDecrypt
}
return plaintext, nil
}

View File

@@ -0,0 +1,208 @@
package crypto
import (
"crypto/rand"
"fmt"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestEncryptDecrypt(t *testing.T) {
tests := []struct {
name string
keySize int
plaintext string
associatedData []byte
}{
{
name: "AES-128 with short plaintext",
keySize: 16,
plaintext: "Hello, World!",
associatedData: []byte("test-aad"),
},
{
name: "AES-192 with medium plaintext",
keySize: 24,
plaintext: "This is a longer message to test encryption and decryption",
associatedData: []byte("associated-data-192"),
},
{
name: "AES-256 with unicode",
keySize: 32,
plaintext: "Hello 世界! 🌍 Testing unicode characters", //nolint:gosmopolitan
associatedData: []byte("unicode-test"),
},
{
name: "No associated data",
keySize: 32,
plaintext: "Testing without associated data",
associatedData: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Generate random key
key := make([]byte, tt.keySize)
_, err := rand.Read(key)
require.NoError(t, err, "Failed to generate random key")
plaintext := []byte(tt.plaintext)
// Test encryption
ciphertext, err := Encrypt(key, plaintext, tt.associatedData)
require.NoError(t, err, "Encrypt should succeed")
// Verify ciphertext is different from plaintext (unless empty)
if len(plaintext) > 0 {
assert.NotEqual(t, plaintext, ciphertext)
}
// Test decryption
decrypted, err := Decrypt(key, ciphertext, tt.associatedData)
require.NoError(t, err, "Decrypt should succeed")
// Verify decrypted text matches original
assert.Equal(t, plaintext, decrypted, "Decrypted text should match original")
})
}
}
func TestEncryptWithInvalidKeySize(t *testing.T) {
invalidKeySizes := []int{8, 12, 33, 47, 55, 128}
for _, keySize := range invalidKeySizes {
t.Run(fmt.Sprintf("Key size %d", keySize), func(t *testing.T) {
key := make([]byte, keySize)
plaintext := []byte("test message")
_, err := Encrypt(key, plaintext, nil)
require.Error(t, err)
assert.ErrorContains(t, err, "invalid key size")
})
}
}
func TestDecryptWithInvalidKeySize(t *testing.T) {
invalidKeySizes := []int{8, 12, 33, 47, 55, 128}
for _, keySize := range invalidKeySizes {
t.Run(fmt.Sprintf("Key size %d", keySize), func(t *testing.T) {
key := make([]byte, keySize)
ciphertext := []byte("fake ciphertext")
_, err := Decrypt(key, ciphertext, nil)
require.Error(t, err)
assert.ErrorContains(t, err, "invalid key size")
})
}
}
func TestDecryptWithInvalidCiphertext(t *testing.T) {
key := make([]byte, 32)
_, err := rand.Read(key)
require.NoError(t, err, "Failed to generate random key")
tests := []struct {
name string
ciphertext []byte
}{
{
name: "empty ciphertext",
ciphertext: []byte{},
},
{
name: "too short ciphertext",
ciphertext: []byte("short"),
},
{
name: "random invalid data",
ciphertext: []byte("this is not valid encrypted data"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := Decrypt(key, tt.ciphertext, nil)
require.Error(t, err)
require.ErrorIs(t, err, ErrDecrypt)
})
}
}
func TestDecryptWithWrongKey(t *testing.T) {
// Generate two different keys
key1 := make([]byte, 32)
key2 := make([]byte, 32)
_, err := rand.Read(key1)
require.NoError(t, err)
_, err = rand.Read(key2)
require.NoError(t, err)
plaintext := []byte("secret message")
// Encrypt with key1
ciphertext, err := Encrypt(key1, plaintext, nil)
require.NoError(t, err)
// Try to decrypt with key2
_, err = Decrypt(key2, ciphertext, nil)
require.Error(t, err)
require.ErrorIs(t, err, ErrDecrypt)
}
func TestDecryptWithWrongAssociatedData(t *testing.T) {
key := make([]byte, 32)
_, err := rand.Read(key)
require.NoError(t, err, "Failed to generate random key")
plaintext := []byte("secret message")
correctAAD := []byte("correct-aad")
wrongAAD := []byte("wrong-aad")
// Encrypt with correct AAD
ciphertext, err := Encrypt(key, plaintext, correctAAD)
require.NoError(t, err)
// Try to decrypt with wrong AAD
_, err = Decrypt(key, ciphertext, wrongAAD)
require.Error(t, err)
require.ErrorIs(t, err, ErrDecrypt)
// Verify correct AAD works
decrypted, err := Decrypt(key, ciphertext, correctAAD)
require.NoError(t, err)
assert.Equal(t, plaintext, decrypted, "Decrypted text should match original when using correct AAD")
}
func TestEncryptDecryptConsistency(t *testing.T) {
key := make([]byte, 32)
_, err := rand.Read(key)
require.NoError(t, err)
plaintext := []byte("consistency test message")
associatedData := []byte("test-aad")
// Encrypt multiple times and verify we get different ciphertexts (due to random IV)
ciphertext1, err := Encrypt(key, plaintext, associatedData)
require.NoError(t, err)
ciphertext2, err := Encrypt(key, plaintext, associatedData)
require.NoError(t, err)
// Ciphertexts should be different (due to random IV)
assert.NotEqual(t, ciphertext1, ciphertext2, "Multiple encryptions of same plaintext should produce different ciphertexts")
// Both should decrypt to the same plaintext
decrypted1, err := Decrypt(key, ciphertext1, associatedData)
require.NoError(t, err)
decrypted2, err := Decrypt(key, ciphertext2, associatedData)
require.NoError(t, err)
assert.Equal(t, plaintext, decrypted1, "First decrypted text should match original")
assert.Equal(t, plaintext, decrypted2, "Second decrypted text should match original")
assert.Equal(t, decrypted1, decrypted2, "Both decrypted texts should be identical")
}

View File

@@ -0,0 +1,50 @@
package jwk
import (
"fmt"
"github.com/lestrrat-go/jwx/v3/jwk"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/common"
)
type KeyProviderOpts struct {
EnvConfig *common.EnvConfigSchema
DB *gorm.DB
Kek []byte
}
type KeyProvider interface {
Init(opts KeyProviderOpts) error
LoadKey() (jwk.Key, error)
SaveKey(key jwk.Key) error
}
func GetKeyProvider(db *gorm.DB, envConfig *common.EnvConfigSchema, instanceID string) (keyProvider KeyProvider, err error) {
// Load the encryption key (KEK) if present
kek, err := LoadKeyEncryptionKey(envConfig, instanceID)
if err != nil {
return nil, fmt.Errorf("failed to load encryption key: %w", err)
}
// Get the key provider
switch envConfig.KeysStorage {
case "file", "":
keyProvider = &KeyProviderFile{}
case "database":
keyProvider = &KeyProviderDatabase{}
default:
return nil, fmt.Errorf("invalid key storage '%s'", envConfig.KeysStorage)
}
err = keyProvider.Init(KeyProviderOpts{
DB: db,
EnvConfig: envConfig,
Kek: kek,
})
if err != nil {
return nil, fmt.Errorf("failed to init key provider of type '%s': %w", envConfig.KeysStorage, err)
}
return keyProvider, nil
}

View File

@@ -0,0 +1,109 @@
package jwk
import (
"context"
"encoding/base64"
"errors"
"fmt"
"time"
"github.com/lestrrat-go/jwx/v3/jwk"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/model"
cryptoutils "github.com/pocket-id/pocket-id/backend/internal/utils/crypto"
)
const PrivateKeyDBKey = "jwt_private_key.json"
type KeyProviderDatabase struct {
db *gorm.DB
kek []byte
}
func (f *KeyProviderDatabase) Init(opts KeyProviderOpts) error {
if len(opts.Kek) == 0 {
return errors.New("an encryption key is required when using the 'database' key provider")
}
f.db = opts.DB
f.kek = opts.Kek
return nil
}
func (f *KeyProviderDatabase) LoadKey() (key jwk.Key, err error) {
row := model.KV{
Key: PrivateKeyDBKey,
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
err = f.db.WithContext(ctx).First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
// Key not present in the database - return nil so a new one can be generated
return nil, nil
} else if err != nil {
return nil, fmt.Errorf("failed to retrieve private key from the database: %w", err)
}
if row.Value == nil || *row.Value == "" {
// Key not present in the database - return nil so a new one can be generated
return nil, nil
}
// Decode from base64
enc, err := base64.StdEncoding.DecodeString(*row.Value)
if err != nil {
return nil, fmt.Errorf("failed to read encrypted private key: not a valid base64-encoded value: %w", err)
}
// Decrypt the data
data, err := cryptoutils.Decrypt(f.kek, enc, nil)
if err != nil {
return nil, fmt.Errorf("failed to decrypt private key: %w", err)
}
// Parse the key
key, err = jwk.ParseKey(data)
if err != nil {
return nil, fmt.Errorf("failed to parse encrypted private key: %w", err)
}
return key, nil
}
func (f *KeyProviderDatabase) SaveKey(key jwk.Key) error {
// Encode the key to JSON
data, err := EncodeJWKBytes(key)
if err != nil {
return fmt.Errorf("failed to encode key to JSON: %w", err)
}
// Encrypt the key then encode to Base64
enc, err := cryptoutils.Encrypt(f.kek, data, nil)
if err != nil {
return fmt.Errorf("failed to encrypt key: %w", err)
}
encB64 := base64.StdEncoding.EncodeToString(enc)
// Save to database
row := model.KV{
Key: PrivateKeyDBKey,
Value: &encB64,
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
err = f.db.WithContext(ctx).Create(&row).Error
if err != nil {
// There's one scenario where if Pocket ID is started fresh with more than 1 replica, they both could be trying to create the private key in the database at the same time
// In this case, only one of the replicas will succeed; the other one(s) will return an error here, which will cascade down and cause the replica(s) to crash and be restarted (at that point they'll load the then-existing key from the database)
return fmt.Errorf("failed to store private key in database: %w", err)
}
return nil
}
// Compile-time interface check
var _ KeyProvider = (*KeyProviderDatabase)(nil)

View File

@@ -0,0 +1,275 @@
package jwk
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"encoding/base64"
"testing"
"github.com/lestrrat-go/jwx/v3/jwk"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/pocket-id/pocket-id/backend/internal/model"
cryptoutils "github.com/pocket-id/pocket-id/backend/internal/utils/crypto"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
func TestKeyProviderDatabase_Init(t *testing.T) {
t.Run("Init fails when KEK is not provided", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
provider := &KeyProviderDatabase{}
err := provider.Init(KeyProviderOpts{
DB: db,
Kek: nil, // No KEK
})
require.Error(t, err, "Expected error when KEK is not provided")
require.ErrorContains(t, err, "encryption key is required")
})
t.Run("Init succeeds with KEK", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
provider := &KeyProviderDatabase{}
err := provider.Init(KeyProviderOpts{
DB: db,
Kek: generateTestKEK(t),
})
require.NoError(t, err, "Expected no error when KEK is provided")
})
}
func TestKeyProviderDatabase_LoadKey(t *testing.T) {
// Generate a test key to use in our tests
pk, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
key, err := jwk.Import(pk)
require.NoError(t, err)
t.Run("LoadKey with no existing key", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
kek := generateTestKEK(t)
provider := &KeyProviderDatabase{}
err := provider.Init(KeyProviderOpts{
DB: db,
Kek: kek,
})
require.NoError(t, err)
// Load key when none exists
loadedKey, err := provider.LoadKey()
require.NoError(t, err)
assert.Nil(t, loadedKey, "Expected nil key when no key exists in database")
})
t.Run("LoadKey with existing key", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
kek := generateTestKEK(t)
provider := &KeyProviderDatabase{}
err := provider.Init(KeyProviderOpts{
DB: db,
Kek: kek,
})
require.NoError(t, err)
// Save a key
err = provider.SaveKey(key)
require.NoError(t, err)
// Load the key
loadedKey, err := provider.LoadKey()
require.NoError(t, err)
assert.NotNil(t, loadedKey, "Expected non-nil key when key exists in database")
// Verify the loaded key is the same as the original
keyBytes, err := EncodeJWKBytes(key)
require.NoError(t, err)
loadedKeyBytes, err := EncodeJWKBytes(loadedKey)
require.NoError(t, err)
assert.Equal(t, keyBytes, loadedKeyBytes, "Expected loaded key to match original key")
})
t.Run("LoadKey with invalid base64", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
kek := generateTestKEK(t)
provider := &KeyProviderDatabase{}
err := provider.Init(KeyProviderOpts{
DB: db,
Kek: kek,
})
require.NoError(t, err)
// Insert invalid base64 data
invalidBase64 := "not-valid-base64"
err = db.Create(&model.KV{
Key: PrivateKeyDBKey,
Value: &invalidBase64,
}).Error
require.NoError(t, err)
// Attempt to load the key
loadedKey, err := provider.LoadKey()
require.Error(t, err, "Expected error when loading key with invalid base64")
require.ErrorContains(t, err, "not a valid base64-encoded value")
assert.Nil(t, loadedKey, "Expected nil key when loading fails")
})
t.Run("LoadKey with invalid encrypted data", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
kek := generateTestKEK(t)
provider := &KeyProviderDatabase{}
err := provider.Init(KeyProviderOpts{
DB: db,
Kek: kek,
})
require.NoError(t, err)
// Insert valid base64 but invalid encrypted data
invalidData := base64.StdEncoding.EncodeToString([]byte("not-valid-encrypted-data"))
err = db.Create(&model.KV{
Key: PrivateKeyDBKey,
Value: &invalidData,
}).Error
require.NoError(t, err)
// Attempt to load the key
loadedKey, err := provider.LoadKey()
require.Error(t, err, "Expected error when loading key with invalid encrypted data")
require.ErrorContains(t, err, "failed to decrypt")
assert.Nil(t, loadedKey, "Expected nil key when loading fails")
})
t.Run("LoadKey with valid encrypted data but wrong KEK", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
originalKek := generateTestKEK(t)
// Save a key with the original KEK
originalProvider := &KeyProviderDatabase{}
err := originalProvider.Init(KeyProviderOpts{
DB: db,
Kek: originalKek,
})
require.NoError(t, err)
err = originalProvider.SaveKey(key)
require.NoError(t, err)
// Now try to load with a different KEK
differentKek := generateTestKEK(t)
differentProvider := &KeyProviderDatabase{}
err = differentProvider.Init(KeyProviderOpts{
DB: db,
Kek: differentKek,
})
require.NoError(t, err)
// Attempt to load the key with the wrong KEK
loadedKey, err := differentProvider.LoadKey()
require.Error(t, err, "Expected error when loading key with wrong KEK")
require.ErrorContains(t, err, "failed to decrypt")
assert.Nil(t, loadedKey, "Expected nil key when loading fails")
})
t.Run("LoadKey with invalid key data", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
kek := generateTestKEK(t)
provider := &KeyProviderDatabase{}
err := provider.Init(KeyProviderOpts{
DB: db,
Kek: kek,
})
require.NoError(t, err)
// Create invalid key data (valid JSON but not a valid JWK)
invalidKeyData := []byte(`{"not": "a valid jwk"}`)
// Encrypt the invalid key data
encryptedData, err := cryptoutils.Encrypt(kek, invalidKeyData, nil)
require.NoError(t, err)
// Base64 encode the encrypted data
encodedData := base64.StdEncoding.EncodeToString(encryptedData)
// Save to database
err = db.Create(&model.KV{
Key: PrivateKeyDBKey,
Value: &encodedData,
}).Error
require.NoError(t, err)
// Attempt to load the key
loadedKey, err := provider.LoadKey()
require.Error(t, err, "Expected error when loading invalid key data")
require.ErrorContains(t, err, "failed to parse")
assert.Nil(t, loadedKey, "Expected nil key when loading fails")
})
}
func TestKeyProviderDatabase_SaveKey(t *testing.T) {
// Generate a test key to use in our tests
pk, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
key, err := jwk.Import(pk)
require.NoError(t, err)
t.Run("SaveKey and verify database record", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
kek := generateTestKEK(t)
provider := &KeyProviderDatabase{}
err := provider.Init(KeyProviderOpts{
DB: db,
Kek: kek,
})
require.NoError(t, err)
// Save the key
err = provider.SaveKey(key)
require.NoError(t, err, "Expected no error when saving key")
// Verify record exists in database
var kv model.KV
err = db.Where("key = ?", PrivateKeyDBKey).First(&kv).Error
require.NoError(t, err, "Expected to find key in database")
require.NotNil(t, kv.Value, "Expected non-nil value in database")
assert.NotEmpty(t, *kv.Value, "Expected non-empty value in database")
// Decode and decrypt to verify content
encBytes, err := base64.StdEncoding.DecodeString(*kv.Value)
require.NoError(t, err, "Expected valid base64 encoding")
decBytes, err := cryptoutils.Decrypt(kek, encBytes, nil)
require.NoError(t, err, "Expected valid encrypted data")
parsedKey, err := jwk.ParseKey(decBytes)
require.NoError(t, err, "Expected valid JWK data")
// Compare keys
keyBytes, err := EncodeJWKBytes(key)
require.NoError(t, err)
parsedKeyBytes, err := EncodeJWKBytes(parsedKey)
require.NoError(t, err)
assert.Equal(t, keyBytes, parsedKeyBytes, "Expected saved key to match original key")
})
}
func generateTestKEK(t *testing.T) []byte {
t.Helper()
// Generate a 32-byte kek
kek := make([]byte, 32)
_, err := rand.Read(kek)
require.NoError(t, err)
return kek
}

View File

@@ -0,0 +1,202 @@
package jwk
import (
"encoding/base64"
"fmt"
"os"
"path/filepath"
"github.com/lestrrat-go/jwx/v3/jwk"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/utils"
cryptoutils "github.com/pocket-id/pocket-id/backend/internal/utils/crypto"
)
const (
// PrivateKeyFile is the path in the data/keys folder where the key is stored
// This is a JSON file containing a key encoded as JWK
PrivateKeyFile = "jwt_private_key.json"
// PrivateKeyFileEncrypted is the path in the data/keys folder where the encrypted key is stored
// This is a encrypted JSON file containing a key encoded as JWK
PrivateKeyFileEncrypted = "jwt_private_key.json.enc"
)
type KeyProviderFile struct {
envConfig *common.EnvConfigSchema
kek []byte
}
func (f *KeyProviderFile) Init(opts KeyProviderOpts) error {
f.envConfig = opts.EnvConfig
f.kek = opts.Kek
return nil
}
func (f *KeyProviderFile) LoadKey() (jwk.Key, error) {
if len(f.kek) > 0 {
return f.loadEncryptedKey()
}
return f.loadKey()
}
func (f *KeyProviderFile) SaveKey(key jwk.Key) error {
if len(f.kek) > 0 {
return f.saveKeyEncrypted(key)
}
return f.saveKey(key)
}
func (f *KeyProviderFile) loadKey() (jwk.Key, error) {
var key jwk.Key
// First, check if we have a JWK file
// If we do, then we just load that
jwkPath := f.jwkPath()
ok, err := utils.FileExists(jwkPath)
if err != nil {
return nil, fmt.Errorf("failed to check if private key file exists at path '%s': %w", jwkPath, err)
}
if !ok {
// File doesn't exist, no key was loaded
return nil, nil
}
data, err := os.ReadFile(jwkPath)
if err != nil {
return nil, fmt.Errorf("failed to read private key file at path '%s': %w", jwkPath, err)
}
key, err = jwk.ParseKey(data)
if err != nil {
return nil, fmt.Errorf("failed to parse private key file at path '%s': %w", jwkPath, err)
}
return key, nil
}
func (f *KeyProviderFile) loadEncryptedKey() (key jwk.Key, err error) {
// First, check if we have an encrypted JWK file
// If we do, then we just load that
encJwkPath := f.encJwkPath()
ok, err := utils.FileExists(encJwkPath)
if err != nil {
return nil, fmt.Errorf("failed to check if encrypted private key file exists at path '%s': %w", encJwkPath, err)
}
if ok {
encB64, err := os.ReadFile(encJwkPath)
if err != nil {
return nil, fmt.Errorf("failed to read encrypted private key file at path '%s': %w", encJwkPath, err)
}
// Decode from base64
enc := make([]byte, base64.StdEncoding.DecodedLen(len(encB64)))
n, err := base64.StdEncoding.Decode(enc, encB64)
if err != nil {
return nil, fmt.Errorf("failed to read encrypted private key file at path '%s': not a valid base64-encoded file: %w", encJwkPath, err)
}
// Decrypt the data
data, err := cryptoutils.Decrypt(f.kek, enc[:n], nil)
if err != nil {
return nil, fmt.Errorf("failed to decrypt private key file at path '%s': %w", encJwkPath, err)
}
// Parse the key
key, err = jwk.ParseKey(data)
if err != nil {
return nil, fmt.Errorf("failed to parse encrypted private key file at path '%s': %w", encJwkPath, err)
}
return key, nil
}
// Check if we have an un-encrypted JWK file
key, err = f.loadKey()
if err != nil {
return nil, fmt.Errorf("failed to load un-encrypted key file: %w", err)
}
if key == nil {
// No key exists, encrypted or un-encrypted
return nil, nil
}
// If we are here, we have loaded a key that was un-encrypted
// We need to replace the plaintext key with the encrypted one before we return
err = f.saveKeyEncrypted(key)
if err != nil {
return nil, fmt.Errorf("failed to save encrypted key file: %w", err)
}
jwkPath := f.jwkPath()
err = os.Remove(jwkPath)
if err != nil {
return nil, fmt.Errorf("failed to remove un-encrypted key file at path '%s': %w", jwkPath, err)
}
return key, nil
}
func (f *KeyProviderFile) saveKey(key jwk.Key) error {
err := os.MkdirAll(f.envConfig.KeysPath, 0700)
if err != nil {
return fmt.Errorf("failed to create directory '%s' for key file: %w", f.envConfig.KeysPath, err)
}
jwkPath := f.jwkPath()
keyFile, err := os.OpenFile(jwkPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return fmt.Errorf("failed to create key file at path '%s': %w", jwkPath, err)
}
defer keyFile.Close()
// Write the JSON file to disk
err = EncodeJWK(keyFile, key)
if err != nil {
return fmt.Errorf("failed to write key file at path '%s': %w", jwkPath, err)
}
return nil
}
func (f *KeyProviderFile) saveKeyEncrypted(key jwk.Key) error {
err := os.MkdirAll(f.envConfig.KeysPath, 0700)
if err != nil {
return fmt.Errorf("failed to create directory '%s' for encrypted key file: %w", f.envConfig.KeysPath, err)
}
// Encode the key to JSON
data, err := EncodeJWKBytes(key)
if err != nil {
return fmt.Errorf("failed to encode key to JSON: %w", err)
}
// Encrypt the key then encode to Base64
enc, err := cryptoutils.Encrypt(f.kek, data, nil)
if err != nil {
return fmt.Errorf("failed to encrypt key: %w", err)
}
encB64 := make([]byte, base64.StdEncoding.EncodedLen(len(enc)))
base64.StdEncoding.Encode(encB64, enc)
// Write to disk
encJwkPath := f.encJwkPath()
err = os.WriteFile(encJwkPath, encB64, 0600)
if err != nil {
return fmt.Errorf("failed to write encrypted key file at path '%s': %w", encJwkPath, err)
}
return nil
}
func (f *KeyProviderFile) jwkPath() string {
return filepath.Join(f.envConfig.KeysPath, PrivateKeyFile)
}
func (f *KeyProviderFile) encJwkPath() string {
return filepath.Join(f.envConfig.KeysPath, PrivateKeyFileEncrypted)
}
// Compile-time interface check
var _ KeyProvider = (*KeyProviderFile)(nil)

View File

@@ -0,0 +1,320 @@
package jwk
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"encoding/base64"
"os"
"path/filepath"
"testing"
"github.com/lestrrat-go/jwx/v3/jwk"
"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/utils"
cryptoutils "github.com/pocket-id/pocket-id/backend/internal/utils/crypto"
)
func TestKeyProviderFile_LoadKey(t *testing.T) {
// Generate a test key to use in our tests
pk, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
key, err := jwk.Import(pk)
require.NoError(t, err)
t.Run("LoadKey with no existing key", func(t *testing.T) {
tempDir := t.TempDir()
provider := &KeyProviderFile{}
err := provider.Init(KeyProviderOpts{
EnvConfig: &common.EnvConfigSchema{
KeysPath: tempDir,
},
})
require.NoError(t, err)
// Load key when none exists
loadedKey, err := provider.LoadKey()
require.NoError(t, err)
assert.Nil(t, loadedKey, "Expected nil key when no key exists")
})
t.Run("LoadKey with no existing key (with kek)", func(t *testing.T) {
tempDir := t.TempDir()
provider := &KeyProviderFile{}
err = provider.Init(KeyProviderOpts{
EnvConfig: &common.EnvConfigSchema{
KeysPath: tempDir,
},
Kek: makeKEK(t),
})
require.NoError(t, err)
// Load key when none exists
loadedKey, err := provider.LoadKey()
require.NoError(t, err)
assert.Nil(t, loadedKey, "Expected nil key when no key exists")
})
t.Run("LoadKey with unencrypted key", func(t *testing.T) {
tempDir := t.TempDir()
provider := &KeyProviderFile{}
err := provider.Init(KeyProviderOpts{
EnvConfig: &common.EnvConfigSchema{
KeysPath: tempDir,
},
})
require.NoError(t, err)
// Save a key
err = provider.SaveKey(key)
require.NoError(t, err)
// Make sure the key file exists
keyPath := filepath.Join(tempDir, PrivateKeyFile)
exists, err := utils.FileExists(keyPath)
require.NoError(t, err)
assert.True(t, exists, "Expected key file to exist")
// Load the key
loadedKey, err := provider.LoadKey()
require.NoError(t, err)
assert.NotNil(t, loadedKey, "Expected non-nil key when key exists")
// Verify the loaded key is the same as the original
keyBytes, err := EncodeJWKBytes(key)
require.NoError(t, err)
loadedKeyBytes, err := EncodeJWKBytes(loadedKey)
require.NoError(t, err)
assert.Equal(t, keyBytes, loadedKeyBytes, "Expected loaded key to match original key")
})
t.Run("LoadKey with encrypted key", func(t *testing.T) {
tempDir := t.TempDir()
provider := &KeyProviderFile{}
err = provider.Init(KeyProviderOpts{
EnvConfig: &common.EnvConfigSchema{
KeysPath: tempDir,
},
Kek: makeKEK(t),
})
require.NoError(t, err)
// Save a key (will be encrypted)
err = provider.SaveKey(key)
require.NoError(t, err)
// Make sure the encrypted key file exists
encKeyPath := filepath.Join(tempDir, PrivateKeyFileEncrypted)
exists, err := utils.FileExists(encKeyPath)
require.NoError(t, err)
assert.True(t, exists, "Expected encrypted key file to exist")
// Make sure the unencrypted key file does not exist
keyPath := filepath.Join(tempDir, PrivateKeyFile)
exists, err = utils.FileExists(keyPath)
require.NoError(t, err)
assert.False(t, exists, "Expected unencrypted key file to not exist")
// Load the key
loadedKey, err := provider.LoadKey()
require.NoError(t, err)
assert.NotNil(t, loadedKey, "Expected non-nil key when encrypted key exists")
// Verify the loaded key is the same as the original
keyBytes, err := EncodeJWKBytes(key)
require.NoError(t, err)
loadedKeyBytes, err := EncodeJWKBytes(loadedKey)
require.NoError(t, err)
assert.Equal(t, keyBytes, loadedKeyBytes, "Expected loaded key to match original key")
})
t.Run("LoadKey replaces unencrypted key with encrypted key when kek is provided", func(t *testing.T) {
tempDir := t.TempDir()
// First, create an unencrypted key
providerNoKek := &KeyProviderFile{}
err := providerNoKek.Init(KeyProviderOpts{
EnvConfig: &common.EnvConfigSchema{
KeysPath: tempDir,
},
})
require.NoError(t, err)
// Save an unencrypted key
err = providerNoKek.SaveKey(key)
require.NoError(t, err)
// Verify unencrypted key exists
keyPath := filepath.Join(tempDir, PrivateKeyFile)
exists, err := utils.FileExists(keyPath)
require.NoError(t, err)
assert.True(t, exists, "Expected unencrypted key file to exist")
// Now create a provider with a kek
kek := make([]byte, 32)
_, err = rand.Read(kek)
require.NoError(t, err)
providerWithKek := &KeyProviderFile{}
err = providerWithKek.Init(KeyProviderOpts{
EnvConfig: &common.EnvConfigSchema{
KeysPath: tempDir,
},
Kek: kek,
})
require.NoError(t, err)
// Load the key - this should convert the unencrypted key to encrypted
loadedKey, err := providerWithKek.LoadKey()
require.NoError(t, err)
assert.NotNil(t, loadedKey, "Expected non-nil key when loading and converting key")
// Verify the unencrypted key no longer exists
exists, err = utils.FileExists(keyPath)
require.NoError(t, err)
assert.False(t, exists, "Expected unencrypted key file to be removed")
// Verify the encrypted key file exists
encKeyPath := filepath.Join(tempDir, PrivateKeyFileEncrypted)
exists, err = utils.FileExists(encKeyPath)
require.NoError(t, err)
assert.True(t, exists, "Expected encrypted key file to exist after conversion")
// Verify the key data
keyBytes, err := EncodeJWKBytes(key)
require.NoError(t, err)
loadedKeyBytes, err := EncodeJWKBytes(loadedKey)
require.NoError(t, err)
assert.Equal(t, keyBytes, loadedKeyBytes, "Expected loaded key to match original key after conversion")
})
}
func TestKeyProviderFile_SaveKey(t *testing.T) {
// Generate a test key to use in our tests
pk, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
key, err := jwk.Import(pk)
require.NoError(t, err)
t.Run("SaveKey unencrypted", func(t *testing.T) {
tempDir := t.TempDir()
provider := &KeyProviderFile{}
err := provider.Init(KeyProviderOpts{
EnvConfig: &common.EnvConfigSchema{
KeysPath: tempDir,
},
})
require.NoError(t, err)
// Save the key
err = provider.SaveKey(key)
require.NoError(t, err)
// Verify the key file exists
keyPath := filepath.Join(tempDir, PrivateKeyFile)
exists, err := utils.FileExists(keyPath)
require.NoError(t, err)
assert.True(t, exists, "Expected key file to exist")
// Verify the content of the key file
data, err := os.ReadFile(keyPath)
require.NoError(t, err)
parsedKey, err := jwk.ParseKey(data)
require.NoError(t, err)
// Compare the saved key with the original
keyBytes, err := EncodeJWKBytes(key)
require.NoError(t, err)
parsedKeyBytes, err := EncodeJWKBytes(parsedKey)
require.NoError(t, err)
assert.Equal(t, keyBytes, parsedKeyBytes, "Expected saved key to match original key")
})
t.Run("SaveKey encrypted", func(t *testing.T) {
tempDir := t.TempDir()
// Generate a 64-byte kek
kek := makeKEK(t)
provider := &KeyProviderFile{}
err = provider.Init(KeyProviderOpts{
EnvConfig: &common.EnvConfigSchema{
KeysPath: tempDir,
},
Kek: kek,
})
require.NoError(t, err)
// Save the key (will be encrypted)
err = provider.SaveKey(key)
require.NoError(t, err)
// Verify the encrypted key file exists
encKeyPath := filepath.Join(tempDir, PrivateKeyFileEncrypted)
exists, err := utils.FileExists(encKeyPath)
require.NoError(t, err)
assert.True(t, exists, "Expected encrypted key file to exist")
// Verify the unencrypted key file doesn't exist
keyPath := filepath.Join(tempDir, PrivateKeyFile)
exists, err = utils.FileExists(keyPath)
require.NoError(t, err)
assert.False(t, exists, "Expected unencrypted key file to not exist")
// Manually decrypt the encrypted key file to verify it contains the correct key
encB64, err := os.ReadFile(encKeyPath)
require.NoError(t, err)
// Decode from base64
enc := make([]byte, base64.StdEncoding.DecodedLen(len(encB64)))
n, err := base64.StdEncoding.Decode(enc, encB64)
require.NoError(t, err)
enc = enc[:n] // Trim any padding
// Decrypt the data
data, err := cryptoutils.Decrypt(kek, enc, nil)
require.NoError(t, err)
// Parse the key
parsedKey, err := jwk.ParseKey(data)
require.NoError(t, err)
// Compare the decrypted key with the original
keyBytes, err := EncodeJWKBytes(key)
require.NoError(t, err)
parsedKeyBytes, err := EncodeJWKBytes(parsedKey)
require.NoError(t, err)
assert.Equal(t, keyBytes, parsedKeyBytes, "Expected decrypted key to match original key")
})
}
func makeKEK(t *testing.T) []byte {
t.Helper()
// Generate a 32-byte kek
kek := make([]byte, 32)
_, err := rand.Read(kek)
require.NoError(t, err)
return kek
}

View File

@@ -0,0 +1,180 @@
package jwk
import (
"bytes"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/hmac"
"crypto/rand"
"crypto/rsa"
"crypto/sha3"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"hash"
"io"
"os"
"github.com/lestrrat-go/jwx/v3/jwa"
"github.com/lestrrat-go/jwx/v3/jwk"
"github.com/pocket-id/pocket-id/backend/internal/common"
)
const (
// KeyUsageSigning is the usage for the private keys, for the "use" property
KeyUsageSigning = "sig"
)
// EncodeJWK encodes a jwk.Key to a writable stream.
func EncodeJWK(w io.Writer, key jwk.Key) error {
enc := json.NewEncoder(w)
enc.SetEscapeHTML(false)
return enc.Encode(key)
}
// EncodeJWKBytes encodes a jwk.Key to a byte slice.
func EncodeJWKBytes(key jwk.Key) ([]byte, error) {
b := &bytes.Buffer{}
err := EncodeJWK(b, key)
if err != nil {
return nil, err
}
return b.Bytes(), nil
}
// LoadKeyEncryptionKey loads the key encryption key for JWKs
func LoadKeyEncryptionKey(envConfig *common.EnvConfigSchema, instanceID string) (kek []byte, err error) {
// Try getting the key from the env var as string
kekInput := []byte(envConfig.EncryptionKey)
// If there's nothing in the env, try loading from file
if len(kekInput) == 0 && envConfig.EncryptionKeyFile != "" {
kekInput, err = os.ReadFile(envConfig.EncryptionKeyFile)
if err != nil {
return nil, fmt.Errorf("failed to read key file '%s': %w", envConfig.EncryptionKeyFile, err)
}
}
// If there's still no key, return
if len(kekInput) == 0 {
return nil, nil
}
// We need a 256-bit key for encryption with AES-GCM-256
// We use HMAC with SHA3-256 here to derive the key from the one passed as input
// The key is tied to a specific instance of Pocket ID
h := hmac.New(func() hash.Hash { return sha3.New256() }, kekInput)
fmt.Fprint(h, "pocketid/"+instanceID+"/jwk-kek")
kek = h.Sum(nil)
return kek, nil
}
// ImportRawKey imports a crypto key in "raw" format (e.g. crypto.PrivateKey) into a jwk.Key.
// It also populates additional fields such as the key ID, usage, and alg.
func ImportRawKey(rawKey any, alg string, crv string) (jwk.Key, error) {
key, err := jwk.Import(rawKey)
if err != nil {
return nil, fmt.Errorf("failed to import generated private key: %w", err)
}
// Generate the key ID
kid, err := generateRandomKeyID()
if err != nil {
return nil, fmt.Errorf("failed to generate key ID: %w", err)
}
_ = key.Set(jwk.KeyIDKey, kid)
// Set other required fields
_ = key.Set(jwk.KeyUsageKey, KeyUsageSigning)
EnsureAlgInKey(key, alg, crv)
return key, nil
}
// generateRandomKeyID generates a random key ID.
func generateRandomKeyID() (string, error) {
buf := make([]byte, 8)
_, err := io.ReadFull(rand.Reader, buf)
if err != nil {
return "", fmt.Errorf("failed to read random bytes: %w", err)
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}
// EnsureAlgInKey ensures that the key contains an "alg" parameter (and "crv", if needed), set depending on the key type
func EnsureAlgInKey(key jwk.Key, alg string, crv string) {
_, ok := key.Algorithm()
if ok {
// Algorithm is already set
return
}
if alg != "" {
_ = key.Set(jwk.AlgorithmKey, alg)
if crv != "" {
eca, ok := jwa.LookupEllipticCurveAlgorithm(crv)
if ok {
switch key.KeyType() {
case jwa.EC():
_ = key.Set(jwk.ECDSACrvKey, eca)
case jwa.OKP():
_ = key.Set(jwk.OKPCrvKey, eca)
}
}
}
return
}
// If we don't have an algorithm, set the default for the key type
switch key.KeyType() {
case jwa.RSA():
// Default to RS256 for RSA keys
_ = key.Set(jwk.AlgorithmKey, jwa.RS256())
case jwa.EC():
// Default to ES256 for ECDSA keys
_ = key.Set(jwk.AlgorithmKey, jwa.ES256())
_ = key.Set(jwk.ECDSACrvKey, jwa.P256())
case jwa.OKP():
// Default to EdDSA and Ed25519 for OKP keys
_ = key.Set(jwk.AlgorithmKey, jwa.EdDSA())
_ = key.Set(jwk.OKPCrvKey, jwa.Ed25519())
}
}
// GenerateKey generates a new jwk.Key
func GenerateKey(alg string, crv string) (key jwk.Key, err error) {
var rawKey any
switch alg {
case jwa.RS256().String():
rawKey, err = rsa.GenerateKey(rand.Reader, 2048)
case jwa.RS384().String():
rawKey, err = rsa.GenerateKey(rand.Reader, 3072)
case jwa.RS512().String():
rawKey, err = rsa.GenerateKey(rand.Reader, 4096)
case jwa.ES256().String():
rawKey, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
case jwa.ES384().String():
rawKey, err = ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
case jwa.ES512().String():
rawKey, err = ecdsa.GenerateKey(elliptic.P521(), rand.Reader)
case jwa.EdDSA().String():
switch crv {
case jwa.Ed25519().String():
_, rawKey, err = ed25519.GenerateKey(rand.Reader)
default:
return nil, errors.New("unsupported curve for EdDSA algorithm")
}
default:
return nil, errors.New("unsupported key algorithm")
}
if err != nil {
return nil, fmt.Errorf("failed to generate private key: %w", err)
}
// Import the raw key
return ImportRawKey(rawKey, alg, crv)
}

View File

@@ -0,0 +1,324 @@
package jwk
import (
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"testing"
"github.com/lestrrat-go/jwx/v3/jwa"
"github.com/lestrrat-go/jwx/v3/jwk"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGenerateKey(t *testing.T) {
tests := []struct {
name string
alg string
crv string
expectError bool
expectedAlg jwa.SignatureAlgorithm
}{
{
name: "RS256",
alg: jwa.RS256().String(),
crv: "",
expectError: false,
expectedAlg: jwa.RS256(),
},
{
name: "RS384",
alg: jwa.RS384().String(),
crv: "",
expectError: false,
expectedAlg: jwa.RS384(),
},
// Skip the RS512 test as generating a RSA-4096 key can take some time
/* {
name: "RS512",
alg: jwa.RS512().String(),
crv: "",
expectError: false,
expectedAlg: jwa.RS512(),
}, */
{
name: "ES256",
alg: jwa.ES256().String(),
crv: jwa.P256().String(),
expectError: false,
expectedAlg: jwa.ES256(),
},
{
name: "ES384",
alg: jwa.ES384().String(),
crv: jwa.P384().String(),
expectError: false,
expectedAlg: jwa.ES384(),
},
{
name: "ES512",
alg: jwa.ES512().String(),
crv: jwa.P521().String(),
expectError: false,
expectedAlg: jwa.ES512(),
},
{
name: "EdDSA with Ed25519",
alg: jwa.EdDSA().String(),
crv: jwa.Ed25519().String(),
expectError: false,
expectedAlg: jwa.EdDSA(),
},
{
name: "EdDSA with unsupported curve",
alg: jwa.EdDSA().String(),
crv: "unsupported",
expectError: true,
},
{
name: "Unsupported algorithm",
alg: "UNSUPPORTED",
crv: "",
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
key, err := GenerateKey(tt.alg, tt.crv)
if tt.expectError {
require.Error(t, err)
assert.Nil(t, key)
return
}
require.NoError(t, err)
require.NotNil(t, key)
// Verify the algorithm is set correctly
alg, ok := key.Algorithm()
require.True(t, ok, "algorithm should be set in the key")
assert.Equal(t, tt.expectedAlg.String(), alg.String())
// Verify other required fields are set
kid, ok := key.KeyID()
assert.True(t, ok, "key ID should be set")
assert.NotEmpty(t, kid, "key ID should not be empty")
usage, ok := key.KeyUsage()
assert.True(t, ok, "key usage should be set")
assert.Equal(t, KeyUsageSigning, usage)
var crv any
_ = key.Get("crv", &crv)
// Verify key type matches expected algorithm
switch tt.expectedAlg {
case jwa.RS256(), jwa.RS384(), jwa.RS512():
assert.Equal(t, jwa.RSA(), key.KeyType())
assert.Nil(t, crv)
case jwa.ES256(), jwa.ES384(), jwa.ES512():
assert.Equal(t, jwa.EC(), key.KeyType())
eca, ok := crv.(jwa.EllipticCurveAlgorithm)
_ = assert.NotNil(t, crv) &&
assert.True(t, ok) &&
assert.Equal(t, tt.crv, eca.String())
case jwa.EdDSA():
assert.Equal(t, jwa.OKP(), key.KeyType())
eca, ok := crv.(jwa.EllipticCurveAlgorithm)
_ = assert.NotNil(t, crv) &&
assert.True(t, ok) &&
assert.Equal(t, tt.crv, eca.String())
}
})
}
}
func TestEnsureAlgInKey(t *testing.T) {
// Generate an RSA-2048 key
rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
t.Run("does not change alg already set", func(t *testing.T) {
// Import the RSA key
key, err := jwk.Import(rsaKey)
require.NoError(t, err)
// Pre-set the algorithm
_ = key.Set(jwk.AlgorithmKey, jwa.RS256())
// Call EnsureAlgInKey with a different algorithm
EnsureAlgInKey(key, jwa.RS384().String(), "")
// Verify the algorithm wasn't changed
alg, ok := key.Algorithm()
require.True(t, ok)
assert.Equal(t, jwa.RS256().String(), alg.String())
})
t.Run("set algorithm to explicitly-provided value", func(t *testing.T) {
tests := []struct {
name string
keyGen func() (any, error)
alg string
crv string
expectedAlg jwa.SignatureAlgorithm
expectedCrv string
}{
{
name: "RSA key with RS384",
keyGen: func() (any, error) {
return rsaKey, nil
},
alg: jwa.RS384().String(),
crv: "",
expectedAlg: jwa.RS384(),
expectedCrv: "",
},
{
name: "ECDSA key with ES384",
keyGen: func() (any, error) {
return ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
},
alg: jwa.ES384().String(),
crv: jwa.P384().String(),
expectedAlg: jwa.ES384(),
expectedCrv: jwa.P384().String(),
},
{
name: "Ed25519 key with EdDSA",
keyGen: func() (any, error) {
_, priv, err := ed25519.GenerateKey(rand.Reader)
return priv, err
},
alg: jwa.EdDSA().String(),
crv: jwa.Ed25519().String(),
expectedAlg: jwa.EdDSA(),
expectedCrv: jwa.Ed25519().String(),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rawKey, err := tt.keyGen()
require.NoError(t, err)
key, err := jwk.Import(rawKey)
require.NoError(t, err)
// Ensure no algorithm is set initially
_, ok := key.Algorithm()
assert.False(t, ok)
// Call EnsureAlgInKey
EnsureAlgInKey(key, tt.alg, tt.crv)
// Verify the algorithm was set correctly
alg, ok := key.Algorithm()
require.True(t, ok)
assert.Equal(t, tt.expectedAlg.String(), alg.String())
// Verify curve if expected
if tt.expectedCrv != "" {
var crv any
_ = key.Get("crv", &crv)
require.NotNil(t, crv)
eca, ok := crv.(jwa.EllipticCurveAlgorithm)
require.True(t, ok)
assert.Equal(t, tt.expectedCrv, eca.String())
}
})
}
})
t.Run("set default algorithms if not present", func(t *testing.T) {
tests := []struct {
name string
keyGen func() (any, error)
expectedAlg jwa.SignatureAlgorithm
expectedCrv string
}{
{
name: "RSA key defaults to RS256",
keyGen: func() (any, error) {
return rsaKey, nil
},
expectedAlg: jwa.RS256(),
expectedCrv: "",
},
{
name: "ECDSA key defaults to ES256 with P256",
keyGen: func() (any, error) {
return ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
},
expectedAlg: jwa.ES256(),
expectedCrv: jwa.P256().String(),
},
{
name: "Ed25519 key defaults to EdDSA with Ed25519",
keyGen: func() (any, error) {
_, priv, err := ed25519.GenerateKey(rand.Reader)
return priv, err
},
expectedAlg: jwa.EdDSA(),
expectedCrv: jwa.Ed25519().String(),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rawKey, err := tt.keyGen()
require.NoError(t, err)
key, err := jwk.Import(rawKey)
require.NoError(t, err)
// Ensure no algorithm is set initially
_, ok := key.Algorithm()
assert.False(t, ok)
// Call EnsureAlgInKey with empty parameters
EnsureAlgInKey(key, "", "")
// Verify the default algorithm was set
alg, ok := key.Algorithm()
require.True(t, ok)
assert.Equal(t, tt.expectedAlg.String(), alg.String())
// Verify curve if expected
if tt.expectedCrv != "" {
var crv any
_ = key.Get("crv", &crv)
require.NotNil(t, crv)
eca, ok := crv.(jwa.EllipticCurveAlgorithm)
require.True(t, ok)
assert.Equal(t, tt.expectedCrv, eca.String())
}
})
}
})
t.Run("invalid curve should not set curve parameter", func(t *testing.T) {
rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
key, err := jwk.Import(rsaKey)
require.NoError(t, err)
// Call EnsureAlgInKey with invalid curve
EnsureAlgInKey(key, jwa.RS256().String(), "invalid-curve")
// Verify algorithm was set but curve was not
alg, ok := key.Algorithm()
require.True(t, ok)
assert.Equal(t, jwa.RS256().String(), alg.String())
var crv any
_ = key.Get("crv", &crv)
assert.Nil(t, crv)
})
}

View File

@@ -1,69 +0,0 @@
package utils
import (
"crypto/rand"
"encoding/base64"
"fmt"
"io"
"github.com/lestrrat-go/jwx/v3/jwa"
"github.com/lestrrat-go/jwx/v3/jwk"
)
const (
// KeyUsageSigning is the usage for the private keys, for the "use" property
KeyUsageSigning = "sig"
)
// ImportRawKey imports a crypto key in "raw" format (e.g. crypto.PrivateKey) into a jwk.Key.
// It also populates additional fields such as the key ID, usage, and alg.
func ImportRawKey(rawKey any) (jwk.Key, error) {
key, err := jwk.Import(rawKey)
if err != nil {
return nil, fmt.Errorf("failed to import generated private key: %w", err)
}
// Generate the key ID
kid, err := generateRandomKeyID()
if err != nil {
return nil, fmt.Errorf("failed to generate key ID: %w", err)
}
_ = key.Set(jwk.KeyIDKey, kid)
// Set other required fields
_ = key.Set(jwk.KeyUsageKey, KeyUsageSigning)
EnsureAlgInKey(key)
return key, nil
}
// generateRandomKeyID generates a random key ID.
func generateRandomKeyID() (string, error) {
buf := make([]byte, 8)
_, err := io.ReadFull(rand.Reader, buf)
if err != nil {
return "", fmt.Errorf("failed to read random bytes: %w", err)
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}
// EnsureAlgInKey ensures that the key contains an "alg" parameter, set depending on the key type
func EnsureAlgInKey(key jwk.Key) {
_, ok := key.Algorithm()
if ok {
// Algorithm is already set
return
}
switch key.KeyType() {
case jwa.RSA():
// Default to RS256 for RSA keys
_ = key.Set(jwk.AlgorithmKey, jwa.RS256())
case jwa.EC():
// Default to ES256 for ECDSA keys
_ = key.Set(jwk.AlgorithmKey, jwa.ES256())
case jwa.OKP():
// Default to EdDSA for OKP keys
_ = key.Set(jwk.AlgorithmKey, jwa.EdDSA())
}
}

View File

@@ -0,0 +1,51 @@
package sqlite
import (
"database/sql/driver"
"errors"
"fmt"
"strings"
sqlitelib "github.com/glebarez/go-sqlite"
"golang.org/x/text/unicode/norm"
)
func RegisterSqliteFunctions() {
// Register the `normalize(text, form)` function, which performs Unicode normalization on the text
// This is currently only used in migration functions
sqlitelib.MustRegisterDeterministicScalarFunction("normalize", 2, func(ctx *sqlitelib.FunctionContext, args []driver.Value) (driver.Value, error) {
if len(args) != 2 {
return nil, errors.New("normalize requires 2 arguments")
}
arg0, ok := args[0].(string)
if !ok {
return nil, fmt.Errorf("first argument for normalize is not a string: %T", args[0])
}
arg1, ok := args[1].(string)
if !ok {
return nil, fmt.Errorf("second argument for normalize is not a string: %T", args[1])
}
var form norm.Form
switch strings.ToLower(arg1) {
case "nfc":
form = norm.NFC
case "nfd":
form = norm.NFD
case "nfkc":
form = norm.NFKC
case "nfkd":
form = norm.NFKD
default:
return nil, fmt.Errorf("unsupported form: %s", arg1)
}
if len(arg0) == 0 {
return arg0, nil
}
return form.String(arg0), nil
})
}

View File

@@ -1,9 +1,8 @@
package service
// This file is only imported by unit tests
package testing
import (
"io"
"net/http"
"strings"
"testing"
"time"
@@ -18,10 +17,18 @@ import (
"gorm.io/gorm/logger"
"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 newDatabaseForTest(t *testing.T) *gorm.DB {
func init() {
sqliteutil.RegisterSqliteFunctions()
}
// NewDatabaseForTest returns a new instance of GORM connected to an in-memory SQLite database.
// Each database connection is unique for the test.
// All migrations are automatically performed.
func NewDatabaseForTest(t *testing.T) *gorm.DB {
t.Helper()
// Get a name for this in-memory database that is specific to the test
@@ -68,30 +75,3 @@ type testLoggerAdapter struct {
func (l testLoggerAdapter) Printf(format string, args ...any) {
l.t.Logf(format, args...)
}
// MockRoundTripper is a custom http.RoundTripper that returns responses based on the URL
type MockRoundTripper struct {
Err error
Responses map[string]*http.Response
}
// RoundTrip implements the http.RoundTripper interface
func (m *MockRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
// Check if we have a specific response for this URL
for url, resp := range m.Responses {
if req.URL.String() == url {
return resp, nil
}
}
return NewMockResponse(http.StatusNotFound, ""), nil
}
// NewMockResponse creates an http.Response with the given status code and body
func NewMockResponse(statusCode int, body string) *http.Response {
return &http.Response{
StatusCode: statusCode,
Body: io.NopCloser(strings.NewReader(body)),
Header: make(http.Header),
}
}

View File

@@ -0,0 +1,38 @@
// This file is only imported by unit tests
package testing
import (
"io"
"net/http"
"strings"
_ "github.com/golang-migrate/migrate/v4/source/file"
)
// MockRoundTripper is a custom http.RoundTripper that returns responses based on the URL
type MockRoundTripper struct {
Err error
Responses map[string]*http.Response
}
// RoundTrip implements the http.RoundTripper interface
func (m *MockRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
// Check if we have a specific response for this URL
for url, resp := range m.Responses {
if req.URL.String() == url {
return resp, nil
}
}
return NewMockResponse(http.StatusNotFound, ""), nil
}
// NewMockResponse creates an http.Response with the given status code and body
func NewMockResponse(statusCode int, body string) *http.Response {
return &http.Response{
StatusCode: statusCode,
Body: io.NopCloser(strings.NewReader(body)),
Header: make(http.Header),
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,3 @@
DROP INDEX IF EXISTS idx_signup_tokens_expires_at;
DROP INDEX IF EXISTS idx_signup_tokens_token;
DROP TABLE IF EXISTS signup_tokens;

View File

@@ -0,0 +1,11 @@
CREATE TABLE signup_tokens (
id UUID NOT NULL PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL,
token VARCHAR(255) NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
usage_limit INTEGER NOT NULL DEFAULT 1,
usage_count INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX idx_signup_tokens_token ON signup_tokens(token);
CREATE INDEX idx_signup_tokens_expires_at ON signup_tokens(expires_at);

View File

@@ -0,0 +1,4 @@
ALTER TABLE audit_logs ALTER COLUMN ip_address SET NOT NULL;
DROP INDEX IF EXISTS idx_audit_logs_created_at;
DROP INDEX IF EXISTS idx_audit_logs_user_agent;

View File

@@ -0,0 +1,5 @@
ALTER TABLE audit_logs ALTER COLUMN ip_address DROP NOT NULL;
-- Add missing indexes
CREATE INDEX idx_audit_logs_created_at ON audit_logs(created_at);
CREATE INDEX idx_audit_logs_user_agent ON audit_logs(user_agent);

View File

@@ -0,0 +1 @@
DROP TABLE kv;

View File

@@ -0,0 +1,6 @@
-- The "kv" tables contains miscellaneous key-value pairs
CREATE TABLE kv
(
"key" TEXT NOT NULL PRIMARY KEY,
"value" TEXT
);

View File

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

View File

@@ -0,0 +1,34 @@
-- Normalize (form NFC) all existing values in the database
DO $$
BEGIN
-- This function is available only if the server's encoding is UTF8
IF current_setting('server_encoding') = 'UTF8' THEN
UPDATE api_keys SET
name = normalize(name, NFC),
description = normalize(description, NFC);
UPDATE app_config_variables SET
"value" = normalize("value", NFC)
WHERE "key" = 'appName';
UPDATE custom_claims SET
"key" = normalize("key", NFC),
"value" = normalize("value", NFC);
UPDATE oidc_clients SET
name = normalize(name, NFC);
UPDATE users SET
username = normalize(username, NFC),
email = normalize(email, NFC),
first_name = normalize(first_name, NFC),
last_name = normalize(last_name, NFC);
UPDATE user_groups SET
friendly_name = normalize(friendly_name, NFC),
"name" = normalize("name", NFC);
ELSE
RAISE NOTICE 'Skipping normalization: server_encoding is %', current_setting('server_encoding');
END IF;
END;
$$ LANGUAGE plpgsql;

View File

@@ -0,0 +1,3 @@
DROP INDEX IF EXISTS idx_signup_tokens_expires_at;
DROP INDEX IF EXISTS idx_signup_tokens_token;
DROP TABLE IF EXISTS signup_tokens;

View File

@@ -0,0 +1,11 @@
CREATE TABLE signup_tokens (
id TEXT NOT NULL PRIMARY KEY,
created_at DATETIME NOT NULL,
token TEXT NOT NULL UNIQUE,
expires_at DATETIME NOT NULL,
usage_limit INTEGER NOT NULL DEFAULT 1,
usage_count INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX idx_signup_tokens_token ON signup_tokens(token);
CREATE INDEX idx_signup_tokens_expires_at ON signup_tokens(expires_at);

View File

@@ -0,0 +1,30 @@
-- Re-create the table with non-nullable ip_address
-- We then move the data and rename the table
CREATE TABLE audit_logs_new
(
id TEXT NOT NULL PRIMARY KEY,
created_at DATETIME,
event TEXT NOT NULL,
ip_address TEXT NOT NULL,
user_agent TEXT NOT NULL,
data BLOB NOT NULL,
user_id TEXT REFERENCES users,
country TEXT,
city TEXT
);
INSERT INTO audit_logs_new
SELECT id, created_at, event, ip_address, user_agent, data, user_id, country, city
FROM audit_logs;
DROP TABLE audit_logs;
ALTER TABLE audit_logs_new RENAME TO audit_logs;
-- Re-create indexes
CREATE INDEX idx_audit_logs_event ON audit_logs(event);
CREATE INDEX idx_audit_logs_created_at ON audit_logs(created_at);
CREATE INDEX idx_audit_logs_user_id ON audit_logs(user_id);
CREATE INDEX idx_audit_logs_user_agent ON audit_logs(user_agent);
CREATE INDEX idx_audit_logs_client_name ON audit_logs((json_extract(data, '$.clientName')));
CREATE INDEX idx_audit_logs_country ON audit_logs(country);

View File

@@ -0,0 +1,30 @@
-- Re-create the table with nullable ip_address
-- We then move the data and rename the table
CREATE TABLE audit_logs_new
(
id TEXT NOT NULL PRIMARY KEY,
created_at DATETIME,
event TEXT NOT NULL,
ip_address TEXT,
user_agent TEXT NOT NULL,
data BLOB NOT NULL,
user_id TEXT REFERENCES users,
country TEXT,
city TEXT
);
INSERT INTO audit_logs_new
SELECT id, created_at, event, ip_address, user_agent, data, user_id, country, city
FROM audit_logs;
DROP TABLE audit_logs;
ALTER TABLE audit_logs_new RENAME TO audit_logs;
-- Re-create indexes
CREATE INDEX idx_audit_logs_event ON audit_logs(event);
CREATE INDEX idx_audit_logs_created_at ON audit_logs(created_at);
CREATE INDEX idx_audit_logs_user_id ON audit_logs(user_id);
CREATE INDEX idx_audit_logs_user_agent ON audit_logs(user_agent);
CREATE INDEX idx_audit_logs_client_name ON audit_logs((json_extract(data, '$.clientName')));
CREATE INDEX idx_audit_logs_country ON audit_logs(country);

View File

@@ -0,0 +1 @@
DROP TABLE kv;

View File

@@ -0,0 +1,6 @@
-- The "kv" tables contains miscellaneous key-value pairs
CREATE TABLE kv
(
"key" TEXT NOT NULL PRIMARY KEY,
"value" TEXT NOT NULL
);

View File

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

View File

@@ -0,0 +1,25 @@
-- Normalize (form NFC) all existing values in the database
UPDATE api_keys SET
name = normalize(name, 'nfc'),
description = normalize(description, 'nfc');
UPDATE app_config_variables SET
"value" = normalize("value", 'nfc')
WHERE "key" = 'appName';
UPDATE custom_claims SET
"key" = normalize("key", 'nfc'),
"value" = normalize("value", 'nfc');
UPDATE oidc_clients SET
name = normalize(name, 'nfc');
UPDATE users SET
username = normalize(username, 'nfc'),
email = normalize(email, 'nfc'),
first_name = normalize(first_name, 'nfc'),
last_name = normalize(last_name, 'nfc');
UPDATE user_groups SET
friendly_name = normalize(friendly_name, 'nfc'),
"name" = normalize("name", 'nfc');

View File

@@ -9,8 +9,8 @@ services:
- "./data:/app/data"
# Optional healthcheck
healthcheck:
test: "curl -f http://localhost:1411/healthz"
test: [ "CMD", "/app/pocket-id", "healthcheck" ]
interval: 1m30s
timeout: 5s
retries: 2
start_period: 10s
start_period: 10s

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