mirror of
https://github.com/pocket-id/pocket-id.git
synced 2025-12-10 07:12:56 +03:00
Compare commits
73 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1488565ea | ||
|
|
35d5f887ce | ||
|
|
4c76de45ed | ||
|
|
68fc9c0659 | ||
|
|
2952b15755 | ||
|
|
ef1d599662 | ||
|
|
4e49d3932a | ||
|
|
86d3c08494 | ||
|
|
7b4ccd1f30 | ||
|
|
f145903eb0 | ||
|
|
d3bc1797b6 | ||
|
|
db94f81937 | ||
|
|
b03e91b653 | ||
|
|
505bdcb8ba | ||
|
|
f103a54790 | ||
|
|
e1de593dcd | ||
|
|
45f42772b1 | ||
|
|
98152640b1 | ||
|
|
04e235e805 | ||
|
|
ae737dddaa | ||
|
|
f565c702e5 | ||
|
|
f945b44bc9 | ||
|
|
857b9cc864 | ||
|
|
bf042563e9 | ||
|
|
49f1ab2f75 | ||
|
|
e46f60ac8d | ||
|
|
5c9e504291 | ||
|
|
7fe83f8087 | ||
|
|
43f0114c57 | ||
|
|
1a41b05f60 | ||
|
|
81315790a8 | ||
|
|
8c8fc2304d | ||
|
|
15ece0ab30 | ||
|
|
5550729120 | ||
|
|
9872608d61 | ||
|
|
be52660227 | ||
|
|
237342e876 | ||
|
|
cfbfbc9753 | ||
|
|
aefb308536 | ||
|
|
031181ad2a | ||
|
|
dbf3da41f3 | ||
|
|
3a2902789e | ||
|
|
459a4fd727 | ||
|
|
2ecc1abbad | ||
|
|
92c57ada1a | ||
|
|
fceb6fa7b4 | ||
|
|
c290c027fb | ||
|
|
ca205a8c73 | ||
|
|
968cf0b307 | ||
|
|
fd8bee94a4 | ||
|
|
41ac1be082 | ||
|
|
dd9b1d26ea | ||
|
|
4b829757b2 | ||
|
|
b5b01cb6dd | ||
|
|
287314f016 | ||
|
|
73e7e0b1c5 | ||
|
|
d070b9a778 | ||
|
|
d976bf5965 | ||
|
|
052ac008c3 | ||
|
|
57a2b2bc83 | ||
|
|
043f82ad79 | ||
|
|
ba61cdba4e | ||
|
|
dcd1ae96e0 | ||
|
|
1fdb058386 | ||
|
|
29cb5513a0 | ||
|
|
6db57d9f27 | ||
|
|
1a77bd9914 | ||
|
|
350335711b | ||
|
|
988c425150 | ||
|
|
23827ba1d1 | ||
|
|
7d36bda769 | ||
|
|
8c559ea067 | ||
|
|
88832d4bc9 |
1
.github/CODEOWNERS
vendored
Normal file
1
.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1 @@
|
||||
* @pocket-id/maintainers
|
||||
2
.github/ISSUE_TEMPLATE/bug.yml
vendored
2
.github/ISSUE_TEMPLATE/bug.yml
vendored
@@ -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:
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/feature.yml
vendored
2
.github/ISSUE_TEMPLATE/feature.yml
vendored
@@ -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
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/language-request.yml
vendored
2
.github/ISSUE_TEMPLATE/language-request.yml
vendored
@@ -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
|
||||
|
||||
16
.github/workflows/build-next.yml
vendored
16
.github/workflows/build-next.yml
vendored
@@ -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
|
||||
|
||||
36
.github/workflows/release.yml
vendored
36
.github/workflows/release.yml
vendored
@@ -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 }}
|
||||
|
||||
73
CHANGELOG.md
73
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -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
18
Dockerfile-distroless
Normal 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"]
|
||||
@@ -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"]
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
83
backend/internal/cmds/healthcheck.go
Normal file
83
backend/internal/cmds/healthcheck.go
Normal 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)
|
||||
}
|
||||
107
backend/internal/cmds/key_rotate.go
Normal file
107
backend/internal/cmds/key_rotate.go
Normal 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
|
||||
}
|
||||
214
backend/internal/cmds/key_rotate_test.go
Normal file
214
backend/internal/cmds/key_rotate_test.go
Normal 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, "")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
36
backend/internal/cmds/root.go
Normal file
36
backend/internal/cmds/root.go
Normal 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)
|
||||
}
|
||||
}
|
||||
19
backend/internal/cmds/version.go
Normal file
19
backend/internal/cmds/version.go
Normal 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)
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
188
backend/internal/common/env_config_test.go
Normal file
188
backend/internal/common/env_config_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
197
backend/internal/dto/dto_mapper_test.go
Normal file
197
backend/internal/dto/dto_mapper_test.go
Normal 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)
|
||||
}
|
||||
94
backend/internal/dto/dto_normalize.go
Normal file
94
backend/internal/dto/dto_normalize.go
Normal 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
|
||||
}
|
||||
84
backend/internal/dto/dto_normalize_test.go
Normal file
84
backend/internal/dto/dto_normalize_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
21
backend/internal/dto/signup_token_dto.go
Normal file
21
backend/internal/dto/signup_token_dto.go
Normal 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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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:"-"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
11
backend/internal/model/kv.go
Normal file
11
backend/internal/model/kv.go
Normal 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"
|
||||
}
|
||||
28
backend/internal/model/signup_token.go
Normal file
28
backend/internal/model/signup_token.go
Normal 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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
24
backend/internal/utils/cmd_util.go
Normal file
24
backend/internal/utils/cmd_util.go
Normal 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
|
||||
}
|
||||
69
backend/internal/utils/crypto/crypto.go
Normal file
69
backend/internal/utils/crypto/crypto.go
Normal 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
|
||||
}
|
||||
208
backend/internal/utils/crypto/crypto_test.go
Normal file
208
backend/internal/utils/crypto/crypto_test.go
Normal 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")
|
||||
}
|
||||
50
backend/internal/utils/jwk/key_provider.go
Normal file
50
backend/internal/utils/jwk/key_provider.go
Normal 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
|
||||
}
|
||||
109
backend/internal/utils/jwk/key_provider_database.go
Normal file
109
backend/internal/utils/jwk/key_provider_database.go
Normal 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)
|
||||
275
backend/internal/utils/jwk/key_provider_database_test.go
Normal file
275
backend/internal/utils/jwk/key_provider_database_test.go
Normal 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
|
||||
}
|
||||
202
backend/internal/utils/jwk/key_provider_file.go
Normal file
202
backend/internal/utils/jwk/key_provider_file.go
Normal 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)
|
||||
320
backend/internal/utils/jwk/key_provider_file_test.go
Normal file
320
backend/internal/utils/jwk/key_provider_file_test.go
Normal 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
|
||||
}
|
||||
180
backend/internal/utils/jwk/utils.go
Normal file
180
backend/internal/utils/jwk/utils.go
Normal 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)
|
||||
}
|
||||
324
backend/internal/utils/jwk/utils_test.go
Normal file
324
backend/internal/utils/jwk/utils_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
51
backend/internal/utils/sqlite/sqlite_util.go
Normal file
51
backend/internal/utils/sqlite/sqlite_util.go
Normal 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
|
||||
})
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
38
backend/internal/utils/testing/round_tripper.go
Normal file
38
backend/internal/utils/testing/round_tripper.go
Normal 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
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE kv;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- The "kv" tables contains miscellaneous key-value pairs
|
||||
CREATE TABLE kv
|
||||
(
|
||||
"key" TEXT NOT NULL PRIMARY KEY,
|
||||
"value" TEXT
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
-- No-op
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE kv;
|
||||
@@ -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
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
-- No-op
|
||||
@@ -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');
|
||||
@@ -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
Reference in New Issue
Block a user