mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-03-28 10:16:37 +00:00
Compare commits
80 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
f5cece3b0e | ||
|
|
d5485238b8 | ||
|
|
ac5a121f66 | ||
|
|
481df3bcb9 | ||
|
|
7677a3de2c | ||
|
|
1f65c01b04 | ||
|
|
d5928f6fea | ||
|
|
bef77ac8dc | ||
|
|
c8eb034c49 | ||
|
|
c77167df46 | ||
|
|
3717a663d9 | ||
|
|
5814549cbe | ||
|
|
2e5d268798 | ||
|
|
4ed312251e | ||
|
|
946c534b08 | ||
|
|
883877adec | ||
|
|
215531d65c | ||
|
|
c0f055c3c0 | ||
|
|
d77044882d | ||
|
|
d6795300b1 | ||
|
|
fd3c76ffa3 | ||
|
|
698bc3a35a | ||
|
|
1bcb50edc3 | ||
|
|
9700afb9cb | ||
|
|
9ce82fb205 | ||
|
|
2935236ace |
1
.github/CODEOWNERS
vendored
Normal file
1
.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
* @pocket-id/maintainers
|
||||||
22
.github/workflows/build-next.yml
vendored
22
.github/workflows/build-next.yml
vendored
@@ -5,6 +5,10 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: build-next-image
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-next:
|
build-next:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -58,7 +62,7 @@ jobs:
|
|||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
- name: Build binaries
|
- name: Build binaries
|
||||||
run: sh scripts/development/build-binaries.sh
|
run: sh scripts/development/build-binaries.sh --docker-only
|
||||||
|
|
||||||
- name: Build and push container image
|
- name: Build and push container image
|
||||||
id: build-push-image
|
id: build-push-image
|
||||||
@@ -69,10 +73,24 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
tags: ${{ env.DOCKER_IMAGE_NAME }}:next
|
tags: ${{ env.DOCKER_IMAGE_NAME }}:next
|
||||||
file: Dockerfile-prebuilt
|
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
|
- name: Container image attestation
|
||||||
uses: actions/attest-build-provenance@v2
|
uses: actions/attest-build-provenance@v2
|
||||||
with:
|
with:
|
||||||
subject-name: "${{ env.DOCKER_IMAGE_NAME }}"
|
subject-name: "${{ env.DOCKER_IMAGE_NAME }}"
|
||||||
subject-digest: ${{ steps.build-push-image.outputs.digest }}
|
subject-digest: ${{ steps.build-push-image.outputs.digest }}
|
||||||
push-to-registry: true
|
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
|
uses: docker/setup-qemu-action@v3
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Set DOCKER_IMAGE_NAME
|
- name: Set DOCKER_IMAGE_NAME
|
||||||
run: |
|
run: |
|
||||||
# Lowercase REPO_OWNER which is required for containers
|
# Lowercase REPO_OWNER which is required for containers
|
||||||
REPO_OWNER=${{ github.repository_owner }}
|
REPO_OWNER=${{ github.repository_owner }}
|
||||||
DOCKER_IMAGE_NAME="ghcr.io/${REPO_OWNER,,}/pocket-id"
|
DOCKER_IMAGE_NAME="ghcr.io/${REPO_OWNER,,}/pocket-id"
|
||||||
echo "DOCKER_IMAGE_NAME=${DOCKER_IMAGE_NAME}" >>${GITHUB_ENV}
|
echo "DOCKER_IMAGE_NAME=${DOCKER_IMAGE_NAME}" >>${GITHUB_ENV}
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
@@ -53,17 +51,26 @@ jobs:
|
|||||||
type=semver,pattern={{version}},prefix=v
|
type=semver,pattern={{version}},prefix=v
|
||||||
type=semver,pattern={{major}}.{{minor}},prefix=v
|
type=semver,pattern={{major}}.{{minor}},prefix=v
|
||||||
type=semver,pattern={{major}},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
|
- name: Install frontend dependencies
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
run: npm ci
|
run: npm ci
|
||||||
- name: Build frontend
|
- name: Build frontend
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
- name: Build binaries
|
- name: Build binaries
|
||||||
run: sh scripts/development/build-binaries.sh
|
run: sh scripts/development/build-binaries.sh
|
||||||
|
|
||||||
- name: Build and push container image
|
- name: Build and push container image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
id: container-build-push
|
id: container-build-push
|
||||||
@@ -74,19 +81,32 @@ jobs:
|
|||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
file: Dockerfile-prebuilt
|
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
|
- name: Binary attestation
|
||||||
uses: actions/attest-build-provenance@v2
|
uses: actions/attest-build-provenance@v2
|
||||||
with:
|
with:
|
||||||
subject-path: "backend/.bin/pocket-id-**"
|
subject-path: "backend/.bin/pocket-id-**"
|
||||||
|
|
||||||
- name: Container image attestation
|
- name: Container image attestation
|
||||||
uses: actions/attest-build-provenance@v2
|
uses: actions/attest-build-provenance@v2
|
||||||
with:
|
with:
|
||||||
subject-name: "${{ env.DOCKER_IMAGE_NAME }}"
|
subject-name: "${{ env.DOCKER_IMAGE_NAME }}"
|
||||||
subject-digest: ${{ steps.container-build-push.outputs.digest }}
|
subject-digest: ${{ steps.container-build-push.outputs.digest }}
|
||||||
push-to-registry: true
|
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
|
- name: Upload binaries to release
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
78
CHANGELOG.md
78
CHANGELOG.md
@@ -1,3 +1,81 @@
|
|||||||
|
## [](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
|
||||||
|
|
||||||
|
* allow setting unix socket mode ([#661](https://github.com/pocket-id/pocket-id/issues/661)) ([7677a3d](https://github.com/pocket-id/pocket-id/commit/7677a3de2c923c11a58bc8c4d1b2121d403a1504))
|
||||||
|
* auto-focus on the login buttons ([#647](https://github.com/pocket-id/pocket-id/issues/647)) ([d679530](https://github.com/pocket-id/pocket-id/commit/d6795300b158b85dd9feadd561b6ecd891f5db0d))
|
||||||
|
* configurable local ipv6 ranges for audit log ([#657](https://github.com/pocket-id/pocket-id/issues/657)) ([d548523](https://github.com/pocket-id/pocket-id/commit/d5485238b8fd4cc566af00eae2b17d69a119f991))
|
||||||
|
* location filter for global audit log ([#662](https://github.com/pocket-id/pocket-id/issues/662)) ([ac5a121](https://github.com/pocket-id/pocket-id/commit/ac5a121f664b8127d0faf30c0f93432f30e7f33a))
|
||||||
|
* ui accent colors ([#643](https://github.com/pocket-id/pocket-id/issues/643)) ([883877a](https://github.com/pocket-id/pocket-id/commit/883877adec6fc3e65bd5a705499449959b894fb5))
|
||||||
|
* use icon instead of text on application image update hover state ([215531d](https://github.com/pocket-id/pocket-id/commit/215531d65c6683609b0b4a5505fdb72696fdb93e))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* allow images with uppercase file extension ([1bcb50e](https://github.com/pocket-id/pocket-id/commit/1bcb50edc335886dd722a4c69960c48cc3cd1687))
|
||||||
|
* center oidc client images if they are smaller than the box ([946c534](https://github.com/pocket-id/pocket-id/commit/946c534b0877a074a6b658060f9af27e4061397c))
|
||||||
|
* explicitly cache images to prevent unexpected behavior ([2e5d268](https://github.com/pocket-id/pocket-id/commit/2e5d2687982186c12e530492292d49895cb6043a))
|
||||||
|
* reduce duration of animations on login and signin page ([#648](https://github.com/pocket-id/pocket-id/issues/648)) ([d770448](https://github.com/pocket-id/pocket-id/commit/d77044882d5a41da22df1c0099c1eb1f20bcbc5b))
|
||||||
|
* use inline style for dynamic background image URL instead of Tailwind class ([bef77ac](https://github.com/pocket-id/pocket-id/commit/bef77ac8dca2b98b6732677aaafbc28f79d00487))
|
||||||
|
## [](https://github.com/pocket-id/pocket-id/compare/v1.3.0...v) (2025-06-09)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* change timestamp of `client_credentials.sql` migration ([2935236](https://github.com/pocket-id/pocket-id/commit/2935236acee9c78c2fe6787ec8b5f53ae0eca047))
|
||||||
|
|
||||||
## [](https://github.com/pocket-id/pocket-id/compare/v1.2.0...v) (2025-06-09)
|
## [](https://github.com/pocket-id/pocket-id/compare/v1.2.0...v) (2025-06-09)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -48,5 +48,7 @@ RUN chmod +x /app/pocket-id && \
|
|||||||
EXPOSE 1411
|
EXPOSE 1411
|
||||||
ENV APP_ENV=production
|
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"]
|
ENTRYPOINT ["sh", "/app/docker/entrypoint.sh"]
|
||||||
CMD ["/app/pocket-id"]
|
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
|
# This Dockerfile embeds a pre-built binary for the given Linux architecture
|
||||||
# Binaries must be built using ./scripts/development/build-binaries.sh first
|
# Binaries must be built using "./scripts/development/build-binaries.sh --docker-only"
|
||||||
|
|
||||||
FROM alpine
|
FROM alpine
|
||||||
|
|
||||||
@@ -16,5 +16,7 @@ COPY ./scripts/docker /app/docker
|
|||||||
EXPOSE 1411
|
EXPOSE 1411
|
||||||
ENV APP_ENV=production
|
ENV APP_ENV=production
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=90s --timeout=5s --start-period=10s --retries=3 CMD [ "/app/pocket-id", "healthcheck" ]
|
||||||
|
|
||||||
ENTRYPOINT ["/app/docker/entrypoint.sh"]
|
ENTRYPOINT ["/app/docker/entrypoint.sh"]
|
||||||
CMD ["/app/pocket-id"]
|
CMD ["/app/pocket-id"]
|
||||||
|
|||||||
12
backend/.air.toml
Normal file
12
backend/.air.toml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
root = "."
|
||||||
|
tmp_dir = ".bin"
|
||||||
|
|
||||||
|
[build]
|
||||||
|
bin = "./.bin/pocket-id"
|
||||||
|
cmd = "CGO_ENABLED=0 go build -o ./.bin/pocket-id ./cmd"
|
||||||
|
exclude_dir = ["resources", ".bin", "data"]
|
||||||
|
exclude_regex = [".*_test\\.go"]
|
||||||
|
stop_on_error = true
|
||||||
|
|
||||||
|
[misc]
|
||||||
|
clean_on_exit = true
|
||||||
@@ -1,15 +1,9 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
|
|
||||||
_ "time/tzdata"
|
_ "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/cmds"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// @title Pocket ID API
|
// @title Pocket ID API
|
||||||
@@ -17,27 +11,5 @@ import (
|
|||||||
// @description.markdown
|
// @description.markdown
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// Get the command
|
cmds.Execute()
|
||||||
// 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())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,11 +19,13 @@ require (
|
|||||||
github.com/golang-migrate/migrate/v4 v4.18.2
|
github.com/golang-migrate/migrate/v4 v4.18.2
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/hashicorp/go-uuid v1.0.3
|
github.com/hashicorp/go-uuid v1.0.3
|
||||||
|
github.com/jinzhu/copier v0.4.0
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/lestrrat-go/httprc/v3 v3.0.0-beta2
|
github.com/lestrrat-go/httprc/v3 v3.0.0-beta2
|
||||||
github.com/lestrrat-go/jwx/v3 v3.0.1
|
github.com/lestrrat-go/jwx/v3 v3.0.1
|
||||||
github.com/mileusna/useragent v1.3.5
|
github.com/mileusna/useragent v1.3.5
|
||||||
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.2
|
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
|
github.com/stretchr/testify v1.10.0
|
||||||
go.opentelemetry.io/contrib/exporters/autoexport v0.59.0
|
go.opentelemetry.io/contrib/exporters/autoexport v0.59.0
|
||||||
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.60.0
|
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.60.0
|
||||||
@@ -68,6 +70,7 @@ require (
|
|||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 // indirect
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 // indirect
|
||||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||||
github.com/hashicorp/go-multierror v1.1.1 // 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/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/pgx/v5 v5.7.2 // indirect
|
github.com/jackc/pgx/v5 v5.7.2 // indirect
|
||||||
@@ -98,6 +101,7 @@ require (
|
|||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||||
github.com/segmentio/asm v1.2.0 // 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/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||||
github.com/x448/float16 v0.8.4 // indirect
|
github.com/x448/float16 v0.8.4 // 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 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
|
||||||
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
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/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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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.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 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
||||||
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
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 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 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/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 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
|
||||||
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
|
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 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
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/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 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||||
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
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 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
|
||||||
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
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.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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
|||||||
@@ -11,13 +11,9 @@ import (
|
|||||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
"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/job"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/utils/signals"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func Bootstrap() error {
|
func Bootstrap(ctx context.Context) error {
|
||||||
// Get a context that is canceled when the application is stopping
|
|
||||||
ctx := signals.SignalContext(context.Background())
|
|
||||||
|
|
||||||
initApplicationImages()
|
initApplicationImages()
|
||||||
|
|
||||||
// Initialize the tracer and metrics exporter
|
// Initialize the tracer and metrics exporter
|
||||||
@@ -59,11 +55,12 @@ func Bootstrap() error {
|
|||||||
|
|
||||||
// Invoke all shutdown functions
|
// Invoke all shutdown functions
|
||||||
// We give these a timeout of 5s
|
// 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)
|
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer shutdownCancel()
|
defer shutdownCancel()
|
||||||
err = utils.
|
err = utils.
|
||||||
NewServiceRunner(shutdownFns...).
|
NewServiceRunner(shutdownFns...).
|
||||||
Run(shutdownCtx)
|
Run(shutdownCtx) //nolint:contextcheck
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error shutting down services: %v", err)
|
log.Printf("Error shutting down services: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/pocket-id/pocket-id/backend/frontend"
|
"github.com/pocket-id/pocket-id/backend/frontend"
|
||||||
@@ -45,8 +48,26 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
|
|||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
r := gin.Default()
|
// do not log these URLs
|
||||||
r.Use(gin.Logger())
|
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 {
|
if !common.EnvConfig.TrustProxy {
|
||||||
_ = r.SetTrustedProxies(nil)
|
_ = r.SetTrustedProxies(nil)
|
||||||
@@ -119,6 +140,18 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
|
|||||||
return nil, fmt.Errorf("failed to create %s listener: %w", network, err)
|
return nil, fmt.Errorf("failed to create %s listener: %w", network, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set the socket mode if using a Unix socket
|
||||||
|
if network == "unix" && common.EnvConfig.UnixSocketMode != "" {
|
||||||
|
mode, err := strconv.ParseUint(common.EnvConfig.UnixSocketMode, 8, 32)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse UNIX socket mode '%s': %w", common.EnvConfig.UnixSocketMode, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Chmod(addr, os.FileMode(mode)); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to set UNIX socket mode '%s': %w", common.EnvConfig.UnixSocketMode, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Service runner function
|
// Service runner function
|
||||||
runFn := func(ctx context.Context) error {
|
runFn := func(ctx context.Context) error {
|
||||||
log.Printf("Server listening on %s", addr)
|
log.Printf("Server listening on %s", addr)
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client) (sv
|
|||||||
|
|
||||||
svc.geoLiteService = service.NewGeoLiteService(httpClient)
|
svc.geoLiteService = service.NewGeoLiteService(httpClient)
|
||||||
svc.auditLogService = service.NewAuditLogService(db, svc.appConfigService, svc.emailService, svc.geoLiteService)
|
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.userService = service.NewUserService(db, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService)
|
||||||
svc.customClaimService = service.NewCustomClaimService(db)
|
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"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/bootstrap"
|
"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/common"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
"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/service"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/utils/signals"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// OneTimeAccessToken creates a one-time access token for the given user
|
var oneTimeAccessTokenCmd = &cobra.Command{
|
||||||
// Args must contain the username or email of the user
|
Use: "one-time-access-token [username or email]",
|
||||||
func OneTimeAccessToken(args []string) error {
|
Short: "Generates a one-time access token for the given user",
|
||||||
// Get a context that is canceled when the application is stopping
|
Args: cobra.ExactArgs(1),
|
||||||
ctx := signals.SignalContext(context.Background())
|
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
|
// Connect to the database
|
||||||
// Note length is 2 because the first argument is always the command (one-time-access-token)
|
db := bootstrap.NewDatabase()
|
||||||
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
|
// Create the access token
|
||||||
db := bootstrap.NewDatabase()
|
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
|
// Create a new access token that expires in 1 hour
|
||||||
var oneTimeAccessToken *model.OneTimeAccessToken
|
oneTimeAccessToken, txErr = service.NewOneTimeAccessToken(user.ID, time.Now().Add(time.Hour))
|
||||||
err := db.Transaction(func(tx *gorm.DB) error {
|
if txErr != nil {
|
||||||
// Load the user to retrieve the user ID
|
return fmt.Errorf("failed to generate access token: %w", txErr)
|
||||||
var user model.User
|
}
|
||||||
queryCtx, queryCancel := context.WithTimeout(ctx, 10*time.Second)
|
|
||||||
defer queryCancel()
|
queryCtx, queryCancel = context.WithTimeout(cmd.Context(), 10*time.Second)
|
||||||
txErr := tx.
|
defer queryCancel()
|
||||||
WithContext(queryCtx).
|
txErr = tx.
|
||||||
Where("username = ? OR email = ?", userArg, userArg).
|
WithContext(queryCtx).
|
||||||
First(&user).
|
Create(oneTimeAccessToken).
|
||||||
Error
|
Error
|
||||||
switch {
|
if txErr != nil {
|
||||||
case errors.Is(txErr, gorm.ErrRecordNotFound):
|
return fmt.Errorf("failed to save access token: %w", txErr)
|
||||||
return errors.New("user not found")
|
}
|
||||||
case txErr != nil:
|
|
||||||
return fmt.Errorf("failed to query for user: %w", txErr)
|
return nil
|
||||||
case user.ID == "":
|
})
|
||||||
return errors.New("invalid user loaded: ID is empty")
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a new access token that expires in 1 hour
|
// Print the result
|
||||||
oneTimeAccessToken, txErr = service.NewOneTimeAccessToken(user.ID, time.Now().Add(time.Hour))
|
fmt.Printf(`A one-time access token valid for 1 hour has been created for "%s".`+"\n", userArg)
|
||||||
if txErr != nil {
|
fmt.Printf("Use the following URL to sign in once: %s/lc/%s\n", common.EnvConfig.AppURL, oneTimeAccessToken.Token)
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
})
|
},
|
||||||
if err != nil {
|
}
|
||||||
return err
|
|
||||||
}
|
func init() {
|
||||||
|
rootCmd.AddCommand(oneTimeAccessTokenCmd)
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
package common
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
|
||||||
@@ -18,9 +20,10 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
DbProviderSqlite DbProvider = "sqlite"
|
DbProviderSqlite DbProvider = "sqlite"
|
||||||
DbProviderPostgres DbProvider = "postgres"
|
DbProviderPostgres DbProvider = "postgres"
|
||||||
MaxMindGeoLiteCityUrl string = "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-City&license_key=%s&suffix=tar.gz"
|
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 {
|
type EnvConfigSchema struct {
|
||||||
@@ -30,12 +33,17 @@ type EnvConfigSchema struct {
|
|||||||
DbConnectionString string `env:"DB_CONNECTION_STRING"`
|
DbConnectionString string `env:"DB_CONNECTION_STRING"`
|
||||||
UploadPath string `env:"UPLOAD_PATH"`
|
UploadPath string `env:"UPLOAD_PATH"`
|
||||||
KeysPath string `env:"KEYS_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"`
|
Port string `env:"PORT"`
|
||||||
Host string `env:"HOST"`
|
Host string `env:"HOST"`
|
||||||
UnixSocket string `env:"UNIX_SOCKET"`
|
UnixSocket string `env:"UNIX_SOCKET"`
|
||||||
|
UnixSocketMode string `env:"UNIX_SOCKET_MODE"`
|
||||||
MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY"`
|
MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY"`
|
||||||
GeoLiteDBPath string `env:"GEOLITE_DB_PATH"`
|
GeoLiteDBPath string `env:"GEOLITE_DB_PATH"`
|
||||||
GeoLiteDBUrl string `env:"GEOLITE_DB_URL"`
|
GeoLiteDBUrl string `env:"GEOLITE_DB_URL"`
|
||||||
|
LocalIPv6Ranges string `env:"LOCAL_IPV6_RANGES"`
|
||||||
UiConfigDisabled bool `env:"UI_CONFIG_DISABLED"`
|
UiConfigDisabled bool `env:"UI_CONFIG_DISABLED"`
|
||||||
MetricsEnabled bool `env:"METRICS_ENABLED"`
|
MetricsEnabled bool `env:"METRICS_ENABLED"`
|
||||||
TracingEnabled bool `env:"TRACING_ENABLED"`
|
TracingEnabled bool `env:"TRACING_ENABLED"`
|
||||||
@@ -43,50 +51,83 @@ type EnvConfigSchema struct {
|
|||||||
AnalyticsDisabled bool `env:"ANALYTICS_DISABLED"`
|
AnalyticsDisabled bool `env:"ANALYTICS_DISABLED"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var EnvConfig = &EnvConfigSchema{
|
var EnvConfig = defaultConfig()
|
||||||
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: "",
|
|
||||||
MaxMindLicenseKey: "",
|
|
||||||
GeoLiteDBPath: "data/GeoLite2-City.mmdb",
|
|
||||||
GeoLiteDBUrl: MaxMindGeoLiteCityUrl,
|
|
||||||
UiConfigDisabled: false,
|
|
||||||
MetricsEnabled: false,
|
|
||||||
TracingEnabled: false,
|
|
||||||
TrustProxy: false,
|
|
||||||
AnalyticsDisabled: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
if err := env.ParseWithOptions(EnvConfig, env.Options{}); err != nil {
|
err := parseEnvConfig()
|
||||||
log.Fatal(err)
|
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
|
// Validate the environment variables
|
||||||
switch EnvConfig.DbProvider {
|
switch EnvConfig.DbProvider {
|
||||||
case DbProviderSqlite:
|
case DbProviderSqlite:
|
||||||
if EnvConfig.DbConnectionString == "" {
|
if EnvConfig.DbConnectionString == "" {
|
||||||
log.Fatal("Missing required env var 'DB_CONNECTION_STRING' for SQLite database")
|
EnvConfig.DbConnectionString = defaultSqliteConnString
|
||||||
}
|
}
|
||||||
case DbProviderPostgres:
|
case DbProviderPostgres:
|
||||||
if EnvConfig.DbConnectionString == "" {
|
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:
|
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)
|
parsedAppUrl, err := url.Parse(EnvConfig.AppURL)
|
||||||
if err != nil {
|
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 != "" {
|
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 {
|
func (e *OidcAuthorizationPendingError) HttpStatusCode() int {
|
||||||
return http.StatusBadRequest
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package controller
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
@@ -247,6 +248,8 @@ func (acc *AppConfigController) getImage(c *gin.Context, name string, imageType
|
|||||||
mimeType := utils.GetImageMimeType(imageType)
|
mimeType := utils.GetImageMimeType(imageType)
|
||||||
|
|
||||||
c.Header("Content-Type", mimeType)
|
c.Header("Content-Type", mimeType)
|
||||||
|
|
||||||
|
utils.SetCacheControlHeader(c, 15*time.Minute, 24*time.Hour)
|
||||||
c.File(imagePath)
|
c.File(imagePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
|
|||||||
// @Param filters[userId] query string false "Filter by user ID"
|
// @Param filters[userId] query string false "Filter by user ID"
|
||||||
// @Param filters[event] query string false "Filter by event type"
|
// @Param filters[event] query string false "Filter by event type"
|
||||||
// @Param filters[clientName] query string false "Filter by client name"
|
// @Param filters[clientName] query string false "Filter by client name"
|
||||||
|
// @Param filters[location] query string false "Filter by location type (external or internal)"
|
||||||
// @Success 200 {object} dto.Paginated[dto.AuditLogDto]
|
// @Success 200 {object} dto.Paginated[dto.AuditLogDto]
|
||||||
// @Router /api/audit-logs/all [get]
|
// @Router /api/audit-logs/all [get]
|
||||||
func (alc *AuditLogController) listAllAuditLogsHandler(c *gin.Context) {
|
func (alc *AuditLogController) listAllAuditLogsHandler(c *gin.Context) {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ func (tc *TestController) resetAndSeedHandler(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
skipLdap := c.Query("skip-ldap") == "true"
|
skipLdap := c.Query("skip-ldap") == "true"
|
||||||
|
skipSeed := c.Query("skip-seed") == "true"
|
||||||
|
|
||||||
if err := tc.TestService.ResetDatabase(); err != nil {
|
if err := tc.TestService.ResetDatabase(); err != nil {
|
||||||
_ = c.Error(err)
|
_ = c.Error(err)
|
||||||
@@ -44,9 +45,11 @@ func (tc *TestController) resetAndSeedHandler(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tc.TestService.SeedDatabase(baseURL); err != nil {
|
if !skipSeed {
|
||||||
_ = c.Error(err)
|
if err := tc.TestService.SeedDatabase(baseURL); err != nil {
|
||||||
return
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tc.TestService.ResetAppConfig(c.Request.Context()); err != nil {
|
if err := tc.TestService.ResetAppConfig(c.Request.Context()); err != nil {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
@@ -88,6 +89,7 @@ func (oc *OidcController) authorizeHandler(c *gin.Context) {
|
|||||||
response := dto.AuthorizeOidcClientResponseDto{
|
response := dto.AuthorizeOidcClientResponseDto{
|
||||||
Code: code,
|
Code: code,
|
||||||
CallbackURL: callbackURL,
|
CallbackURL: callbackURL,
|
||||||
|
Issuer: common.EnvConfig.AppURL,
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, response)
|
c.JSON(http.StatusOK, response)
|
||||||
@@ -545,6 +547,8 @@ func (oc *OidcController) getClientLogoHandler(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
utils.SetCacheControlHeader(c, 15*time.Minute, 12*time.Hour)
|
||||||
|
|
||||||
c.Header("Content-Type", mimeType)
|
c.Header("Content-Type", mimeType)
|
||||||
c.File(imagePath)
|
c.File(imagePath)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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-token", authMiddleware.Add(), uc.createAdminOneTimeAccessTokenHandler)
|
||||||
group.POST("/users/:id/one-time-access-email", authMiddleware.Add(), uc.RequestOneTimeAccessEmailAsAdminHandler)
|
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/: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.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/:id/profile-picture", authMiddleware.Add(), uc.resetUserProfilePictureHandler)
|
||||||
group.DELETE("/users/me/profile-picture", authMiddleware.WithAdminNotRequired().Add(), uc.resetCurrentUserProfilePictureHandler)
|
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 {
|
type UserController struct {
|
||||||
@@ -250,10 +256,7 @@ func (uc *UserController) getUserProfilePictureHandler(c *gin.Context) {
|
|||||||
defer picture.Close()
|
defer picture.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
_, ok := c.GetQuery("skipCache")
|
utils.SetCacheControlHeader(c, 15*time.Minute, 1*time.Hour)
|
||||||
if !ok {
|
|
||||||
c.Header("Cache-Control", "public, max-age=900")
|
|
||||||
}
|
|
||||||
|
|
||||||
c.DataFromReader(http.StatusOK, size, "image/png", picture, nil)
|
c.DataFromReader(http.StatusOK, size, "image/png", picture, nil)
|
||||||
}
|
}
|
||||||
@@ -443,14 +446,23 @@ func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, userDto)
|
c.JSON(http.StatusOK, userDto)
|
||||||
}
|
}
|
||||||
|
|
||||||
// getSetupAccessTokenHandler godoc
|
// signUpInitialAdmin godoc
|
||||||
// @Summary Setup initial admin
|
// @Summary Sign up initial admin user
|
||||||
// @Description Generate setup access token for initial admin user configuration
|
// @Description Sign up and generate setup access token for initial admin user
|
||||||
// @Tags Users
|
// @Tags Users
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param body body dto.SignUpDto true "User information"
|
||||||
// @Success 200 {object} dto.UserDto
|
// @Success 200 {object} dto.UserDto
|
||||||
// @Router /api/one-time-access-token/setup [post]
|
// @Router /api/signup/setup [post]
|
||||||
func (uc *UserController) getSetupAccessTokenHandler(c *gin.Context) {
|
func (uc *UserController) signUpInitialAdmin(c *gin.Context) {
|
||||||
user, token, err := uc.userService.SetupInitialAdmin(c.Request.Context())
|
var input dto.SignUpDto
|
||||||
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, token, err := uc.userService.SignUpInitialAdmin(c.Request.Context(), input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
@@ -498,6 +510,128 @@ func (uc *UserController) updateUserGroups(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, userDto)
|
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 := c.ShouldBindJSON(&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
|
// updateUser is an internal helper method, not exposed as an API endpoint
|
||||||
func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
|
func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
|
||||||
var input dto.UserCreateDto
|
var input dto.UserCreateDto
|
||||||
|
|||||||
@@ -69,20 +69,21 @@ func (wkc *WellKnownController) computeOIDCConfiguration() ([]byte, error) {
|
|||||||
return nil, fmt.Errorf("failed to get key algorithm: %w", err)
|
return nil, fmt.Errorf("failed to get key algorithm: %w", err)
|
||||||
}
|
}
|
||||||
config := map[string]any{
|
config := map[string]any{
|
||||||
"issuer": appUrl,
|
"issuer": appUrl,
|
||||||
"authorization_endpoint": appUrl + "/authorize",
|
"authorization_endpoint": appUrl + "/authorize",
|
||||||
"token_endpoint": appUrl + "/api/oidc/token",
|
"token_endpoint": appUrl + "/api/oidc/token",
|
||||||
"userinfo_endpoint": appUrl + "/api/oidc/userinfo",
|
"userinfo_endpoint": appUrl + "/api/oidc/userinfo",
|
||||||
"end_session_endpoint": appUrl + "/api/oidc/end-session",
|
"end_session_endpoint": appUrl + "/api/oidc/end-session",
|
||||||
"introspection_endpoint": appUrl + "/api/oidc/introspect",
|
"introspection_endpoint": appUrl + "/api/oidc/introspect",
|
||||||
"device_authorization_endpoint": appUrl + "/api/oidc/device/authorize",
|
"device_authorization_endpoint": appUrl + "/api/oidc/device/authorize",
|
||||||
"jwks_uri": appUrl + "/.well-known/jwks.json",
|
"jwks_uri": appUrl + "/.well-known/jwks.json",
|
||||||
"grant_types_supported": []string{service.GrantTypeAuthorizationCode, service.GrantTypeRefreshToken, service.GrantTypeDeviceCode},
|
"grant_types_supported": []string{service.GrantTypeAuthorizationCode, service.GrantTypeRefreshToken, service.GrantTypeDeviceCode},
|
||||||
"scopes_supported": []string{"openid", "profile", "email", "groups"},
|
"scopes_supported": []string{"openid", "profile", "email", "groups"},
|
||||||
"claims_supported": []string{"sub", "given_name", "family_name", "name", "email", "email_verified", "preferred_username", "picture", "groups"},
|
"claims_supported": []string{"sub", "given_name", "family_name", "name", "email", "email_verified", "preferred_username", "picture", "groups"},
|
||||||
"response_types_supported": []string{"code", "id_token"},
|
"response_types_supported": []string{"code", "id_token"},
|
||||||
"subject_types_supported": []string{"public"},
|
"subject_types_supported": []string{"public"},
|
||||||
"id_token_signing_alg_values_supported": []string{alg.String()},
|
"id_token_signing_alg_values_supported": []string{alg.String()},
|
||||||
|
"authorization_response_iss_parameter_supported": true,
|
||||||
}
|
}
|
||||||
return json.Marshal(config)
|
return json.Marshal(config)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ type AppConfigUpdateDto struct {
|
|||||||
EmailsVerified string `json:"emailsVerified" binding:"required"`
|
EmailsVerified string `json:"emailsVerified" binding:"required"`
|
||||||
DisableAnimations string `json:"disableAnimations" binding:"required"`
|
DisableAnimations string `json:"disableAnimations" binding:"required"`
|
||||||
AllowOwnAccountEdit string `json:"allowOwnAccountEdit" 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"`
|
SmtpHost string `json:"smtpHost"`
|
||||||
SmtpPort string `json:"smtpPort"`
|
SmtpPort string `json:"smtpPort"`
|
||||||
SmtpFrom string `json:"smtpFrom" binding:"omitempty,email"`
|
SmtpFrom string `json:"smtpFrom" binding:"omitempty,email"`
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package dto
|
package dto
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
|
||||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -9,18 +8,19 @@ type AuditLogDto struct {
|
|||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
CreatedAt datatype.DateTime `json:"createdAt"`
|
CreatedAt datatype.DateTime `json:"createdAt"`
|
||||||
|
|
||||||
Event model.AuditLogEvent `json:"event"`
|
Event string `json:"event"`
|
||||||
IpAddress string `json:"ipAddress"`
|
IpAddress string `json:"ipAddress"`
|
||||||
Country string `json:"country"`
|
Country string `json:"country"`
|
||||||
City string `json:"city"`
|
City string `json:"city"`
|
||||||
Device string `json:"device"`
|
Device string `json:"device"`
|
||||||
UserID string `json:"userID"`
|
UserID string `json:"userID"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Data model.AuditLogData `json:"data"`
|
Data map[string]string `json:"data"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuditLogFilterDto struct {
|
type AuditLogFilterDto struct {
|
||||||
UserID string `form:"filters[userId]"`
|
UserID string `form:"filters[userId]"`
|
||||||
Event string `form:"filters[event]"`
|
Event string `form:"filters[event]"`
|
||||||
ClientName string `form:"filters[clientName]"`
|
ClientName string `form:"filters[clientName]"`
|
||||||
|
Location string `form:"filters[location]"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,162 +1,27 @@
|
|||||||
package dto
|
package dto
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"fmt"
|
||||||
"reflect"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
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
|
// MapStructList maps a list of source structs to a list of destination structs
|
||||||
func MapStructList[S any, D any](source []S, destination *[]D) error {
|
func MapStructList[S any, D any](source []S, destination *[]D) (err error) {
|
||||||
*destination = make([]D, 0, len(source))
|
*destination = make([]D, len(source))
|
||||||
|
|
||||||
for _, item := range source {
|
for i, item := range source {
|
||||||
var destItem D
|
err = MapStruct(item, &((*destination)[i]))
|
||||||
if err := MapStruct(item, &destItem); err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("failed to map field %d: %w", i, err)
|
||||||
}
|
}
|
||||||
*destination = append(*destination, destItem)
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MapStruct maps a source struct to a destination struct
|
// MapStruct maps a source struct to a destination struct
|
||||||
func MapStruct[S any, D any](source S, destination *D) error {
|
func MapStruct(source any, destination any) error {
|
||||||
// Ensure destination is a non-nil pointer
|
return copier.CopyWithOption(destination, source, copier.Option{
|
||||||
destValue := reflect.ValueOf(destination)
|
DeepCopy: true,
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
@@ -57,6 +57,7 @@ type AuthorizeOidcClientRequestDto struct {
|
|||||||
type AuthorizeOidcClientResponseDto struct {
|
type AuthorizeOidcClientResponseDto struct {
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
CallbackURL string `json:"callbackURL"`
|
CallbackURL string `json:"callbackURL"`
|
||||||
|
Issuer string `json:"issuer"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuthorizationRequiredDto struct {
|
type AuthorizationRequiredDto struct {
|
||||||
@@ -149,7 +150,7 @@ type AuthorizedOidcClientDto struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type OidcClientPreviewDto struct {
|
type OidcClientPreviewDto struct {
|
||||||
IdToken map[string]interface{} `json:"idToken"`
|
IdToken map[string]any `json:"idToken"`
|
||||||
AccessToken map[string]interface{} `json:"accessToken"`
|
AccessToken map[string]any `json:"accessToken"`
|
||||||
UserInfo map[string]interface{} `json:"userInfo"`
|
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"`
|
||||||
|
}
|
||||||
@@ -44,3 +44,11 @@ type OneTimeAccessEmailAsAdminDto struct {
|
|||||||
type UserUpdateUserGroupDto struct {
|
type UserUpdateUserGroupDto struct {
|
||||||
UserGroupIds []string `json:"userGroupIds" binding:"required"`
|
UserGroupIds []string `json:"userGroupIds" binding:"required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SignUpDto 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"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ import (
|
|||||||
"github.com/go-playground/validator/v10"
|
"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 {
|
var validateUsername validator.Func = func(fl validator.FieldLevel) bool {
|
||||||
// [a-zA-Z0-9] : The username must start with an alphanumeric character
|
return validateUsernameRegex.MatchString(fl.Field().String())
|
||||||
// [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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ func (s *Scheduler) RegisterDbCleanupJobs(ctx context.Context, db *gorm.DB) erro
|
|||||||
return errors.Join(
|
return errors.Join(
|
||||||
s.registerJob(ctx, "ClearWebauthnSessions", def, jobs.clearWebauthnSessions, true),
|
s.registerJob(ctx, "ClearWebauthnSessions", def, jobs.clearWebauthnSessions, true),
|
||||||
s.registerJob(ctx, "ClearOneTimeAccessTokens", def, jobs.clearOneTimeAccessTokens, 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, "ClearOidcAuthorizationCodes", def, jobs.clearOidcAuthorizationCodes, true),
|
||||||
s.registerJob(ctx, "ClearOidcRefreshTokens", def, jobs.clearOidcRefreshTokens, true),
|
s.registerJob(ctx, "ClearOidcRefreshTokens", def, jobs.clearOidcRefreshTokens, true),
|
||||||
s.registerJob(ctx, "ClearAuditLogs", def, jobs.clearAuditLogs, true),
|
s.registerJob(ctx, "ClearAuditLogs", def, jobs.clearAuditLogs, true),
|
||||||
@@ -60,6 +61,21 @@ func (j *DbCleanupJobs) clearOneTimeAccessTokens(ctx context.Context) error {
|
|||||||
return nil
|
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
|
// ClearOidcAuthorizationCodes deletes OIDC authorization codes that have expired
|
||||||
func (j *DbCleanupJobs) clearOidcAuthorizationCodes(ctx context.Context) error {
|
func (j *DbCleanupJobs) clearOidcAuthorizationCodes(ctx context.Context) error {
|
||||||
st := j.db.
|
st := j.db.
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AppConfigVariable struct {
|
type AppConfigVariable struct {
|
||||||
@@ -35,8 +37,10 @@ type AppConfig struct {
|
|||||||
AppName AppConfigVariable `key:"appName,public"` // Public
|
AppName AppConfigVariable `key:"appName,public"` // Public
|
||||||
SessionDuration AppConfigVariable `key:"sessionDuration"`
|
SessionDuration AppConfigVariable `key:"sessionDuration"`
|
||||||
EmailsVerified AppConfigVariable `key:"emailsVerified"`
|
EmailsVerified AppConfigVariable `key:"emailsVerified"`
|
||||||
|
AccentColor AppConfigVariable `key:"accentColor,public"` // Public
|
||||||
DisableAnimations AppConfigVariable `key:"disableAnimations,public"` // Public
|
DisableAnimations AppConfigVariable `key:"disableAnimations,public"` // Public
|
||||||
AllowOwnAccountEdit AppConfigVariable `key:"allowOwnAccountEdit,public"` // Public
|
AllowOwnAccountEdit AppConfigVariable `key:"allowOwnAccountEdit,public"` // Public
|
||||||
|
AllowUserSignups AppConfigVariable `key:"allowUserSignups,public"` // Public
|
||||||
// Internal
|
// Internal
|
||||||
BackgroundImageType AppConfigVariable `key:"backgroundImageType,internal"` // Internal
|
BackgroundImageType AppConfigVariable `key:"backgroundImageType,internal"` // Internal
|
||||||
LogoLightImageType AppConfigVariable `key:"logoLightImageType,internal"` // Internal
|
LogoLightImageType AppConfigVariable `key:"logoLightImageType,internal"` // Internal
|
||||||
@@ -47,7 +51,7 @@ type AppConfig struct {
|
|||||||
SmtpPort AppConfigVariable `key:"smtpPort"`
|
SmtpPort AppConfigVariable `key:"smtpPort"`
|
||||||
SmtpFrom AppConfigVariable `key:"smtpFrom"`
|
SmtpFrom AppConfigVariable `key:"smtpFrom"`
|
||||||
SmtpUser AppConfigVariable `key:"smtpUser"`
|
SmtpUser AppConfigVariable `key:"smtpUser"`
|
||||||
SmtpPassword AppConfigVariable `key:"smtpPassword"`
|
SmtpPassword AppConfigVariable `key:"smtpPassword,sensitive"`
|
||||||
SmtpTls AppConfigVariable `key:"smtpTls"`
|
SmtpTls AppConfigVariable `key:"smtpTls"`
|
||||||
SmtpSkipCertVerify AppConfigVariable `key:"smtpSkipCertVerify"`
|
SmtpSkipCertVerify AppConfigVariable `key:"smtpSkipCertVerify"`
|
||||||
EmailLoginNotificationEnabled AppConfigVariable `key:"emailLoginNotificationEnabled"`
|
EmailLoginNotificationEnabled AppConfigVariable `key:"emailLoginNotificationEnabled"`
|
||||||
@@ -58,7 +62,7 @@ type AppConfig struct {
|
|||||||
LdapEnabled AppConfigVariable `key:"ldapEnabled,public"` // Public
|
LdapEnabled AppConfigVariable `key:"ldapEnabled,public"` // Public
|
||||||
LdapUrl AppConfigVariable `key:"ldapUrl"`
|
LdapUrl AppConfigVariable `key:"ldapUrl"`
|
||||||
LdapBindDn AppConfigVariable `key:"ldapBindDn"`
|
LdapBindDn AppConfigVariable `key:"ldapBindDn"`
|
||||||
LdapBindPassword AppConfigVariable `key:"ldapBindPassword"`
|
LdapBindPassword AppConfigVariable `key:"ldapBindPassword,sensitive"`
|
||||||
LdapBase AppConfigVariable `key:"ldapBase"`
|
LdapBase AppConfigVariable `key:"ldapBase"`
|
||||||
LdapUserSearchFilter AppConfigVariable `key:"ldapUserSearchFilter"`
|
LdapUserSearchFilter AppConfigVariable `key:"ldapUserSearchFilter"`
|
||||||
LdapUserGroupSearchFilter AppConfigVariable `key:"ldapUserGroupSearchFilter"`
|
LdapUserGroupSearchFilter AppConfigVariable `key:"ldapUserGroupSearchFilter"`
|
||||||
@@ -76,7 +80,7 @@ type AppConfig struct {
|
|||||||
LdapSoftDeleteUsers AppConfigVariable `key:"ldapSoftDeleteUsers"`
|
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
|
// Use reflection to iterate through all fields
|
||||||
cfgValue := reflect.ValueOf(c).Elem()
|
cfgValue := reflect.ValueOf(c).Elem()
|
||||||
cfgType := cfgValue.Type()
|
cfgType := cfgValue.Type()
|
||||||
@@ -96,11 +100,16 @@ func (c *AppConfig) ToAppConfigVariableSlice(showAll bool) []AppConfigVariable {
|
|||||||
continue
|
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{
|
appConfigVariable := AppConfigVariable{
|
||||||
Key: key,
|
Key: key,
|
||||||
Value: fieldValue.FieldByName("Value").String(),
|
Value: value,
|
||||||
}
|
}
|
||||||
|
|
||||||
res = append(res, appConfigVariable)
|
res = append(res, appConfigVariable)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ type AuditLog struct {
|
|||||||
Base
|
Base
|
||||||
|
|
||||||
Event AuditLogEvent `sortable:"true"`
|
Event AuditLogEvent `sortable:"true"`
|
||||||
IpAddress string `sortable:"true"`
|
IpAddress *string `sortable:"true"`
|
||||||
Country string `sortable:"true"`
|
Country string `sortable:"true"`
|
||||||
City string `sortable:"true"`
|
City string `sortable:"true"`
|
||||||
UserAgent string `sortable:"true"`
|
UserAgent string `sortable:"true"`
|
||||||
@@ -28,6 +28,7 @@ type AuditLogEvent string //nolint:recvcheck
|
|||||||
const (
|
const (
|
||||||
AuditLogEventSignIn AuditLogEvent = "SIGN_IN"
|
AuditLogEventSignIn AuditLogEvent = "SIGN_IN"
|
||||||
AuditLogEventOneTimeAccessTokenSignIn AuditLogEvent = "TOKEN_SIGN_IN"
|
AuditLogEventOneTimeAccessTokenSignIn AuditLogEvent = "TOKEN_SIGN_IN"
|
||||||
|
AuditLogEventAccountCreated AuditLogEvent = "ACCOUNT_CREATED"
|
||||||
AuditLogEventClientAuthorization AuditLogEvent = "CLIENT_AUTHORIZATION"
|
AuditLogEventClientAuthorization AuditLogEvent = "CLIENT_AUTHORIZATION"
|
||||||
AuditLogEventNewClientAuthorization AuditLogEvent = "NEW_CLIENT_AUTHORIZATION"
|
AuditLogEventNewClientAuthorization AuditLogEvent = "NEW_CLIENT_AUTHORIZATION"
|
||||||
AuditLogEventDeviceCodeAuthorization AuditLogEvent = "DEVICE_CODE_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,8 @@ func (s *AppConfigService) getDefaultDbConfig() *model.AppConfig {
|
|||||||
EmailsVerified: model.AppConfigVariable{Value: "false"},
|
EmailsVerified: model.AppConfigVariable{Value: "false"},
|
||||||
DisableAnimations: model.AppConfigVariable{Value: "false"},
|
DisableAnimations: model.AppConfigVariable{Value: "false"},
|
||||||
AllowOwnAccountEdit: model.AppConfigVariable{Value: "true"},
|
AllowOwnAccountEdit: model.AppConfigVariable{Value: "true"},
|
||||||
|
AllowUserSignups: model.AppConfigVariable{Value: "disabled"},
|
||||||
|
AccentColor: model.AppConfigVariable{Value: "default"},
|
||||||
// Internal
|
// Internal
|
||||||
BackgroundImageType: model.AppConfigVariable{Value: "jpg"},
|
BackgroundImageType: model.AppConfigVariable{Value: "jpg"},
|
||||||
LogoLightImageType: model.AppConfigVariable{Value: "svg"},
|
LogoLightImageType: model.AppConfigVariable{Value: "svg"},
|
||||||
@@ -232,7 +234,7 @@ func (s *AppConfigService) UpdateAppConfig(ctx context.Context, input dto.AppCon
|
|||||||
s.dbConfig.Store(cfg)
|
s.dbConfig.Store(cfg)
|
||||||
|
|
||||||
// Return the updated config
|
// Return the updated config
|
||||||
res := cfg.ToAppConfigVariableSlice(true)
|
res := cfg.ToAppConfigVariableSlice(true, false)
|
||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,11 +319,11 @@ func (s *AppConfigService) UpdateAppConfigValues(ctx context.Context, keysAndVal
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *AppConfigService) ListAppConfig(showAll bool) []model.AppConfigVariable {
|
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) {
|
func (s *AppConfigService) UpdateImage(ctx context.Context, uploadedFile *multipart.FileHeader, imageName string, oldImageType string) (err error) {
|
||||||
fileType := utils.GetFileExtension(uploadedFile.Filename)
|
fileType := strings.ToLower(utils.GetFileExtension(uploadedFile.Filename))
|
||||||
mimeType := utils.GetImageMimeType(fileType)
|
mimeType := utils.GetImageMimeType(fileType)
|
||||||
if mimeType == "" {
|
if mimeType == "" {
|
||||||
return &common.FileTypeNotSupportedError{}
|
return &common.FileTypeNotSupportedError{}
|
||||||
@@ -368,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) {
|
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 the UI config is disabled, only load from the env
|
||||||
if common.EnvConfig.UiConfigDisabled {
|
if common.EnvConfig.UiConfigDisabled {
|
||||||
dest, err := s.loadDbConfigFromEnv(ctx, s.db)
|
dest, err := s.loadDbConfigFromEnv(ctx, tx)
|
||||||
return dest, err
|
return dest, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import (
|
|||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
"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/dto"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
"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
|
// 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) {
|
func TestLoadDbConfig(t *testing.T) {
|
||||||
t.Run("empty config table", func(t *testing.T) {
|
t.Run("empty config table", func(t *testing.T) {
|
||||||
db := newDatabaseForTest(t)
|
db := testutils.NewDatabaseForTest(t)
|
||||||
service := &AppConfigService{
|
service := &AppConfigService{
|
||||||
db: db,
|
db: db,
|
||||||
}
|
}
|
||||||
@@ -36,7 +38,7 @@ func TestLoadDbConfig(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("loads value from config table", func(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
|
// Populate the config table with some initial values
|
||||||
err := db.
|
err := db.
|
||||||
@@ -66,7 +68,7 @@ func TestLoadDbConfig(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("ignores unknown config keys", func(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
|
// Add an entry with a key that doesn't exist in the config struct
|
||||||
err := db.Create([]model.AppConfigVariable{
|
err := db.Create([]model.AppConfigVariable{
|
||||||
@@ -87,7 +89,7 @@ func TestLoadDbConfig(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("loading config multiple times", func(t *testing.T) {
|
t.Run("loading config multiple times", func(t *testing.T) {
|
||||||
db := newDatabaseForTest(t)
|
db := testutils.NewDatabaseForTest(t)
|
||||||
|
|
||||||
// Initial state
|
// Initial state
|
||||||
err := db.Create([]model.AppConfigVariable{
|
err := db.Create([]model.AppConfigVariable{
|
||||||
@@ -129,7 +131,7 @@ func TestLoadDbConfig(t *testing.T) {
|
|||||||
common.EnvConfig.UiConfigDisabled = true
|
common.EnvConfig.UiConfigDisabled = true
|
||||||
|
|
||||||
// Create database with config that should be ignored
|
// Create database with config that should be ignored
|
||||||
db := newDatabaseForTest(t)
|
db := testutils.NewDatabaseForTest(t)
|
||||||
err := db.Create([]model.AppConfigVariable{
|
err := db.Create([]model.AppConfigVariable{
|
||||||
{Key: "appName", Value: "DB App"},
|
{Key: "appName", Value: "DB App"},
|
||||||
{Key: "sessionDuration", Value: "120"},
|
{Key: "sessionDuration", Value: "120"},
|
||||||
@@ -165,7 +167,7 @@ func TestLoadDbConfig(t *testing.T) {
|
|||||||
common.EnvConfig.UiConfigDisabled = false
|
common.EnvConfig.UiConfigDisabled = false
|
||||||
|
|
||||||
// Create database with config values that should take precedence
|
// Create database with config values that should take precedence
|
||||||
db := newDatabaseForTest(t)
|
db := testutils.NewDatabaseForTest(t)
|
||||||
err := db.Create([]model.AppConfigVariable{
|
err := db.Create([]model.AppConfigVariable{
|
||||||
{Key: "appName", Value: "DB App"},
|
{Key: "appName", Value: "DB App"},
|
||||||
{Key: "sessionDuration", Value: "120"},
|
{Key: "sessionDuration", Value: "120"},
|
||||||
@@ -189,7 +191,7 @@ func TestLoadDbConfig(t *testing.T) {
|
|||||||
|
|
||||||
func TestUpdateAppConfigValues(t *testing.T) {
|
func TestUpdateAppConfigValues(t *testing.T) {
|
||||||
t.Run("update single value", func(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
|
// Create a service with default config
|
||||||
service := &AppConfigService{
|
service := &AppConfigService{
|
||||||
@@ -214,7 +216,7 @@ func TestUpdateAppConfigValues(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("update multiple values", func(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
|
// Create a service with default config
|
||||||
service := &AppConfigService{
|
service := &AppConfigService{
|
||||||
@@ -258,7 +260,7 @@ func TestUpdateAppConfigValues(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("empty value resets to default", func(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
|
// Create a service with default config
|
||||||
service := &AppConfigService{
|
service := &AppConfigService{
|
||||||
@@ -279,7 +281,7 @@ func TestUpdateAppConfigValues(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("error with odd number of arguments", func(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
|
// Create a service with default config
|
||||||
service := &AppConfigService{
|
service := &AppConfigService{
|
||||||
@@ -295,7 +297,7 @@ func TestUpdateAppConfigValues(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("error with invalid key", func(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
|
// Create a service with default config
|
||||||
service := &AppConfigService{
|
service := &AppConfigService{
|
||||||
@@ -313,7 +315,7 @@ func TestUpdateAppConfigValues(t *testing.T) {
|
|||||||
|
|
||||||
func TestUpdateAppConfig(t *testing.T) {
|
func TestUpdateAppConfig(t *testing.T) {
|
||||||
t.Run("updates configuration values from DTO", func(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
|
// Create a service with default config
|
||||||
service := &AppConfigService{
|
service := &AppConfigService{
|
||||||
@@ -386,7 +388,7 @@ func TestUpdateAppConfig(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("empty values reset to defaults", func(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
|
// Create a service with default config and modify some values
|
||||||
service := &AppConfigService{
|
service := &AppConfigService{
|
||||||
@@ -451,7 +453,7 @@ func TestUpdateAppConfig(t *testing.T) {
|
|||||||
// Disable UI config
|
// Disable UI config
|
||||||
common.EnvConfig.UiConfigDisabled = true
|
common.EnvConfig.UiConfigDisabled = true
|
||||||
|
|
||||||
db := newDatabaseForTest(t)
|
db := testutils.NewDatabaseForTest(t)
|
||||||
service := &AppConfigService{
|
service := &AppConfigService{
|
||||||
db: db,
|
db: db,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
userAgentParser "github.com/mileusna/useragent"
|
userAgentParser "github.com/mileusna/useragent"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
"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
|
// 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)
|
country, city, err := s.geoliteService.GetLocationByIP(ipAddress)
|
||||||
if err != nil {
|
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{
|
auditLog := model.AuditLog{
|
||||||
Event: event,
|
Event: event,
|
||||||
IpAddress: ipAddress,
|
|
||||||
Country: country,
|
Country: country,
|
||||||
City: city,
|
City: city,
|
||||||
UserAgent: userAgent,
|
UserAgent: userAgent,
|
||||||
@@ -41,22 +42,31 @@ func (s *AuditLogService) Create(ctx context.Context, event model.AuditLogEvent,
|
|||||||
Data: data,
|
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
|
// Save the audit log in the database
|
||||||
err = tx.
|
err = tx.
|
||||||
WithContext(ctx).
|
WithContext(ctx).
|
||||||
Create(&auditLog).
|
Create(&auditLog).
|
||||||
Error
|
Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to create audit log: %v", err)
|
slog.Error("Failed to create audit log", "error", err)
|
||||||
return model.AuditLog{}
|
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
|
// 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 {
|
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
|
// Count the number of times the user has logged in from the same device
|
||||||
var count int64
|
var count int64
|
||||||
@@ -67,7 +77,7 @@ func (s *AuditLogService) CreateNewSignInWithEmail(ctx context.Context, ipAddres
|
|||||||
Count(&count).
|
Count(&count).
|
||||||
Error
|
Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to count audit logs: %v\n", err)
|
log.Printf("Failed to count audit logs: %v", err)
|
||||||
return createdAuditLog
|
return createdAuditLog
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,6 +160,14 @@ func (s *AuditLogService) ListAllAuditLogs(ctx context.Context, sortedPagination
|
|||||||
return nil, utils.PaginationResponse{}, fmt.Errorf("unsupported database dialect: %s", dialect)
|
return nil, utils.PaginationResponse{}, fmt.Errorf("unsupported database dialect: %s", dialect)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if filters.Location != "" {
|
||||||
|
switch filters.Location {
|
||||||
|
case "external":
|
||||||
|
query = query.Where("country != 'Internal Network'")
|
||||||
|
case "internal":
|
||||||
|
query = query.Where("country = 'Internal Network'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pagination, err := utils.PaginateAndSort(sortedPaginationRequest, query, &logs)
|
pagination, err := utils.PaginateAndSort(sortedPaginationRequest, query, &logs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
|
|
||||||
"github.com/fxamacker/cbor/v2"
|
"github.com/fxamacker/cbor/v2"
|
||||||
"github.com/go-webauthn/webauthn/protocol"
|
"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/jwk"
|
||||||
"github.com/lestrrat-go/jwx/v3/jwt"
|
"github.com/lestrrat-go/jwx/v3/jwt"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -25,6 +26,7 @@ import (
|
|||||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
"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"
|
"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)
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to import private key: %w", err)
|
return fmt.Errorf("failed to import private key: %w", err)
|
||||||
}
|
}
|
||||||
@@ -310,6 +312,50 @@ func (s *TestService) SeedDatabase(baseURL string) error {
|
|||||||
return err
|
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
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"net/netip"
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -22,9 +23,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type GeoLiteService struct {
|
type GeoLiteService struct {
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
disableUpdater bool
|
disableUpdater bool
|
||||||
mutex sync.RWMutex
|
mutex sync.RWMutex
|
||||||
|
localIPv6Ranges []*net.IPNet
|
||||||
}
|
}
|
||||||
|
|
||||||
var localhostIPNets = []*net.IPNet{
|
var localhostIPNets = []*net.IPNet{
|
||||||
@@ -54,17 +56,84 @@ func NewGeoLiteService(httpClient *http.Client) *GeoLiteService {
|
|||||||
service.disableUpdater = true
|
service.disableUpdater = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize IPv6 local ranges
|
||||||
|
if err := service.initializeIPv6LocalRanges(); err != nil {
|
||||||
|
log.Printf("Warning: Failed to initialize IPv6 local ranges: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
return service
|
return service
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// initializeIPv6LocalRanges parses the LOCAL_IPV6_RANGES environment variable
|
||||||
|
func (s *GeoLiteService) initializeIPv6LocalRanges() error {
|
||||||
|
rangesEnv := common.EnvConfig.LocalIPv6Ranges
|
||||||
|
if rangesEnv == "" {
|
||||||
|
return nil // No local IPv6 ranges configured
|
||||||
|
}
|
||||||
|
|
||||||
|
ranges := strings.Split(rangesEnv, ",")
|
||||||
|
localRanges := make([]*net.IPNet, 0, len(ranges))
|
||||||
|
|
||||||
|
for _, rangeStr := range ranges {
|
||||||
|
rangeStr = strings.TrimSpace(rangeStr)
|
||||||
|
if rangeStr == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
_, ipNet, err := net.ParseCIDR(rangeStr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid IPv6 range '%s': %w", rangeStr, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure it's an IPv6 range
|
||||||
|
if ipNet.IP.To4() != nil {
|
||||||
|
return fmt.Errorf("range '%s' is not a valid IPv6 range", rangeStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
localRanges = append(localRanges, ipNet)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.localIPv6Ranges = localRanges
|
||||||
|
|
||||||
|
if len(localRanges) > 0 {
|
||||||
|
log.Printf("Initialized %d IPv6 local ranges", len(localRanges))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isLocalIPv6 checks if the given IPv6 address is within any of the configured local ranges
|
||||||
|
func (s *GeoLiteService) isLocalIPv6(ip net.IP) bool {
|
||||||
|
if ip.To4() != nil {
|
||||||
|
return false // Not an IPv6 address
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, localRange := range s.localIPv6Ranges {
|
||||||
|
if localRange.Contains(ip) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func (s *GeoLiteService) DisableUpdater() bool {
|
func (s *GeoLiteService) DisableUpdater() bool {
|
||||||
return s.disableUpdater
|
return s.disableUpdater
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLocationByIP returns the country and city of the given IP address.
|
// GetLocationByIP returns the country and city of the given IP address.
|
||||||
func (s *GeoLiteService) GetLocationByIP(ipAddress string) (country, city string, err error) {
|
func (s *GeoLiteService) GetLocationByIP(ipAddress string) (country, city string, err error) {
|
||||||
|
if ipAddress == "" {
|
||||||
|
return "", "", nil
|
||||||
|
}
|
||||||
|
|
||||||
// Check the IP address against known private IP ranges
|
// Check the IP address against known private IP ranges
|
||||||
if ip := net.ParseIP(ipAddress); ip != nil {
|
if ip := net.ParseIP(ipAddress); ip != nil {
|
||||||
|
// Check IPv6 local ranges first
|
||||||
|
if s.isLocalIPv6(ip) {
|
||||||
|
return "Internal Network", "LAN", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check existing IPv4 ranges
|
||||||
for _, ipNet := range tailscaleIPNets {
|
for _, ipNet := range tailscaleIPNets {
|
||||||
if ipNet.Contains(ip) {
|
if ipNet.Contains(ip) {
|
||||||
return "Internal Network", "Tailscale", nil
|
return "Internal Network", "Tailscale", nil
|
||||||
@@ -82,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.
|
// Race condition between reading and writing the database.
|
||||||
s.mutex.RLock()
|
s.mutex.RLock()
|
||||||
defer s.mutex.RUnlock()
|
defer s.mutex.RUnlock()
|
||||||
@@ -92,11 +166,6 @@ func (s *GeoLiteService) GetLocationByIP(ipAddress string) (country, city string
|
|||||||
}
|
}
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
|
|
||||||
addr, err := netip.ParseAddr(ipAddress)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", fmt.Errorf("failed to parse IP address: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var record struct {
|
var record struct {
|
||||||
City struct {
|
City struct {
|
||||||
Names map[string]string `maxminddb:"names"`
|
Names map[string]string `maxminddb:"names"`
|
||||||
|
|||||||
220
backend/internal/service/geolite_service_test.go
Normal file
220
backend/internal/service/geolite_service_test.go
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"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) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
localRanges string
|
||||||
|
testIP string
|
||||||
|
expectedCountry string
|
||||||
|
expectedCity string
|
||||||
|
expectError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "IPv6 in local range",
|
||||||
|
localRanges: "2001:0db8:abcd:000::/56,2001:0db8:abcd:001::/56",
|
||||||
|
testIP: "2001:0db8:abcd:000::1",
|
||||||
|
expectedCountry: "Internal Network",
|
||||||
|
expectedCity: "LAN",
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IPv6 not in local range",
|
||||||
|
localRanges: "2001:0db8:abcd:000::/56",
|
||||||
|
testIP: "2001:0db8:ffff:000::1",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Multiple ranges - second range match",
|
||||||
|
localRanges: "2001:0db8:abcd:000::/56,2001:0db8:abcd:001::/56",
|
||||||
|
testIP: "2001:0db8:abcd:001::1",
|
||||||
|
expectedCountry: "Internal Network",
|
||||||
|
expectedCity: "LAN",
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Empty local ranges",
|
||||||
|
localRanges: "",
|
||||||
|
testIP: "2001:0db8:abcd:000::1",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IPv4 private address still works",
|
||||||
|
localRanges: "2001:0db8:abcd:000::/56",
|
||||||
|
testIP: "192.168.1.1",
|
||||||
|
expectedCountry: "Internal Network",
|
||||||
|
expectedCity: "LAN",
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IPv6 loopback",
|
||||||
|
localRanges: "2001:0db8:abcd:000::/56",
|
||||||
|
testIP: "::1",
|
||||||
|
expectedCountry: "Internal Network",
|
||||||
|
expectedCity: "localhost",
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
originalConfig := common.EnvConfig.LocalIPv6Ranges
|
||||||
|
common.EnvConfig.LocalIPv6Ranges = tt.localRanges
|
||||||
|
defer func() {
|
||||||
|
common.EnvConfig.LocalIPv6Ranges = originalConfig
|
||||||
|
}()
|
||||||
|
|
||||||
|
service := NewGeoLiteService(&http.Client{})
|
||||||
|
|
||||||
|
country, city, err := service.GetLocationByIP(tt.testIP)
|
||||||
|
|
||||||
|
if tt.expectError {
|
||||||
|
if err == nil && country != "Internal Network" {
|
||||||
|
t.Errorf("Expected error or internal network classification for external IP")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tt.expectedCountry, country)
|
||||||
|
assert.Equal(t, tt.expectedCity, city)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGeoLiteService_isLocalIPv6(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
localRanges string
|
||||||
|
testIP string
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Valid IPv6 in range",
|
||||||
|
localRanges: "2001:0db8:abcd:000::/56",
|
||||||
|
testIP: "2001:0db8:abcd:000::1",
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Valid IPv6 not in range",
|
||||||
|
localRanges: "2001:0db8:abcd:000::/56",
|
||||||
|
testIP: "2001:0db8:ffff:000::1",
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IPv4 address should return false",
|
||||||
|
localRanges: "2001:0db8:abcd:000::/56",
|
||||||
|
testIP: "192.168.1.1",
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "No ranges configured",
|
||||||
|
localRanges: "",
|
||||||
|
testIP: "2001:0db8:abcd:000::1",
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Edge of range",
|
||||||
|
localRanges: "2001:0db8:abcd:000::/56",
|
||||||
|
testIP: "2001:0db8:abcd:00ff:ffff:ffff:ffff:ffff",
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
originalConfig := common.EnvConfig.LocalIPv6Ranges
|
||||||
|
common.EnvConfig.LocalIPv6Ranges = tt.localRanges
|
||||||
|
defer func() {
|
||||||
|
common.EnvConfig.LocalIPv6Ranges = originalConfig
|
||||||
|
}()
|
||||||
|
|
||||||
|
service := NewGeoLiteService(&http.Client{})
|
||||||
|
ip := net.ParseIP(tt.testIP)
|
||||||
|
if ip == nil {
|
||||||
|
t.Fatalf("Invalid test IP: %s", tt.testIP)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := service.isLocalIPv6(ip)
|
||||||
|
assert.Equal(t, tt.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGeoLiteService_initializeIPv6LocalRanges(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
envValue string
|
||||||
|
expectError bool
|
||||||
|
expectCount int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Valid IPv6 ranges",
|
||||||
|
envValue: "2001:0db8:abcd:000::/56,2001:0db8:abcd:001::/56",
|
||||||
|
expectError: false,
|
||||||
|
expectCount: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Empty environment variable",
|
||||||
|
envValue: "",
|
||||||
|
expectError: false,
|
||||||
|
expectCount: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Invalid CIDR notation",
|
||||||
|
envValue: "2001:0db8:abcd:000::/999",
|
||||||
|
expectError: true,
|
||||||
|
expectCount: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IPv4 range in IPv6 env var",
|
||||||
|
envValue: "192.168.1.0/24",
|
||||||
|
expectError: true,
|
||||||
|
expectCount: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Mixed valid and invalid ranges",
|
||||||
|
envValue: "2001:0db8:abcd:000::/56,invalid-range",
|
||||||
|
expectError: true,
|
||||||
|
expectCount: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Whitespace handling",
|
||||||
|
envValue: " 2001:0db8:abcd:000::/56 , 2001:0db8:abcd:001::/56 ",
|
||||||
|
expectError: false,
|
||||||
|
expectCount: 2,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
originalConfig := common.EnvConfig.LocalIPv6Ranges
|
||||||
|
common.EnvConfig.LocalIPv6Ranges = tt.envValue
|
||||||
|
defer func() {
|
||||||
|
common.EnvConfig.LocalIPv6Ranges = originalConfig
|
||||||
|
}()
|
||||||
|
|
||||||
|
service := &GeoLiteService{
|
||||||
|
httpClient: &http.Client{},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := service.initializeIPv6LocalRanges()
|
||||||
|
|
||||||
|
if tt.expectError {
|
||||||
|
require.Error(t, err)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Len(t, service.localIPv6Ranges, tt.expectCount)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,23 +2,20 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
|
||||||
"crypto/rsa"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/lestrrat-go/jwx/v3/jwa"
|
"github.com/lestrrat-go/jwx/v3/jwa"
|
||||||
"github.com/lestrrat-go/jwx/v3/jwk"
|
"github.com/lestrrat-go/jwx/v3/jwk"
|
||||||
"github.com/lestrrat-go/jwx/v3/jwt"
|
"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/common"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
"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 (
|
const (
|
||||||
@@ -26,8 +23,9 @@ const (
|
|||||||
// This is a JSON file containing a key encoded as JWK
|
// This is a JSON file containing a key encoded as JWK
|
||||||
PrivateKeyFile = "jwt_private_key.json"
|
PrivateKeyFile = "jwt_private_key.json"
|
||||||
|
|
||||||
// RsaKeySize is the size, in bits, of the RSA key to generate if none is found
|
// PrivateKeyFileEncrypted is the path in the data/keys folder where the encrypted key is stored
|
||||||
RsaKeySize = 2048
|
// 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 is the usage for the private keys, for the "use" property
|
||||||
KeyUsageSigning = "sig"
|
KeyUsageSigning = "sig"
|
||||||
@@ -59,58 +57,74 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type JwtService struct {
|
type JwtService struct {
|
||||||
|
envConfig *common.EnvConfigSchema
|
||||||
privateKey jwk.Key
|
privateKey jwk.Key
|
||||||
keyId string
|
keyId string
|
||||||
appConfigService *AppConfigService
|
appConfigService *AppConfigService
|
||||||
jwksEncoded []byte
|
jwksEncoded []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewJwtService(appConfigService *AppConfigService) *JwtService {
|
func NewJwtService(db *gorm.DB, appConfigService *AppConfigService) *JwtService {
|
||||||
service := &JwtService{}
|
service := &JwtService{}
|
||||||
|
|
||||||
// Ensure keys are generated or loaded
|
// 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)
|
log.Fatalf("Failed to initialize jwt service: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return service
|
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.appConfigService = appConfigService
|
||||||
|
s.envConfig = envConfig
|
||||||
|
|
||||||
// Ensure keys are generated or loaded
|
// 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(db *gorm.DB) error {
|
||||||
func (s *JwtService) loadOrGenerateKey(keysPath string) error {
|
// Get the key provider
|
||||||
var key jwk.Key
|
keyProvider, err := jwkutils.GetKeyProvider(db, s.envConfig, s.appConfigService.GetDbConfig().InstanceID.Value)
|
||||||
|
|
||||||
// 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)
|
|
||||||
if err != nil {
|
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)
|
err = s.SetKey(key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to set private key: %w", err)
|
return fmt.Errorf("failed to set private key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we are here, we need to generate a new key
|
// 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 {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to generate new private key: %w", err)
|
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)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,13 +200,13 @@ func (s *JwtService) GenerateAccessToken(user model.User) (string, error) {
|
|||||||
Subject(user.ID).
|
Subject(user.ID).
|
||||||
Expiration(now.Add(s.appConfigService.GetDbConfig().SessionDuration.AsDurationMinutes())).
|
Expiration(now.Add(s.appConfigService.GetDbConfig().SessionDuration.AsDurationMinutes())).
|
||||||
IssuedAt(now).
|
IssuedAt(now).
|
||||||
Issuer(common.EnvConfig.AppURL).
|
Issuer(s.envConfig.AppURL).
|
||||||
Build()
|
Build()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to build token: %w", err)
|
return "", fmt.Errorf("failed to build token: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = SetAudienceString(token, common.EnvConfig.AppURL)
|
err = SetAudienceString(token, s.envConfig.AppURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to set 'aud' claim in token: %w", err)
|
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.WithValidate(true),
|
||||||
jwt.WithKey(alg, s.privateKey),
|
jwt.WithKey(alg, s.privateKey),
|
||||||
jwt.WithAcceptableSkew(clockSkew),
|
jwt.WithAcceptableSkew(clockSkew),
|
||||||
jwt.WithAudience(common.EnvConfig.AppURL),
|
jwt.WithAudience(s.envConfig.AppURL),
|
||||||
jwt.WithIssuer(common.EnvConfig.AppURL),
|
jwt.WithIssuer(s.envConfig.AppURL),
|
||||||
jwt.WithValidator(TokenTypeValidator(AccessTokenJWTType)),
|
jwt.WithValidator(TokenTypeValidator(AccessTokenJWTType)),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -246,7 +254,7 @@ func (s *JwtService) BuildIDToken(userClaims map[string]any, clientID string, no
|
|||||||
token, err := jwt.NewBuilder().
|
token, err := jwt.NewBuilder().
|
||||||
Expiration(now.Add(1 * time.Hour)).
|
Expiration(now.Add(1 * time.Hour)).
|
||||||
IssuedAt(now).
|
IssuedAt(now).
|
||||||
Issuer(common.EnvConfig.AppURL).
|
Issuer(s.envConfig.AppURL).
|
||||||
Build()
|
Build()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to build token: %w", err)
|
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.WithValidate(true),
|
||||||
jwt.WithKey(alg, s.privateKey),
|
jwt.WithKey(alg, s.privateKey),
|
||||||
jwt.WithAcceptableSkew(clockSkew),
|
jwt.WithAcceptableSkew(clockSkew),
|
||||||
jwt.WithIssuer(common.EnvConfig.AppURL),
|
jwt.WithIssuer(s.envConfig.AppURL),
|
||||||
jwt.WithValidator(TokenTypeValidator(IDTokenJWTType)),
|
jwt.WithValidator(TokenTypeValidator(IDTokenJWTType)),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -335,7 +343,7 @@ func (s *JwtService) BuildOAuthAccessToken(user model.User, clientID string) (jw
|
|||||||
Subject(user.ID).
|
Subject(user.ID).
|
||||||
Expiration(now.Add(1 * time.Hour)).
|
Expiration(now.Add(1 * time.Hour)).
|
||||||
IssuedAt(now).
|
IssuedAt(now).
|
||||||
Issuer(common.EnvConfig.AppURL).
|
Issuer(s.envConfig.AppURL).
|
||||||
Build()
|
Build()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to build token: %w", err)
|
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.WithValidate(true),
|
||||||
jwt.WithKey(alg, s.privateKey),
|
jwt.WithKey(alg, s.privateKey),
|
||||||
jwt.WithAcceptableSkew(clockSkew),
|
jwt.WithAcceptableSkew(clockSkew),
|
||||||
jwt.WithIssuer(common.EnvConfig.AppURL),
|
jwt.WithIssuer(s.envConfig.AppURL),
|
||||||
jwt.WithValidator(TokenTypeValidator(OAuthAccessTokenJWTType)),
|
jwt.WithValidator(TokenTypeValidator(OAuthAccessTokenJWTType)),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -393,7 +401,7 @@ func (s *JwtService) GenerateOAuthRefreshToken(userID string, clientID string, r
|
|||||||
Subject(userID).
|
Subject(userID).
|
||||||
Expiration(now.Add(RefreshTokenDuration)).
|
Expiration(now.Add(RefreshTokenDuration)).
|
||||||
IssuedAt(now).
|
IssuedAt(now).
|
||||||
Issuer(common.EnvConfig.AppURL).
|
Issuer(s.envConfig.AppURL).
|
||||||
Build()
|
Build()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to build token: %w", err)
|
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.WithValidate(true),
|
||||||
jwt.WithKey(alg, s.privateKey),
|
jwt.WithKey(alg, s.privateKey),
|
||||||
jwt.WithAcceptableSkew(clockSkew),
|
jwt.WithAcceptableSkew(clockSkew),
|
||||||
jwt.WithIssuer(common.EnvConfig.AppURL),
|
jwt.WithIssuer(s.envConfig.AppURL),
|
||||||
jwt.WithValidator(TokenTypeValidator(OAuthRefreshTokenJWTType)),
|
jwt.WithValidator(TokenTypeValidator(OAuthRefreshTokenJWTType)),
|
||||||
)
|
)
|
||||||
if err != nil {
|
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)
|
return nil, fmt.Errorf("failed to get public key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
utils.EnsureAlgInKey(pubKey)
|
jwkutils.EnsureAlgInKey(pubKey, "", "")
|
||||||
|
|
||||||
return pubKey, nil
|
return pubKey, nil
|
||||||
}
|
}
|
||||||
@@ -517,56 +525,6 @@ func (s *JwtService) GetKeyAlg() (jwa.KeyAlgorithm, error) {
|
|||||||
return alg, nil
|
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
|
// GetIsAdmin returns the value of the "isAdmin" claim in the token
|
||||||
func GetIsAdmin(token jwt.Token) (bool, error) {
|
func GetIsAdmin(token jwt.Token) (bool, error) {
|
||||||
if !token.Has(IsAdminClaim) {
|
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/common"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
"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) {
|
func TestJwtService_Init(t *testing.T) {
|
||||||
@@ -33,9 +33,16 @@ func TestJwtService_Init(t *testing.T) {
|
|||||||
// Create a temporary directory for the test
|
// Create a temporary directory for the test
|
||||||
tempDir := t.TempDir()
|
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
|
// Initialize the JWT service
|
||||||
service := &JwtService{}
|
service := &JwtService{}
|
||||||
err := service.init(mockConfig, tempDir)
|
err := service.init(nil, mockConfig, mockEnvConfig)
|
||||||
require.NoError(t, err, "Failed to initialize JWT service")
|
require.NoError(t, err, "Failed to initialize JWT service")
|
||||||
|
|
||||||
// Verify the private key was set
|
// Verify the private key was set
|
||||||
@@ -66,9 +73,16 @@ func TestJwtService_Init(t *testing.T) {
|
|||||||
// Create a temporary directory for the test
|
// Create a temporary directory for the test
|
||||||
tempDir := t.TempDir()
|
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
|
// First create a service to generate a key
|
||||||
firstService := &JwtService{}
|
firstService := &JwtService{}
|
||||||
err := firstService.init(mockConfig, tempDir)
|
err := firstService.init(nil, mockConfig, mockEnvConfig)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Get the key ID of the first service
|
// 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
|
// Now create a new service that should load the existing key
|
||||||
secondService := &JwtService{}
|
secondService := &JwtService{}
|
||||||
err = secondService.init(mockConfig, tempDir)
|
err = secondService.init(nil, mockConfig, mockEnvConfig)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Verify the loaded key has the same ID as the original
|
// 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
|
// Create a temporary directory for the test
|
||||||
tempDir := t.TempDir()
|
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
|
// Create a new JWK and save it to disk
|
||||||
origKeyID := createECDSAKeyJWK(t, tempDir)
|
origKeyID := createECDSAKeyJWK(t, tempDir)
|
||||||
|
|
||||||
// Now create a new service that should load the existing key
|
// Now create a new service that should load the existing key
|
||||||
svc := &JwtService{}
|
svc := &JwtService{}
|
||||||
err := svc.init(mockConfig, tempDir)
|
err := svc.init(nil, mockConfig, mockEnvConfig)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Ensure loaded key has the right algorithm
|
// Ensure loaded key has the right algorithm
|
||||||
@@ -113,12 +134,19 @@ func TestJwtService_Init(t *testing.T) {
|
|||||||
// Create a temporary directory for the test
|
// Create a temporary directory for the test
|
||||||
tempDir := t.TempDir()
|
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
|
// Create a new JWK and save it to disk
|
||||||
origKeyID := createEdDSAKeyJWK(t, tempDir)
|
origKeyID := createEdDSAKeyJWK(t, tempDir)
|
||||||
|
|
||||||
// Now create a new service that should load the existing key
|
// Now create a new service that should load the existing key
|
||||||
svc := &JwtService{}
|
svc := &JwtService{}
|
||||||
err := svc.init(mockConfig, tempDir)
|
err := svc.init(nil, mockConfig, mockEnvConfig)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Ensure loaded key has the right algorithm and curve
|
// 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
|
// Create a temporary directory for the test
|
||||||
tempDir := t.TempDir()
|
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
|
// Create a JWT service with initialized key
|
||||||
service := &JwtService{}
|
service := &JwtService{}
|
||||||
err := service.init(mockConfig, tempDir)
|
err := service.init(nil, mockConfig, mockEnvConfig)
|
||||||
require.NoError(t, err, "Failed to initialize JWT service")
|
require.NoError(t, err, "Failed to initialize JWT service")
|
||||||
|
|
||||||
// Get the JWK (public key)
|
// Get the JWK (public key)
|
||||||
@@ -178,12 +213,19 @@ func TestJwtService_GetPublicJWK(t *testing.T) {
|
|||||||
// Create a temporary directory for the test
|
// Create a temporary directory for the test
|
||||||
tempDir := t.TempDir()
|
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
|
// Create an ECDSA key and save it as JWK
|
||||||
originalKeyID := createECDSAKeyJWK(t, tempDir)
|
originalKeyID := createECDSAKeyJWK(t, tempDir)
|
||||||
|
|
||||||
// Create a JWT service that loads the ECDSA key
|
// Create a JWT service that loads the ECDSA key
|
||||||
service := &JwtService{}
|
service := &JwtService{}
|
||||||
err := service.init(mockConfig, tempDir)
|
err := service.init(nil, mockConfig, mockEnvConfig)
|
||||||
require.NoError(t, err, "Failed to initialize JWT service")
|
require.NoError(t, err, "Failed to initialize JWT service")
|
||||||
|
|
||||||
// Get the JWK (public key)
|
// Get the JWK (public key)
|
||||||
@@ -216,12 +258,19 @@ func TestJwtService_GetPublicJWK(t *testing.T) {
|
|||||||
// Create a temporary directory for the test
|
// Create a temporary directory for the test
|
||||||
tempDir := t.TempDir()
|
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
|
// Create an EdDSA key and save it as JWK
|
||||||
originalKeyID := createEdDSAKeyJWK(t, tempDir)
|
originalKeyID := createEdDSAKeyJWK(t, tempDir)
|
||||||
|
|
||||||
// Create a JWT service that loads the EdDSA key
|
// Create a JWT service that loads the EdDSA key
|
||||||
service := &JwtService{}
|
service := &JwtService{}
|
||||||
err := service.init(mockConfig, tempDir)
|
err := service.init(nil, mockConfig, mockEnvConfig)
|
||||||
require.NoError(t, err, "Failed to initialize JWT service")
|
require.NoError(t, err, "Failed to initialize JWT service")
|
||||||
|
|
||||||
// Get the JWK (public key)
|
// Get the JWK (public key)
|
||||||
@@ -276,16 +325,16 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Setup the environment variable required by the token verification
|
// Setup the environment variable required by the token verification
|
||||||
originalAppURL := common.EnvConfig.AppURL
|
mockEnvConfig := &common.EnvConfigSchema{
|
||||||
common.EnvConfig.AppURL = "https://test.example.com"
|
AppURL: "https://test.example.com",
|
||||||
defer func() {
|
KeysStorage: "file",
|
||||||
common.EnvConfig.AppURL = originalAppURL
|
KeysPath: tempDir,
|
||||||
}()
|
}
|
||||||
|
|
||||||
t.Run("generates token for regular user", func(t *testing.T) {
|
t.Run("generates token for regular user", func(t *testing.T) {
|
||||||
// Create a JWT service
|
// Create a JWT service
|
||||||
service := &JwtService{}
|
service := &JwtService{}
|
||||||
err := service.init(mockConfig, tempDir)
|
err := service.init(nil, mockConfig, mockEnvConfig)
|
||||||
require.NoError(t, err, "Failed to initialize JWT service")
|
require.NoError(t, err, "Failed to initialize JWT service")
|
||||||
|
|
||||||
// Create a test user
|
// Create a test user
|
||||||
@@ -328,7 +377,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
|
|||||||
t.Run("generates token for admin user", func(t *testing.T) {
|
t.Run("generates token for admin user", func(t *testing.T) {
|
||||||
// Create a JWT service
|
// Create a JWT service
|
||||||
service := &JwtService{}
|
service := &JwtService{}
|
||||||
err := service.init(mockConfig, tempDir)
|
err := service.init(nil, mockConfig, mockEnvConfig)
|
||||||
require.NoError(t, err, "Failed to initialize JWT service")
|
require.NoError(t, err, "Failed to initialize JWT service")
|
||||||
|
|
||||||
// Create a test admin user
|
// Create a test admin user
|
||||||
@@ -364,7 +413,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
service := &JwtService{}
|
service := &JwtService{}
|
||||||
err := service.init(customMockConfig, tempDir)
|
err := service.init(nil, customMockConfig, mockEnvConfig)
|
||||||
require.NoError(t, err, "Failed to initialize JWT service")
|
require.NoError(t, err, "Failed to initialize JWT service")
|
||||||
|
|
||||||
// Create a test user
|
// Create a test user
|
||||||
@@ -399,7 +448,10 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
|
|||||||
|
|
||||||
// Create a JWT service that loads the key
|
// Create a JWT service that loads the key
|
||||||
service := &JwtService{}
|
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")
|
require.NoError(t, err, "Failed to initialize JWT service")
|
||||||
|
|
||||||
// Verify it loaded the right key
|
// Verify it loaded the right key
|
||||||
@@ -453,7 +505,10 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
|
|||||||
|
|
||||||
// Create a JWT service that loads the key
|
// Create a JWT service that loads the key
|
||||||
service := &JwtService{}
|
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")
|
require.NoError(t, err, "Failed to initialize JWT service")
|
||||||
|
|
||||||
// Verify it loaded the right key
|
// Verify it loaded the right key
|
||||||
@@ -507,7 +562,10 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
|
|||||||
|
|
||||||
// Create a JWT service that loads the key
|
// Create a JWT service that loads the key
|
||||||
service := &JwtService{}
|
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")
|
require.NoError(t, err, "Failed to initialize JWT service")
|
||||||
|
|
||||||
// Verify it loaded the right key
|
// Verify it loaded the right key
|
||||||
@@ -563,16 +621,16 @@ func TestGenerateVerifyIdToken(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Setup the environment variable required by the token verification
|
// Setup the environment variable required by the token verification
|
||||||
originalAppURL := common.EnvConfig.AppURL
|
mockEnvConfig := &common.EnvConfigSchema{
|
||||||
common.EnvConfig.AppURL = "https://test.example.com"
|
AppURL: "https://test.example.com",
|
||||||
defer func() {
|
KeysStorage: "file",
|
||||||
common.EnvConfig.AppURL = originalAppURL
|
KeysPath: tempDir,
|
||||||
}()
|
}
|
||||||
|
|
||||||
t.Run("generates and verifies ID token with standard claims", func(t *testing.T) {
|
t.Run("generates and verifies ID token with standard claims", func(t *testing.T) {
|
||||||
// Create a JWT service
|
// Create a JWT service
|
||||||
service := &JwtService{}
|
service := &JwtService{}
|
||||||
err := service.init(mockConfig, tempDir)
|
err := service.init(nil, mockConfig, mockEnvConfig)
|
||||||
require.NoError(t, err, "Failed to initialize JWT service")
|
require.NoError(t, err, "Failed to initialize JWT service")
|
||||||
|
|
||||||
// Create test claims
|
// Create test claims
|
||||||
@@ -601,7 +659,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
|
|||||||
assert.Equal(t, []string{clientID}, audience, "Audience should contain the client ID")
|
assert.Equal(t, []string{clientID}, audience, "Audience should contain the client ID")
|
||||||
issuer, ok := claims.Issuer()
|
issuer, ok := claims.Issuer()
|
||||||
_ = assert.True(t, ok, "Issuer not found in token") &&
|
_ = 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
|
// Check token expiration time is approximately 1 hour from now
|
||||||
expectedExp := time.Now().Add(1 * time.Hour)
|
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) {
|
t.Run("can accept expired tokens if told so", func(t *testing.T) {
|
||||||
// Create a JWT service
|
// Create a JWT service
|
||||||
service := &JwtService{}
|
service := &JwtService{}
|
||||||
err := service.init(mockConfig, tempDir)
|
err := service.init(nil, mockConfig, mockEnvConfig)
|
||||||
require.NoError(t, err, "Failed to initialize JWT service")
|
require.NoError(t, err, "Failed to initialize JWT service")
|
||||||
|
|
||||||
// Create test claims
|
// Create test claims
|
||||||
@@ -628,7 +686,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
|
|||||||
// Create a token that's already expired
|
// Create a token that's already expired
|
||||||
token, err := jwt.NewBuilder().
|
token, err := jwt.NewBuilder().
|
||||||
Subject(userClaims["sub"].(string)).
|
Subject(userClaims["sub"].(string)).
|
||||||
Issuer(common.EnvConfig.AppURL).
|
Issuer(service.envConfig.AppURL).
|
||||||
Audience([]string{clientID}).
|
Audience([]string{clientID}).
|
||||||
IssuedAt(time.Now().Add(-2 * time.Hour)).
|
IssuedAt(time.Now().Add(-2 * time.Hour)).
|
||||||
Expiration(time.Now().Add(-1 * time.Hour)). // Expired 1 hour ago
|
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")
|
assert.Equal(t, userClaims["sub"], subject, "Token subject should match user ID")
|
||||||
issuer, ok := claims.Issuer()
|
issuer, ok := claims.Issuer()
|
||||||
_ = assert.True(t, ok, "Issuer not found in token") &&
|
_ = 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) {
|
t.Run("generates and verifies ID token with nonce", func(t *testing.T) {
|
||||||
// Create a JWT service
|
// Create a JWT service
|
||||||
service := &JwtService{}
|
service := &JwtService{}
|
||||||
err := service.init(mockConfig, tempDir)
|
err := service.init(nil, mockConfig, mockEnvConfig)
|
||||||
require.NoError(t, err, "Failed to initialize JWT service")
|
require.NoError(t, err, "Failed to initialize JWT service")
|
||||||
|
|
||||||
// Create test claims with nonce
|
// 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) {
|
t.Run("fails verification with incorrect issuer", func(t *testing.T) {
|
||||||
// Create a JWT service
|
// Create a JWT service
|
||||||
service := &JwtService{}
|
service := &JwtService{}
|
||||||
err := service.init(mockConfig, tempDir)
|
err := service.init(nil, mockConfig, mockEnvConfig)
|
||||||
require.NoError(t, err, "Failed to initialize JWT service")
|
require.NoError(t, err, "Failed to initialize JWT service")
|
||||||
|
|
||||||
// Generate a token with standard claims
|
// Generate a token with standard claims
|
||||||
@@ -714,7 +772,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
|
|||||||
require.NoError(t, err, "Failed to generate ID token")
|
require.NoError(t, err, "Failed to generate ID token")
|
||||||
|
|
||||||
// Temporarily change the app URL to simulate wrong issuer
|
// 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
|
// Verify should fail due to issuer mismatch
|
||||||
_, err = service.VerifyIdToken(tokenString, false)
|
_, err = service.VerifyIdToken(tokenString, false)
|
||||||
@@ -731,7 +789,10 @@ func TestGenerateVerifyIdToken(t *testing.T) {
|
|||||||
|
|
||||||
// Create a JWT service that loads the key
|
// Create a JWT service that loads the key
|
||||||
service := &JwtService{}
|
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")
|
require.NoError(t, err, "Failed to initialize JWT service")
|
||||||
|
|
||||||
// Verify it loaded the right key
|
// 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")
|
assert.Equal(t, "eddsauser456", subject, "Token subject should match user ID")
|
||||||
issuer, ok := claims.Issuer()
|
issuer, ok := claims.Issuer()
|
||||||
_ = assert.True(t, ok, "Issuer not found in token") &&
|
_ = 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
|
// Verify the key type is OKP
|
||||||
publicKey, err := service.GetPublicJWK()
|
publicKey, err := service.GetPublicJWK()
|
||||||
@@ -784,7 +845,10 @@ func TestGenerateVerifyIdToken(t *testing.T) {
|
|||||||
|
|
||||||
// Create a JWT service that loads the key
|
// Create a JWT service that loads the key
|
||||||
service := &JwtService{}
|
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")
|
require.NoError(t, err, "Failed to initialize JWT service")
|
||||||
|
|
||||||
// Verify it loaded the right key
|
// Verify it loaded the right key
|
||||||
@@ -795,7 +859,6 @@ func TestGenerateVerifyIdToken(t *testing.T) {
|
|||||||
// Create test claims
|
// Create test claims
|
||||||
userClaims := map[string]interface{}{
|
userClaims := map[string]interface{}{
|
||||||
"sub": "ecdsauser456",
|
"sub": "ecdsauser456",
|
||||||
"name": "ECDSA User",
|
|
||||||
"email": "ecdsauser@example.com",
|
"email": "ecdsauser@example.com",
|
||||||
}
|
}
|
||||||
const clientID = "ecdsa-client-123"
|
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")
|
assert.Equal(t, "ecdsauser456", subject, "Token subject should match user ID")
|
||||||
issuer, ok := claims.Issuer()
|
issuer, ok := claims.Issuer()
|
||||||
_ = assert.True(t, ok, "Issuer not found in token") &&
|
_ = 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
|
// Verify the key type is EC
|
||||||
publicKey, err := service.GetPublicJWK()
|
publicKey, err := service.GetPublicJWK()
|
||||||
@@ -837,7 +900,10 @@ func TestGenerateVerifyIdToken(t *testing.T) {
|
|||||||
|
|
||||||
// Create a JWT service that loads the key
|
// Create a JWT service that loads the key
|
||||||
service := &JwtService{}
|
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")
|
require.NoError(t, err, "Failed to initialize JWT service")
|
||||||
|
|
||||||
// Verify it loaded the right key
|
// 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")
|
assert.Equal(t, "rsauser456", subject, "Token subject should match user ID")
|
||||||
issuer, ok := claims.Issuer()
|
issuer, ok := claims.Issuer()
|
||||||
_ = assert.True(t, ok, "Issuer not found in token") &&
|
_ = 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 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")
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -892,16 +948,16 @@ func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Setup the environment variable required by the token verification
|
// Setup the environment variable required by the token verification
|
||||||
originalAppURL := common.EnvConfig.AppURL
|
mockEnvConfig := &common.EnvConfigSchema{
|
||||||
common.EnvConfig.AppURL = "https://test.example.com"
|
AppURL: "https://test.example.com",
|
||||||
defer func() {
|
KeysStorage: "file",
|
||||||
common.EnvConfig.AppURL = originalAppURL
|
KeysPath: tempDir,
|
||||||
}()
|
}
|
||||||
|
|
||||||
t.Run("generates and verifies OAuth access token with standard claims", func(t *testing.T) {
|
t.Run("generates and verifies OAuth access token with standard claims", func(t *testing.T) {
|
||||||
// Create a JWT service
|
// Create a JWT service
|
||||||
service := &JwtService{}
|
service := &JwtService{}
|
||||||
err := service.init(mockConfig, tempDir)
|
err := service.init(nil, mockConfig, mockEnvConfig)
|
||||||
require.NoError(t, err, "Failed to initialize JWT service")
|
require.NoError(t, err, "Failed to initialize JWT service")
|
||||||
|
|
||||||
// Create a test user
|
// 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")
|
assert.Equal(t, []string{clientID}, audience, "Audience should contain the client ID")
|
||||||
issuer, ok := claims.Issuer()
|
issuer, ok := claims.Issuer()
|
||||||
_ = assert.True(t, ok, "Issuer not found in token") &&
|
_ = 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
|
// Check token expiration time is approximately 1 hour from now
|
||||||
expectedExp := time.Now().Add(1 * time.Hour)
|
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) {
|
t.Run("fails verification for expired token", func(t *testing.T) {
|
||||||
// Create a JWT service with a mock function to generate an expired token
|
// Create a JWT service with a mock function to generate an expired token
|
||||||
service := &JwtService{}
|
service := &JwtService{}
|
||||||
err := service.init(mockConfig, tempDir)
|
err := service.init(nil, mockConfig, mockEnvConfig)
|
||||||
require.NoError(t, err, "Failed to initialize JWT service")
|
require.NoError(t, err, "Failed to initialize JWT service")
|
||||||
|
|
||||||
// Create a test user
|
// Create a test user
|
||||||
@@ -961,7 +1017,7 @@ func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
|
|||||||
Expiration(time.Now().Add(-1 * time.Hour)). // Expired 1 hour ago
|
Expiration(time.Now().Add(-1 * time.Hour)). // Expired 1 hour ago
|
||||||
IssuedAt(time.Now().Add(-2 * time.Hour)).
|
IssuedAt(time.Now().Add(-2 * time.Hour)).
|
||||||
Audience([]string{clientID}).
|
Audience([]string{clientID}).
|
||||||
Issuer(common.EnvConfig.AppURL).
|
Issuer(service.envConfig.AppURL).
|
||||||
Build()
|
Build()
|
||||||
require.NoError(t, err, "Failed to build token")
|
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) {
|
t.Run("fails verification with invalid signature", func(t *testing.T) {
|
||||||
// Create two JWT services with different keys
|
// Create two JWT services with different keys
|
||||||
service1 := &JwtService{}
|
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")
|
require.NoError(t, err, "Failed to initialize first JWT service")
|
||||||
|
|
||||||
service2 := &JwtService{}
|
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")
|
require.NoError(t, err, "Failed to initialize second JWT service")
|
||||||
|
|
||||||
// Create a test user
|
// Create a test user
|
||||||
@@ -1014,7 +1076,10 @@ func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
|
|||||||
|
|
||||||
// Create a JWT service that loads the key
|
// Create a JWT service that loads the key
|
||||||
service := &JwtService{}
|
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")
|
require.NoError(t, err, "Failed to initialize JWT service")
|
||||||
|
|
||||||
// Verify it loaded the right key
|
// Verify it loaded the right key
|
||||||
@@ -1068,7 +1133,10 @@ func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
|
|||||||
|
|
||||||
// Create a JWT service that loads the key
|
// Create a JWT service that loads the key
|
||||||
service := &JwtService{}
|
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")
|
require.NoError(t, err, "Failed to initialize JWT service")
|
||||||
|
|
||||||
// Verify it loaded the right key
|
// Verify it loaded the right key
|
||||||
@@ -1122,7 +1190,10 @@ func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
|
|||||||
|
|
||||||
// Create a JWT service that loads the key
|
// Create a JWT service that loads the key
|
||||||
service := &JwtService{}
|
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")
|
require.NoError(t, err, "Failed to initialize JWT service")
|
||||||
|
|
||||||
// Verify it loaded the right key
|
// Verify it loaded the right key
|
||||||
@@ -1176,16 +1247,16 @@ func TestGenerateVerifyOAuthRefreshToken(t *testing.T) {
|
|||||||
mockConfig := NewTestAppConfigService(&model.AppConfig{})
|
mockConfig := NewTestAppConfigService(&model.AppConfig{})
|
||||||
|
|
||||||
// Setup the environment variable required by the token verification
|
// Setup the environment variable required by the token verification
|
||||||
originalAppURL := common.EnvConfig.AppURL
|
mockEnvConfig := &common.EnvConfigSchema{
|
||||||
common.EnvConfig.AppURL = "https://test.example.com"
|
AppURL: "https://test.example.com",
|
||||||
defer func() {
|
KeysStorage: "file",
|
||||||
common.EnvConfig.AppURL = originalAppURL
|
KeysPath: tempDir,
|
||||||
}()
|
}
|
||||||
|
|
||||||
t.Run("generates and verifies refresh token", func(t *testing.T) {
|
t.Run("generates and verifies refresh token", func(t *testing.T) {
|
||||||
// Create a JWT service
|
// Create a JWT service
|
||||||
service := &JwtService{}
|
service := &JwtService{}
|
||||||
err := service.init(mockConfig, tempDir)
|
err := service.init(nil, mockConfig, mockEnvConfig)
|
||||||
require.NoError(t, err, "Failed to initialize JWT service")
|
require.NoError(t, err, "Failed to initialize JWT service")
|
||||||
|
|
||||||
// Create a test user
|
// Create a test user
|
||||||
@@ -1211,7 +1282,7 @@ func TestGenerateVerifyOAuthRefreshToken(t *testing.T) {
|
|||||||
t.Run("fails verification for expired token", func(t *testing.T) {
|
t.Run("fails verification for expired token", func(t *testing.T) {
|
||||||
// Create a JWT service
|
// Create a JWT service
|
||||||
service := &JwtService{}
|
service := &JwtService{}
|
||||||
err := service.init(mockConfig, tempDir)
|
err := service.init(nil, mockConfig, mockEnvConfig)
|
||||||
require.NoError(t, err, "Failed to initialize JWT service")
|
require.NoError(t, err, "Failed to initialize JWT service")
|
||||||
|
|
||||||
// Generate a token using JWT directly to create an expired token
|
// 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
|
Expiration(time.Now().Add(-1 * time.Hour)). // Expired 1 hour ago
|
||||||
IssuedAt(time.Now().Add(-2 * time.Hour)).
|
IssuedAt(time.Now().Add(-2 * time.Hour)).
|
||||||
Audience([]string{"client123"}).
|
Audience([]string{"client123"}).
|
||||||
Issuer(common.EnvConfig.AppURL).
|
Issuer(service.envConfig.AppURL).
|
||||||
Build()
|
Build()
|
||||||
require.NoError(t, err, "Failed to build token")
|
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) {
|
t.Run("fails verification with invalid signature", func(t *testing.T) {
|
||||||
// Create two JWT services with different keys
|
// Create two JWT services with different keys
|
||||||
service1 := &JwtService{}
|
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")
|
require.NoError(t, err, "Failed to initialize first JWT service")
|
||||||
|
|
||||||
service2 := &JwtService{}
|
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")
|
require.NoError(t, err, "Failed to initialize second JWT service")
|
||||||
|
|
||||||
// Generate a token with the first service
|
// Generate a token with the first service
|
||||||
@@ -1308,7 +1385,10 @@ func TestGetTokenType(t *testing.T) {
|
|||||||
// Initialize the JWT service
|
// Initialize the JWT service
|
||||||
mockConfig := NewTestAppConfigService(&model.AppConfig{})
|
mockConfig := NewTestAppConfigService(&model.AppConfig{})
|
||||||
service := &JwtService{}
|
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")
|
require.NoError(t, err, "Failed to initialize JWT service")
|
||||||
|
|
||||||
buildTokenForType := func(t *testing.T, typ string, setClaimsFn func(b *jwt.Builder)) string {
|
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 {
|
func importKey(t *testing.T, privateKeyRaw any, path string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
privateKey, err := utils.ImportRawKey(privateKeyRaw)
|
privateKey, err := jwkutils.ImportRawKey(privateKeyRaw, "", "")
|
||||||
require.NoError(t, err, "Failed to import private key")
|
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")
|
require.NoError(t, err, "Failed to save key")
|
||||||
|
|
||||||
kid, _ := privateKey.KeyID()
|
kid, _ := privateKey.KeyID()
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
"github.com/go-ldap/ldap/v3"
|
"github.com/go-ldap/ldap/v3"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
@@ -122,7 +125,7 @@ func (s *LdapService) SyncGroups(ctx context.Context, tx *gorm.DB, client *ldap.
|
|||||||
ldapGroupIDs := make(map[string]struct{}, len(result.Entries))
|
ldapGroupIDs := make(map[string]struct{}, len(result.Entries))
|
||||||
|
|
||||||
for _, value := range 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
|
// Skip groups without a valid LDAP ID
|
||||||
if ldapId == "" {
|
if ldapId == "" {
|
||||||
@@ -194,7 +197,7 @@ func (s *LdapService) SyncGroups(ctx context.Context, tx *gorm.DB, client *ldap.
|
|||||||
syncGroup := dto.UserGroupCreateDto{
|
syncGroup := dto.UserGroupCreateDto{
|
||||||
Name: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.Value),
|
Name: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.Value),
|
||||||
FriendlyName: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.Value),
|
FriendlyName: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.Value),
|
||||||
LdapID: value.GetAttributeValue(dbConfig.LdapAttributeGroupUniqueIdentifier.Value),
|
LdapID: ldapId,
|
||||||
}
|
}
|
||||||
|
|
||||||
if databaseGroup.ID == "" {
|
if databaseGroup.ID == "" {
|
||||||
@@ -286,7 +289,7 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
|
|||||||
ldapUserIDs := make(map[string]struct{}, len(result.Entries))
|
ldapUserIDs := make(map[string]struct{}, len(result.Entries))
|
||||||
|
|
||||||
for _, value := range 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
|
// Skip users without a valid LDAP ID
|
||||||
if ldapId == "" {
|
if ldapId == "" {
|
||||||
@@ -468,3 +471,21 @@ func getDNProperty(property string, str string) string {
|
|||||||
// CN not found, return an empty string
|
// CN not found, return an empty string
|
||||||
return ""
|
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 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()
|
tx.Rollback()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
_, err := s.verifyClientCredentialsInternal(ctx, tx, clientAuthCredentialsFromCreateTokensDto(&input))
|
_, err := s.verifyClientCredentialsInternal(ctx, tx, clientAuthCredentialsFromCreateTokensDto(&input), true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return CreatedTokens{}, err
|
return CreatedTokens{}, err
|
||||||
}
|
}
|
||||||
@@ -336,7 +336,7 @@ func (s *OidcService) createTokenFromAuthorizationCode(ctx context.Context, inpu
|
|||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
client, err := s.verifyClientCredentialsInternal(ctx, tx, clientAuthCredentialsFromCreateTokensDto(&input))
|
client, err := s.verifyClientCredentialsInternal(ctx, tx, clientAuthCredentialsFromCreateTokensDto(&input), true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return CreatedTokens{}, err
|
return CreatedTokens{}, err
|
||||||
}
|
}
|
||||||
@@ -420,7 +420,7 @@ func (s *OidcService) createTokenFromRefreshToken(ctx context.Context, input dto
|
|||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
client, err := s.verifyClientCredentialsInternal(ctx, tx, clientAuthCredentialsFromCreateTokensDto(&input))
|
client, err := s.verifyClientCredentialsInternal(ctx, tx, clientAuthCredentialsFromCreateTokensDto(&input), true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return CreatedTokens{}, err
|
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) {
|
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
|
// Get the type of the token and the client ID
|
||||||
tokenType, token, err := s.jwtService.GetTokenType(tokenString)
|
tokenType, token, err := s.jwtService.GetTokenType(tokenString)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -498,24 +503,16 @@ func (s *OidcService) IntrospectToken(ctx context.Context, creds ClientAuthCrede
|
|||||||
return introspectDto, nil //nolint:nilerr
|
return introspectDto, nil //nolint:nilerr
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we don't have a client ID, get it from the token
|
// Get the audience from the token
|
||||||
// Otherwise, we need to make sure that the client ID passed as credential matches
|
|
||||||
tokenAudiences, _ := token.Audience()
|
tokenAudiences, _ := token.Audience()
|
||||||
if len(tokenAudiences) != 1 || tokenAudiences[0] == "" {
|
if len(tokenAudiences) != 1 || tokenAudiences[0] == "" {
|
||||||
// We just treat the token as invalid
|
|
||||||
introspectDto.Active = false
|
introspectDto.Active = false
|
||||||
return introspectDto, nil
|
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
|
// Audience must match the client ID
|
||||||
client, err := s.verifyClientCredentialsInternal(ctx, s.db, creds)
|
if client.ID != tokenAudiences[0] {
|
||||||
if err != nil {
|
return introspectDto, &common.OidcMissingClientCredentialsError{}
|
||||||
return introspectDto, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Introspect the token
|
// Introspect the token
|
||||||
@@ -820,7 +817,7 @@ func (s *OidcService) GetClientLogo(ctx context.Context, clientID string) (strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *OidcService) UpdateClientLogo(ctx context.Context, clientID string, file *multipart.FileHeader) error {
|
func (s *OidcService) UpdateClientLogo(ctx context.Context, clientID string, file *multipart.FileHeader) error {
|
||||||
fileType := utils.GetFileExtension(file.Filename)
|
fileType := strings.ToLower(utils.GetFileExtension(file.Filename))
|
||||||
if mimeType := utils.GetImageMimeType(fileType); mimeType == "" {
|
if mimeType := utils.GetImageMimeType(fileType); mimeType == "" {
|
||||||
return &common.FileTypeNotSupportedError{}
|
return &common.FileTypeNotSupportedError{}
|
||||||
}
|
}
|
||||||
@@ -1137,7 +1134,7 @@ func (s *OidcService) CreateDeviceAuthorization(ctx context.Context, input dto.O
|
|||||||
ClientSecret: input.ClientSecret,
|
ClientSecret: input.ClientSecret,
|
||||||
ClientAssertionType: input.ClientAssertionType,
|
ClientAssertionType: input.ClientAssertionType,
|
||||||
ClientAssertion: input.ClientAssertion,
|
ClientAssertion: input.ClientAssertion,
|
||||||
})
|
}, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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) {
|
func (s *OidcService) verifyClientCredentialsInternal(ctx context.Context, tx *gorm.DB, input ClientAuthCredentials, allowPublicClientsWithoutAuth bool) (client *model.OidcClient, err error) {
|
||||||
// First, ensure we have a valid client ID
|
isClientAssertion := input.ClientAssertionType == ClientAssertionTypeJWTBearer && input.ClientAssertion != ""
|
||||||
if input.ClientID == "" {
|
|
||||||
|
// 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{}
|
return nil, &common.OidcMissingClientCredentialsError{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load the OIDC client's configuration
|
// Load the OIDC client's configuration
|
||||||
var client model.OidcClient
|
err = tx.
|
||||||
err := tx.
|
|
||||||
WithContext(ctx).
|
WithContext(ctx).
|
||||||
First(&client, "id = ?", input.ClientID).
|
First(&client, "id = ?", clientID).
|
||||||
Error
|
Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) && isClientAssertion {
|
||||||
|
return nil, &common.OidcClientAssertionInvalidError{}
|
||||||
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// We have 3 options
|
// Validate credentials based on the authentication method
|
||||||
// If credentials are provided, we validate them; otherwise, we can continue without credentials for public clients only
|
|
||||||
switch {
|
switch {
|
||||||
// First, if we have a client secret, we validate it
|
// First, if we have a client secret, we validate it
|
||||||
case input.ClientSecret != "":
|
case input.ClientSecret != "":
|
||||||
@@ -1410,21 +1422,21 @@ func (s *OidcService) verifyClientCredentialsInternal(ctx context.Context, tx *g
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, &common.OidcClientSecretInvalidError{}
|
return nil, &common.OidcClientSecretInvalidError{}
|
||||||
}
|
}
|
||||||
return &client, nil
|
return client, nil
|
||||||
|
|
||||||
// Next, check if we want to use client assertions from federated identities
|
// Next, check if we want to use client assertions from federated identities
|
||||||
case input.ClientAssertionType == ClientAssertionTypeJWTBearer && input.ClientAssertion != "":
|
case isClientAssertion:
|
||||||
err = s.verifyClientAssertionFromFederatedIdentities(ctx, &client, input)
|
err = s.verifyClientAssertionFromFederatedIdentities(ctx, client, input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Invalid assertion for client '%s': %v", client.ID, err)
|
log.Printf("Invalid assertion for client '%s': %v", client.ID, err)
|
||||||
return nil, &common.OidcClientAssertionInvalidError{}
|
return nil, &common.OidcClientAssertionInvalidError{}
|
||||||
}
|
}
|
||||||
return &client, nil
|
return client, nil
|
||||||
|
|
||||||
// There's no credentials
|
// There's no credentials
|
||||||
// This is allowed only if the client is public
|
// This is allowed only if the client is public
|
||||||
case client.IsPublic:
|
case client.IsPublic && allowPublicClientsWithoutAuth:
|
||||||
return &client, nil
|
return client, nil
|
||||||
|
|
||||||
// If we're here, we have no credentials AND the client is not public, so credentials are required
|
// If we're here, we have no credentials AND the client is not public, so credentials are required
|
||||||
default:
|
default:
|
||||||
@@ -1523,6 +1535,23 @@ func (s *OidcService) verifyClientAssertionFromFederatedIdentities(ctx context.C
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// extractClientIDFromAssertion extracts the client_id from the JWT assertion's 'sub' claim
|
||||||
|
func (s *OidcService) extractClientIDFromAssertion(assertion string) (string, error) {
|
||||||
|
// Parse the JWT without verification first to get the claims
|
||||||
|
insecureToken, err := jwt.ParseInsecure([]byte(assertion))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to parse JWT assertion: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract the subject claim which must be the client_id according to RFC 7523
|
||||||
|
sub, ok := insecureToken.Subject()
|
||||||
|
if !ok || sub == "" {
|
||||||
|
return "", fmt.Errorf("missing or invalid 'sub' claim in JWT assertion")
|
||||||
|
}
|
||||||
|
|
||||||
|
return sub, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *OidcService) GetClientPreview(ctx context.Context, clientID string, userID string, scopes string) (*dto.OidcClientPreviewDto, error) {
|
func (s *OidcService) GetClientPreview(ctx context.Context, clientID string, userID string, scopes string) (*dto.OidcClientPreviewDto, error) {
|
||||||
tx := s.db.Begin()
|
tx := s.db.Begin()
|
||||||
defer func() {
|
defer func() {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
|
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
"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/dto"
|
||||||
|
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
// generateTestECDSAKey creates an ECDSA key for testing
|
// generateTestECDSAKey creates an ECDSA key for testing
|
||||||
@@ -62,12 +63,12 @@ func TestOidcService_jwkSetForURL(t *testing.T) {
|
|||||||
)
|
)
|
||||||
mockResponses := map[string]*http.Response{
|
mockResponses := map[string]*http.Response{
|
||||||
//nolint:bodyclose
|
//nolint:bodyclose
|
||||||
url1: NewMockResponse(http.StatusOK, string(jwkSetJSON1)),
|
url1: testutils.NewMockResponse(http.StatusOK, string(jwkSetJSON1)),
|
||||||
//nolint:bodyclose
|
//nolint:bodyclose
|
||||||
url2: NewMockResponse(http.StatusOK, string(jwkSetJSON2)),
|
url2: testutils.NewMockResponse(http.StatusOK, string(jwkSetJSON2)),
|
||||||
}
|
}
|
||||||
httpClient := &http.Client{
|
httpClient := &http.Client{
|
||||||
Transport: &MockRoundTripper{
|
Transport: &testutils.MockRoundTripper{
|
||||||
Responses: mockResponses,
|
Responses: mockResponses,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -134,13 +135,12 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
|
|||||||
const (
|
const (
|
||||||
federatedClientIssuer = "https://external-idp.com"
|
federatedClientIssuer = "https://external-idp.com"
|
||||||
federatedClientAudience = "https://pocket-id.com"
|
federatedClientAudience = "https://pocket-id.com"
|
||||||
federatedClientSubject = "123456abcdef"
|
|
||||||
federatedClientIssuerDefaults = "https://external-idp-defaults.com/"
|
federatedClientIssuerDefaults = "https://external-idp-defaults.com/"
|
||||||
)
|
)
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
// Create a test database
|
// Create a test database
|
||||||
db := newDatabaseForTest(t)
|
db := testutils.NewDatabaseForTest(t)
|
||||||
|
|
||||||
// Create two JWKs for testing
|
// Create two JWKs for testing
|
||||||
privateJWK, jwkSetJSON := generateTestECDSAKey(t)
|
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
|
// Create a mock HTTP client with custom transport to return the JWKS
|
||||||
httpClient := &http.Client{
|
httpClient := &http.Client{
|
||||||
Transport: &MockRoundTripper{
|
Transport: &testutils.MockRoundTripper{
|
||||||
Responses: map[string]*http.Response{
|
Responses: map[string]*http.Response{
|
||||||
//nolint:bodyclose
|
//nolint:bodyclose
|
||||||
federatedClientIssuer + "/jwks.json": NewMockResponse(http.StatusOK, string(jwkSetJSON)),
|
federatedClientIssuer + "/jwks.json": testutils.NewMockResponse(http.StatusOK, string(jwkSetJSON)),
|
||||||
//nolint:bodyclose
|
//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{
|
federatedClient, err := s.CreateClient(t.Context(), dto.OidcClientCreateDto{
|
||||||
Name: "Federated Client",
|
Name: "Federated Client",
|
||||||
CallbackURLs: []string{"https://example.com/callback"},
|
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{
|
Credentials: dto.OidcClientCredentialsDto{
|
||||||
FederatedIdentities: []dto.OidcClientFederatedIdentityDto{
|
FederatedIdentities: []dto.OidcClientFederatedIdentityDto{
|
||||||
{
|
{
|
||||||
Issuer: federatedClientIssuer,
|
Issuer: federatedClientIssuer,
|
||||||
Audience: federatedClientAudience,
|
Audience: federatedClientAudience,
|
||||||
Subject: federatedClientSubject,
|
Subject: federatedClient.ID,
|
||||||
JWKS: federatedClientIssuer + "/jwks.json",
|
JWKS: federatedClientIssuer + "/jwks.json",
|
||||||
},
|
},
|
||||||
{Issuer: federatedClientIssuerDefaults},
|
{Issuer: federatedClientIssuerDefaults},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}, "test-user-id")
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Test cases for confidential client (using client secret)
|
// 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{
|
client, err := s.verifyClientCredentialsInternal(t.Context(), s.db, ClientAuthCredentials{
|
||||||
ClientID: confidentialClient.ID,
|
ClientID: confidentialClient.ID,
|
||||||
ClientSecret: confidentialSecret,
|
ClientSecret: confidentialSecret,
|
||||||
})
|
}, true)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, client)
|
require.NotNil(t, client)
|
||||||
assert.Equal(t, confidentialClient.ID, client.ID)
|
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{
|
client, err := s.verifyClientCredentialsInternal(t.Context(), s.db, ClientAuthCredentials{
|
||||||
ClientID: confidentialClient.ID,
|
ClientID: confidentialClient.ID,
|
||||||
ClientSecret: "invalid-secret",
|
ClientSecret: "invalid-secret",
|
||||||
})
|
}, true)
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
require.ErrorIs(t, err, &common.OidcClientSecretInvalidError{})
|
require.ErrorIs(t, err, &common.OidcClientSecretInvalidError{})
|
||||||
assert.Nil(t, client)
|
assert.Nil(t, client)
|
||||||
@@ -234,7 +240,7 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
|
|||||||
// Test with missing client secret
|
// Test with missing client secret
|
||||||
client, err := s.verifyClientCredentialsInternal(t.Context(), s.db, ClientAuthCredentials{
|
client, err := s.verifyClientCredentialsInternal(t.Context(), s.db, ClientAuthCredentials{
|
||||||
ClientID: confidentialClient.ID,
|
ClientID: confidentialClient.ID,
|
||||||
})
|
}, true)
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
require.ErrorIs(t, err, &common.OidcMissingClientCredentialsError{})
|
require.ErrorIs(t, err, &common.OidcMissingClientCredentialsError{})
|
||||||
assert.Nil(t, client)
|
assert.Nil(t, client)
|
||||||
@@ -247,11 +253,21 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
|
|||||||
// Public clients don't require client secret
|
// Public clients don't require client secret
|
||||||
client, err := s.verifyClientCredentialsInternal(t.Context(), s.db, ClientAuthCredentials{
|
client, err := s.verifyClientCredentialsInternal(t.Context(), s.db, ClientAuthCredentials{
|
||||||
ClientID: publicClient.ID,
|
ClientID: publicClient.ID,
|
||||||
})
|
}, true)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, client)
|
require.NotNil(t, client)
|
||||||
assert.Equal(t, publicClient.ID, client.ID)
|
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
|
// Test cases for federated client using JWT assertion
|
||||||
@@ -261,7 +277,7 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
|
|||||||
token, err := jwt.NewBuilder().
|
token, err := jwt.NewBuilder().
|
||||||
Issuer(federatedClientIssuer).
|
Issuer(federatedClientIssuer).
|
||||||
Audience([]string{federatedClientAudience}).
|
Audience([]string{federatedClientAudience}).
|
||||||
Subject(federatedClientSubject).
|
Subject(federatedClient.ID).
|
||||||
IssuedAt(time.Now()).
|
IssuedAt(time.Now()).
|
||||||
Expiration(time.Now().Add(10 * time.Minute)).
|
Expiration(time.Now().Add(10 * time.Minute)).
|
||||||
Build()
|
Build()
|
||||||
@@ -274,7 +290,7 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
|
|||||||
ClientID: federatedClient.ID,
|
ClientID: federatedClient.ID,
|
||||||
ClientAssertionType: ClientAssertionTypeJWTBearer,
|
ClientAssertionType: ClientAssertionTypeJWTBearer,
|
||||||
ClientAssertion: string(signedToken),
|
ClientAssertion: string(signedToken),
|
||||||
})
|
}, true)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, client)
|
require.NotNil(t, client)
|
||||||
assert.Equal(t, federatedClient.ID, client.ID)
|
assert.Equal(t, federatedClient.ID, client.ID)
|
||||||
@@ -286,7 +302,7 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
|
|||||||
ClientID: federatedClient.ID,
|
ClientID: federatedClient.ID,
|
||||||
ClientAssertionType: ClientAssertionTypeJWTBearer,
|
ClientAssertionType: ClientAssertionTypeJWTBearer,
|
||||||
ClientAssertion: "invalid.jwt.token",
|
ClientAssertion: "invalid.jwt.token",
|
||||||
})
|
}, true)
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
require.ErrorIs(t, err, &common.OidcClientAssertionInvalidError{})
|
require.ErrorIs(t, err, &common.OidcClientAssertionInvalidError{})
|
||||||
assert.Nil(t, client)
|
assert.Nil(t, client)
|
||||||
@@ -298,7 +314,7 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
|
|||||||
builder := jwt.NewBuilder().
|
builder := jwt.NewBuilder().
|
||||||
Issuer(federatedClientIssuer).
|
Issuer(federatedClientIssuer).
|
||||||
Audience([]string{federatedClientAudience}).
|
Audience([]string{federatedClientAudience}).
|
||||||
Subject(federatedClientSubject).
|
Subject(federatedClient.ID).
|
||||||
IssuedAt(time.Now()).
|
IssuedAt(time.Now()).
|
||||||
Expiration(time.Now().Add(10 * time.Minute))
|
Expiration(time.Now().Add(10 * time.Minute))
|
||||||
|
|
||||||
@@ -315,7 +331,7 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
|
|||||||
ClientID: federatedClient.ID,
|
ClientID: federatedClient.ID,
|
||||||
ClientAssertionType: ClientAssertionTypeJWTBearer,
|
ClientAssertionType: ClientAssertionTypeJWTBearer,
|
||||||
ClientAssertion: string(signedToken),
|
ClientAssertion: string(signedToken),
|
||||||
})
|
}, true)
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
require.ErrorIs(t, err, &common.OidcClientAssertionInvalidError{})
|
require.ErrorIs(t, err, &common.OidcClientAssertionInvalidError{})
|
||||||
require.Nil(t, client)
|
require.Nil(t, client)
|
||||||
@@ -356,7 +372,7 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
|
|||||||
ClientID: federatedClient.ID,
|
ClientID: federatedClient.ID,
|
||||||
ClientAssertionType: ClientAssertionTypeJWTBearer,
|
ClientAssertionType: ClientAssertionTypeJWTBearer,
|
||||||
ClientAssertion: string(signedToken),
|
ClientAssertion: string(signedToken),
|
||||||
})
|
}, true)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, client)
|
require.NotNil(t, client)
|
||||||
assert.Equal(t, federatedClient.ID, client.ID)
|
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()
|
isLdapUser := user.LdapID != nil && s.appConfigService.GetDbConfig().LdapEnabled.IsTrue()
|
||||||
allowOwnAccountEdit := s.appConfigService.GetDbConfig().AllowOwnAccountEdit.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
|
user.Locale = updatedUser.Locale
|
||||||
} else {
|
} else {
|
||||||
|
// Full update: Allow updating all personal fields
|
||||||
user.FirstName = updatedUser.FirstName
|
user.FirstName = updatedUser.FirstName
|
||||||
user.LastName = updatedUser.LastName
|
user.LastName = updatedUser.LastName
|
||||||
user.Email = updatedUser.Email
|
user.Email = updatedUser.Email
|
||||||
user.Username = updatedUser.Username
|
user.Username = updatedUser.Username
|
||||||
user.Locale = updatedUser.Locale
|
user.Locale = updatedUser.Locale
|
||||||
|
|
||||||
|
// Admin-only fields: Only allow updates when not updating own account
|
||||||
if !updateOwnUser {
|
if !updateOwnUser {
|
||||||
user.IsAdmin = updatedUser.IsAdmin
|
user.IsAdmin = updatedUser.IsAdmin
|
||||||
user.Disabled = updatedUser.Disabled
|
user.Disabled = updatedUser.Disabled
|
||||||
@@ -523,7 +529,7 @@ func (s *UserService) UpdateUserGroups(ctx context.Context, id string, userGroup
|
|||||||
return user, nil
|
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()
|
tx := s.db.Begin()
|
||||||
defer func() {
|
defer func() {
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
@@ -533,26 +539,23 @@ func (s *UserService) SetupInitialAdmin(ctx context.Context) (model.User, string
|
|||||||
if err := tx.WithContext(ctx).Model(&model.User{}).Count(&userCount).Error; err != nil {
|
if err := tx.WithContext(ctx).Model(&model.User{}).Count(&userCount).Error; err != nil {
|
||||||
return model.User{}, "", err
|
return model.User{}, "", err
|
||||||
}
|
}
|
||||||
if userCount > 1 {
|
if userCount != 0 {
|
||||||
return model.User{}, "", &common.SetupAlreadyCompletedError{}
|
return model.User{}, "", &common.SetupAlreadyCompletedError{}
|
||||||
}
|
}
|
||||||
|
|
||||||
user := model.User{
|
userToCreate := dto.UserCreateDto{
|
||||||
FirstName: "Admin",
|
FirstName: signUpData.FirstName,
|
||||||
LastName: "Admin",
|
LastName: signUpData.LastName,
|
||||||
Username: "admin",
|
Username: signUpData.Username,
|
||||||
Email: "admin@admin.com",
|
Email: signUpData.Email,
|
||||||
IsAdmin: true,
|
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
|
return model.User{}, "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(user.Credentials) > 0 {
|
|
||||||
return model.User{}, "", &common.SetupAlreadyCompletedError{}
|
|
||||||
}
|
|
||||||
|
|
||||||
token, err := s.jwtService.GenerateAccessToken(user)
|
token, err := s.jwtService.GenerateAccessToken(user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return model.User{}, "", err
|
return model.User{}, "", err
|
||||||
@@ -630,6 +633,110 @@ func (s *UserService) disableUserInternal(ctx context.Context, userID string, tx
|
|||||||
Error
|
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) {
|
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
|
// If expires at is less than 15 minutes, use a 6-character token instead of 16
|
||||||
tokenLength := 16
|
tokenLength := 16
|
||||||
@@ -650,3 +757,20 @@ func NewOneTimeAccessToken(userID string, expiresAt time.Time) (*model.OneTimeAc
|
|||||||
|
|
||||||
return o, nil
|
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")
|
||||||
|
}
|
||||||
@@ -2,7 +2,11 @@ package utils
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
// BearerAuth returns the value of the bearer token in the Authorization header if present
|
// BearerAuth returns the value of the bearer token in the Authorization header if present
|
||||||
@@ -16,3 +20,14 @@ func BearerAuth(r *http.Request) (string, bool) {
|
|||||||
|
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetCacheControlHeader sets the Cache-Control header for the response.
|
||||||
|
func SetCacheControlHeader(ctx *gin.Context, maxAge, staleWhileRevalidate time.Duration) {
|
||||||
|
_, ok := ctx.GetQuery("skipCache")
|
||||||
|
if !ok {
|
||||||
|
maxAgeSeconds := strconv.Itoa(int(maxAge.Seconds()))
|
||||||
|
staleWhileRevalidateSeconds := strconv.Itoa(int(staleWhileRevalidate.Seconds()))
|
||||||
|
ctx.Header("Cache-Control", "public, max-age="+maxAgeSeconds+", stale-while-revalidate="+staleWhileRevalidateSeconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|||||||
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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
package service
|
// This file is only imported by unit tests
|
||||||
|
|
||||||
|
package testing
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -21,7 +20,10 @@ import (
|
|||||||
"github.com/pocket-id/pocket-id/backend/resources"
|
"github.com/pocket-id/pocket-id/backend/resources"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newDatabaseForTest(t *testing.T) *gorm.DB {
|
// 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()
|
t.Helper()
|
||||||
|
|
||||||
// Get a name for this in-memory database that is specific to the test
|
// Get a name for this in-memory database that is specific to the test
|
||||||
@@ -68,30 +70,3 @@ type testLoggerAdapter struct {
|
|||||||
func (l testLoggerAdapter) Printf(format string, args ...any) {
|
func (l testLoggerAdapter) Printf(format string, args ...any) {
|
||||||
l.t.Logf(format, args...)
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP INDEX idx_audit_logs_country;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
CREATE INDEX idx_audit_logs_country ON audit_logs(country);
|
||||||
@@ -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 @@
|
|||||||
|
DROP INDEX idx_audit_logs_country;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
CREATE INDEX idx_audit_logs_country ON audit_logs(country);
|
||||||
@@ -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
|
||||||
|
);
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
pocket-id:
|
pocket-id:
|
||||||
image: ghcr.io/pocket-id/pocket-id
|
image: ghcr.io/pocket-id/pocket-id:v1
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file: .env
|
env_file: .env
|
||||||
ports:
|
ports:
|
||||||
@@ -9,8 +9,8 @@ services:
|
|||||||
- "./data:/app/data"
|
- "./data:/app/data"
|
||||||
# Optional healthcheck
|
# Optional healthcheck
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: "curl -f http://localhost:1411/healthz"
|
test: [ "CMD", "/app/pocket-id", "healthcheck" ]
|
||||||
interval: 1m30s
|
interval: 1m30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 2
|
retries: 2
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
"my_account": "Můj Účet",
|
"my_account": "Můj Účet",
|
||||||
"logout": "Odhlásit se",
|
"logout": "Odhlásit se",
|
||||||
"confirm": "Potvrdit",
|
"confirm": "Potvrdit",
|
||||||
|
"docs": "Dokumentace",
|
||||||
"key": "Klíč",
|
"key": "Klíč",
|
||||||
"value": "Hodnota",
|
"value": "Hodnota",
|
||||||
"remove_custom_claim": "Odstranit vlastní nárok",
|
"remove_custom_claim": "Odstranit vlastní nárok",
|
||||||
@@ -64,11 +65,9 @@
|
|||||||
"do_you_want_to_sign_out_of_pocketid_with_the_account": "Chcete se s účtem <b>{username}</b> odhlásit z Pocket ID?",
|
"do_you_want_to_sign_out_of_pocketid_with_the_account": "Chcete se s účtem <b>{username}</b> odhlásit z Pocket ID?",
|
||||||
"sign_in_to_appname": "Přihlásit se k {appName}",
|
"sign_in_to_appname": "Přihlásit se k {appName}",
|
||||||
"please_try_to_sign_in_again": "Zkuste se prosím znovu přihlásit.",
|
"please_try_to_sign_in_again": "Zkuste se prosím znovu přihlásit.",
|
||||||
"authenticate_yourself_with_your_passkey_to_access_the_admin_panel": "Autentizujte se pomocí Vašeho přístupového klíče pro přístup k administrátorskému panelu.",
|
"authenticate_with_passkey_to_access_account": "Pro přístup k vašemu účtu použijte přístupový klíč.",
|
||||||
"authenticate": "Autentizovat",
|
"authenticate": "Autentizovat",
|
||||||
"appname_setup": "{appName} konfigurace",
|
|
||||||
"please_try_again": "Prosím, zkuste znovu.",
|
"please_try_again": "Prosím, zkuste znovu.",
|
||||||
"you_are_about_to_sign_in_to_the_initial_admin_account": "Chystáte se přihlásit k počátečnímu účtu správce. Kdokoli s tímto odkazem může přistupovat k účtu, dokud nebude přidán přístupový účet. Prosím nastavte přístupový klíč co nejdříve, abyste zabránili neoprávněnému přístupu.",
|
|
||||||
"continue": "Pokračovat",
|
"continue": "Pokračovat",
|
||||||
"alternative_sign_in": "Alternativní přihlášení",
|
"alternative_sign_in": "Alternativní přihlášení",
|
||||||
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "Pokud nemáte přístup k Vašemu přístupovému klíči, můžete se přihlášit pomocí jedné z následujících metod.",
|
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "Pokud nemáte přístup k Vašemu přístupovému klíči, můžete se přihlášit pomocí jedné z následujících metod.",
|
||||||
@@ -179,7 +178,7 @@
|
|||||||
"email_login_notification": "E-mailovová oznámení o přihlášení",
|
"email_login_notification": "E-mailovová oznámení o přihlášení",
|
||||||
"send_an_email_to_the_user_when_they_log_in_from_a_new_device": "Poslat uživateli e-mail, když se přihlásí z nového zařízení.",
|
"send_an_email_to_the_user_when_they_log_in_from_a_new_device": "Poslat uživateli e-mail, když se přihlásí z nového zařízení.",
|
||||||
"emai_login_code_requested_by_user": "Přihlašovací kód e-mailu vyžádaný uživatelem",
|
"emai_login_code_requested_by_user": "Přihlašovací kód e-mailu vyžádaný uživatelem",
|
||||||
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Umožňuje uživatelům přihlásit se pomocí přihlašovacího kódu, který je odeslán na jejich e-mail. To výrazně snižuje bezpečnost, protože každý, kdo má přístup k e-mailu uživatele, může vstoupit.",
|
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Umožňuje uživatelům přihlásit se pomocí přihlašovacího kódu bze použití přístupového klíče, který je odeslán na jejich e-mail. To výrazně snižuje bezpečnost, protože každý, kdo má přístup k e-mailu uživatele, může vstoupit.",
|
||||||
"email_login_code_from_admin": "Poslat e-mail přihlašovacímu kódu od administrátora",
|
"email_login_code_from_admin": "Poslat e-mail přihlašovacímu kódu od administrátora",
|
||||||
"allows_an_admin_to_send_a_login_code_to_the_user": "Umožňuje administrátorovi odeslat přihlašovací kód uživateli e-mailem.",
|
"allows_an_admin_to_send_a_login_code_to_the_user": "Umožňuje administrátorovi odeslat přihlašovací kód uživateli e-mailem.",
|
||||||
"send_test_email": "Odeslat testovací e-mail",
|
"send_test_email": "Odeslat testovací e-mail",
|
||||||
@@ -313,19 +312,21 @@
|
|||||||
"reset": "Obnovit",
|
"reset": "Obnovit",
|
||||||
"reset_to_default": "Obnovit výchozí",
|
"reset_to_default": "Obnovit výchozí",
|
||||||
"profile_picture_has_been_reset": "Profilový obrázek byl obnoven. Aktualizace může trvat několik minut.",
|
"profile_picture_has_been_reset": "Profilový obrázek byl obnoven. Aktualizace může trvat několik minut.",
|
||||||
"select_the_language_you_want_to_use": "Vyberte jazyk, který chcete použít. Některé jazyky nemusí být plně přeloženy.",
|
"select_the_language_you_want_to_use": "Vyberte jazyk, který chcete použít. Upozorňujeme, že některé texty mohou být automaticky přeloženy a mohou být nepřesné.",
|
||||||
|
"contribute_to_translation": "Pokud narazíte na nějaký problém, můžete přispět k překladu na <link href='https://crowdin.com/project/pocket-id'>Crowdin</link>.",
|
||||||
"personal": "Osobní",
|
"personal": "Osobní",
|
||||||
"global": "Globální",
|
"global": "Globální",
|
||||||
"all_users": "Všichni uživatelé",
|
"all_users": "Všichni uživatelé",
|
||||||
"all_events": "Všechny události",
|
"all_events": "Všechny události",
|
||||||
"all_clients": "Všichni klienti",
|
"all_clients": "Všichni klienti",
|
||||||
|
"all_locations": "Všechna místa",
|
||||||
"global_audit_log": "Globální protokol auditu",
|
"global_audit_log": "Globální protokol auditu",
|
||||||
"see_all_account_activities_from_the_last_3_months": "Zobrazit veškerou aktivitu uživatele za poslední 3 měsíce.",
|
"see_all_account_activities_from_the_last_3_months": "Zobrazit veškerou aktivitu uživatele za poslední 3 měsíce.",
|
||||||
"token_sign_in": "Přihlášení tokenem",
|
"token_sign_in": "Přihlášení tokenem",
|
||||||
"client_authorization": "Autorizace klienta",
|
"client_authorization": "Autorizace klienta",
|
||||||
"new_client_authorization": "Nová autorizace klienta",
|
"new_client_authorization": "Nová autorizace klienta",
|
||||||
"disable_animations": "Zakázat animace",
|
"disable_animations": "Zakázat animace",
|
||||||
"turn_off_ui_animations": "Vypnout všechny animace v celém administrátorském rozhraní.",
|
"turn_off_ui_animations": "Vypnout animace v celém uživatelském rozhraní.",
|
||||||
"user_disabled": "Účet deaktivován",
|
"user_disabled": "Účet deaktivován",
|
||||||
"disabled_users_cannot_log_in_or_use_services": "Zakázaní uživatelé se nemohou přihlásit nebo používat služby.",
|
"disabled_users_cannot_log_in_or_use_services": "Zakázaní uživatelé se nemohou přihlásit nebo používat služby.",
|
||||||
"user_disabled_successfully": "Uživatel byl úspěšně deaktivován.",
|
"user_disabled_successfully": "Uživatel byl úspěšně deaktivován.",
|
||||||
@@ -346,30 +347,77 @@
|
|||||||
"the_device_has_been_authorized": "Zařízení bylo autorizováno.",
|
"the_device_has_been_authorized": "Zařízení bylo autorizováno.",
|
||||||
"enter_code_displayed_in_previous_step": "Zadejte kód, který byl zobrazen v předchozím kroku.",
|
"enter_code_displayed_in_previous_step": "Zadejte kód, který byl zobrazen v předchozím kroku.",
|
||||||
"authorize": "Autorizovat",
|
"authorize": "Autorizovat",
|
||||||
"federated_client_credentials": "Federated Client Credentials",
|
"federated_client_credentials": "Údaje o klientovi ve federaci",
|
||||||
"federated_client_credentials_description": "Using federated client credentials, you can authenticate OIDC clients using JWT tokens issued by third-party authorities.",
|
"federated_client_credentials_description": "Pomocí federovaných přihlašovacích údajů klienta můžete ověřit klienty OIDC pomocí JWT tokenů vydaných třetí stranou.",
|
||||||
"add_federated_client_credential": "Add Federated Client Credential",
|
"add_federated_client_credential": "Přidat údaje federovaného klienta",
|
||||||
"add_another_federated_client_credential": "Add another federated client credential",
|
"add_another_federated_client_credential": "Přidat dalšího federovaného klienta",
|
||||||
"oidc_allowed_group_count": "Počet povolených skupin",
|
"oidc_allowed_group_count": "Počet povolených skupin",
|
||||||
"unrestricted": "Bez omezení",
|
"unrestricted": "Bez omezení",
|
||||||
"show_advanced_options": "Show Advanced Options",
|
"show_advanced_options": "Zobrazit rozšířené možnosti",
|
||||||
"hide_advanced_options": "Hide Advanced Options",
|
"hide_advanced_options": "Skrýt rozšířené rožnosti",
|
||||||
"oidc_data_preview": "OIDC Data Preview",
|
"oidc_data_preview": "Náhled OIDC dat",
|
||||||
"preview_the_oidc_data_that_would_be_sent_for_different_users": "Preview the OIDC data that would be sent for different users",
|
"preview_the_oidc_data_that_would_be_sent_for_different_users": "Náhled údajů OIDC, které by měly být odeslány pro různé uživatele",
|
||||||
"id_token": "ID Token",
|
"id_token": "ID Token",
|
||||||
"access_token": "Access Token",
|
"access_token": "Access Token",
|
||||||
"userinfo": "Userinfo",
|
"userinfo": "Userinfo",
|
||||||
"id_token_payload": "ID Token Payload",
|
"id_token_payload": "ID Token Payload",
|
||||||
"access_token_payload": "Access Token Payload",
|
"access_token_payload": "Access Token Payload",
|
||||||
"userinfo_endpoint_response": "Userinfo Endpoint Response",
|
"userinfo_endpoint_response": "Userinfo Endpoint Response",
|
||||||
"copy": "Copy",
|
"copy": "Kopírovat",
|
||||||
"no_preview_data_available": "No preview data available",
|
"no_preview_data_available": "Nejsou k dispozici žádná náhledová data",
|
||||||
"copy_all": "Copy All",
|
"copy_all": "Kopírovat vše",
|
||||||
"preview": "Preview",
|
"preview": "Náhled",
|
||||||
"preview_for_user": "Preview for {name} ({email})",
|
"preview_for_user": "Náhled pro {name} ({email})",
|
||||||
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Preview the OIDC data that would be sent for this user",
|
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Náhled OIDC dat, která by byla odeslána pro uživatele",
|
||||||
"show": "Show",
|
"show": "Zobrazit",
|
||||||
"select_an_option": "Select an option",
|
"select_an_option": "Vyberte možnost",
|
||||||
"select_user": "Select User",
|
"select_user": "Vyberte uživatele",
|
||||||
"error": "Error"
|
"error": "Chyba",
|
||||||
|
"select_an_accent_color_to_customize_the_appearance_of_pocket_id": "Vyberte barvu zvýraznění k přizpůsobení vzhledu Pocket ID.",
|
||||||
|
"accent_color": "Barva zvýraznění",
|
||||||
|
"custom_accent_color": "Vlastní zvýrazňující barva",
|
||||||
|
"custom_accent_color_description": "Zadejte vlastní barvu pomocí platných CSS barev (např. hex, rgb, hsl).",
|
||||||
|
"color_value": "Hodnota barvy",
|
||||||
|
"apply": "Použít",
|
||||||
|
"signup_token": "Registrační token",
|
||||||
|
"create_a_signup_token_to_allow_new_user_registration": "Vytvořit registrační token pro povolení registrace nového uživatele.",
|
||||||
|
"usage_limit": "Limit využití",
|
||||||
|
"number_of_times_token_can_be_used": "Kolikrát lze použít registrační token.",
|
||||||
|
"expires": "Vyprší",
|
||||||
|
"signup": "Zaregistrovat se",
|
||||||
|
"signup_requires_valid_token": "Pro vytvoření účtu je vyžadován platný registrační token",
|
||||||
|
"validating_signup_token": "Ověřování registračního tokenu",
|
||||||
|
"go_to_login": "Přejít na přihlášení",
|
||||||
|
"signup_to_appname": "Zaregistrujte se do {appName}",
|
||||||
|
"create_your_account_to_get_started": "Vytvořte si svůj účet a začněte.",
|
||||||
|
"initial_account_creation_description": "Vytvořte si prosím svůj účet, abyste mohli začít. Později si budete moci nastavit přístupový klíč.",
|
||||||
|
"setup_your_passkey": "Nastavte svůj přístupový klíč",
|
||||||
|
"create_a_passkey_to_securely_access_your_account": "Vytvořte přístupový klíč pro bezpečný přístup k vašemu účtu. Toto bude váš hlavní způsob přihlášení.",
|
||||||
|
"skip_for_now": "Prozatím přeskočit",
|
||||||
|
"account_created": "Účet vytvořen",
|
||||||
|
"enable_user_signups": "Povolit registraci uživatelů",
|
||||||
|
"enable_user_signups_description": "Určuje, zda by měla být funkce registrace uživatele povolena.",
|
||||||
|
"user_signups_are_disabled": "Registrace uživatelů jsou v současné době zakázány",
|
||||||
|
"create_signup_token": "Vytvořit registrační token",
|
||||||
|
"view_active_signup_tokens": "Zobrazit aktivní registrační tokeny",
|
||||||
|
"manage_signup_tokens": "Spravovat registrační tokeny",
|
||||||
|
"view_and_manage_active_signup_tokens": "Zobrazit a spravovat aktivní registrační tokeny.",
|
||||||
|
"signup_token_deleted_successfully": "Registrační token byl úspěšně odstraněn.",
|
||||||
|
"expired": "Vypršel",
|
||||||
|
"used_up": "Použito",
|
||||||
|
"active": "Aktivní",
|
||||||
|
"usage": "Využití",
|
||||||
|
"created": "Vytvořeno",
|
||||||
|
"token": "Token",
|
||||||
|
"loading": "Načítání",
|
||||||
|
"delete_signup_token": "Odstranit registrační token",
|
||||||
|
"are_you_sure_you_want_to_delete_this_signup_token": "Jste si jisti, že chcete odstranit tento registrační token? Tuto akci nelze vrátit zpět.",
|
||||||
|
"signup_disabled_description": "Registrace uživatelů jsou kompletně zakázány. Nové uživatelské účty mohou vytvářet pouze správci.",
|
||||||
|
"signup_with_token": "Zaregistrovat se s tokenem",
|
||||||
|
"signup_with_token_description": "Uživatelé se mohou zaregistrovat pouze pomocí platného registračního tokenu který byl vytvořen správcem.",
|
||||||
|
"signup_open": "Otevřená registrace",
|
||||||
|
"signup_open_description": "Kdokoli si může vytvořit nový účet bez omezení.",
|
||||||
|
"of": "z",
|
||||||
|
"skip_passkey_setup": "Přeskočit nastavení přístupového klíče",
|
||||||
|
"skip_passkey_setup_description": "Je důrazně doporučeno nastavit přístupový klíč, bez něho se nebudete moci přihlásit, jakmile aktuální relace vyprší."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,373 +3,421 @@
|
|||||||
"my_account": "Min konto",
|
"my_account": "Min konto",
|
||||||
"logout": "Log ud",
|
"logout": "Log ud",
|
||||||
"confirm": "Bekræft",
|
"confirm": "Bekræft",
|
||||||
"key": "Key",
|
"docs": "Dokumentation",
|
||||||
"value": "Value",
|
"key": "Nøgle",
|
||||||
"remove_custom_claim": "Remove custom claim",
|
"value": "Værdi",
|
||||||
"add_custom_claim": "Add custom claim",
|
"remove_custom_claim": "Fjern brugerdefineret claim",
|
||||||
"add_another": "Add another",
|
"add_custom_claim": "Tilføj brugerdefineret claim",
|
||||||
"select_a_date": "Select a date",
|
"add_another": "Tilføj endnu en",
|
||||||
"select_file": "Select File",
|
"select_a_date": "Vælg en dato",
|
||||||
"profile_picture": "Profile Picture",
|
"select_file": "Vælg en fil",
|
||||||
"profile_picture_is_managed_by_ldap_server": "The profile picture is managed by the LDAP server and cannot be changed here.",
|
"profile_picture": "Profilbillede",
|
||||||
"click_profile_picture_to_upload_custom": "Click on the profile picture to upload a custom one from your files.",
|
"profile_picture_is_managed_by_ldap_server": "Profilbilledet administreres af LDAP-serveren og kan ikke ændres her.",
|
||||||
"image_should_be_in_format": "The image should be in PNG or JPEG format.",
|
"click_profile_picture_to_upload_custom": "Klik på profilbilledet for at uploade et brugerdefineret billede fra dine filer.",
|
||||||
"items_per_page": "Items per page",
|
"image_should_be_in_format": "Billedet skal være i PNG eller JPEG-format.",
|
||||||
"no_items_found": "No items found",
|
"items_per_page": "Emner pr. side",
|
||||||
|
"no_items_found": "Ingen emner fundet",
|
||||||
"search": "Søg...",
|
"search": "Søg...",
|
||||||
"expand_card": "Expand card",
|
"expand_card": "Udvid kortet",
|
||||||
"copied": "Kopieret",
|
"copied": "Kopieret",
|
||||||
"click_to_copy": "Click to copy",
|
"click_to_copy": "Klik for at kopiere",
|
||||||
"something_went_wrong": "Something went wrong",
|
"something_went_wrong": "Noget gik galt",
|
||||||
"go_back_to_home": "Go back to home",
|
"go_back_to_home": "Gå tilbage til hjem",
|
||||||
"dont_have_access_to_your_passkey": "Don't have access to your passkey?",
|
"dont_have_access_to_your_passkey": "Har du ikke adgang til din adgangsnøgle?",
|
||||||
"login_background": "Login background",
|
"login_background": "Log ind baggrund",
|
||||||
"logo": "Logo",
|
"logo": "Logo",
|
||||||
"login_code": "Login Code",
|
"login_code": "Loginkode",
|
||||||
"create_a_login_code_to_sign_in_without_a_passkey_once": "Create a login code that the user can use to sign in without a passkey once.",
|
"create_a_login_code_to_sign_in_without_a_passkey_once": "Opret en loginkode, som brugeren kan bruge til at logge ind uden en adgangsnøgle én gang.",
|
||||||
"one_hour": "1 time",
|
"one_hour": "1 time",
|
||||||
"twelve_hours": "12 timer",
|
"twelve_hours": "12 timer",
|
||||||
"one_day": "1 dag",
|
"one_day": "1 dag",
|
||||||
"one_week": "1 uge",
|
"one_week": "1 uge",
|
||||||
"one_month": "1 måned",
|
"one_month": "1 måned",
|
||||||
"expiration": "Expiration",
|
"expiration": "Udløbstid",
|
||||||
"generate_code": "Generer kode",
|
"generate_code": "Generer kode",
|
||||||
"name": "Navn",
|
"name": "Navn",
|
||||||
"browser_unsupported": "Browser unsupported",
|
"browser_unsupported": "Browseren understøttes ikke",
|
||||||
"this_browser_does_not_support_passkeys": "This browser doesn't support passkeys. Please use an alternative sign in method.",
|
"this_browser_does_not_support_passkeys": "Denne browser understøtter ikke adgangsnøgler. Benyt venligst en alternativ login metode.",
|
||||||
"an_unknown_error_occurred": "An unknown error occurred",
|
"an_unknown_error_occurred": "En ukendt fejl opstod",
|
||||||
"authentication_process_was_aborted": "The authentication process was aborted",
|
"authentication_process_was_aborted": "Godkendelsesprocessen blev afbrudt",
|
||||||
"error_occurred_with_authenticator": "An error occurred with the authenticator",
|
"error_occurred_with_authenticator": "Der opstod en fejl med godkendelsesenheden",
|
||||||
"authenticator_does_not_support_discoverable_credentials": "The authenticator does not support discoverable credentials",
|
"authenticator_does_not_support_discoverable_credentials": "Godkenderen understøtter ikke synlige legitimationsoplysninger",
|
||||||
"authenticator_does_not_support_resident_keys": "The authenticator does not support resident keys",
|
"authenticator_does_not_support_resident_keys": "Godkenderen understøtter ikke gemte nøgler",
|
||||||
"passkey_was_previously_registered": "This passkey was previously registered",
|
"passkey_was_previously_registered": "Denne adgangsnøgle er allerede registreret",
|
||||||
"authenticator_does_not_support_any_of_the_requested_algorithms": "The authenticator does not support any of the requested algorithms",
|
"authenticator_does_not_support_any_of_the_requested_algorithms": "Godkenderen understøtter ikke nogen af de algoritmer, der anmodes om",
|
||||||
"authenticator_timed_out": "The authenticator timed out",
|
"authenticator_timed_out": "Godkenderen overskred tidsgrænsen",
|
||||||
"critical_error_occurred_contact_administrator": "A critical error occurred. Please contact your administrator.",
|
"critical_error_occurred_contact_administrator": "En kritisk fejl opstod. Kontakt venligst din administrator.",
|
||||||
"sign_in_to": "Sign in to {name}",
|
"sign_in_to": "Log ind på {name}",
|
||||||
"client_not_found": "Client not found",
|
"client_not_found": "Klient ikke fundet",
|
||||||
"client_wants_to_access_the_following_information": "<b>{client}</b> wants to access the following information:",
|
"client_wants_to_access_the_following_information": "{client} ønsker at få adgang til følgende oplysninger:",
|
||||||
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "Do you want to sign in to <b>{client}</b> with your {appName} account?",
|
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "Vil du logge ind på {client} med din {appName}-konto?",
|
||||||
"email": "Email",
|
"email": "E-mail",
|
||||||
"view_your_email_address": "View your email address",
|
"view_your_email_address": "Se din e-mailadresse",
|
||||||
"profile": "Profile",
|
"profile": "Profil",
|
||||||
"view_your_profile_information": "View your profile information",
|
"view_your_profile_information": "Se dine profiloplysninger",
|
||||||
"groups": "Groups",
|
"groups": "Grupper",
|
||||||
"view_the_groups_you_are_a_member_of": "View the groups you are a member of",
|
"view_the_groups_you_are_a_member_of": "Se de grupper, du er medlem af",
|
||||||
"cancel": "Cancel",
|
"cancel": "Annuller",
|
||||||
"sign_in": "Sign in",
|
"sign_in": "Log ind",
|
||||||
"try_again": "Try again",
|
"try_again": "Prøv igen",
|
||||||
"client_logo": "Client Logo",
|
"client_logo": "Klientlogo",
|
||||||
"sign_out": "Sign out",
|
"sign_out": "Log ud",
|
||||||
"do_you_want_to_sign_out_of_pocketid_with_the_account": "Do you want to sign out of {appName} with the account <b>{username}</b>?",
|
"do_you_want_to_sign_out_of_pocketid_with_the_account": "Vil du logge ud af {appName} med kontoen <b>{username}</b>?",
|
||||||
"sign_in_to_appname": "Sign in to {appName}",
|
"sign_in_to_appname": "Log ind på {appName}",
|
||||||
"please_try_to_sign_in_again": "Please try to sign in again.",
|
"please_try_to_sign_in_again": "Prøv at logge ind igen.",
|
||||||
"authenticate_yourself_with_your_passkey_to_access_the_admin_panel": "Authenticate yourself with your passkey to access the admin panel.",
|
"authenticate_with_passkey_to_access_account": "Bekræft din identitet med din adgangskode for at få adgang til din konto.",
|
||||||
"authenticate": "Authenticate",
|
"authenticate": "Bekræft identitet",
|
||||||
"appname_setup": "{appName} Setup",
|
"please_try_again": "Prøv venligst igen.",
|
||||||
"please_try_again": "Please try again.",
|
"continue": "Fortsæt",
|
||||||
"you_are_about_to_sign_in_to_the_initial_admin_account": "You're about to sign in to the initial admin account. Anyone with this link can access the account until a passkey is added. Please set up a passkey as soon as possible to prevent unauthorized access.",
|
"alternative_sign_in": "Andre loginmetoder",
|
||||||
"continue": "Continue",
|
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "Hvis du ikke har adgang til din adgangsnøgle, kan du logge ind med en af følgende metoder.",
|
||||||
"alternative_sign_in": "Alternative Sign In",
|
"use_your_passkey_instead": "Vil du i stedet bruge din adgangsnøgle?",
|
||||||
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "If you don't have access to your passkey, you can sign in using one of the following methods.",
|
|
||||||
"use_your_passkey_instead": "Use your passkey instead?",
|
|
||||||
"email_login": "E-mail Login",
|
"email_login": "E-mail Login",
|
||||||
"enter_a_login_code_to_sign_in": "Enter a login code to sign in.",
|
"enter_a_login_code_to_sign_in": "Indtast en loginkode for at logge ind.",
|
||||||
"request_a_login_code_via_email": "Request a login code via email.",
|
"request_a_login_code_via_email": "Anmod om en loginkode via e-mail.",
|
||||||
"go_back": "Gå tilbage",
|
"go_back": "Gå tilbage",
|
||||||
"an_email_has_been_sent_to_the_provided_email_if_it_exists_in_the_system": "An email has been sent to the provided email, if it exists in the system.",
|
"an_email_has_been_sent_to_the_provided_email_if_it_exists_in_the_system": "En e-mail er blevet sendt til den angivne e-mailadresse, hvis den findes i systemet.",
|
||||||
"enter_code": "Indtast kode",
|
"enter_code": "Indtast kode",
|
||||||
"enter_your_email_address_to_receive_an_email_with_a_login_code": "Enter your email address to receive an email with a login code.",
|
"enter_your_email_address_to_receive_an_email_with_a_login_code": "Indtast din e-mailadresse for at modtage en login-kode.\n",
|
||||||
"your_email": "Din e-mail",
|
"your_email": "Din e-mail",
|
||||||
"submit": "Submit",
|
"submit": "Indsend",
|
||||||
"enter_the_code_you_received_to_sign_in": "Enter the code you received to sign in.",
|
"enter_the_code_you_received_to_sign_in": "Indtast den kode, du har modtaget, for at logge ind.",
|
||||||
"code": "Code",
|
"code": "Kode",
|
||||||
"invalid_redirect_url": "Invalid redirect URL",
|
"invalid_redirect_url": "Ugyldig redirect-URL",
|
||||||
"audit_log": "Audit Log",
|
"audit_log": "Aktivitetslog",
|
||||||
"users": "Brugere",
|
"users": "Brugere",
|
||||||
"user_groups": "Brugergrupper",
|
"user_groups": "Brugergrupper",
|
||||||
"oidc_clients": "OIDC Clients",
|
"oidc_clients": "OIDC-klienter",
|
||||||
"api_keys": "API Keys",
|
"api_keys": "API-nøgler",
|
||||||
"application_configuration": "Application Configuration",
|
"application_configuration": "Applikationsindstillinger",
|
||||||
"settings": "Indstillinger",
|
"settings": "Indstillinger",
|
||||||
"update_pocket_id": "Opdater Pocket ID",
|
"update_pocket_id": "Opdater Pocket ID",
|
||||||
"powered_by": "Powered by",
|
"powered_by": "Drevet af",
|
||||||
"see_your_account_activities_from_the_last_3_months": "See your account activities from the last 3 months.",
|
"see_your_account_activities_from_the_last_3_months": "Se dine kontoaktiviteter fra de sidste 3 måneder.",
|
||||||
"time": "Tid",
|
"time": "Tid",
|
||||||
"event": "Event",
|
"event": "Hændelse",
|
||||||
"approximate_location": "Approximate Location",
|
"approximate_location": "Omtrentlig placering",
|
||||||
"ip_address": "IP-adresse",
|
"ip_address": "IP-adresse",
|
||||||
"device": "Enhed",
|
"device": "Enhed",
|
||||||
"client": "Klient",
|
"client": "Klient",
|
||||||
"unknown": "Ukendt",
|
"unknown": "Ukendt",
|
||||||
"account_details_updated_successfully": "Account details updated successfully",
|
"account_details_updated_successfully": "Kontodetaljer blev opdateret",
|
||||||
"profile_picture_updated_successfully": "Profile picture updated successfully. It may take a few minutes to update.",
|
"profile_picture_updated_successfully": "Profilbillede opdateret. Det kan tage et par minutter før ændringen vises.",
|
||||||
"account_settings": "Account Settings",
|
"account_settings": "Kontoindstillinger",
|
||||||
"passkey_missing": "Passkey missing",
|
"passkey_missing": "Adgangsnøgle mangler",
|
||||||
"please_provide_a_passkey_to_prevent_losing_access_to_your_account": "Please add a passkey to prevent losing access to your account.",
|
"please_provide_a_passkey_to_prevent_losing_access_to_your_account": "Tilføj en adgangsnøgle for at undgå at miste adgangen til din konto.",
|
||||||
"single_passkey_configured": "Single Passkey Configured",
|
"single_passkey_configured": "Én adgangsnøgle er konfigureret",
|
||||||
"it_is_recommended_to_add_more_than_one_passkey": "It is recommended to add more than one passkey to avoid losing access to your account.",
|
"it_is_recommended_to_add_more_than_one_passkey": "Det anbefales at tilføje mere end én adgangsnøgle for at undgå at miste adgangen til din konto.",
|
||||||
"account_details": "Account Details",
|
"account_details": "Kontooplysninger",
|
||||||
"passkeys": "Passkeys",
|
"passkeys": "Adgangsnøgler",
|
||||||
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Manage your passkeys that you can use to authenticate yourself.",
|
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Administrér dine adgangsnøgler, som du kan bruge til at godkende dig selv.",
|
||||||
"add_passkey": "Add Passkey",
|
"add_passkey": "Tilføj adgangsnøgle",
|
||||||
"create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey": "Create a one-time login code to sign in from a different device without a passkey.",
|
"create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey": "Opret en engangskode for at logge ind fra en anden enhed uden en adgangsnøgle.",
|
||||||
"create": "Create",
|
"create": "Opret",
|
||||||
"first_name": "First name",
|
"first_name": "Fornavn",
|
||||||
"last_name": "Last name",
|
"last_name": "Efternavn",
|
||||||
"username": "Username",
|
"username": "Brugernavn",
|
||||||
"save": "Save",
|
"save": "Gem",
|
||||||
"username_can_only_contain": "Username can only contain lowercase letters, numbers, underscores, dots, hyphens, and '@' symbols",
|
"username_can_only_contain": "Brugernavn må kun indeholde små bogstaver, tal, understregninger (_), punktummer (.), bindestreger (-) og @-tegn",
|
||||||
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Sign in using the following code. The code will expire in 15 minutes.",
|
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Log ind med nedenstående kode. Koden udløber om 15 minutter.",
|
||||||
"or_visit": "or visit",
|
"or_visit": "eller besøg",
|
||||||
"added_on": "Added on",
|
"added_on": "Tilføjet den",
|
||||||
"rename": "Rename",
|
"rename": "Omdøb",
|
||||||
"delete": "Delete",
|
"delete": "Slet",
|
||||||
"are_you_sure_you_want_to_delete_this_passkey": "Are you sure you want to delete this passkey?",
|
"are_you_sure_you_want_to_delete_this_passkey": "Er du sikker på, at du vil slette denne adgangsnøgle?",
|
||||||
"passkey_deleted_successfully": "Passkey deleted successfully",
|
"passkey_deleted_successfully": "Adgangsnøgle blev slettet",
|
||||||
"delete_passkey_name": "Delete {passkeyName}",
|
"delete_passkey_name": "Slet {passkeyName}",
|
||||||
"passkey_name_updated_successfully": "Passkey name updated successfully",
|
"passkey_name_updated_successfully": "Navnet på adgangsnøglen blev opdateret",
|
||||||
"name_passkey": "Name Passkey",
|
"name_passkey": "Navngiv adgangsnøgle",
|
||||||
"name_your_passkey_to_easily_identify_it_later": "Name your passkey to easily identify it later.",
|
"name_your_passkey_to_easily_identify_it_later": "Giv din adgangsnøgle et navn, så du nemt kan genkende den senere.",
|
||||||
"create_api_key": "Create API Key",
|
"create_api_key": "Opret API-nøgle",
|
||||||
"add_a_new_api_key_for_programmatic_access": "Add a new API key for programmatic access.",
|
"add_a_new_api_key_for_programmatic_access": "Tilføj en ny API-nøgle til programmatisk adgang.",
|
||||||
"add_api_key": "Add API Key",
|
"add_api_key": "Tilføj API-nøgle",
|
||||||
"manage_api_keys": "Manage API Keys",
|
"manage_api_keys": "Administrér API-nøgler",
|
||||||
"api_key_created": "API Key Created",
|
"api_key_created": "API-nøgle oprettet",
|
||||||
"for_security_reasons_this_key_will_only_be_shown_once": "For security reasons, this key will only be shown once. Please store it securely.",
|
"for_security_reasons_this_key_will_only_be_shown_once": "Af sikkerhedshensyn vises denne nøgle kun én gang. Gem den et sikkert sted.",
|
||||||
"description": "Description",
|
"description": "Beskrivelse",
|
||||||
"api_key": "API Key",
|
"api_key": "API-nøgle",
|
||||||
"close": "Close",
|
"close": "Luk",
|
||||||
"name_to_identify_this_api_key": "Name to identify this API key.",
|
"name_to_identify_this_api_key": "Navn til at identificere denne API-nøgle.",
|
||||||
"expires_at": "Expires At",
|
"expires_at": "Udløber den",
|
||||||
"when_this_api_key_will_expire": "When this API key will expire.",
|
"when_this_api_key_will_expire": "Hvornår denne API-nøgle udløber.",
|
||||||
"optional_description_to_help_identify_this_keys_purpose": "Optional description to help identify this key's purpose.",
|
"optional_description_to_help_identify_this_keys_purpose": "Valgfri beskrivelse for at identificere nøglens formål.",
|
||||||
"expiration_date_must_be_in_the_future": "Expiration date must be in the future",
|
"expiration_date_must_be_in_the_future": "Udløbsdatoen skal ligge i fremtiden",
|
||||||
"revoke_api_key": "Revoke API Key",
|
"revoke_api_key": "Tilbagekald API-nøgle",
|
||||||
"never": "Never",
|
"never": "Aldrig",
|
||||||
"revoke": "Revoke",
|
"revoke": "Tilbagekald",
|
||||||
"api_key_revoked_successfully": "API key revoked successfully",
|
"api_key_revoked_successfully": "API-nøgle blev tilbagekaldt",
|
||||||
"are_you_sure_you_want_to_revoke_the_api_key_apikeyname": "Are you sure you want to revoke the API key \"{apiKeyName}\"? This will break any integrations using this key.",
|
"are_you_sure_you_want_to_revoke_the_api_key_apikeyname": "Er du sikker på, at du vil tilbagekalde API-nøglen \"{apiKeyName}\"? Dette vil afbryde alle integrationer, der bruger nøglen.",
|
||||||
"last_used": "Last Used",
|
"last_used": "Sidst brugt",
|
||||||
"actions": "Actions",
|
"actions": "Handlinger",
|
||||||
"images_updated_successfully": "Images updated successfully",
|
"images_updated_successfully": "Billeder blev opdateret",
|
||||||
"general": "General",
|
"general": "Generelt",
|
||||||
"configure_smtp_to_send_emails": "Enable email notifications to alert users when a login is detected from a new device or location.",
|
"configure_smtp_to_send_emails": "Aktivér e-mailnotifikationer for at advare brugere, når et login registreres fra en ny enhed eller placering.",
|
||||||
"ldap": "LDAP",
|
"ldap": "LDAP",
|
||||||
"configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server": "Configure LDAP settings to sync users and groups from an LDAP server.",
|
"configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server": "Konfigurer LDAP-indstillinger for at synkronisere brugere og grupper fra en LDAP-server",
|
||||||
"images": "Images",
|
"images": "Billeder",
|
||||||
"update": "Update",
|
"update": "Opdater",
|
||||||
"email_configuration_updated_successfully": "Email configuration updated successfully",
|
"email_configuration_updated_successfully": "E-mailkonfiguration blev opdateret",
|
||||||
"save_changes_question": "Save changes?",
|
"save_changes_question": "Gem ændringer?",
|
||||||
"you_have_to_save_the_changes_before_sending_a_test_email_do_you_want_to_save_now": "You have to save the changes before sending a test email. Do you want to save now?",
|
"you_have_to_save_the_changes_before_sending_a_test_email_do_you_want_to_save_now": "Du skal gemme ændringerne, før du kan sende en test-e-mail. Vil du gemme nu?",
|
||||||
"save_and_send": "Save and send",
|
"save_and_send": "Gem og send",
|
||||||
"test_email_sent_successfully": "Test email sent successfully to your email address.",
|
"test_email_sent_successfully": "Test-e-mail blev sendt til din e-mailadresse.",
|
||||||
"failed_to_send_test_email": "Failed to send test email. Check the server logs for more information.",
|
"failed_to_send_test_email": "Kunne ikke sende test-e-mail. Tjek serverloggen for flere oplysninger.",
|
||||||
"smtp_configuration": "SMTP Configuration",
|
"smtp_configuration": "SMTP-konfiguration",
|
||||||
"smtp_host": "SMTP Host",
|
"smtp_host": "SMTP-vært",
|
||||||
"smtp_port": "SMTP Port",
|
"smtp_port": "SMTP-port",
|
||||||
"smtp_user": "SMTP User",
|
"smtp_user": "SMTP-bruger",
|
||||||
"smtp_password": "SMTP Password",
|
"smtp_password": "SMTP-adgangskode",
|
||||||
"smtp_from": "SMTP From",
|
"smtp_from": "SMTP-afsender",
|
||||||
"smtp_tls_option": "SMTP TLS Option",
|
"smtp_tls_option": "SMTP TLS-indstilling",
|
||||||
"email_tls_option": "Email TLS Option",
|
"email_tls_option": "E-mail TLS-indstilling",
|
||||||
"skip_certificate_verification": "Skip Certificate Verification",
|
"skip_certificate_verification": "Spring certifikatverificering over",
|
||||||
"this_can_be_useful_for_selfsigned_certificates": "This can be useful for self-signed certificates.",
|
"this_can_be_useful_for_selfsigned_certificates": "Dette kan være nyttigt ved selvsignerede certifikater.",
|
||||||
"enabled_emails": "Enabled Emails",
|
"enabled_emails": "Aktiverede e-mails",
|
||||||
"email_login_notification": "Email Login Notification",
|
"email_login_notification": "Notifikation om login via e-mail",
|
||||||
"send_an_email_to_the_user_when_they_log_in_from_a_new_device": "Send an email to the user when they log in from a new device.",
|
"send_an_email_to_the_user_when_they_log_in_from_a_new_device": "Send en e-mail til brugeren, når de logger ind fra en ny enhed.",
|
||||||
"emai_login_code_requested_by_user": "Email Login Code Requested by User",
|
"emai_login_code_requested_by_user": "Tillad brugere at anmode om login-koder via e-mail",
|
||||||
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Allows users to bypass passkeys by requesting a login code sent to their email. This reduces the security significantly as anyone with access to the user's email can gain entry.",
|
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Tillader brugere at omgå adgangsnøgler ved at anmode om en login-kode sendt til deres e-mail. Dette reducerer sikkerheden væsentligt, da enhver med adgang til brugerens e-mail kan få adgang.\n",
|
||||||
"email_login_code_from_admin": "Email Login Code from Admin",
|
"email_login_code_from_admin": "Tillad administratorer at sende login-koder via e-mail",
|
||||||
"allows_an_admin_to_send_a_login_code_to_the_user": "Allows an admin to send a login code to the user via email.",
|
"allows_an_admin_to_send_a_login_code_to_the_user": "Giver en administrator mulighed for at sende en login-kode til brugeren via e-mail.",
|
||||||
"send_test_email": "Send test email",
|
"send_test_email": "Send test-e-mail",
|
||||||
"application_configuration_updated_successfully": "Application configuration updated successfully",
|
"application_configuration_updated_successfully": "Applikationsindstillinger blev opdateret",
|
||||||
"application_name": "Application Name",
|
"application_name": "Applikationsnavn",
|
||||||
"session_duration": "Session Duration",
|
"session_duration": "Sessionsvarighed",
|
||||||
"the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "The duration of a session in minutes before the user has to sign in again.",
|
"the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "Varighed i minutter før brugeren skal logge ind igen.",
|
||||||
"enable_self_account_editing": "Enable Self-Account Editing",
|
"enable_self_account_editing": "Aktivér redigering af egen konto",
|
||||||
"whether_the_users_should_be_able_to_edit_their_own_account_details": "Whether the users should be able to edit their own account details.",
|
"whether_the_users_should_be_able_to_edit_their_own_account_details": "Om brugere må redigere deres egne kontooplysninger.",
|
||||||
"emails_verified": "Emails Verified",
|
"emails_verified": "E-mailadresser verificeret",
|
||||||
"whether_the_users_email_should_be_marked_as_verified_for_the_oidc_clients": "Whether the user's email should be marked as verified for the OIDC clients.",
|
"whether_the_users_email_should_be_marked_as_verified_for_the_oidc_clients": "Om brugerens e-mail skal markeres som verificeret for OIDC-klienter.",
|
||||||
"ldap_configuration_updated_successfully": "LDAP configuration updated successfully",
|
"ldap_configuration_updated_successfully": "LDAP-konfiguration blev opdateret",
|
||||||
"ldap_disabled_successfully": "LDAP disabled successfully",
|
"ldap_disabled_successfully": "LDAP blev deaktiveret",
|
||||||
"ldap_sync_finished": "LDAP sync finished",
|
"ldap_sync_finished": "LDAP-synkronisering fuldført",
|
||||||
"client_configuration": "Client Configuration",
|
"client_configuration": "Klientkonfiguration",
|
||||||
"ldap_url": "LDAP URL",
|
"ldap_url": "LDAP-URL",
|
||||||
"ldap_bind_dn": "LDAP Bind DN",
|
"ldap_bind_dn": "LDAP Bind DN",
|
||||||
"ldap_bind_password": "LDAP Bind Password",
|
"ldap_bind_password": "LDAP-bindingsadgangskode",
|
||||||
"ldap_base_dn": "LDAP Base DN",
|
"ldap_base_dn": "LDAP Base DN",
|
||||||
"user_search_filter": "User Search Filter",
|
"user_search_filter": "Brugersøgningsfilter",
|
||||||
"the_search_filter_to_use_to_search_or_sync_users": "The Search filter to use to search/sync users.",
|
"the_search_filter_to_use_to_search_or_sync_users": "Søgefilteret der bruges til at finde eller synkronisere brugere.",
|
||||||
"groups_search_filter": "Groups Search Filter",
|
"groups_search_filter": "Gruppesøgningsfilter",
|
||||||
"the_search_filter_to_use_to_search_or_sync_groups": "The Search filter to use to search/sync groups.",
|
"the_search_filter_to_use_to_search_or_sync_groups": "Søgefilteret der bruges til at finde eller synkronisere grupper.",
|
||||||
"attribute_mapping": "Attribute Mapping",
|
"attribute_mapping": "Attributtilknytning",
|
||||||
"user_unique_identifier_attribute": "User Unique Identifier Attribute",
|
"user_unique_identifier_attribute": "Unik brugeridentifikator-attribut",
|
||||||
"the_value_of_this_attribute_should_never_change": "The value of this attribute should never change.",
|
"the_value_of_this_attribute_should_never_change": "Værdien af denne attribut bør aldrig ændres.",
|
||||||
"username_attribute": "Username Attribute",
|
"username_attribute": "Brugernavn-attribut",
|
||||||
"user_mail_attribute": "User Mail Attribute",
|
"user_mail_attribute": "E-mail-attribut",
|
||||||
"user_first_name_attribute": "User First Name Attribute",
|
"user_first_name_attribute": "Fornavns-attribut",
|
||||||
"user_last_name_attribute": "User Last Name Attribute",
|
"user_last_name_attribute": "Efternavns-attribut",
|
||||||
"user_profile_picture_attribute": "User Profile Picture Attribute",
|
"user_profile_picture_attribute": "Profilbilled-attribut",
|
||||||
"the_value_of_this_attribute_can_either_be_a_url_binary_or_base64_encoded_image": "The value of this attribute can either be a URL, a binary or a base64 encoded image.",
|
"the_value_of_this_attribute_can_either_be_a_url_binary_or_base64_encoded_image": "Værdien af denne attribut kan være en URL, en binær fil eller et base64-kodet billede.",
|
||||||
"group_members_attribute": "Group Members Attribute",
|
"group_members_attribute": "Gruppemedlems-attribut",
|
||||||
"the_attribute_to_use_for_querying_members_of_a_group": "The attribute to use for querying members of a group.",
|
"the_attribute_to_use_for_querying_members_of_a_group": "Attributten der bruges til at hente gruppemedlemmer.",
|
||||||
"group_unique_identifier_attribute": "Group Unique Identifier Attribute",
|
"group_unique_identifier_attribute": "Unik gruppe-ID-attribut",
|
||||||
"group_name_attribute": "Group Name Attribute",
|
"group_name_attribute": "Gruppenavns-attribut",
|
||||||
"admin_group_name": "Admin Group Name",
|
"admin_group_name": "Administratorgruppe-navn",
|
||||||
"members_of_this_group_will_have_admin_privileges_in_pocketid": "Members of this group will have Admin Privileges in Pocket ID.",
|
"members_of_this_group_will_have_admin_privileges_in_pocketid": "Medlemmer af denne gruppe får administratorrettigheder i Pocket ID.",
|
||||||
"disable": "Disable",
|
"disable": "Deaktivér",
|
||||||
"sync_now": "Sync now",
|
"sync_now": "Synkronisér nu",
|
||||||
"enable": "Enable",
|
"enable": "Aktivér",
|
||||||
"user_created_successfully": "User created successfully",
|
"user_created_successfully": "Bruger blev oprettet",
|
||||||
"create_user": "Create User",
|
"create_user": "Opret bruger",
|
||||||
"add_a_new_user_to_appname": "Add a new user to {appName}",
|
"add_a_new_user_to_appname": "Tilføj en ny bruger til {appName}",
|
||||||
"add_user": "Add User",
|
"add_user": "Tilføj bruger",
|
||||||
"manage_users": "Manage Users",
|
"manage_users": "Administrér brugere",
|
||||||
"admin_privileges": "Admin Privileges",
|
"admin_privileges": "Administratorrettigheder",
|
||||||
"admins_have_full_access_to_the_admin_panel": "Admins have full access to the admin panel.",
|
"admins_have_full_access_to_the_admin_panel": "Administratorer har fuld adgang til administratorpanelet.",
|
||||||
"delete_firstname_lastname": "Delete {firstName} {lastName}",
|
"delete_firstname_lastname": "Slet {firstName} {lastName}",
|
||||||
"are_you_sure_you_want_to_delete_this_user": "Are you sure you want to delete this user?",
|
"are_you_sure_you_want_to_delete_this_user": "Er du sikker på, at du vil slette denne bruger?",
|
||||||
"user_deleted_successfully": "User deleted successfully",
|
"user_deleted_successfully": "Brugeren blev slettet",
|
||||||
"role": "Role",
|
"role": "Rolle",
|
||||||
"source": "Source",
|
"source": "Kilde",
|
||||||
"admin": "Admin",
|
"admin": "Administrator",
|
||||||
"user": "User",
|
"user": "Bruger",
|
||||||
"local": "Local",
|
"local": "Lokal",
|
||||||
"toggle_menu": "Toggle menu",
|
"toggle_menu": "Åbn/luk menu",
|
||||||
"edit": "Edit",
|
"edit": "Redigér",
|
||||||
"user_groups_updated_successfully": "User groups updated successfully",
|
"user_groups_updated_successfully": "Brugergrupper blev opdateret",
|
||||||
"user_updated_successfully": "User updated successfully",
|
"user_updated_successfully": "Bruger blev opdateret",
|
||||||
"custom_claims_updated_successfully": "Custom claims updated successfully",
|
"custom_claims_updated_successfully": "Brugerdefinerede claims blev opdateret",
|
||||||
"back": "Back",
|
"back": "Tilbage",
|
||||||
"user_details_firstname_lastname": "User Details {firstName} {lastName}",
|
"user_details_firstname_lastname": "Brugeroplysninger for {firstName} {lastName}",
|
||||||
"manage_which_groups_this_user_belongs_to": "Manage which groups this user belongs to.",
|
"manage_which_groups_this_user_belongs_to": "Administrér hvilke grupper denne bruger tilhører.",
|
||||||
"custom_claims": "Custom Claims",
|
"custom_claims": "Brugerdefinerede claims",
|
||||||
"custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user": "Custom claims are key-value pairs that can be used to store additional information about a user. These claims will be included in the ID token if the scope 'profile' is requested.",
|
"custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user": "Brugerdefinerede claims er nøgle-værdi-par, der kan bruges til at gemme yderligere information om en bruger. Disse claims vil blive inkluderet i ID-tokenet, hvis scopen ’profile’ er anmodet.",
|
||||||
"user_group_created_successfully": "User group created successfully",
|
"user_group_created_successfully": "Brugergruppe blev oprettet",
|
||||||
"create_user_group": "Create User Group",
|
"create_user_group": "Opret brugergruppe",
|
||||||
"create_a_new_group_that_can_be_assigned_to_users": "Create a new group that can be assigned to users.",
|
"create_a_new_group_that_can_be_assigned_to_users": "Opret en ny gruppe, der kan tildeles brugere.",
|
||||||
"add_group": "Add Group",
|
"add_group": "Tilføj gruppe",
|
||||||
"manage_user_groups": "Manage User Groups",
|
"manage_user_groups": "Administrér brugergrupper",
|
||||||
"friendly_name": "Friendly Name",
|
"friendly_name": "Kaldenavn",
|
||||||
"name_that_will_be_displayed_in_the_ui": "Name that will be displayed in the UI",
|
"name_that_will_be_displayed_in_the_ui": "Navn der vises i brugerfladen",
|
||||||
"name_that_will_be_in_the_groups_claim": "Name that will be in the \"groups\" claim",
|
"name_that_will_be_in_the_groups_claim": "Navn der vises i “groups”-claimet",
|
||||||
"delete_name": "Delete {name}",
|
"delete_name": "Slet {name}",
|
||||||
"are_you_sure_you_want_to_delete_this_user_group": "Are you sure you want to delete this user group?",
|
"are_you_sure_you_want_to_delete_this_user_group": "Er du sikker på, at du vil slette denne brugergruppe?",
|
||||||
"user_group_deleted_successfully": "User group deleted successfully",
|
"user_group_deleted_successfully": "Brugergruppe blev slettet",
|
||||||
"user_count": "User Count",
|
"user_count": "Antal brugere",
|
||||||
"user_group_updated_successfully": "User group updated successfully",
|
"user_group_updated_successfully": "Brugergruppe blev opdateret",
|
||||||
"users_updated_successfully": "Users updated successfully",
|
"users_updated_successfully": "Brugere blev opdateret",
|
||||||
"user_group_details_name": "User Group Details {name}",
|
"user_group_details_name": "Detaljer for brugergruppe {name}",
|
||||||
"assign_users_to_this_group": "Assign users to this group.",
|
"assign_users_to_this_group": "Tildel brugere til denne gruppe.",
|
||||||
"custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user_prioritized": "Custom claims are key-value pairs that can be used to store additional information about a user. These claims will be included in the ID token if the scope 'profile' is requested. Custom claims defined on the user will be prioritized if there are conflicts.",
|
"custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user_prioritized": "Brugerdefinerede claims er nøgle-værdi-par, der bruges til at gemme yderligere information om en bruger. Disse claims vil blive inkluderet i ID-tokenet, hvis scopen ’profile’ anmodes. Brugerdefinerede claims defineret direkte på brugeren har prioritet, hvis der opstår konflikter.",
|
||||||
"oidc_client_created_successfully": "OIDC client created successfully",
|
"oidc_client_created_successfully": "OIDC-klient blev oprettet",
|
||||||
"create_oidc_client": "Create OIDC Client",
|
"create_oidc_client": "Opret OIDC-klient",
|
||||||
"add_a_new_oidc_client_to_appname": "Add a new OIDC client to {appName}.",
|
"add_a_new_oidc_client_to_appname": "Tilføj en ny OIDC-klient til {appName}",
|
||||||
"add_oidc_client": "Add OIDC Client",
|
"add_oidc_client": "Tilføj OIDC-klient",
|
||||||
"manage_oidc_clients": "Manage OIDC Clients",
|
"manage_oidc_clients": "Administrér OIDC-klienter",
|
||||||
"one_time_link": "One Time Link",
|
"one_time_link": "Engangslink",
|
||||||
"use_this_link_to_sign_in_once": "Use this link to sign in once. This is needed for users who haven't added a passkey yet or have lost it.",
|
"use_this_link_to_sign_in_once": "Brug dette link til at logge ind én gang. Det er nødvendigt for brugere, som endnu ikke har tilføjet en adgangsnøgle eller har mistet den.",
|
||||||
"add": "Add",
|
"add": "Tilføj",
|
||||||
"callback_urls": "Callback URLs",
|
"callback_urls": "Callback-URL’er",
|
||||||
"logout_callback_urls": "Logout Callback URLs",
|
"logout_callback_urls": "Logout Callback-URL’er",
|
||||||
"public_client": "Public Client",
|
"public_client": "Public-klient",
|
||||||
"public_clients_description": "Public clients do not have a client secret. They are designed for mobile, web, and native applications where secrets cannot be securely stored.",
|
"public_clients_description": "Public-klienter har ikke en klienthemmelighed. De er designet til mobil-, web- og native-apps, hvor hemmeligheder ikke kan opbevares sikkert.",
|
||||||
"pkce": "PKCE",
|
"pkce": "PKCE",
|
||||||
"public_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks": "Public Key Code Exchange is a security feature to prevent CSRF and authorization code interception attacks.",
|
"public_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks": "Public Key Code Exchange er en sikkerhedsfunktion, der beskytter mod CSRF- og authorization code-angreb.",
|
||||||
"name_logo": "{name} logo",
|
"name_logo": "Logo for {name}",
|
||||||
"change_logo": "Change Logo",
|
"change_logo": "Skift logo",
|
||||||
"upload_logo": "Upload Logo",
|
"upload_logo": "Upload logo",
|
||||||
"remove_logo": "Remove Logo",
|
"remove_logo": "Fjern logo",
|
||||||
"are_you_sure_you_want_to_delete_this_oidc_client": "Are you sure you want to delete this OIDC client?",
|
"are_you_sure_you_want_to_delete_this_oidc_client": "Er du sikker på, at du vil slette denne OIDC-klient?",
|
||||||
"oidc_client_deleted_successfully": "OIDC client deleted successfully",
|
"oidc_client_deleted_successfully": "OIDC-klient blev slettet",
|
||||||
"authorization_url": "Authorization URL",
|
"authorization_url": "Authorization URL",
|
||||||
"oidc_discovery_url": "OIDC Discovery URL",
|
"oidc_discovery_url": "OIDC Discovery URL",
|
||||||
"token_url": "Token URL",
|
"token_url": "Token URL",
|
||||||
"userinfo_url": "Userinfo URL",
|
"userinfo_url": "Userinfo URL",
|
||||||
"logout_url": "Logout URL",
|
"logout_url": "Logout URL",
|
||||||
"certificate_url": "Certificate URL",
|
"certificate_url": "Certificate URL",
|
||||||
"enabled": "Enabled",
|
"enabled": "Aktiveret",
|
||||||
"disabled": "Disabled",
|
"disabled": "Deaktiveret",
|
||||||
"oidc_client_updated_successfully": "OIDC client updated successfully",
|
"oidc_client_updated_successfully": "OIDC-klient blev opdateret",
|
||||||
"create_new_client_secret": "Create new client secret",
|
"create_new_client_secret": "Opret ny client secret",
|
||||||
"are_you_sure_you_want_to_create_a_new_client_secret": "Are you sure you want to create a new client secret? The old one will be invalidated.",
|
"are_you_sure_you_want_to_create_a_new_client_secret": "Vil du oprette en ny client secret? Den gamle bliver ugyldig og kan ikke længere bruges.",
|
||||||
"generate": "Generate",
|
"generate": "Generér",
|
||||||
"new_client_secret_created_successfully": "New client secret created successfully",
|
"new_client_secret_created_successfully": "Ny client secret blev oprettet",
|
||||||
"allowed_user_groups_updated_successfully": "Allowed user groups updated successfully",
|
"allowed_user_groups_updated_successfully": "Tilladte brugergrupper blev opdateret",
|
||||||
"oidc_client_name": "OIDC Client {name}",
|
"oidc_client_name": "OIDC-klient {name}",
|
||||||
"client_id": "Client ID",
|
"client_id": "Client ID",
|
||||||
"client_secret": "Client secret",
|
"client_secret": "Client secret",
|
||||||
"show_more_details": "Show more details",
|
"show_more_details": "Vis flere detaljer",
|
||||||
"allowed_user_groups": "Allowed User Groups",
|
"allowed_user_groups": "Tilladte brugergrupper",
|
||||||
"add_user_groups_to_this_client_to_restrict_access_to_users_in_these_groups": "Add user groups to this client to restrict access to users in these groups. If no user groups are selected, all users will have access to this client.",
|
"add_user_groups_to_this_client_to_restrict_access_to_users_in_these_groups": "Tilføj brugergrupper til denne klient for at begrænse adgangen til brugere i disse grupper. Hvis ingen brugergrupper er valgt, vil alle brugere have adgang til klienten.",
|
||||||
"favicon": "Favicon",
|
"favicon": "Favicon",
|
||||||
"light_mode_logo": "Light Mode Logo",
|
"light_mode_logo": "Logo til lys tilstand",
|
||||||
"dark_mode_logo": "Dark Mode Logo",
|
"dark_mode_logo": "Logo til mørk tilstand",
|
||||||
"background_image": "Background Image",
|
"background_image": "Baggrundsbillede",
|
||||||
"language": "Language",
|
"language": "Sprog",
|
||||||
"reset_profile_picture_question": "Reset profile picture?",
|
"reset_profile_picture_question": "Nulstil profilbillede?",
|
||||||
"this_will_remove_the_uploaded_image_and_reset_the_profile_picture_to_default": "This will remove the uploaded image, and reset the profile picture to default. Do you want to continue?",
|
"this_will_remove_the_uploaded_image_and_reset_the_profile_picture_to_default": "Dette vil fjerne det uploadede billede og nulstille profilbilledet til standard. Vil du fortsætte?",
|
||||||
"reset": "Reset",
|
"reset": "Nulstil",
|
||||||
"reset_to_default": "Reset to default",
|
"reset_to_default": "Nulstil til standard",
|
||||||
"profile_picture_has_been_reset": "Profile picture has been reset. It may take a few minutes to update.",
|
"profile_picture_has_been_reset": "Profilbilledet er blevet nulstillet. Det kan tage et par minutter at opdatere.",
|
||||||
"select_the_language_you_want_to_use": "Select the language you want to use. Some languages may not be fully translated.",
|
"select_the_language_you_want_to_use": "Vælg det sprog, du ønsker at bruge. Bemærk, at nogle tekster kan blive oversat automatisk og derfor kan være unøjagtige.",
|
||||||
"personal": "Personal",
|
"contribute_to_translation": "Hvis du finder et problem, er du velkommen til at bidrage til oversættelsen på <link href='https://crowdin.com/project/pocket-id'>Crowdin</link>.",
|
||||||
|
"personal": "Personlig",
|
||||||
"global": "Global",
|
"global": "Global",
|
||||||
"all_users": "All Users",
|
"all_users": "Alle brugere",
|
||||||
"all_events": "All Events",
|
"all_events": "Alle hændelser",
|
||||||
"all_clients": "All Clients",
|
"all_clients": "Alle klienter",
|
||||||
"global_audit_log": "Global Audit Log",
|
"all_locations": "Alle lokationer",
|
||||||
"see_all_account_activities_from_the_last_3_months": "See all user activity for the last 3 months.",
|
"global_audit_log": "Global aktivitetslog",
|
||||||
"token_sign_in": "Token Sign In",
|
"see_all_account_activities_from_the_last_3_months": "Se al brugeraktivitet for de seneste 3 måneder.",
|
||||||
"client_authorization": "Client Authorization",
|
"token_sign_in": "Token-login",
|
||||||
"new_client_authorization": "New Client Authorization",
|
"client_authorization": "Godkendelse af klient",
|
||||||
"disable_animations": "Disable Animations",
|
"new_client_authorization": "Ny klientgodkendelse",
|
||||||
"turn_off_ui_animations": "Turn off all animations throughout the Admin UI.",
|
"disable_animations": "Deaktiver animationer",
|
||||||
"user_disabled": "Account Disabled",
|
"turn_off_ui_animations": "Slå animationer fra for hele brugergrænsefladen.",
|
||||||
"disabled_users_cannot_log_in_or_use_services": "Disabled users cannot log in or use services.",
|
"user_disabled": "Konto deaktiveret",
|
||||||
"user_disabled_successfully": "User has been disabled successfully.",
|
"disabled_users_cannot_log_in_or_use_services": "Deaktiverede brugere kan ikke logge ind eller bruge tjenester.",
|
||||||
"user_enabled_successfully": "User has been enabled successfully.",
|
"user_disabled_successfully": "Brugeren blev deaktiveret.",
|
||||||
|
"user_enabled_successfully": "Brugeren blev aktiveret.",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
"disable_firstname_lastname": "Disable {firstName} {lastName}",
|
"disable_firstname_lastname": "Deaktiver {firstName} {lastName}",
|
||||||
"are_you_sure_you_want_to_disable_this_user": "Are you sure you want to disable this user? They will not be able to log in or access any services.",
|
"are_you_sure_you_want_to_disable_this_user": "Er du sikker på, at du vil deaktivere denne bruger? Brugeren vil ikke kunne logge ind eller få adgang til tjenester.",
|
||||||
"ldap_soft_delete_users": "Keep disabled users from LDAP.",
|
"ldap_soft_delete_users": "Behold deaktiverede brugere i LDAP.",
|
||||||
"ldap_soft_delete_users_description": "When enabled, users removed from LDAP will be disabled rather than deleted from the system.",
|
"ldap_soft_delete_users_description": "Når aktiveret, vil brugere fjernet fra LDAP blive deaktiveret i stedet for at blive slettet fra systemet.",
|
||||||
"login_code_email_success": "The login code has been sent to the user.",
|
"login_code_email_success": "Loginkoden er sendt til brugeren.",
|
||||||
"send_email": "Send Email",
|
"send_email": "Send e-mail",
|
||||||
"show_code": "Show Code",
|
"show_code": "Vis kode",
|
||||||
"callback_url_description": "URL(s) provided by your client. Will be automatically added if left blank. Wildcards (*) are supported, but best avoided for better security.",
|
"callback_url_description": "En eller flere URL’er angivet af din klient. Tilføjes automatisk, hvis feltet er tomt. Wildcards (*) understøttes, men bør undgås af hensyn til sikkerheden.",
|
||||||
"logout_callback_url_description": "URL(s) provided by your client for logout. Wildcards (*) are supported, but best avoided for better security.",
|
"logout_callback_url_description": "En eller flere URL’er angivet af din klient til logout. Wildcards (*) understøttes, men bør undgås af hensyn til sikkerheden.",
|
||||||
"api_key_expiration": "API Key Expiration",
|
"api_key_expiration": "Udløb af API-nøgle",
|
||||||
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Send an email to the user when their API key is about to expire.",
|
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Send en e-mail til brugeren, når deres API-nøgle er ved at udløbe.",
|
||||||
"authorize_device": "Authorize Device",
|
"authorize_device": "Godkend enhed",
|
||||||
"the_device_has_been_authorized": "The device has been authorized.",
|
"the_device_has_been_authorized": "Enheden er godkendt.",
|
||||||
"enter_code_displayed_in_previous_step": "Enter the code that was displayed in the previous step.",
|
"enter_code_displayed_in_previous_step": "Indtast koden, der blev vist i det forrige trin.",
|
||||||
"authorize": "Authorize",
|
"authorize": "Godkend",
|
||||||
"federated_client_credentials": "Federated Client Credentials",
|
"federated_client_credentials": "Federated klientlegitimationsoplysninger",
|
||||||
"federated_client_credentials_description": "Using federated client credentials, you can authenticate OIDC clients using JWT tokens issued by third-party authorities.",
|
"federated_client_credentials_description": "Ved hjælp af federated klientlegitimationsoplysninger kan du godkende OIDC-klienter med JWT-tokens udstedt af tredjepartsudbydere.",
|
||||||
"add_federated_client_credential": "Add Federated Client Credential",
|
"add_federated_client_credential": "Tilføj federated klientlegitimation",
|
||||||
"add_another_federated_client_credential": "Add another federated client credential",
|
"add_another_federated_client_credential": "Tilføj endnu en federated klientlegitimation",
|
||||||
"oidc_allowed_group_count": "Allowed Group Count",
|
"oidc_allowed_group_count": "Tilladt antal grupper",
|
||||||
"unrestricted": "Unrestricted",
|
"unrestricted": "Ubegrænset",
|
||||||
"show_advanced_options": "Show Advanced Options",
|
"show_advanced_options": "Vis avancerede indstillinger",
|
||||||
"hide_advanced_options": "Hide Advanced Options",
|
"hide_advanced_options": "Skjul avancerede indstillinger",
|
||||||
"oidc_data_preview": "OIDC Data Preview",
|
"oidc_data_preview": "Forhåndsvisning af OIDC-data",
|
||||||
"preview_the_oidc_data_that_would_be_sent_for_different_users": "Preview the OIDC data that would be sent for different users",
|
"preview_the_oidc_data_that_would_be_sent_for_different_users": "Forhåndsvis OIDC-data, der ville blive sendt for forskellige brugere",
|
||||||
"id_token": "ID Token",
|
"id_token": "ID-token",
|
||||||
"access_token": "Access Token",
|
"access_token": "Adgangstoken",
|
||||||
"userinfo": "Userinfo",
|
"userinfo": "Brugerinfo",
|
||||||
"id_token_payload": "ID Token Payload",
|
"id_token_payload": "ID-token-payload",
|
||||||
"access_token_payload": "Access Token Payload",
|
"access_token_payload": "Adgangstoken-payload",
|
||||||
"userinfo_endpoint_response": "Userinfo Endpoint Response",
|
"userinfo_endpoint_response": "Svar fra brugerinfo-endpoint",
|
||||||
"copy": "Copy",
|
"copy": "Kopiér",
|
||||||
"no_preview_data_available": "No preview data available",
|
"no_preview_data_available": "Ingen forhåndsvisningsdata tilgængelig",
|
||||||
"copy_all": "Copy All",
|
"copy_all": "Kopiér alt",
|
||||||
"preview": "Preview",
|
"preview": "Forhåndsvisning",
|
||||||
"preview_for_user": "Preview for {name} ({email})",
|
"preview_for_user": "Forhåndsvisning for {name} ({email})",
|
||||||
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Preview the OIDC data that would be sent for this user",
|
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Forhåndsvis OIDC-data, der ville blive sendt for denne bruger",
|
||||||
"show": "Show",
|
"show": "Vis",
|
||||||
"select_an_option": "Select an option",
|
"select_an_option": "Vælg en indstilling",
|
||||||
"select_user": "Select User",
|
"select_user": "Vælg en bruger",
|
||||||
"error": "Error"
|
"error": "Fejl",
|
||||||
|
"select_an_accent_color_to_customize_the_appearance_of_pocket_id": "Vælg en accentfarve for at tilpasse udseendet af Pocket ID.",
|
||||||
|
"accent_color": "Accentfarve",
|
||||||
|
"custom_accent_color": "Brugerdefineret accentfarve",
|
||||||
|
"custom_accent_color_description": "Indtast en brugerdefineret farve i et gyldigt CSS-format (f.eks. hex, rgb, hsl).",
|
||||||
|
"color_value": "Farveværdi",
|
||||||
|
"apply": "Anvend",
|
||||||
|
"signup_token": "Tilmeldingstoken",
|
||||||
|
"create_a_signup_token_to_allow_new_user_registration": "Opret en tilmeldingstoken for at tillade registrering af nye brugere.",
|
||||||
|
"usage_limit": "Brugsbegrænsning",
|
||||||
|
"number_of_times_token_can_be_used": "Antal gange, som tilmeldingstokenet kan bruges.",
|
||||||
|
"expires": "Udløber",
|
||||||
|
"signup": "Tilmeld",
|
||||||
|
"signup_requires_valid_token": "Der kræves en gyldig tilmeldingstoken for at oprette en konto.",
|
||||||
|
"validating_signup_token": "Validering af tilmeldingstoken",
|
||||||
|
"go_to_login": "Gå til login",
|
||||||
|
"signup_to_appname": "Tilmeld dig {appName}",
|
||||||
|
"create_your_account_to_get_started": "Opret din konto for at komme i gang.",
|
||||||
|
"initial_account_creation_description": "Opret din konto for at komme i gang. Du kan oprette en adgangskode senere.",
|
||||||
|
"setup_your_passkey": "Opret din adgangskode",
|
||||||
|
"create_a_passkey_to_securely_access_your_account": "Opret en adgangskode for at få sikker adgang til din konto. Dette bliver din primære måde at logge ind på.",
|
||||||
|
"skip_for_now": "Spring over for nu",
|
||||||
|
"account_created": "Konto oprettet",
|
||||||
|
"enable_user_signups": "Aktiver brugerregistrering",
|
||||||
|
"enable_user_signups_description": "Om brugerregistreringsfunktionen skal være aktiveret.",
|
||||||
|
"user_signups_are_disabled": "Brugerregistrering er i øjeblikket deaktiveret.",
|
||||||
|
"create_signup_token": "Opret tilmeldingstoken",
|
||||||
|
"view_active_signup_tokens": "Vis aktive tilmeldingstokener",
|
||||||
|
"manage_signup_tokens": "Administrer tilmeldingstokener",
|
||||||
|
"view_and_manage_active_signup_tokens": "Se og administrer aktive tilmeldingstokens.",
|
||||||
|
"signup_token_deleted_successfully": "Tilmeldingstoken slettet.",
|
||||||
|
"expired": "Udløbet",
|
||||||
|
"used_up": "Brugt op",
|
||||||
|
"active": "Aktiv",
|
||||||
|
"usage": "Anvendelse",
|
||||||
|
"created": "Oprettet",
|
||||||
|
"token": "Token",
|
||||||
|
"loading": "Indlæsning",
|
||||||
|
"delete_signup_token": "Slet tilmeldingstoken",
|
||||||
|
"are_you_sure_you_want_to_delete_this_signup_token": "Er du sikker på, at du vil slette denne tilmeldingstoken? Denne handling kan ikke fortrydes.",
|
||||||
|
"signup_disabled_description": "Brugerregistreringer er fuldstændigt deaktiveret. Kun administratorer kan oprette nye brugerkonti.",
|
||||||
|
"signup_with_token": "Tilmeld dig med token",
|
||||||
|
"signup_with_token_description": "Brugere kan kun tilmelde sig ved hjælp af en gyldig tilmeldingstoken, der er oprettet af en administrator.",
|
||||||
|
"signup_open": "Åben tilmelding",
|
||||||
|
"signup_open_description": "Alle kan oprette en ny konto uden begrænsninger.",
|
||||||
|
"of": "af",
|
||||||
|
"skip_passkey_setup": "Spring Passkey-opsætning over",
|
||||||
|
"skip_passkey_setup_description": "Det anbefales stærkt at oprette en adgangskode, da du ellers bliver låst ude af din konto, så snart sessionen udløber."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
"my_account": "Mein Konto",
|
"my_account": "Mein Konto",
|
||||||
"logout": "Abmelden",
|
"logout": "Abmelden",
|
||||||
"confirm": "Bestätigen",
|
"confirm": "Bestätigen",
|
||||||
|
"docs": "Dokumentation",
|
||||||
"key": "Schlüssel",
|
"key": "Schlüssel",
|
||||||
"value": "Wert",
|
"value": "Wert",
|
||||||
"remove_custom_claim": "Benutzerdefinierten Claim entfernen",
|
"remove_custom_claim": "Benutzerdefinierten Claim entfernen",
|
||||||
@@ -35,7 +36,7 @@
|
|||||||
"expiration": "Ablaufdatum",
|
"expiration": "Ablaufdatum",
|
||||||
"generate_code": "Code erzeugen",
|
"generate_code": "Code erzeugen",
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
"browser_unsupported": "Browser nicht unterstützt",
|
"browser_unsupported": "Browser wird nicht unterstützt",
|
||||||
"this_browser_does_not_support_passkeys": "Dieser Browser unterstützt keine Passkeys. Bitte verwende eine alternative Anmeldemethode.",
|
"this_browser_does_not_support_passkeys": "Dieser Browser unterstützt keine Passkeys. Bitte verwende eine alternative Anmeldemethode.",
|
||||||
"an_unknown_error_occurred": "Ein unbekannter Fehler ist aufgetreten",
|
"an_unknown_error_occurred": "Ein unbekannter Fehler ist aufgetreten",
|
||||||
"authentication_process_was_aborted": "Der Authentifizierungsprozess wurde abgebrochen",
|
"authentication_process_was_aborted": "Der Authentifizierungsprozess wurde abgebrochen",
|
||||||
@@ -64,11 +65,9 @@
|
|||||||
"do_you_want_to_sign_out_of_pocketid_with_the_account": "Möchtest du dich mit deinem Konto <b>{username}</b> von Pocket ID abmelden?",
|
"do_you_want_to_sign_out_of_pocketid_with_the_account": "Möchtest du dich mit deinem Konto <b>{username}</b> von Pocket ID abmelden?",
|
||||||
"sign_in_to_appname": "Bei {appName} anmelden",
|
"sign_in_to_appname": "Bei {appName} anmelden",
|
||||||
"please_try_to_sign_in_again": "Bitte versuche dich erneut anzumelden.",
|
"please_try_to_sign_in_again": "Bitte versuche dich erneut anzumelden.",
|
||||||
"authenticate_yourself_with_your_passkey_to_access_the_admin_panel": "Authentifiziere dich mit deinem Passkey, um auf das Admin Panel zugreifen zu können.",
|
"authenticate_with_passkey_to_access_account": "Melde dich mit deinem Passwort an, um auf dein Konto zuzugreifen.",
|
||||||
"authenticate": "Authentifizieren",
|
"authenticate": "Authentifizieren",
|
||||||
"appname_setup": "{appName} Einrichtung",
|
|
||||||
"please_try_again": "Bitte versuche es noch einmal.",
|
"please_try_again": "Bitte versuche es noch einmal.",
|
||||||
"you_are_about_to_sign_in_to_the_initial_admin_account": "Du bist dabei, dich beim initialen Administratorkonto anzumelden. Jeder, der diesen Link hat, kann auf das Konto zugreifen, bis ein Passkey hinzugefügt wird. Bitte richte so schnell wie möglich einen Passkey ein, um unbefugten Zugriff zu verhindern.",
|
|
||||||
"continue": "Fortsetzen",
|
"continue": "Fortsetzen",
|
||||||
"alternative_sign_in": "Alternative Anmeldemethoden",
|
"alternative_sign_in": "Alternative Anmeldemethoden",
|
||||||
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "Wenn du keinen Zugang zu deinem Passkey hast, kannst du dich mit einer der folgenden Methoden anmelden.",
|
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "Wenn du keinen Zugang zu deinem Passkey hast, kannst du dich mit einer der folgenden Methoden anmelden.",
|
||||||
@@ -179,7 +178,7 @@
|
|||||||
"email_login_notification": "E-Mail Benachrichtigung bei Login",
|
"email_login_notification": "E-Mail Benachrichtigung bei Login",
|
||||||
"send_an_email_to_the_user_when_they_log_in_from_a_new_device": "Sende dem Benutzer eine E-Mail, wenn er sich von einem neuen Gerät aus anmeldet.",
|
"send_an_email_to_the_user_when_they_log_in_from_a_new_device": "Sende dem Benutzer eine E-Mail, wenn er sich von einem neuen Gerät aus anmeldet.",
|
||||||
"emai_login_code_requested_by_user": "E-Mail-Logincode angefordert vom Benutzer",
|
"emai_login_code_requested_by_user": "E-Mail-Logincode angefordert vom Benutzer",
|
||||||
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Ermöglicht Benutzern, den Passkey zu umgehen, indem sie das Senden eines Logincodes an ihre E-Mail-Adresse anfordern. Dies reduziert die Sicherheit erheblich, da jeder, der Zugriff auf die E-Mail des Benutzers hat, Zugang bekommen kann.",
|
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Ermöglicht Benutzern Passkeys zu umgehen, indem sie einen Login-Code anfordern, der an ihre E-Mail gesendet wurde. Dies verringert die Sicherheit erheblich, da jeder, der Zugriff auf die E-Mail des Benutzers hat, Zugang erhalten kann.",
|
||||||
"email_login_code_from_admin": "E-Mail-Logincode von Administratoren",
|
"email_login_code_from_admin": "E-Mail-Logincode von Administratoren",
|
||||||
"allows_an_admin_to_send_a_login_code_to_the_user": "Erlaube Administratoren das Senden von Logincodes an den Nutzer via E-Mail.",
|
"allows_an_admin_to_send_a_login_code_to_the_user": "Erlaube Administratoren das Senden von Logincodes an den Nutzer via E-Mail.",
|
||||||
"send_test_email": "Test-E-Mail senden",
|
"send_test_email": "Test-E-Mail senden",
|
||||||
@@ -233,7 +232,7 @@
|
|||||||
"user_deleted_successfully": "Benutzer erfolgreich gelöscht",
|
"user_deleted_successfully": "Benutzer erfolgreich gelöscht",
|
||||||
"role": "Rolle",
|
"role": "Rolle",
|
||||||
"source": "Quelle",
|
"source": "Quelle",
|
||||||
"admin": "Admin",
|
"admin": "Administrator",
|
||||||
"user": "Benutzer",
|
"user": "Benutzer",
|
||||||
"local": "Lokal",
|
"local": "Lokal",
|
||||||
"toggle_menu": "Menü umschalten",
|
"toggle_menu": "Menü umschalten",
|
||||||
@@ -313,12 +312,14 @@
|
|||||||
"reset": "Zurücksetzen",
|
"reset": "Zurücksetzen",
|
||||||
"reset_to_default": "Auf Standard zurücksetzen",
|
"reset_to_default": "Auf Standard zurücksetzen",
|
||||||
"profile_picture_has_been_reset": "Das Profilbild wurde zurückgesetzt. Es kann einige Minuten dauern, bis es aktualisiert wird.",
|
"profile_picture_has_been_reset": "Das Profilbild wurde zurückgesetzt. Es kann einige Minuten dauern, bis es aktualisiert wird.",
|
||||||
"select_the_language_you_want_to_use": "Wähle die Sprache aus, die du verwenden möchtest. Einige Sprachen sind möglicherweise nicht vollständig übersetzt.",
|
"select_the_language_you_want_to_use": "Wähl die Sprache aus, die du benutzen willst. Bitte beachte, dass manche Texte automatisch übersetzt werden und vielleicht nicht ganz richtig sind.",
|
||||||
|
"contribute_to_translation": "Wenn du ein Problem findest, kannst du gerne bei der Übersetzung auf <link href='https://crowdin.com/project/pocket-id'>Crowdin</link> mitmachen.",
|
||||||
"personal": "Persönlich",
|
"personal": "Persönlich",
|
||||||
"global": "Global",
|
"global": "Global",
|
||||||
"all_users": "Alle Benutzer",
|
"all_users": "Alle Benutzer",
|
||||||
"all_events": "Alle Ereignisse",
|
"all_events": "Alle Ereignisse",
|
||||||
"all_clients": "Alle Clients",
|
"all_clients": "Alle Clients",
|
||||||
|
"all_locations": "Alle Orte",
|
||||||
"global_audit_log": "Globaler Aktivitäts-Log",
|
"global_audit_log": "Globaler Aktivitäts-Log",
|
||||||
"see_all_account_activities_from_the_last_3_months": "Sieh dir alle Benutzeraktivitäten der letzten 3 Monate an.",
|
"see_all_account_activities_from_the_last_3_months": "Sieh dir alle Benutzeraktivitäten der letzten 3 Monate an.",
|
||||||
"token_sign_in": "Token-Anmeldung",
|
"token_sign_in": "Token-Anmeldung",
|
||||||
@@ -347,29 +348,76 @@
|
|||||||
"enter_code_displayed_in_previous_step": "Gib den Code ein, der im vorherigen Schritt angezeigt wurde.",
|
"enter_code_displayed_in_previous_step": "Gib den Code ein, der im vorherigen Schritt angezeigt wurde.",
|
||||||
"authorize": "Autorisieren",
|
"authorize": "Autorisieren",
|
||||||
"federated_client_credentials": "Federated Client Credentials",
|
"federated_client_credentials": "Federated Client Credentials",
|
||||||
"federated_client_credentials_description": "Using federated client credentials, you can authenticate OIDC clients using JWT tokens issued by third-party authorities.",
|
"federated_client_credentials_description": "Mit Hilfe von Verbund-Client-Anmeldeinformationen kannst du OIDC-Clients mit JWT-Tokens authentifizieren, die von Drittanbietern ausgestellt wurden.",
|
||||||
"add_federated_client_credential": "Add Federated Client Credential",
|
"add_federated_client_credential": "Föderierte Client-Anmeldeinfos hinzufügen",
|
||||||
"add_another_federated_client_credential": "Add another federated client credential",
|
"add_another_federated_client_credential": "Weitere Anmeldeinformationen für einen Verbundclient hinzufügen",
|
||||||
"oidc_allowed_group_count": "Erlaubte Gruppenanzahl",
|
"oidc_allowed_group_count": "Erlaubte Gruppenanzahl",
|
||||||
"unrestricted": "Uneingeschränkt",
|
"unrestricted": "Uneingeschränkt",
|
||||||
"show_advanced_options": "Show Advanced Options",
|
"show_advanced_options": "Erweiterte Optionen anzeigen",
|
||||||
"hide_advanced_options": "Hide Advanced Options",
|
"hide_advanced_options": "Erweiterte Optionen ausblenden",
|
||||||
"oidc_data_preview": "OIDC Data Preview",
|
"oidc_data_preview": "OIDC Daten-Vorschau",
|
||||||
"preview_the_oidc_data_that_would_be_sent_for_different_users": "Preview the OIDC data that would be sent for different users",
|
"preview_the_oidc_data_that_would_be_sent_for_different_users": "Vorschau der OIDC-Daten, die für verschiedene Nutzer gesendet werden sollen",
|
||||||
"id_token": "ID Token",
|
"id_token": "ID Token",
|
||||||
"access_token": "Access Token",
|
"access_token": "Access Token",
|
||||||
"userinfo": "Userinfo",
|
"userinfo": "Userinfo",
|
||||||
"id_token_payload": "ID Token Payload",
|
"id_token_payload": "ID Token Payload",
|
||||||
"access_token_payload": "Access Token Payload",
|
"access_token_payload": "Access Token Payload",
|
||||||
"userinfo_endpoint_response": "Userinfo Endpoint Response",
|
"userinfo_endpoint_response": "Userinfo Endpoint Antwort",
|
||||||
"copy": "Copy",
|
"copy": "Kopieren",
|
||||||
"no_preview_data_available": "No preview data available",
|
"no_preview_data_available": "Keine Vorschaudaten verfügbar",
|
||||||
"copy_all": "Copy All",
|
"copy_all": "Alles kopieren",
|
||||||
"preview": "Preview",
|
"preview": "Vorschau",
|
||||||
"preview_for_user": "Preview for {name} ({email})",
|
"preview_for_user": "Vorschau für {name} ({email})",
|
||||||
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Preview the OIDC data that would be sent for this user",
|
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Vorschau der OIDC-Daten, für diesen Benutzer",
|
||||||
"show": "Show",
|
"show": "Anzeigen",
|
||||||
"select_an_option": "Select an option",
|
"select_an_option": "Wähle eine Option",
|
||||||
"select_user": "Select User",
|
"select_user": "Benutzer auswählen",
|
||||||
"error": "Error"
|
"error": "Fehler",
|
||||||
|
"select_an_accent_color_to_customize_the_appearance_of_pocket_id": "Wähl eine Akzentfarbe aus, um das Aussehen von Pocket ID anzupassen.",
|
||||||
|
"accent_color": "Akzentfarbe",
|
||||||
|
"custom_accent_color": "Benutzerdefinierte Akzentfarbe",
|
||||||
|
"custom_accent_color_description": "Geben Sie eine benutzerdefinierte Farbe mit gültigen CSS-Farbformaten ein (z.B. hex, rgb, hsl).",
|
||||||
|
"color_value": "Farbwert",
|
||||||
|
"apply": "Übernehmen",
|
||||||
|
"signup_token": "Anmeldungstoken",
|
||||||
|
"create_a_signup_token_to_allow_new_user_registration": "Erstell ein Anmeldetoken, damit sich neue Benutzer registrieren können.",
|
||||||
|
"usage_limit": "Nutzungsbeschränkung",
|
||||||
|
"number_of_times_token_can_be_used": "Wie oft der Anmeldetoken benutzt werden kann.",
|
||||||
|
"expires": "Läuft ab",
|
||||||
|
"signup": "Anmelden",
|
||||||
|
"signup_requires_valid_token": "Zum Erstellen eines Kontos brauchst du einen gültigen Anmeldetoken.",
|
||||||
|
"validating_signup_token": "Anmeldungstoken bestätigen",
|
||||||
|
"go_to_login": "Zum Login gehen",
|
||||||
|
"signup_to_appname": "Melde dich bei „ {appName}“ an",
|
||||||
|
"create_your_account_to_get_started": "Erstell dein Konto, um loszulegen.",
|
||||||
|
"initial_account_creation_description": "Erstell dein Konto, um loszulegen. Du kannst später einen Passkey einrichten.",
|
||||||
|
"setup_your_passkey": "Passwort einrichten",
|
||||||
|
"create_a_passkey_to_securely_access_your_account": "Erstell einen Passkey, um sicher auf dein Konto zuzugreifen. Das wird deine Hauptmethode zum Anmelden sein.",
|
||||||
|
"skip_for_now": "Jetzt überspringen",
|
||||||
|
"account_created": "Konto erstellt",
|
||||||
|
"enable_user_signups": "Benutzeranmeldungen aktivieren",
|
||||||
|
"enable_user_signups_description": "Ob die Funktion zur Benutzeranmeldung aktiviert werden soll.",
|
||||||
|
"user_signups_are_disabled": "Benutzeranmeldungen sind im Moment deaktiviert.",
|
||||||
|
"create_signup_token": "Anmeldungstoken erstellen",
|
||||||
|
"view_active_signup_tokens": "Aktive Anmeldetoken anzeigen",
|
||||||
|
"manage_signup_tokens": "Anmeldungstoken verwalten",
|
||||||
|
"view_and_manage_active_signup_tokens": "Aktive Anmeldetoken anzeigen und verwalten.",
|
||||||
|
"signup_token_deleted_successfully": "Anmeldungstoken erfolgreich gelöscht.",
|
||||||
|
"expired": "Abgelaufen",
|
||||||
|
"used_up": "Aufgebraucht",
|
||||||
|
"active": "Aktiv",
|
||||||
|
"usage": "Verwendung",
|
||||||
|
"created": "Erstellt",
|
||||||
|
"token": "Token",
|
||||||
|
"loading": "Laden",
|
||||||
|
"delete_signup_token": "Anmeldungstoken löschen",
|
||||||
|
"are_you_sure_you_want_to_delete_this_signup_token": "Willst du diesen Anmeldetoken wirklich löschen? Das kannst du nicht rückgängig machen.",
|
||||||
|
"signup_disabled_description": "Benutzeranmeldungen sind komplett deaktiviert. Nur Admins können neue Benutzerkonten erstellen.",
|
||||||
|
"signup_with_token": "Mit Token anmelden",
|
||||||
|
"signup_with_token_description": "Benutzer können sich nur mit einem gültigen Anmeldetoken anmelden, das von einem Administrator erstellt wurde.",
|
||||||
|
"signup_open": "Anmeldung offen",
|
||||||
|
"signup_open_description": "Jeder kann ohne Einschränkungen ein neues Konto erstellen.",
|
||||||
|
"of": "von",
|
||||||
|
"skip_passkey_setup": "Passwort-Einrichtung überspringen",
|
||||||
|
"skip_passkey_setup_description": "Es wird dringend empfohlen, einen Passkey einzurichten, da du sonst nach Ablauf der Sitzung aus deinem Konto ausgesperrt wirst."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,11 +65,9 @@
|
|||||||
"do_you_want_to_sign_out_of_pocketid_with_the_account": "Do you want to sign out of {appName} with the account <b>{username}</b>?",
|
"do_you_want_to_sign_out_of_pocketid_with_the_account": "Do you want to sign out of {appName} with the account <b>{username}</b>?",
|
||||||
"sign_in_to_appname": "Sign in to {appName}",
|
"sign_in_to_appname": "Sign in to {appName}",
|
||||||
"please_try_to_sign_in_again": "Please try to sign in again.",
|
"please_try_to_sign_in_again": "Please try to sign in again.",
|
||||||
"authenticate_yourself_with_your_passkey_to_access_the_admin_panel": "Authenticate yourself with your passkey to access the admin panel.",
|
"authenticate_with_passkey_to_access_account": "Authenticate yourself with your passkey to access your account.",
|
||||||
"authenticate": "Authenticate",
|
"authenticate": "Authenticate",
|
||||||
"appname_setup": "{appName} Setup",
|
|
||||||
"please_try_again": "Please try again.",
|
"please_try_again": "Please try again.",
|
||||||
"you_are_about_to_sign_in_to_the_initial_admin_account": "You're about to sign in to the initial admin account. Anyone with this link can access the account until a passkey is added. Please set up a passkey as soon as possible to prevent unauthorized access.",
|
|
||||||
"continue": "Continue",
|
"continue": "Continue",
|
||||||
"alternative_sign_in": "Alternative Sign In",
|
"alternative_sign_in": "Alternative Sign In",
|
||||||
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "If you don't have access to your passkey, you can sign in using one of the following methods.",
|
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "If you don't have access to your passkey, you can sign in using one of the following methods.",
|
||||||
@@ -180,7 +178,7 @@
|
|||||||
"email_login_notification": "Email Login Notification",
|
"email_login_notification": "Email Login Notification",
|
||||||
"send_an_email_to_the_user_when_they_log_in_from_a_new_device": "Send an email to the user when they log in from a new device.",
|
"send_an_email_to_the_user_when_they_log_in_from_a_new_device": "Send an email to the user when they log in from a new device.",
|
||||||
"emai_login_code_requested_by_user": "Email Login Code Requested by User",
|
"emai_login_code_requested_by_user": "Email Login Code Requested by User",
|
||||||
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Allows users to bypass passkeys by requesting a login code sent to their email. This reduces the security significantly as anyone with access to the user's email can gain entry.",
|
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Allows users to bypass passkeys by requesting a login code sent to their email. This significantly reduces security as anyone with access to the user's email can gain entry.",
|
||||||
"email_login_code_from_admin": "Email Login Code from Admin",
|
"email_login_code_from_admin": "Email Login Code from Admin",
|
||||||
"allows_an_admin_to_send_a_login_code_to_the_user": "Allows an admin to send a login code to the user via email.",
|
"allows_an_admin_to_send_a_login_code_to_the_user": "Allows an admin to send a login code to the user via email.",
|
||||||
"send_test_email": "Send test email",
|
"send_test_email": "Send test email",
|
||||||
@@ -310,23 +308,25 @@
|
|||||||
"background_image": "Background Image",
|
"background_image": "Background Image",
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
"reset_profile_picture_question": "Reset profile picture?",
|
"reset_profile_picture_question": "Reset profile picture?",
|
||||||
"this_will_remove_the_uploaded_image_and_reset_the_profile_picture_to_default": "This will remove the uploaded image, and reset the profile picture to default. Do you want to continue?",
|
"this_will_remove_the_uploaded_image_and_reset_the_profile_picture_to_default": "This will remove the uploaded image and reset the profile picture to default. Do you want to continue?",
|
||||||
"reset": "Reset",
|
"reset": "Reset",
|
||||||
"reset_to_default": "Reset to default",
|
"reset_to_default": "Reset to default",
|
||||||
"profile_picture_has_been_reset": "Profile picture has been reset. It may take a few minutes to update.",
|
"profile_picture_has_been_reset": "Profile picture has been reset. It may take a few minutes to update.",
|
||||||
"select_the_language_you_want_to_use": "Select the language you want to use. Some languages may not be fully translated.",
|
"select_the_language_you_want_to_use": "Select the language you want to use. Please note that some text may be automatically translated and could be inaccurate.",
|
||||||
|
"contribute_to_translation": "If you find an issue you're welcome to contribute to the translation on <link href='https://crowdin.com/project/pocket-id'>Crowdin</link>.",
|
||||||
"personal": "Personal",
|
"personal": "Personal",
|
||||||
"global": "Global",
|
"global": "Global",
|
||||||
"all_users": "All Users",
|
"all_users": "All Users",
|
||||||
"all_events": "All Events",
|
"all_events": "All Events",
|
||||||
"all_clients": "All Clients",
|
"all_clients": "All Clients",
|
||||||
|
"all_locations": "All Locations",
|
||||||
"global_audit_log": "Global Audit Log",
|
"global_audit_log": "Global Audit Log",
|
||||||
"see_all_account_activities_from_the_last_3_months": "See all user activity for the last 3 months.",
|
"see_all_account_activities_from_the_last_3_months": "See all user activity for the last 3 months.",
|
||||||
"token_sign_in": "Token Sign In",
|
"token_sign_in": "Token Sign In",
|
||||||
"client_authorization": "Client Authorization",
|
"client_authorization": "Client Authorization",
|
||||||
"new_client_authorization": "New Client Authorization",
|
"new_client_authorization": "New Client Authorization",
|
||||||
"disable_animations": "Disable Animations",
|
"disable_animations": "Disable Animations",
|
||||||
"turn_off_ui_animations": "Turn off animations troughout the UI.",
|
"turn_off_ui_animations": "Turn off animations throughout the UI.",
|
||||||
"user_disabled": "Account Disabled",
|
"user_disabled": "Account Disabled",
|
||||||
"disabled_users_cannot_log_in_or_use_services": "Disabled users cannot log in or use services.",
|
"disabled_users_cannot_log_in_or_use_services": "Disabled users cannot log in or use services.",
|
||||||
"user_disabled_successfully": "User has been disabled successfully.",
|
"user_disabled_successfully": "User has been disabled successfully.",
|
||||||
@@ -372,5 +372,52 @@
|
|||||||
"show": "Show",
|
"show": "Show",
|
||||||
"select_an_option": "Select an option",
|
"select_an_option": "Select an option",
|
||||||
"select_user": "Select User",
|
"select_user": "Select User",
|
||||||
"error": "Error"
|
"error": "Error",
|
||||||
|
"select_an_accent_color_to_customize_the_appearance_of_pocket_id": "Select an accent color to customize the appearance of Pocket ID.",
|
||||||
|
"accent_color": "Accent Color",
|
||||||
|
"custom_accent_color": "Custom Accent Color",
|
||||||
|
"custom_accent_color_description": "Enter a custom color using valid CSS color formats (e.g., hex, rgb, hsl).",
|
||||||
|
"color_value": "Color Value",
|
||||||
|
"apply": "Apply",
|
||||||
|
"signup_token": "Signup Token",
|
||||||
|
"create_a_signup_token_to_allow_new_user_registration": "Create a signup token to allow new user registration.",
|
||||||
|
"usage_limit": "Usage Limit",
|
||||||
|
"number_of_times_token_can_be_used": "Number of times the signup token can be used.",
|
||||||
|
"expires": "Expires",
|
||||||
|
"signup": "Sign Up",
|
||||||
|
"signup_requires_valid_token": "A valid signup token is required to create an account",
|
||||||
|
"validating_signup_token": "Validating signup token",
|
||||||
|
"go_to_login": "Go to login",
|
||||||
|
"signup_to_appname": "Sign Up to {appName}",
|
||||||
|
"create_your_account_to_get_started": "Create your account to get started.",
|
||||||
|
"initial_account_creation_description": "Please create your account to get started. You will be able to set up a passkey later.",
|
||||||
|
"setup_your_passkey": "Set up your passkey",
|
||||||
|
"create_a_passkey_to_securely_access_your_account": "Create a passkey to securely access your account. This will be your primary way to sign in.",
|
||||||
|
"skip_for_now": "Skip for now",
|
||||||
|
"account_created": "Account Created",
|
||||||
|
"enable_user_signups": "Enable User Signups",
|
||||||
|
"enable_user_signups_description": "Whether the User Signup functionality should be enabled.",
|
||||||
|
"user_signups_are_disabled": "User signups are currently disabled",
|
||||||
|
"create_signup_token": "Create Signup Token",
|
||||||
|
"view_active_signup_tokens": "View Active Signup Tokens",
|
||||||
|
"manage_signup_tokens": "Manage Signup Tokens",
|
||||||
|
"view_and_manage_active_signup_tokens": "View and manage active signup tokens.",
|
||||||
|
"signup_token_deleted_successfully": "Signup token deleted successfully.",
|
||||||
|
"expired": "Expired",
|
||||||
|
"used_up": "Used Up",
|
||||||
|
"active": "Active",
|
||||||
|
"usage": "Usage",
|
||||||
|
"created": "Created",
|
||||||
|
"token": "Token",
|
||||||
|
"loading": "Loading",
|
||||||
|
"delete_signup_token": "Delete Signup Token",
|
||||||
|
"are_you_sure_you_want_to_delete_this_signup_token": "Are you sure you want to delete this signup token? This action cannot be undone.",
|
||||||
|
"signup_disabled_description": "User signups are completely disabled. Only administrators can create new user accounts.",
|
||||||
|
"signup_with_token": "Signup with token",
|
||||||
|
"signup_with_token_description": "Users can only sign up using a valid signup token created by an administrator.",
|
||||||
|
"signup_open": "Open Signup",
|
||||||
|
"signup_open_description": "Anyone can create a new account without restrictions.",
|
||||||
|
"of": "of",
|
||||||
|
"skip_passkey_setup": "Skip Passkey Setup",
|
||||||
|
"skip_passkey_setup_description": "It's highly recommended to set up a passkey because without one, you will be locked out of your account as soon as the session expires."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
"my_account": "Mi Cuenta",
|
"my_account": "Mi Cuenta",
|
||||||
"logout": "Cerrar sesión",
|
"logout": "Cerrar sesión",
|
||||||
"confirm": "Confirmar",
|
"confirm": "Confirmar",
|
||||||
|
"docs": "Documentos",
|
||||||
"key": "Clave",
|
"key": "Clave",
|
||||||
"value": "Valor",
|
"value": "Valor",
|
||||||
"remove_custom_claim": "Eliminar reclamo personalizado",
|
"remove_custom_claim": "Eliminar reclamo personalizado",
|
||||||
@@ -24,7 +25,7 @@
|
|||||||
"go_back_to_home": "Volver al Inicio",
|
"go_back_to_home": "Volver al Inicio",
|
||||||
"dont_have_access_to_your_passkey": "¿No tiene acceso a su Passkey?",
|
"dont_have_access_to_your_passkey": "¿No tiene acceso a su Passkey?",
|
||||||
"login_background": "Fondo de página de acceso",
|
"login_background": "Fondo de página de acceso",
|
||||||
"logo": "Logo",
|
"logo": "Logotipo",
|
||||||
"login_code": "Código de inicio de sesión",
|
"login_code": "Código de inicio de sesión",
|
||||||
"create_a_login_code_to_sign_in_without_a_passkey_once": "Crear un código de acceso que el usuario pueda utilizar para iniciar sesión sin un Passkey una vez.",
|
"create_a_login_code_to_sign_in_without_a_passkey_once": "Crear un código de acceso que el usuario pueda utilizar para iniciar sesión sin un Passkey una vez.",
|
||||||
"one_hour": "1 hora",
|
"one_hour": "1 hora",
|
||||||
@@ -64,11 +65,9 @@
|
|||||||
"do_you_want_to_sign_out_of_pocketid_with_the_account": "¿Quieres cerrar sesión de Pocket ID con la cuenta <b>{username}</b>?",
|
"do_you_want_to_sign_out_of_pocketid_with_the_account": "¿Quieres cerrar sesión de Pocket ID con la cuenta <b>{username}</b>?",
|
||||||
"sign_in_to_appname": "Iniciar sesión en {appName}",
|
"sign_in_to_appname": "Iniciar sesión en {appName}",
|
||||||
"please_try_to_sign_in_again": "Por favor, intente iniciar sesión de nuevo.",
|
"please_try_to_sign_in_again": "Por favor, intente iniciar sesión de nuevo.",
|
||||||
"authenticate_yourself_with_your_passkey_to_access_the_admin_panel": "Autenticar con tu Passkey para acceder al panel de administración.",
|
"authenticate_with_passkey_to_access_account": "Autentifíquese con su clave de acceso para acceder a su cuenta.",
|
||||||
"authenticate": "Autenticar",
|
"authenticate": "Autenticar",
|
||||||
"appname_setup": "Configuración de {appName}",
|
|
||||||
"please_try_again": "Por favor intente nuevamente.",
|
"please_try_again": "Por favor intente nuevamente.",
|
||||||
"you_are_about_to_sign_in_to_the_initial_admin_account": "Estás a punto de iniciar sesión en la cuenta de administrador inicial. Cualquiera con este enlace puede acceder a la cuenta hasta que se agregue un Passkey. Por favor, configure un Passkey lo antes posible para evitar acceso no autorizado.",
|
|
||||||
"continue": "Continuar",
|
"continue": "Continuar",
|
||||||
"alternative_sign_in": "Inicio de sesión alternativa",
|
"alternative_sign_in": "Inicio de sesión alternativa",
|
||||||
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "Si no tiene acceso a su Passkey, puede iniciar sesión usando uno de los siguientes métodos.",
|
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "Si no tiene acceso a su Passkey, puede iniciar sesión usando uno de los siguientes métodos.",
|
||||||
@@ -138,7 +137,7 @@
|
|||||||
"api_key_created": "API Key creada",
|
"api_key_created": "API Key creada",
|
||||||
"for_security_reasons_this_key_will_only_be_shown_once": "Por razones de seguridad, esta clave sólo se mostrará una vez. Por favor, guárdala de forma segura.",
|
"for_security_reasons_this_key_will_only_be_shown_once": "Por razones de seguridad, esta clave sólo se mostrará una vez. Por favor, guárdala de forma segura.",
|
||||||
"description": "Descripción",
|
"description": "Descripción",
|
||||||
"api_key": "API Key",
|
"api_key": "Clave API",
|
||||||
"close": "Cerrar",
|
"close": "Cerrar",
|
||||||
"name_to_identify_this_api_key": "Nombra esta API Key para identificarla.",
|
"name_to_identify_this_api_key": "Nombra esta API Key para identificarla.",
|
||||||
"expires_at": "Expira el",
|
"expires_at": "Expira el",
|
||||||
@@ -170,206 +169,255 @@
|
|||||||
"smtp_port": "Puerto SMTP",
|
"smtp_port": "Puerto SMTP",
|
||||||
"smtp_user": "Usuario SMTP",
|
"smtp_user": "Usuario SMTP",
|
||||||
"smtp_password": "Contraseña SMTP",
|
"smtp_password": "Contraseña SMTP",
|
||||||
"smtp_from": "SMTP From",
|
"smtp_from": "SMTP Desde",
|
||||||
"smtp_tls_option": "SMTP TLS Option",
|
"smtp_tls_option": "Opción SMTP TLS",
|
||||||
"email_tls_option": "Email TLS Option",
|
"email_tls_option": "Opción TLS para correo electrónico",
|
||||||
"skip_certificate_verification": "Omitir la verificación del certificado",
|
"skip_certificate_verification": "Omitir la verificación del certificado",
|
||||||
"this_can_be_useful_for_selfsigned_certificates": "Esto puede ser útil para certificados autofirmados.",
|
"this_can_be_useful_for_selfsigned_certificates": "Esto puede ser útil para certificados autofirmados.",
|
||||||
"enabled_emails": "Enabled Emails",
|
"enabled_emails": "Correos electrónicos habilitados",
|
||||||
"email_login_notification": "Email Login Notification",
|
"email_login_notification": "Notificación de inicio de sesión por correo electrónico",
|
||||||
"send_an_email_to_the_user_when_they_log_in_from_a_new_device": "Enviar un correo electrónico al usuario cuando inicie sesión desde un dispositivo nuevo.",
|
"send_an_email_to_the_user_when_they_log_in_from_a_new_device": "Enviar un correo electrónico al usuario cuando inicie sesión desde un dispositivo nuevo.",
|
||||||
"emai_login_code_requested_by_user": "Código de acceso solicitado por el usuario",
|
"emai_login_code_requested_by_user": "Código de acceso solicitado por el usuario",
|
||||||
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Permite a los usuarios saltarse las claves de acceso solicitando un código de acceso enviado a su correo electrónico. Esto reduce la seguridad significativamente, ya que cualquiera con acceso al correo electrónico del usuario puede obtener acceso.",
|
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Permite a los usuarios omitir las claves de acceso solicitando un código de inicio de sesión que se envía a su correo electrónico. Esto reduce significativamente la seguridad, ya que cualquier persona con acceso al correo electrónico del usuario puede obtener acceso.",
|
||||||
"email_login_code_from_admin": "Email Login Code from Admin",
|
"email_login_code_from_admin": "Código de inicio de sesión por correo electrónico del administrador",
|
||||||
"allows_an_admin_to_send_a_login_code_to_the_user": "Permite a un administrador enviar un código de acceso al usuario por correo electrónico.",
|
"allows_an_admin_to_send_a_login_code_to_the_user": "Permite a un administrador enviar un código de acceso al usuario por correo electrónico.",
|
||||||
"send_test_email": "Enviar correo de prueba",
|
"send_test_email": "Enviar correo de prueba",
|
||||||
"application_configuration_updated_successfully": "Configuración actualizada correctamente",
|
"application_configuration_updated_successfully": "Configuración actualizada correctamente",
|
||||||
"application_name": "Nombre de la aplicación",
|
"application_name": "Nombre de la aplicación",
|
||||||
"session_duration": "Duración de la sesión",
|
"session_duration": "Duración de la sesión",
|
||||||
"the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "La duración de una sesión en minutos antes de que el usuario tenga que iniciar sesión de nuevo.",
|
"the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "La duración de una sesión en minutos antes de que el usuario tenga que iniciar sesión de nuevo.",
|
||||||
"enable_self_account_editing": "Enable Self-Account Editing",
|
"enable_self_account_editing": "Habilitar la edición de la cuenta personal",
|
||||||
"whether_the_users_should_be_able_to_edit_their_own_account_details": "Si los usuarios deberían poder editar los detalles de su propia cuenta.",
|
"whether_the_users_should_be_able_to_edit_their_own_account_details": "Si los usuarios deberían poder editar los detalles de su propia cuenta.",
|
||||||
"emails_verified": "Emails Verified",
|
"emails_verified": "Correos electrónicos verificados",
|
||||||
"whether_the_users_email_should_be_marked_as_verified_for_the_oidc_clients": "Whether the user's email should be marked as verified for the OIDC clients.",
|
"whether_the_users_email_should_be_marked_as_verified_for_the_oidc_clients": "Si el correo electrónico del usuario debe marcarse como verificado para los clientes OIDC.",
|
||||||
"ldap_configuration_updated_successfully": "LDAP configuration updated successfully",
|
"ldap_configuration_updated_successfully": "Configuración LDAP actualizada correctamente",
|
||||||
"ldap_disabled_successfully": "LDAP disabled successfully",
|
"ldap_disabled_successfully": "LDAP desactivado correctamente",
|
||||||
"ldap_sync_finished": "LDAP sync finished",
|
"ldap_sync_finished": "Sincronización LDAP finalizada",
|
||||||
"client_configuration": "Client Configuration",
|
"client_configuration": "Configuración del cliente",
|
||||||
"ldap_url": "LDAP URL",
|
"ldap_url": "URL LDAP",
|
||||||
"ldap_bind_dn": "LDAP Bind DN",
|
"ldap_bind_dn": "DN de enlace LDAP",
|
||||||
"ldap_bind_password": "LDAP Bind Password",
|
"ldap_bind_password": "Contraseña de enlace LDAP",
|
||||||
"ldap_base_dn": "LDAP Base DN",
|
"ldap_base_dn": "DN base LDAP",
|
||||||
"user_search_filter": "User Search Filter",
|
"user_search_filter": "Filtro de búsqueda de usuarios",
|
||||||
"the_search_filter_to_use_to_search_or_sync_users": "The Search filter to use to search/sync users.",
|
"the_search_filter_to_use_to_search_or_sync_users": "El filtro de búsqueda que se utilizará para buscar/sincronizar usuarios.",
|
||||||
"groups_search_filter": "Groups Search Filter",
|
"groups_search_filter": "Filtro de búsqueda de grupos",
|
||||||
"the_search_filter_to_use_to_search_or_sync_groups": "The Search filter to use to search/sync groups.",
|
"the_search_filter_to_use_to_search_or_sync_groups": "El filtro de búsqueda que se utilizará para buscar/sincronizar grupos.",
|
||||||
"attribute_mapping": "Attribute Mapping",
|
"attribute_mapping": "Asignación de atributos",
|
||||||
"user_unique_identifier_attribute": "User Unique Identifier Attribute",
|
"user_unique_identifier_attribute": "Atributo identificador único de usuario",
|
||||||
"the_value_of_this_attribute_should_never_change": "The value of this attribute should never change.",
|
"the_value_of_this_attribute_should_never_change": "El valor de este atributo nunca debe cambiar.",
|
||||||
"username_attribute": "Atributo Nombre de usuario",
|
"username_attribute": "Atributo Nombre de usuario",
|
||||||
"user_mail_attribute": "Atributo de Correo de Usuario",
|
"user_mail_attribute": "Atributo de Correo de Usuario",
|
||||||
"user_first_name_attribute": "User First Name Attribute",
|
"user_first_name_attribute": "Atributo «Nombre de usuario»",
|
||||||
"user_last_name_attribute": "User Last Name Attribute",
|
"user_last_name_attribute": "Atributo de apellido del usuario",
|
||||||
"user_profile_picture_attribute": "User Profile Picture Attribute",
|
"user_profile_picture_attribute": "Atributo de la imagen del perfil del usuario",
|
||||||
"the_value_of_this_attribute_can_either_be_a_url_binary_or_base64_encoded_image": "The value of this attribute can either be a URL, a binary or a base64 encoded image.",
|
"the_value_of_this_attribute_can_either_be_a_url_binary_or_base64_encoded_image": "El valor de este atributo puede ser una URL, un archivo binario o una imagen codificada en base64.",
|
||||||
"group_members_attribute": "Group Members Attribute",
|
"group_members_attribute": "Atributo de los miembros del grupo",
|
||||||
"the_attribute_to_use_for_querying_members_of_a_group": "The attribute to use for querying members of a group.",
|
"the_attribute_to_use_for_querying_members_of_a_group": "El atributo que se utilizará para consultar los miembros de un grupo.",
|
||||||
"group_unique_identifier_attribute": "Group Unique Identifier Attribute",
|
"group_unique_identifier_attribute": "Atributo identificador único de grupo",
|
||||||
"group_name_attribute": "Group Name Attribute",
|
"group_name_attribute": "Atributo de nombre de grupo",
|
||||||
"admin_group_name": "Admin Group Name",
|
"admin_group_name": "Nombre del grupo de administración",
|
||||||
"members_of_this_group_will_have_admin_privileges_in_pocketid": "Members of this group will have Admin Privileges in Pocket ID.",
|
"members_of_this_group_will_have_admin_privileges_in_pocketid": "Los miembros de este grupo tendrán privilegios de administrador en Pocket ID.",
|
||||||
"disable": "Disable",
|
"disable": "Desactivar",
|
||||||
"sync_now": "Sync now",
|
"sync_now": "Sincronizar ahora",
|
||||||
"enable": "Enable",
|
"enable": "Habilitar",
|
||||||
"user_created_successfully": "User created successfully",
|
"user_created_successfully": "Usuario creado correctamente",
|
||||||
"create_user": "Create User",
|
"create_user": "Crear usuario",
|
||||||
"add_a_new_user_to_appname": "Add a new user to {appName}",
|
"add_a_new_user_to_appname": "Añade un nuevo usuario a {appName}",
|
||||||
"add_user": "Add User",
|
"add_user": "Añadir usuario",
|
||||||
"manage_users": "Manage Users",
|
"manage_users": "Administrar usuarios",
|
||||||
"admin_privileges": "Admin Privileges",
|
"admin_privileges": "Privilegios de administrador",
|
||||||
"admins_have_full_access_to_the_admin_panel": "Admins have full access to the admin panel.",
|
"admins_have_full_access_to_the_admin_panel": "Los administradores tienen acceso completo al panel de administración.",
|
||||||
"delete_firstname_lastname": "Delete {firstName} {lastName}",
|
"delete_firstname_lastname": "Eliminar {firstName} {lastName}",
|
||||||
"are_you_sure_you_want_to_delete_this_user": "Are you sure you want to delete this user?",
|
"are_you_sure_you_want_to_delete_this_user": "¿Estás seguro de que deseas eliminar este usuario?",
|
||||||
"user_deleted_successfully": "User deleted successfully",
|
"user_deleted_successfully": "Usuario eliminado correctamente",
|
||||||
"role": "Role",
|
"role": "Función",
|
||||||
"source": "Source",
|
"source": "Fuente",
|
||||||
"admin": "Admin",
|
"admin": "Administrador",
|
||||||
"user": "User",
|
"user": "Usuario",
|
||||||
"local": "Local",
|
"local": "Local",
|
||||||
"toggle_menu": "Toggle menu",
|
"toggle_menu": "Menú desplegable",
|
||||||
"edit": "Edit",
|
"edit": "Editar",
|
||||||
"user_groups_updated_successfully": "User groups updated successfully",
|
"user_groups_updated_successfully": "Grupos de usuarios actualizados correctamente",
|
||||||
"user_updated_successfully": "User updated successfully",
|
"user_updated_successfully": "Usuario actualizado correctamente",
|
||||||
"custom_claims_updated_successfully": "Custom claims updated successfully",
|
"custom_claims_updated_successfully": "Reclamaciones personalizadas actualizadas correctamente",
|
||||||
"back": "Back",
|
"back": "Atrás",
|
||||||
"user_details_firstname_lastname": "User Details {firstName} {lastName}",
|
"user_details_firstname_lastname": "Detalles del usuario {firstName} {lastName}",
|
||||||
"manage_which_groups_this_user_belongs_to": "Manage which groups this user belongs to.",
|
"manage_which_groups_this_user_belongs_to": "Gestiona los grupos a los que pertenece este usuario.",
|
||||||
"custom_claims": "Custom Claims",
|
"custom_claims": "Reclamaciones personalizadas",
|
||||||
"custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user": "Custom claims are key-value pairs that can be used to store additional information about a user. These claims will be included in the ID token if the scope 'profile' is requested.",
|
"custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user": "Las reclamaciones personalizadas son pares clave-valor que se pueden utilizar para almacenar información adicional sobre un usuario. Estas reclamaciones se incluirán en el token de identificación si se solicita el ámbito «perfil».",
|
||||||
"user_group_created_successfully": "User group created successfully",
|
"user_group_created_successfully": "Grupo de usuarios creado correctamente",
|
||||||
"create_user_group": "Create User Group",
|
"create_user_group": "Crear grupo de usuarios",
|
||||||
"create_a_new_group_that_can_be_assigned_to_users": "Create a new group that can be assigned to users.",
|
"create_a_new_group_that_can_be_assigned_to_users": "Crea un nuevo grupo que se pueda asignar a los usuarios.",
|
||||||
"add_group": "Add Group",
|
"add_group": "Añadir grupo",
|
||||||
"manage_user_groups": "Manage User Groups",
|
"manage_user_groups": "Gestionar grupos de usuarios",
|
||||||
"friendly_name": "Friendly Name",
|
"friendly_name": "Nombre descriptivo",
|
||||||
"name_that_will_be_displayed_in_the_ui": "Name that will be displayed in the UI",
|
"name_that_will_be_displayed_in_the_ui": "Nombre que se mostrará en la interfaz de usuario.",
|
||||||
"name_that_will_be_in_the_groups_claim": "Name that will be in the \"groups\" claim",
|
"name_that_will_be_in_the_groups_claim": "Nombre que aparecerá en la reclamación «grupos».",
|
||||||
"delete_name": "Delete {name}",
|
"delete_name": "Eliminar {name}",
|
||||||
"are_you_sure_you_want_to_delete_this_user_group": "Are you sure you want to delete this user group?",
|
"are_you_sure_you_want_to_delete_this_user_group": "¿Estás seguro de que deseas eliminar este grupo de usuarios?",
|
||||||
"user_group_deleted_successfully": "User group deleted successfully",
|
"user_group_deleted_successfully": "Grupo de usuarios eliminado correctamente",
|
||||||
"user_count": "User Count",
|
"user_count": "Número de usuarios",
|
||||||
"user_group_updated_successfully": "User group updated successfully",
|
"user_group_updated_successfully": "Grupo de usuarios actualizado correctamente",
|
||||||
"users_updated_successfully": "Users updated successfully",
|
"users_updated_successfully": "Usuarios actualizados correctamente",
|
||||||
"user_group_details_name": "User Group Details {name}",
|
"user_group_details_name": "Detalles del grupo de usuarios {name}",
|
||||||
"assign_users_to_this_group": "Assign users to this group.",
|
"assign_users_to_this_group": "Asigna usuarios a este grupo.",
|
||||||
"custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user_prioritized": "Custom claims are key-value pairs that can be used to store additional information about a user. These claims will be included in the ID token if the scope 'profile' is requested. Custom claims defined on the user will be prioritized if there are conflicts.",
|
"custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user_prioritized": "Las reclamaciones personalizadas son pares clave-valor que se pueden utilizar para almacenar información adicional sobre un usuario. Estas reclamaciones se incluirán en el token de identificación si se solicita el ámbito «perfil». Las reclamaciones personalizadas definidas en el usuario tendrán prioridad si hay conflictos.",
|
||||||
"oidc_client_created_successfully": "OIDC client created successfully",
|
"oidc_client_created_successfully": "Cliente OIDC creado correctamente",
|
||||||
"create_oidc_client": "Create OIDC Client",
|
"create_oidc_client": "Crear cliente OIDC",
|
||||||
"add_a_new_oidc_client_to_appname": "Add a new OIDC client to {appName}.",
|
"add_a_new_oidc_client_to_appname": "Añade un nuevo cliente OIDC a {appName}.",
|
||||||
"add_oidc_client": "Add OIDC Client",
|
"add_oidc_client": "Añadir cliente OIDC",
|
||||||
"manage_oidc_clients": "Manage OIDC Clients",
|
"manage_oidc_clients": "Gestionar clientes OIDC",
|
||||||
"one_time_link": "One Time Link",
|
"one_time_link": "Enlace único",
|
||||||
"use_this_link_to_sign_in_once": "Use this link to sign in once. This is needed for users who haven't added a passkey yet or have lost it.",
|
"use_this_link_to_sign_in_once": "Utiliza este enlace para iniciar sesión una vez. Esto es necesario para los usuarios que aún no han añadido una clave de acceso o la han perdido.",
|
||||||
"add": "Add",
|
"add": "Añadir",
|
||||||
"callback_urls": "Callback URLs",
|
"callback_urls": "URL de devolución de llamada",
|
||||||
"logout_callback_urls": "Logout Callback URLs",
|
"logout_callback_urls": "URL de devolución de llamada al cerrar sesión",
|
||||||
"public_client": "Public Client",
|
"public_client": "Cliente público",
|
||||||
"public_clients_description": "Public clients do not have a client secret and use PKCE instead. Enable this if your client is a SPA or mobile app.",
|
"public_clients_description": "Public clients do not have a client secret and use PKCE instead. Enable this if your client is a SPA or mobile app.",
|
||||||
"pkce": "PKCE",
|
"pkce": "PKCE",
|
||||||
"public_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks": "Public Key Code Exchange is a security feature to prevent CSRF and authorization code interception attacks.",
|
"public_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks": "El intercambio de claves públicas es una función de seguridad que evita los ataques CSRF y la interceptación de códigos de autorización.",
|
||||||
"name_logo": "{name} logo",
|
"name_logo": "{name} logotipo",
|
||||||
"change_logo": "Change Logo",
|
"change_logo": "Cambiar logotipo",
|
||||||
"upload_logo": "Subir Logo",
|
"upload_logo": "Subir Logo",
|
||||||
"remove_logo": "Remove Logo",
|
"remove_logo": "Eliminar logotipo",
|
||||||
"are_you_sure_you_want_to_delete_this_oidc_client": "Are you sure you want to delete this OIDC client?",
|
"are_you_sure_you_want_to_delete_this_oidc_client": "¿Estás seguro de que deseas eliminar este cliente OIDC?",
|
||||||
"oidc_client_deleted_successfully": "OIDC client deleted successfully",
|
"oidc_client_deleted_successfully": "Cliente OIDC eliminado correctamente",
|
||||||
"authorization_url": "Authorization URL",
|
"authorization_url": "URL de autorización",
|
||||||
"oidc_discovery_url": "OIDC Discovery URL",
|
"oidc_discovery_url": "URL de descubrimiento de OIDC",
|
||||||
"token_url": "Token URL",
|
"token_url": "URL del token",
|
||||||
"userinfo_url": "Userinfo URL",
|
"userinfo_url": "URL de información del usuario",
|
||||||
"logout_url": "Logout URL",
|
"logout_url": "URL de cierre de sesión",
|
||||||
"certificate_url": "Certificate URL",
|
"certificate_url": "URL del certificado",
|
||||||
"enabled": "Enabled",
|
"enabled": "Habilitado",
|
||||||
"disabled": "Disabled",
|
"disabled": "Discapacitado",
|
||||||
"oidc_client_updated_successfully": "OIDC client updated successfully",
|
"oidc_client_updated_successfully": "Cliente OIDC actualizado correctamente",
|
||||||
"create_new_client_secret": "Create new client secret",
|
"create_new_client_secret": "Crear nuevo secreto de cliente",
|
||||||
"are_you_sure_you_want_to_create_a_new_client_secret": "Are you sure you want to create a new client secret? The old one will be invalidated.",
|
"are_you_sure_you_want_to_create_a_new_client_secret": "¿Estás seguro de que deseas crear un nuevo secreto de cliente? El antiguo quedará invalidado.",
|
||||||
"generate": "Generate",
|
"generate": "Generar",
|
||||||
"new_client_secret_created_successfully": "New client secret created successfully",
|
"new_client_secret_created_successfully": "Se ha creado correctamente un nuevo secreto de cliente.",
|
||||||
"allowed_user_groups_updated_successfully": "Allowed user groups updated successfully",
|
"allowed_user_groups_updated_successfully": "Grupos de usuarios permitidos actualizados correctamente",
|
||||||
"oidc_client_name": "OIDC Client {name}",
|
"oidc_client_name": "Cliente OIDC {name}",
|
||||||
"client_id": "Client ID",
|
"client_id": "ID de cliente",
|
||||||
"client_secret": "Client secret",
|
"client_secret": "Secreto del cliente",
|
||||||
"show_more_details": "Show more details",
|
"show_more_details": "Mostrar más detalles",
|
||||||
"allowed_user_groups": "Allowed User Groups",
|
"allowed_user_groups": "Grupos de usuarios permitidos",
|
||||||
"add_user_groups_to_this_client_to_restrict_access_to_users_in_these_groups": "Add user groups to this client to restrict access to users in these groups. If no user groups are selected, all users will have access to this client.",
|
"add_user_groups_to_this_client_to_restrict_access_to_users_in_these_groups": "Añade grupos de usuarios a este cliente para restringir el acceso a los usuarios de estos grupos. Si no se selecciona ningún grupo de usuarios, todos los usuarios tendrán acceso a este cliente.",
|
||||||
"favicon": "Favicon",
|
"favicon": "Favicon",
|
||||||
"light_mode_logo": "Logo del modo Claro",
|
"light_mode_logo": "Logo del modo Claro",
|
||||||
"dark_mode_logo": "Dark Mode Logo",
|
"dark_mode_logo": "Logotipo en modo oscuro",
|
||||||
"background_image": "Background Image",
|
"background_image": "Imagen de fondo",
|
||||||
"language": "Language",
|
"language": "Idioma",
|
||||||
"reset_profile_picture_question": "Reset profile picture?",
|
"reset_profile_picture_question": "¿Restablecer foto de perfil?",
|
||||||
"this_will_remove_the_uploaded_image_and_reset_the_profile_picture_to_default": "This will remove the uploaded image, and reset the profile picture to default. Do you want to continue?",
|
"this_will_remove_the_uploaded_image_and_reset_the_profile_picture_to_default": "Esto eliminará la imagen subida y restablecerá la imagen de perfil predeterminada. ¿Quieres continuar?",
|
||||||
"reset": "Reset",
|
"reset": "Restablecer",
|
||||||
"reset_to_default": "Reset to default",
|
"reset_to_default": "Restablecer valores predeterminados",
|
||||||
"profile_picture_has_been_reset": "Profile picture has been reset. It may take a few minutes to update.",
|
"profile_picture_has_been_reset": "Se ha restablecido la foto de perfil. La actualización puede tardar unos minutos.",
|
||||||
"select_the_language_you_want_to_use": "Select the language you want to use. Some languages may not be fully translated.",
|
"select_the_language_you_want_to_use": "Selecciona el idioma que deseas utilizar. Ten en cuenta que algunos textos pueden traducirse automáticamente y pueden contener imprecisiones.",
|
||||||
|
"contribute_to_translation": "Si encuentras un problema, puedes contribuir a la traducción en <link href='https://crowdin.com/project/pocket-id'>Crowdin</link>.",
|
||||||
"personal": "Personal",
|
"personal": "Personal",
|
||||||
"global": "Global",
|
"global": "Global",
|
||||||
"all_users": "All Users",
|
"all_users": "Todos los usuarios",
|
||||||
"all_events": "All Events",
|
"all_events": "Todos los eventos",
|
||||||
"all_clients": "All Clients",
|
"all_clients": "Todos los clientes",
|
||||||
"global_audit_log": "Global Audit Log",
|
"all_locations": "Todas las ubicaciones",
|
||||||
"see_all_account_activities_from_the_last_3_months": "See all user activity for the last 3 months.",
|
"global_audit_log": "Registro de auditoría global",
|
||||||
"token_sign_in": "Token Sign In",
|
"see_all_account_activities_from_the_last_3_months": "Ver toda la actividad de los usuarios durante los últimos 3 meses.",
|
||||||
"client_authorization": "Client Authorization",
|
"token_sign_in": "Inicio de sesión con token",
|
||||||
"new_client_authorization": "New Client Authorization",
|
"client_authorization": "Autorización del cliente",
|
||||||
"disable_animations": "Disable Animations",
|
"new_client_authorization": "Autorización de nuevo cliente",
|
||||||
"turn_off_ui_animations": "Turn off all animations throughout the Admin UI.",
|
"disable_animations": "Desactivar animaciones",
|
||||||
"user_disabled": "Account Disabled",
|
"turn_off_ui_animations": "Desactiva las animaciones en toda la interfaz de usuario.",
|
||||||
"disabled_users_cannot_log_in_or_use_services": "Disabled users cannot log in or use services.",
|
"user_disabled": "Cuenta desactivada",
|
||||||
"user_disabled_successfully": "User has been disabled successfully.",
|
"disabled_users_cannot_log_in_or_use_services": "Los usuarios con discapacidad no pueden iniciar sesión ni utilizar los servicios.",
|
||||||
"user_enabled_successfully": "User has been enabled successfully.",
|
"user_disabled_successfully": "El usuario ha sido desactivado correctamente.",
|
||||||
"status": "Status",
|
"user_enabled_successfully": "El usuario se ha habilitado correctamente.",
|
||||||
"disable_firstname_lastname": "Disable {firstName} {lastName}",
|
"status": "Estado",
|
||||||
"are_you_sure_you_want_to_disable_this_user": "Are you sure you want to disable this user? They will not be able to log in or access any services.",
|
"disable_firstname_lastname": "Desactivar {firstName} {lastName}",
|
||||||
"ldap_soft_delete_users": "Keep disabled users from LDAP.",
|
"are_you_sure_you_want_to_disable_this_user": "¿Estás seguro de que deseas desactivar este usuario? No podrá iniciar sesión ni acceder a ningún servicio.",
|
||||||
"ldap_soft_delete_users_description": "When enabled, users removed from LDAP will be disabled rather than deleted from the system.",
|
"ldap_soft_delete_users": "Impide que los usuarios deshabilitados accedan a LDAP.",
|
||||||
"login_code_email_success": "The login code has been sent to the user.",
|
"ldap_soft_delete_users_description": "Cuando está habilitada, los usuarios eliminados de LDAP se desactivarán en lugar de eliminarse del sistema.",
|
||||||
"send_email": "Send Email",
|
"login_code_email_success": "El código de inicio de sesión se ha enviado al usuario.",
|
||||||
"show_code": "Show Code",
|
"send_email": "Enviar correo electrónico",
|
||||||
"callback_url_description": "URL(s) provided by your client. Will be automatically added if left blank. Wildcards (*) are supported, but best avoided for better security.",
|
"show_code": "Mostrar código",
|
||||||
"logout_callback_url_description": "URL(s) provided by your client for logout. Wildcards (*) are supported, but best avoided for better security.",
|
"callback_url_description": "URL proporcionadas por tu cliente. Se añadirán automáticamente si se dejan en blanco. Se admiten comodines (*), pero es mejor evitarlos por motivos de seguridad.",
|
||||||
"api_key_expiration": "API Key Expiration",
|
"logout_callback_url_description": "URL proporcionadas por tu cliente para cerrar sesión. Se admiten comodines (*), pero es mejor evitarlos para mayor seguridad.",
|
||||||
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Send an email to the user when their API key is about to expire.",
|
"api_key_expiration": "Caducidad de la clave API",
|
||||||
"authorize_device": "Authorize Device",
|
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Envía un correo electrónico al usuario cuando tu clave API esté a punto de caducar.",
|
||||||
"the_device_has_been_authorized": "The device has been authorized.",
|
"authorize_device": "Autorizar dispositivo",
|
||||||
"enter_code_displayed_in_previous_step": "Enter the code that was displayed in the previous step.",
|
"the_device_has_been_authorized": "El dispositivo ha sido autorizado.",
|
||||||
"authorize": "Authorize",
|
"enter_code_displayed_in_previous_step": "Introduce el código que se mostró en el paso anterior.",
|
||||||
"federated_client_credentials": "Federated Client Credentials",
|
"authorize": "Autorizar",
|
||||||
"federated_client_credentials_description": "Using federated client credentials, you can authenticate OIDC clients using JWT tokens issued by third-party authorities.",
|
"federated_client_credentials": "Credenciales de cliente federadas",
|
||||||
"add_federated_client_credential": "Add Federated Client Credential",
|
"federated_client_credentials_description": "Mediante credenciales de cliente federadas, puedes autenticar clientes OIDC utilizando tokens JWT emitidos por autoridades de terceros.",
|
||||||
"add_another_federated_client_credential": "Add another federated client credential",
|
"add_federated_client_credential": "Añadir credenciales de cliente federado",
|
||||||
"oidc_allowed_group_count": "Allowed Group Count",
|
"add_another_federated_client_credential": "Añadir otra credencial de cliente federado",
|
||||||
"unrestricted": "Unrestricted",
|
"oidc_allowed_group_count": "Recuento de grupos permitidos",
|
||||||
"show_advanced_options": "Show Advanced Options",
|
"unrestricted": "Sin restricciones",
|
||||||
"hide_advanced_options": "Hide Advanced Options",
|
"show_advanced_options": "Mostrar opciones avanzadas",
|
||||||
"oidc_data_preview": "OIDC Data Preview",
|
"hide_advanced_options": "Ocultar opciones avanzadas",
|
||||||
"preview_the_oidc_data_that_would_be_sent_for_different_users": "Preview the OIDC data that would be sent for different users",
|
"oidc_data_preview": "Vista previa de datos OIDC",
|
||||||
"id_token": "ID Token",
|
"preview_the_oidc_data_that_would_be_sent_for_different_users": "Previsualiza los datos OIDC que se enviarían para diferentes usuarios.",
|
||||||
"access_token": "Access Token",
|
"id_token": "Token de identificación",
|
||||||
"userinfo": "Userinfo",
|
"access_token": "Token de acceso",
|
||||||
"id_token_payload": "ID Token Payload",
|
"userinfo": "Información del usuario",
|
||||||
"access_token_payload": "Access Token Payload",
|
"id_token_payload": "Carga útil del token de identificación",
|
||||||
"userinfo_endpoint_response": "Userinfo Endpoint Response",
|
"access_token_payload": "Carga útil del token de acceso",
|
||||||
"copy": "Copy",
|
"userinfo_endpoint_response": "Respuesta del punto final de información del usuario",
|
||||||
"no_preview_data_available": "No preview data available",
|
"copy": "Copia",
|
||||||
"copy_all": "Copy All",
|
"no_preview_data_available": "No hay datos de vista previa disponibles.",
|
||||||
"preview": "Preview",
|
"copy_all": "Copiar todo",
|
||||||
"preview_for_user": "Preview for {name} ({email})",
|
"preview": "Vista previa",
|
||||||
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Preview the OIDC data that would be sent for this user",
|
"preview_for_user": "Vista previa de « {name} » ({email})",
|
||||||
"show": "Show",
|
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Previsualiza los datos OIDC que se enviarían para este usuario.",
|
||||||
"select_an_option": "Select an option",
|
"show": "Mostrar",
|
||||||
"select_user": "Select User",
|
"select_an_option": "Selecciona una opción",
|
||||||
"error": "Error"
|
"select_user": "Seleccionar usuario",
|
||||||
|
"error": "Error",
|
||||||
|
"select_an_accent_color_to_customize_the_appearance_of_pocket_id": "Selecciona un color de acento para personalizar la apariencia de Pocket ID.",
|
||||||
|
"accent_color": "Color de acento",
|
||||||
|
"custom_accent_color": "Color de acento personalizado",
|
||||||
|
"custom_accent_color_description": "Introduce un color personalizado utilizando formatos de color CSS válidos (por ejemplo, hex, rgb, hsl).",
|
||||||
|
"color_value": "Valor del color",
|
||||||
|
"apply": "Aplicar",
|
||||||
|
"signup_token": "Token de registro",
|
||||||
|
"create_a_signup_token_to_allow_new_user_registration": "Crea un token de registro para permitir el registro de nuevos usuarios.",
|
||||||
|
"usage_limit": "Límite de uso",
|
||||||
|
"number_of_times_token_can_be_used": "Número de veces que se puede utilizar el token de registro.",
|
||||||
|
"expires": "Caduca",
|
||||||
|
"signup": "Regístrate",
|
||||||
|
"signup_requires_valid_token": "Se requiere un token de registro válido para crear una cuenta.",
|
||||||
|
"validating_signup_token": "Validación del token de registro",
|
||||||
|
"go_to_login": "Ir al inicio de sesión",
|
||||||
|
"signup_to_appname": "Regístrate en {appName}",
|
||||||
|
"create_your_account_to_get_started": "Crea tu cuenta para empezar.",
|
||||||
|
"initial_account_creation_description": "Crea tu cuenta para empezar. Podrás configurar una contraseña más adelante.",
|
||||||
|
"setup_your_passkey": "Configura tu clave maestra",
|
||||||
|
"create_a_passkey_to_securely_access_your_account": "Crea una contraseña maestra para acceder de forma segura a tu cuenta. Esta será tu forma principal de iniciar sesión.",
|
||||||
|
"skip_for_now": "Saltar por ahora",
|
||||||
|
"account_created": "Cuenta creada",
|
||||||
|
"enable_user_signups": "Habilitar registros de usuarios",
|
||||||
|
"enable_user_signups_description": "Si se debe habilitar la función de registro de usuarios.",
|
||||||
|
"user_signups_are_disabled": "El registro de usuarios está desactivado actualmente.",
|
||||||
|
"create_signup_token": "Crear token de registro",
|
||||||
|
"view_active_signup_tokens": "Ver tokens de registro activos",
|
||||||
|
"manage_signup_tokens": "Gestionar tokens de registro",
|
||||||
|
"view_and_manage_active_signup_tokens": "Ver y gestionar los tokens de registro activos.",
|
||||||
|
"signup_token_deleted_successfully": "Token de registro eliminado correctamente.",
|
||||||
|
"expired": "Caducado",
|
||||||
|
"used_up": "Agotado",
|
||||||
|
"active": "Activo",
|
||||||
|
"usage": "Uso",
|
||||||
|
"created": "Creado",
|
||||||
|
"token": "Token",
|
||||||
|
"loading": "Cargando",
|
||||||
|
"delete_signup_token": "Eliminar token de registro",
|
||||||
|
"are_you_sure_you_want_to_delete_this_signup_token": "¿Estás seguro de que deseas eliminar este token de registro? Esta acción no se puede deshacer.",
|
||||||
|
"signup_disabled_description": "El registro de usuarios está completamente desactivado. Solo los administradores pueden crear nuevas cuentas de usuario.",
|
||||||
|
"signup_with_token": "Regístrate con token",
|
||||||
|
"signup_with_token_description": "Los usuarios solo pueden registrarse utilizando un token de registro válido creado por un administrador.",
|
||||||
|
"signup_open": "Inscripción abierta",
|
||||||
|
"signup_open_description": "Cualquiera puede crear una nueva cuenta sin restricciones.",
|
||||||
|
"of": "de",
|
||||||
|
"skip_passkey_setup": "Omitir la configuración de la clave de acceso",
|
||||||
|
"skip_passkey_setup_description": "Es muy recomendable configurar una contraseña maestra, ya que sin ella no podrás acceder a tu cuenta una vez que expire la sesión."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,11 @@
|
|||||||
"my_account": "Mon compte",
|
"my_account": "Mon compte",
|
||||||
"logout": "Déconnexion",
|
"logout": "Déconnexion",
|
||||||
"confirm": "Confirmer",
|
"confirm": "Confirmer",
|
||||||
|
"docs": "Documentation",
|
||||||
"key": "Clé",
|
"key": "Clé",
|
||||||
"value": "Valeur",
|
"value": "Valeur",
|
||||||
"remove_custom_claim": "Remove custom claim",
|
"remove_custom_claim": "Supprimer la revendication personnalisée",
|
||||||
"add_custom_claim": "Add custom claim",
|
"add_custom_claim": "Ajouter une revendication personnalisée",
|
||||||
"add_another": "Ajouter un autre",
|
"add_another": "Ajouter un autre",
|
||||||
"select_a_date": "Sélectionner une date",
|
"select_a_date": "Sélectionner une date",
|
||||||
"select_file": "Sélectionner un fichier",
|
"select_file": "Sélectionner un fichier",
|
||||||
@@ -36,7 +37,7 @@
|
|||||||
"generate_code": "Générer un code",
|
"generate_code": "Générer un code",
|
||||||
"name": "Nom",
|
"name": "Nom",
|
||||||
"browser_unsupported": "Navigateur non pris en charge",
|
"browser_unsupported": "Navigateur non pris en charge",
|
||||||
"this_browser_does_not_support_passkeys": "This browser doesn't support passkeys. Please use an alternative sign in method.",
|
"this_browser_does_not_support_passkeys": "Ce navigateur ne supporte pas les clés d'accès. Veuillez utiliser une autre méthode d'authentification.",
|
||||||
"an_unknown_error_occurred": "Une erreur inconnue est survenue",
|
"an_unknown_error_occurred": "Une erreur inconnue est survenue",
|
||||||
"authentication_process_was_aborted": "Le processus d'authentification a été interrompu",
|
"authentication_process_was_aborted": "Le processus d'authentification a été interrompu",
|
||||||
"error_occurred_with_authenticator": "Une erreur est survenue pendant l'authentification",
|
"error_occurred_with_authenticator": "Une erreur est survenue pendant l'authentification",
|
||||||
@@ -64,14 +65,12 @@
|
|||||||
"do_you_want_to_sign_out_of_pocketid_with_the_account": "Voulez-vous vous déconnecter de Pocket ID avec le compte <b>{username}</b>?",
|
"do_you_want_to_sign_out_of_pocketid_with_the_account": "Voulez-vous vous déconnecter de Pocket ID avec le compte <b>{username}</b>?",
|
||||||
"sign_in_to_appname": "Se connecter à {appName}",
|
"sign_in_to_appname": "Se connecter à {appName}",
|
||||||
"please_try_to_sign_in_again": "Veuillez essayer de vous connecter à nouveau.",
|
"please_try_to_sign_in_again": "Veuillez essayer de vous connecter à nouveau.",
|
||||||
"authenticate_yourself_with_your_passkey_to_access_the_admin_panel": "Authentifiez-vous avec votre clé d'accès pour accéder au panneau d'administration.",
|
"authenticate_with_passkey_to_access_account": "Authentifiez-vous avec votre clé d'accès pour accéder à votre compte.",
|
||||||
"authenticate": "S'authentifier",
|
"authenticate": "S'authentifier",
|
||||||
"appname_setup": "Configuration {appName}",
|
|
||||||
"please_try_again": "Veuillez réessayer.",
|
"please_try_again": "Veuillez réessayer.",
|
||||||
"you_are_about_to_sign_in_to_the_initial_admin_account": "Vous êtes sur le point de vous connecter au compte administrateur initial. N'importe qui avec ce lien peut accéder au compte jusqu'à ce qu'une clé d'accès soit ajouté. Veuillez configurer une clé d'accès dès que possible pour éviter tout accès non autorisé.",
|
|
||||||
"continue": "Continuer",
|
"continue": "Continuer",
|
||||||
"alternative_sign_in": "Connexion alternative",
|
"alternative_sign_in": "Connexion alternative",
|
||||||
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "If you don't have access to your passkey, you can sign in using one of the following methods.",
|
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "Si vous n'avez pas accès à votre clé d'accès, vous pouvez vous authentifier en utilisant une des méthodes suivantes.",
|
||||||
"use_your_passkey_instead": "Utiliser votre clé d'accès à la place ?",
|
"use_your_passkey_instead": "Utiliser votre clé d'accès à la place ?",
|
||||||
"email_login": "Connexion par e-mail",
|
"email_login": "Connexion par e-mail",
|
||||||
"enter_a_login_code_to_sign_in": "Entrez un code de connexion pour vous connecter.",
|
"enter_a_login_code_to_sign_in": "Entrez un code de connexion pour vous connecter.",
|
||||||
@@ -107,7 +106,7 @@
|
|||||||
"account_settings": "Paramètres du compte",
|
"account_settings": "Paramètres du compte",
|
||||||
"passkey_missing": "Clé d'accès manquante",
|
"passkey_missing": "Clé d'accès manquante",
|
||||||
"please_provide_a_passkey_to_prevent_losing_access_to_your_account": "Veuillez ajouter une clé d'accès pour éviter de perdre l'accès à votre compte.",
|
"please_provide_a_passkey_to_prevent_losing_access_to_your_account": "Veuillez ajouter une clé d'accès pour éviter de perdre l'accès à votre compte.",
|
||||||
"single_passkey_configured": "Une seul clé d'accès configuré",
|
"single_passkey_configured": "Une seule clé d'accès configurée",
|
||||||
"it_is_recommended_to_add_more_than_one_passkey": "Il est recommandé d'ajouter plus d'une clé d'accès pour éviter de perdre l'accès à votre compte.",
|
"it_is_recommended_to_add_more_than_one_passkey": "Il est recommandé d'ajouter plus d'une clé d'accès pour éviter de perdre l'accès à votre compte.",
|
||||||
"account_details": "Paramètres du compte",
|
"account_details": "Paramètres du compte",
|
||||||
"passkeys": "Clés d'accès",
|
"passkeys": "Clés d'accès",
|
||||||
@@ -154,7 +153,7 @@
|
|||||||
"actions": "Actions",
|
"actions": "Actions",
|
||||||
"images_updated_successfully": "Image mise à jour avec succès",
|
"images_updated_successfully": "Image mise à jour avec succès",
|
||||||
"general": "Général",
|
"general": "Général",
|
||||||
"configure_smtp_to_send_emails": "Enable email notifications to alert users when a login is detected from a new device or location.",
|
"configure_smtp_to_send_emails": "Activer les notifications par e-mail pour alerter les utilisateurs lorsqu'une connexion est détectée à partir d'un nouvel appareil ou d'un nouvel emplacement.",
|
||||||
"ldap": "LDAP",
|
"ldap": "LDAP",
|
||||||
"configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server": "Configurer les paramètres LDAP pour synchroniser les utilisateurs et les groupes à partir d'un serveur LDAP.",
|
"configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server": "Configurer les paramètres LDAP pour synchroniser les utilisateurs et les groupes à partir d'un serveur LDAP.",
|
||||||
"images": "Images",
|
"images": "Images",
|
||||||
@@ -178,11 +177,11 @@
|
|||||||
"enabled_emails": "Emails activés",
|
"enabled_emails": "Emails activés",
|
||||||
"email_login_notification": "Notification de connexion par e-mail",
|
"email_login_notification": "Notification de connexion par e-mail",
|
||||||
"send_an_email_to_the_user_when_they_log_in_from_a_new_device": "Envoyer un email à l'utilisateur lorsqu'il se connecte à partir d'un nouvel appareil.",
|
"send_an_email_to_the_user_when_they_log_in_from_a_new_device": "Envoyer un email à l'utilisateur lorsqu'il se connecte à partir d'un nouvel appareil.",
|
||||||
"emai_login_code_requested_by_user": "Email Login Code Requested by User",
|
"emai_login_code_requested_by_user": "Code de connexion reçu par e-mail à la demande de l'utilisateur.",
|
||||||
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Allows users to bypass passkeys by requesting a login code sent to their email. This reduces the security significantly as anyone with access to the user's email can gain entry.",
|
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Permet aux utilisateurs de contourner les clés d'accès en demandant un code de connexion envoyé à leur adresse e-mail. Cela réduit considérablement la sécurité car toute personne ayant accès à l'e-mail de l'utilisateur peut récupérer la clé d'accès.",
|
||||||
"email_login_code_from_admin": "Email Login Code from Admin",
|
"email_login_code_from_admin": "Code de connexion reçu par e-mail envoyé par l'administrateur.",
|
||||||
"allows_an_admin_to_send_a_login_code_to_the_user": "Allows an admin to send a login code to the user via email.",
|
"allows_an_admin_to_send_a_login_code_to_the_user": "Permet à un administrateur d'envoyer un code de connexion à l'utilisateur par e-mail.",
|
||||||
"send_test_email": "Send test email",
|
"send_test_email": "Envoyer un e-mail de test",
|
||||||
"application_configuration_updated_successfully": "Mise à jour de l'application avec succès",
|
"application_configuration_updated_successfully": "Mise à jour de l'application avec succès",
|
||||||
"application_name": "Nom de l'application",
|
"application_name": "Nom de l'application",
|
||||||
"session_duration": "Durée de la session",
|
"session_duration": "Durée de la session",
|
||||||
@@ -196,9 +195,9 @@
|
|||||||
"ldap_sync_finished": "Synchronisation LDAP terminée",
|
"ldap_sync_finished": "Synchronisation LDAP terminée",
|
||||||
"client_configuration": "Configuration du client",
|
"client_configuration": "Configuration du client",
|
||||||
"ldap_url": "URL du serveur LDAP",
|
"ldap_url": "URL du serveur LDAP",
|
||||||
"ldap_bind_dn": "LDAP Bind DN",
|
"ldap_bind_dn": "Nom d'identification LDAP",
|
||||||
"ldap_bind_password": "Attribuer un mot de passe LDAP",
|
"ldap_bind_password": "Attribuer un mot de passe LDAP",
|
||||||
"ldap_base_dn": "LDAP Base DN",
|
"ldap_base_dn": "DN de base LDAP",
|
||||||
"user_search_filter": "Filtre de recherche utilisateur",
|
"user_search_filter": "Filtre de recherche utilisateur",
|
||||||
"the_search_filter_to_use_to_search_or_sync_users": "Le filtre de recherche à utiliser pour rechercher/synchroniser les utilisateurs.",
|
"the_search_filter_to_use_to_search_or_sync_users": "Le filtre de recherche à utiliser pour rechercher/synchroniser les utilisateurs.",
|
||||||
"groups_search_filter": "Filtre de recherche de groupes",
|
"groups_search_filter": "Filtre de recherche de groupes",
|
||||||
@@ -214,7 +213,7 @@
|
|||||||
"the_value_of_this_attribute_can_either_be_a_url_binary_or_base64_encoded_image": "La valeur de cet attribut peut être une URL, un binaire ou une image encodée en base64.",
|
"the_value_of_this_attribute_can_either_be_a_url_binary_or_base64_encoded_image": "La valeur de cet attribut peut être une URL, un binaire ou une image encodée en base64.",
|
||||||
"group_members_attribute": "Attribut des membres du groupe",
|
"group_members_attribute": "Attribut des membres du groupe",
|
||||||
"the_attribute_to_use_for_querying_members_of_a_group": "L'attribut à utiliser pour interroger les membres d'un groupe.",
|
"the_attribute_to_use_for_querying_members_of_a_group": "L'attribut à utiliser pour interroger les membres d'un groupe.",
|
||||||
"group_unique_identifier_attribute": "Group Unique Identifier Attribute",
|
"group_unique_identifier_attribute": "Attribut d'identifiant unique de groupe",
|
||||||
"group_name_attribute": "Attribut de nom de groupe",
|
"group_name_attribute": "Attribut de nom de groupe",
|
||||||
"admin_group_name": "Nom du groupe administrateur",
|
"admin_group_name": "Nom du groupe administrateur",
|
||||||
"members_of_this_group_will_have_admin_privileges_in_pocketid": "Les membres de ce groupe auront des privilèges d'administrateur dans Pocket ID.",
|
"members_of_this_group_will_have_admin_privileges_in_pocketid": "Les membres de ce groupe auront des privilèges d'administrateur dans Pocket ID.",
|
||||||
@@ -240,7 +239,7 @@
|
|||||||
"edit": "Modifier",
|
"edit": "Modifier",
|
||||||
"user_groups_updated_successfully": "Groupes d'utilisateurs mis à jour avec succès",
|
"user_groups_updated_successfully": "Groupes d'utilisateurs mis à jour avec succès",
|
||||||
"user_updated_successfully": "Utilisateur mis à jour avec succès",
|
"user_updated_successfully": "Utilisateur mis à jour avec succès",
|
||||||
"custom_claims_updated_successfully": "Custom claims updated successfully",
|
"custom_claims_updated_successfully": "Les réclamations personnalisées ont été mises à jour avec succès.",
|
||||||
"back": "Retour",
|
"back": "Retour",
|
||||||
"user_details_firstname_lastname": "Détails de l'utilisateur {firstName} {lastName}",
|
"user_details_firstname_lastname": "Détails de l'utilisateur {firstName} {lastName}",
|
||||||
"manage_which_groups_this_user_belongs_to": "Gérer les groupes auxquels cet utilisateur appartient.",
|
"manage_which_groups_this_user_belongs_to": "Gérer les groupes auxquels cet utilisateur appartient.",
|
||||||
@@ -269,7 +268,7 @@
|
|||||||
"add_oidc_client": "Ajouter un client OIDC",
|
"add_oidc_client": "Ajouter un client OIDC",
|
||||||
"manage_oidc_clients": "Gérer les clients OIDC",
|
"manage_oidc_clients": "Gérer les clients OIDC",
|
||||||
"one_time_link": "Lien de connexion unique",
|
"one_time_link": "Lien de connexion unique",
|
||||||
"use_this_link_to_sign_in_once": "Use this link to sign in once. This is needed for users who haven't added a passkey yet or have lost it.",
|
"use_this_link_to_sign_in_once": "Utilisez ce lien pour vous connecter. Ceci est nécessaire pour les utilisateurs qui n'ont pas encore ajouté de clé d'accès ou l'ont perdu.",
|
||||||
"add": "Ajouter",
|
"add": "Ajouter",
|
||||||
"callback_urls": "URL de callback",
|
"callback_urls": "URL de callback",
|
||||||
"logout_callback_urls": "URL de callback de déconnexion",
|
"logout_callback_urls": "URL de callback de déconnexion",
|
||||||
@@ -299,7 +298,7 @@
|
|||||||
"allowed_user_groups_updated_successfully": "Groupes d'utilisateurs autorisés mis à jour avec succès",
|
"allowed_user_groups_updated_successfully": "Groupes d'utilisateurs autorisés mis à jour avec succès",
|
||||||
"oidc_client_name": "Client OIDC {name}",
|
"oidc_client_name": "Client OIDC {name}",
|
||||||
"client_id": "ID du client",
|
"client_id": "ID du client",
|
||||||
"client_secret": "Client secret",
|
"client_secret": "Secret client",
|
||||||
"show_more_details": "Afficher plus de détails",
|
"show_more_details": "Afficher plus de détails",
|
||||||
"allowed_user_groups": "Groupes d'utilisateurs autorisés",
|
"allowed_user_groups": "Groupes d'utilisateurs autorisés",
|
||||||
"add_user_groups_to_this_client_to_restrict_access_to_users_in_these_groups": "Ajouter des groupes d'utilisateurs à ce client permet de restreindre l'accès aux utilisateurs de ces groupes. Si aucun groupe d'utilisateurs n'est sélectionné, tous les utilisateurs auront accès à ce client.",
|
"add_user_groups_to_this_client_to_restrict_access_to_users_in_these_groups": "Ajouter des groupes d'utilisateurs à ce client permet de restreindre l'accès aux utilisateurs de ces groupes. Si aucun groupe d'utilisateurs n'est sélectionné, tous les utilisateurs auront accès à ce client.",
|
||||||
@@ -309,67 +308,116 @@
|
|||||||
"background_image": "Image d'arrière-plan",
|
"background_image": "Image d'arrière-plan",
|
||||||
"language": "Langue",
|
"language": "Langue",
|
||||||
"reset_profile_picture_question": "Réinitialiser la photo de profil ?",
|
"reset_profile_picture_question": "Réinitialiser la photo de profil ?",
|
||||||
"this_will_remove_the_uploaded_image_and_reset_the_profile_picture_to_default": "Cela réinitialisera l'image de profil par défaut. Voulez-vous continuer ?",
|
"this_will_remove_the_uploaded_image_and_reset_the_profile_picture_to_default": "Cela supprimera l’image téléchargée et réinitialisera la photo de profil par défaut. Voulez-vous continuer ?",
|
||||||
"reset": "Réinitialiser",
|
"reset": "Réinitialiser",
|
||||||
"reset_to_default": "Valeurs par défaut",
|
"reset_to_default": "Valeurs par défaut",
|
||||||
"profile_picture_has_been_reset": "La photo de profil a été réinitialisée. La mise à jour peut prendre quelques minutes.",
|
"profile_picture_has_been_reset": "La photo de profil a été réinitialisée. La mise à jour peut prendre quelques minutes.",
|
||||||
"select_the_language_you_want_to_use": "Sélectionnez la langue que vous souhaitez utiliser. Certaines langues peuvent ne pas être entièrement traduites.",
|
"select_the_language_you_want_to_use": "Choisis la langue que tu veux utiliser. Attention, certains textes peuvent être traduits automatiquement et ne pas être tout à fait exacts.",
|
||||||
"personal": "Personal",
|
"contribute_to_translation": "Si tu trouves un problème, n'hésite pas à contribuer à la traduction sur <link href='https://crowdin.com/project/pocket-id'>Crowdin</link>.",
|
||||||
"global": "Global",
|
"personal": "Personnel",
|
||||||
"all_users": "All Users",
|
"global": "Mondial",
|
||||||
"all_events": "All Events",
|
"all_users": "Tous les utilisateurs",
|
||||||
"all_clients": "All Clients",
|
"all_events": "Tous les événements",
|
||||||
"global_audit_log": "Global Audit Log",
|
"all_clients": "Tous les clients",
|
||||||
"see_all_account_activities_from_the_last_3_months": "See all user activity for the last 3 months.",
|
"all_locations": "Tous les emplacements",
|
||||||
"token_sign_in": "Token Sign In",
|
"global_audit_log": "Journal d'audit global",
|
||||||
"client_authorization": "Client Authorization",
|
"see_all_account_activities_from_the_last_3_months": "Voir toutes les activités des utilisateurs des 3 derniers mois.",
|
||||||
"new_client_authorization": "New Client Authorization",
|
"token_sign_in": "Connexion par jeton",
|
||||||
"disable_animations": "Disable Animations",
|
"client_authorization": "Autorisation client",
|
||||||
"turn_off_ui_animations": "Turn off all animations throughout the Admin UI.",
|
"new_client_authorization": "Nouvelle autorisation client",
|
||||||
"user_disabled": "Account Disabled",
|
"disable_animations": "Désactiver les animations",
|
||||||
"disabled_users_cannot_log_in_or_use_services": "Disabled users cannot log in or use services.",
|
"turn_off_ui_animations": "Désactiver les animations dans toute l'interface.",
|
||||||
"user_disabled_successfully": "User has been disabled successfully.",
|
"user_disabled": "Compte désactivé",
|
||||||
"user_enabled_successfully": "User has been enabled successfully.",
|
"disabled_users_cannot_log_in_or_use_services": "Les utilisateurs désactivés ne peuvent pas se connecter ni utiliser les services.",
|
||||||
"status": "Status",
|
"user_disabled_successfully": "L'utilisateur a été désactivé avec succès.",
|
||||||
"disable_firstname_lastname": "Disable {firstName} {lastName}",
|
"user_enabled_successfully": "L'utilisateur a été activé avec succès.",
|
||||||
"are_you_sure_you_want_to_disable_this_user": "Are you sure you want to disable this user? They will not be able to log in or access any services.",
|
"status": "Statut",
|
||||||
"ldap_soft_delete_users": "Keep disabled users from LDAP.",
|
"disable_firstname_lastname": "Désactiver {firstName} {lastName}",
|
||||||
"ldap_soft_delete_users_description": "When enabled, users removed from LDAP will be disabled rather than deleted from the system.",
|
"are_you_sure_you_want_to_disable_this_user": "Êtes-vous sûr de vouloir désactiver cet utilisateur ? Il ne pourra plus se connecter ni accéder aux services.",
|
||||||
"login_code_email_success": "The login code has been sent to the user.",
|
"ldap_soft_delete_users": "Conserver les utilisateurs désactivés de LDAP.",
|
||||||
"send_email": "Send Email",
|
"ldap_soft_delete_users_description": "Quand activé, les utilisateurs retirés de LDAP seront désactivés plutôt que supprimés du système.",
|
||||||
"show_code": "Show Code",
|
"login_code_email_success": "Le code de connexion a été envoyé à l'utilisateur.",
|
||||||
"callback_url_description": "URL(s) provided by your client. Will be automatically added if left blank. Wildcards (*) are supported, but best avoided for better security.",
|
"send_email": "Envoyer un email",
|
||||||
"logout_callback_url_description": "URL(s) provided by your client for logout. Wildcards (*) are supported, but best avoided for better security.",
|
"show_code": "Afficher le code",
|
||||||
"api_key_expiration": "API Key Expiration",
|
"callback_url_description": "URL(s) fournies par votre client. Sera automatiquement ajoutée si laissée vide. Les jokers (*) sont supportés, mais il est préférable de les éviter pour plus de sécurité.",
|
||||||
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Send an email to the user when their API key is about to expire.",
|
"logout_callback_url_description": "URL(s) fournies par votre client pour la déconnexion. Les jokers (*) sont supportés, mais il est préférable de les éviter pour plus de sécurité.",
|
||||||
"authorize_device": "Authorize Device",
|
"api_key_expiration": "Expiration de la clé API",
|
||||||
"the_device_has_been_authorized": "The device has been authorized.",
|
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Envoyer un email à l'utilisateur lorsque sa clé API est sur le point d'expirer.",
|
||||||
"enter_code_displayed_in_previous_step": "Enter the code that was displayed in the previous step.",
|
"authorize_device": "Autoriser l'appareil",
|
||||||
"authorize": "Authorize",
|
"the_device_has_been_authorized": "L'appareil a été autorisé.",
|
||||||
"federated_client_credentials": "Federated Client Credentials",
|
"enter_code_displayed_in_previous_step": "Entrez le code affiché à l'étape précédente.",
|
||||||
"federated_client_credentials_description": "Using federated client credentials, you can authenticate OIDC clients using JWT tokens issued by third-party authorities.",
|
"authorize": "Autoriser",
|
||||||
"add_federated_client_credential": "Add Federated Client Credential",
|
"federated_client_credentials": "Identifiants client fédérés",
|
||||||
"add_another_federated_client_credential": "Add another federated client credential",
|
"federated_client_credentials_description": "Avec des identifiants clients fédérés, vous pouvez authentifier des clients OIDC avec des tokens JWT émis par des autorités tierces.",
|
||||||
"oidc_allowed_group_count": "Allowed Group Count",
|
"add_federated_client_credential": "Ajouter un identifiant client fédéré",
|
||||||
"unrestricted": "Unrestricted",
|
"add_another_federated_client_credential": "Ajouter un autre identifiant client fédéré",
|
||||||
"show_advanced_options": "Show Advanced Options",
|
"oidc_allowed_group_count": "Nombre de groupes autorisés",
|
||||||
"hide_advanced_options": "Hide Advanced Options",
|
"unrestricted": "Illimité",
|
||||||
"oidc_data_preview": "OIDC Data Preview",
|
"show_advanced_options": "Afficher les options avancées",
|
||||||
"preview_the_oidc_data_that_would_be_sent_for_different_users": "Preview the OIDC data that would be sent for different users",
|
"hide_advanced_options": "Masquer les options avancées",
|
||||||
"id_token": "ID Token",
|
"oidc_data_preview": "Aperçu des données OIDC",
|
||||||
"access_token": "Access Token",
|
"preview_the_oidc_data_that_would_be_sent_for_different_users": "Aperçu des données OIDC qui seraient envoyées pour différents utilisateurs",
|
||||||
"userinfo": "Userinfo",
|
"id_token": "Jeton ID",
|
||||||
"id_token_payload": "ID Token Payload",
|
"access_token": "Jeton d'accès",
|
||||||
"access_token_payload": "Access Token Payload",
|
"userinfo": "Informations utilisateur",
|
||||||
"userinfo_endpoint_response": "Userinfo Endpoint Response",
|
"id_token_payload": "Charge utile du jeton ID",
|
||||||
"copy": "Copy",
|
"access_token_payload": "Charge utile du jeton d'accès",
|
||||||
"no_preview_data_available": "No preview data available",
|
"userinfo_endpoint_response": "Réponse du point d'accès Userinfo",
|
||||||
"copy_all": "Copy All",
|
"copy": "Copier",
|
||||||
"preview": "Preview",
|
"no_preview_data_available": "Aucune donnée d'aperçu disponible",
|
||||||
"preview_for_user": "Preview for {name} ({email})",
|
"copy_all": "Tout copier",
|
||||||
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Preview the OIDC data that would be sent for this user",
|
"preview": "Aperçu",
|
||||||
"show": "Show",
|
"preview_for_user": "Aperçu pour {name} ({email})",
|
||||||
"select_an_option": "Select an option",
|
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Aperçu des données OIDC qui seraient envoyées pour cet utilisateur",
|
||||||
"select_user": "Select User",
|
"show": "Afficher",
|
||||||
"error": "Error"
|
"select_an_option": "Sélectionner une option",
|
||||||
|
"select_user": "Sélectionner un utilisateur",
|
||||||
|
"error": "Erreur",
|
||||||
|
"select_an_accent_color_to_customize_the_appearance_of_pocket_id": "Sélectionnez une couleur d'accent pour personnaliser l'apparence de Pocket ID.",
|
||||||
|
"accent_color": "Couleur d'accent",
|
||||||
|
"custom_accent_color": "Couleur d'accent personnalisée",
|
||||||
|
"custom_accent_color_description": "Entrez une couleur personnalisée en utilisant un format CSS valide (par ex. hex, rgb, hsl).",
|
||||||
|
"color_value": "Valeur de la couleur",
|
||||||
|
"apply": "Appliquer",
|
||||||
|
"signup_token": "Jeton d'inscription",
|
||||||
|
"create_a_signup_token_to_allow_new_user_registration": "Créez un jeton d'inscription pour autoriser l'enregistrement de nouveaux utilisateurs.",
|
||||||
|
"usage_limit": "Limite d'utilisation",
|
||||||
|
"number_of_times_token_can_be_used": "Nombre de fois que le jeton d'inscription peut être utilisé.",
|
||||||
|
"expires": "Expire",
|
||||||
|
"signup": "S'inscrire",
|
||||||
|
"signup_requires_valid_token": "Un jeton d'inscription valide est requis pour créer un compte.",
|
||||||
|
"validating_signup_token": "Validation du jeton d'inscription",
|
||||||
|
"go_to_login": "Aller à la connexion",
|
||||||
|
"signup_to_appname": "Inscription à {appName}",
|
||||||
|
"create_your_account_to_get_started": "Créez votre compte pour commencer.",
|
||||||
|
"initial_account_creation_description": "Veuillez créer votre compte pour commencer. Vous pourrez configurer une clé d'accès ultérieurement.",
|
||||||
|
"setup_your_passkey": "Configurer votre clé d'accès",
|
||||||
|
"create_a_passkey_to_securely_access_your_account": "Créez une clé d'accès pour accéder en toute sécurité à votre compte. Elle sera votre méthode principale de connexion.",
|
||||||
|
"skip_for_now": "Ignorer pour le moment",
|
||||||
|
"account_created": "Compte créé",
|
||||||
|
"enable_user_signups": "Activer les inscriptions utilisateur",
|
||||||
|
"enable_user_signups_description": "Détermine si la fonctionnalité d'inscription des utilisateurs doit être activée.",
|
||||||
|
"user_signups_are_disabled": "Les inscriptions utilisateur sont actuellement désactivées",
|
||||||
|
"create_signup_token": "Créer un jeton d'inscription",
|
||||||
|
"view_active_signup_tokens": "Voir les jetons d'inscription actifs",
|
||||||
|
"manage_signup_tokens": "Gérer les jetons d'inscription",
|
||||||
|
"view_and_manage_active_signup_tokens": "Voir et gérer les jetons d'inscription actifs.",
|
||||||
|
"signup_token_deleted_successfully": "Jeton d'inscription supprimé avec succès.",
|
||||||
|
"expired": "Expiré",
|
||||||
|
"used_up": "Utilisé",
|
||||||
|
"active": "Actif",
|
||||||
|
"usage": "Utilisation",
|
||||||
|
"created": "Créé",
|
||||||
|
"token": "Jeton",
|
||||||
|
"loading": "Chargement",
|
||||||
|
"delete_signup_token": "Supprimer le jeton d'inscription",
|
||||||
|
"are_you_sure_you_want_to_delete_this_signup_token": "Êtes-vous sûr de vouloir supprimer ce jeton d'inscription ? Cette action est irréversible.",
|
||||||
|
"signup_disabled_description": "Les inscriptions utilisateur sont complètement désactivées. Seuls les administrateurs peuvent créer de nouveaux comptes utilisateur.",
|
||||||
|
"signup_with_token": "Inscription avec jeton",
|
||||||
|
"signup_with_token_description": "Les utilisateurs ne peuvent s'inscrire qu'en utilisant un jeton d'inscription valide créé par un administrateur.",
|
||||||
|
"signup_open": "Inscription ouverte",
|
||||||
|
"signup_open_description": "Toute personne peut créer un nouveau compte sans restriction.",
|
||||||
|
"of": "sur",
|
||||||
|
"skip_passkey_setup": "Ignorer la configuration de la clé d'accès",
|
||||||
|
"skip_passkey_setup_description": "Il est fortement recommandé de configurer une clé d'accès, car sans elle, vous serez verrouillé hors de votre compte dès l'expiration de la session."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
"my_account": "Il mio account",
|
"my_account": "Il mio account",
|
||||||
"logout": "Disconnetti",
|
"logout": "Disconnetti",
|
||||||
"confirm": "Conferma",
|
"confirm": "Conferma",
|
||||||
|
"docs": "Documentazione",
|
||||||
"key": "Chiave",
|
"key": "Chiave",
|
||||||
"value": "Valore",
|
"value": "Valore",
|
||||||
"remove_custom_claim": "Rimuovi attributo personalizzato",
|
"remove_custom_claim": "Rimuovi attributo personalizzato",
|
||||||
@@ -64,11 +65,9 @@
|
|||||||
"do_you_want_to_sign_out_of_pocketid_with_the_account": "Vuoi disconnetterti da Pocket ID con l'account <b>{username}</b>?",
|
"do_you_want_to_sign_out_of_pocketid_with_the_account": "Vuoi disconnetterti da Pocket ID con l'account <b>{username}</b>?",
|
||||||
"sign_in_to_appname": "Accedi a {appName}",
|
"sign_in_to_appname": "Accedi a {appName}",
|
||||||
"please_try_to_sign_in_again": "Per favore, prova ad accedere di nuovo.",
|
"please_try_to_sign_in_again": "Per favore, prova ad accedere di nuovo.",
|
||||||
"authenticate_yourself_with_your_passkey_to_access_the_admin_panel": "Autenticati con la tua passkey per accedere al pannello di amministrazione.",
|
"authenticate_with_passkey_to_access_account": "Autenticati con la tua passkey per accedere al tuo account.",
|
||||||
"authenticate": "Autentica",
|
"authenticate": "Autentica",
|
||||||
"appname_setup": "Configurazione di {appName}",
|
|
||||||
"please_try_again": "Per favore, riprova.",
|
"please_try_again": "Per favore, riprova.",
|
||||||
"you_are_about_to_sign_in_to_the_initial_admin_account": "Stai per accedere all'account amministratore iniziale. Chiunque abbia questo link può accedere all'account finché non viene aggiunta una passkey. Configura una passkey il prima possibile per prevenire accessi non autorizzati.",
|
|
||||||
"continue": "Continua",
|
"continue": "Continua",
|
||||||
"alternative_sign_in": "Accesso Alternativo",
|
"alternative_sign_in": "Accesso Alternativo",
|
||||||
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "Se non hai accesso alla tua passkey, puoi accedere utilizzando uno dei seguenti metodi.",
|
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "Se non hai accesso alla tua passkey, puoi accedere utilizzando uno dei seguenti metodi.",
|
||||||
@@ -313,19 +312,21 @@
|
|||||||
"reset": "Reimposta",
|
"reset": "Reimposta",
|
||||||
"reset_to_default": "Ripristina valori predefiniti",
|
"reset_to_default": "Ripristina valori predefiniti",
|
||||||
"profile_picture_has_been_reset": "L'immagine del profilo è stata reimpostata. Potrebbero essere necessari alcuni minuti per l'aggiornamento.",
|
"profile_picture_has_been_reset": "L'immagine del profilo è stata reimpostata. Potrebbero essere necessari alcuni minuti per l'aggiornamento.",
|
||||||
"select_the_language_you_want_to_use": "Seleziona la lingua che desideri utilizzare. Alcune lingue potrebbero non essere completamente tradotte.",
|
"select_the_language_you_want_to_use": "Scegli la lingua che vuoi usare. Tieni presente che alcuni testi potrebbero essere tradotti automaticamente e potrebbero non essere accurati.",
|
||||||
|
"contribute_to_translation": "Se trovi un problema, puoi dare una mano con la traduzione su <link href='https://crowdin.com/project/pocket-id'>Crowdin</link>.",
|
||||||
"personal": "Personale",
|
"personal": "Personale",
|
||||||
"global": "Globale",
|
"global": "Globale",
|
||||||
"all_users": "Tutti gli utenti",
|
"all_users": "Tutti gli utenti",
|
||||||
"all_events": "Tutti gli eventi",
|
"all_events": "Tutti gli eventi",
|
||||||
"all_clients": "Tutti i client",
|
"all_clients": "Tutti i client",
|
||||||
|
"all_locations": "Tutte le posizioni",
|
||||||
"global_audit_log": "Registro attività globale",
|
"global_audit_log": "Registro attività globale",
|
||||||
"see_all_account_activities_from_the_last_3_months": "Visualizza tutte le attività degli utenti degli ultimi 3 mesi.",
|
"see_all_account_activities_from_the_last_3_months": "Visualizza tutte le attività degli utenti degli ultimi 3 mesi.",
|
||||||
"token_sign_in": "Accesso con token",
|
"token_sign_in": "Accesso con token",
|
||||||
"client_authorization": "Autorizzazione client",
|
"client_authorization": "Autorizzazione client",
|
||||||
"new_client_authorization": "Nuova autorizzazione client",
|
"new_client_authorization": "Nuova autorizzazione client",
|
||||||
"disable_animations": "Disabilita animazioni",
|
"disable_animations": "Disabilita animazioni",
|
||||||
"turn_off_ui_animations": "Disattiva tutte le animazioni nell'interfaccia di amministrazione.",
|
"turn_off_ui_animations": "Disattiva tutte le animazioni della UI.",
|
||||||
"user_disabled": "Account disabilitato",
|
"user_disabled": "Account disabilitato",
|
||||||
"disabled_users_cannot_log_in_or_use_services": "Gli utenti disabilitati non possono accedere o utilizzare i servizi.",
|
"disabled_users_cannot_log_in_or_use_services": "Gli utenti disabilitati non possono accedere o utilizzare i servizi.",
|
||||||
"user_disabled_successfully": "Utente disabilitato con successo.",
|
"user_disabled_successfully": "Utente disabilitato con successo.",
|
||||||
@@ -354,22 +355,69 @@
|
|||||||
"unrestricted": "Illimitati",
|
"unrestricted": "Illimitati",
|
||||||
"show_advanced_options": "Mostra Opzioni Avanzate",
|
"show_advanced_options": "Mostra Opzioni Avanzate",
|
||||||
"hide_advanced_options": "Nascondi Opzioni Avanzate",
|
"hide_advanced_options": "Nascondi Opzioni Avanzate",
|
||||||
"oidc_data_preview": "OIDC Data Preview",
|
"oidc_data_preview": "Anteprima Dati OIDC",
|
||||||
"preview_the_oidc_data_that_would_be_sent_for_different_users": "Preview the OIDC data that would be sent for different users",
|
"preview_the_oidc_data_that_would_be_sent_for_different_users": "Anteprima dei dati OIDC che saranno inviati agli utenti",
|
||||||
"id_token": "ID Token",
|
"id_token": "ID Token",
|
||||||
"access_token": "Access Token",
|
"access_token": "Access Token",
|
||||||
"userinfo": "Userinfo",
|
"userinfo": "Userinfo",
|
||||||
"id_token_payload": "ID Token Payload",
|
"id_token_payload": "ID Token Payload",
|
||||||
"access_token_payload": "Access Token Payload",
|
"access_token_payload": "Access Token Payload",
|
||||||
"userinfo_endpoint_response": "Userinfo Endpoint Response",
|
"userinfo_endpoint_response": "Risposta Endpoint Userinfo",
|
||||||
"copy": "Copy",
|
"copy": "Copia",
|
||||||
"no_preview_data_available": "No preview data available",
|
"no_preview_data_available": "Dati di anteprima non disponibili",
|
||||||
"copy_all": "Copy All",
|
"copy_all": "Copia tutto",
|
||||||
"preview": "Preview",
|
"preview": "Anteprima",
|
||||||
"preview_for_user": "Preview for {name} ({email})",
|
"preview_for_user": "Anteprima per {name} ({email})",
|
||||||
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Preview the OIDC data that would be sent for this user",
|
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Anteprima dei dati OIDC che saranno inviati per l'utente",
|
||||||
"show": "Show",
|
"show": "Mostra",
|
||||||
"select_an_option": "Select an option",
|
"select_an_option": "Seleziona un'opzione",
|
||||||
"select_user": "Select User",
|
"select_user": "Seleziona utente",
|
||||||
"error": "Error"
|
"error": "Errore",
|
||||||
|
"select_an_accent_color_to_customize_the_appearance_of_pocket_id": "Seleziona un colore in risalto per personalizzare l'aspetto di Pocket ID.",
|
||||||
|
"accent_color": "Colore in Risalto",
|
||||||
|
"custom_accent_color": "Colore in Risalto Personalizzato",
|
||||||
|
"custom_accent_color_description": "Inserisci un colore personalizzato usando formati di colore CSS validi (es: hex, rgb, hsl).",
|
||||||
|
"color_value": "Valore Colore",
|
||||||
|
"apply": "Applica",
|
||||||
|
"signup_token": "Codice d'iscrizione",
|
||||||
|
"create_a_signup_token_to_allow_new_user_registration": "Crea un codice d'iscrizione per consentire la registrazione di un nuovo utente.",
|
||||||
|
"usage_limit": "Limite di utilizzo",
|
||||||
|
"number_of_times_token_can_be_used": "Numero di volte che il codice d'iscrizione può essere usato.",
|
||||||
|
"expires": "Scadenza",
|
||||||
|
"signup": "Registrati",
|
||||||
|
"signup_requires_valid_token": "È necessario un codice d'iscrizione valido per creare un account",
|
||||||
|
"validating_signup_token": "Convalida codice d'iscrizione",
|
||||||
|
"go_to_login": "Vai alla login",
|
||||||
|
"signup_to_appname": "Accedi a {appName}",
|
||||||
|
"create_your_account_to_get_started": "Crea il tuo account per iniziare.",
|
||||||
|
"initial_account_creation_description": "Crea il tuo account per iniziare. Successivamente sarai in grado di impostare una passkey.",
|
||||||
|
"setup_your_passkey": "Imposta la tua passkey",
|
||||||
|
"create_a_passkey_to_securely_access_your_account": "Crea una passkey per accedere in modo sicuro al tuo account. Questo sarà la modalità principale.",
|
||||||
|
"skip_for_now": "Salta per ora",
|
||||||
|
"account_created": "Account creato",
|
||||||
|
"enable_user_signups": "Abilita Iscrizioni Utente",
|
||||||
|
"enable_user_signups_description": "Indica se la funzionalità di registrazione utente deve essere abilitata.",
|
||||||
|
"user_signups_are_disabled": "Le iscrizioni utente sono attualmente disattivate",
|
||||||
|
"create_signup_token": "Crea Codice d'iscrizione",
|
||||||
|
"view_active_signup_tokens": "Visualizza codici d'iscrizione attivi",
|
||||||
|
"manage_signup_tokens": "Gestisci Codici d'iscrizione",
|
||||||
|
"view_and_manage_active_signup_tokens": "Visualizza e gestisci i codici d'iscrizione attivi.",
|
||||||
|
"signup_token_deleted_successfully": "Codice d'iscrizione eliminato con successo.",
|
||||||
|
"expired": "Scaduto",
|
||||||
|
"used_up": "Utilizzato",
|
||||||
|
"active": "Attivo",
|
||||||
|
"usage": "Utilizzo",
|
||||||
|
"created": "Creato",
|
||||||
|
"token": "Token",
|
||||||
|
"loading": "Caricamento",
|
||||||
|
"delete_signup_token": "Elimina Codice d'iscrizione",
|
||||||
|
"are_you_sure_you_want_to_delete_this_signup_token": "Sei sicuro di voler eliminare questo codice d'iscrizione? Questa azione non può essere annullata.",
|
||||||
|
"signup_disabled_description": "Le iscrizioni utente sono completamente disabilitate. Solo gli amministratori possono creare nuovi account utente.",
|
||||||
|
"signup_with_token": "Registrati con codice",
|
||||||
|
"signup_with_token_description": "Gli utenti possono registrarsi solo usando un codice d'iscrizione valido, creato da un amministratore.",
|
||||||
|
"signup_open": "Apri Registrazione",
|
||||||
|
"signup_open_description": "Chiunque può creare un nuovo account senza restrizioni.",
|
||||||
|
"of": "di",
|
||||||
|
"skip_passkey_setup": "Salta Impostazione Passkey",
|
||||||
|
"skip_passkey_setup_description": "Si consiglia vivamente di impostare una passkey perché senza di essa, sarai tagliato fuori dal tuo account non appena scadrà la sessione."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
"my_account": "Mijn account",
|
"my_account": "Mijn account",
|
||||||
"logout": "Uitloggen",
|
"logout": "Uitloggen",
|
||||||
"confirm": "Bevestigen",
|
"confirm": "Bevestigen",
|
||||||
|
"docs": "Documenten",
|
||||||
"key": "Sleutel",
|
"key": "Sleutel",
|
||||||
"value": "Waarde",
|
"value": "Waarde",
|
||||||
"remove_custom_claim": "Aangepaste claim verwijderen",
|
"remove_custom_claim": "Aangepaste claim verwijderen",
|
||||||
@@ -64,11 +65,9 @@
|
|||||||
"do_you_want_to_sign_out_of_pocketid_with_the_account": "Wilt u zich afmelden bij Pocket ID met het account <b>{username}</b> ?",
|
"do_you_want_to_sign_out_of_pocketid_with_the_account": "Wilt u zich afmelden bij Pocket ID met het account <b>{username}</b> ?",
|
||||||
"sign_in_to_appname": "Meld u aan bij {appName}",
|
"sign_in_to_appname": "Meld u aan bij {appName}",
|
||||||
"please_try_to_sign_in_again": "Probeer opnieuw in te loggen.",
|
"please_try_to_sign_in_again": "Probeer opnieuw in te loggen.",
|
||||||
"authenticate_yourself_with_your_passkey_to_access_the_admin_panel": "Verifieer uzelf met uw toegangscode om toegang te krijgen tot het beheerderspaneel.",
|
"authenticate_with_passkey_to_access_account": "Log in met je passkey om toegang te krijgen tot je account.",
|
||||||
"authenticate": "Authenticeren",
|
"authenticate": "Authenticeren",
|
||||||
"appname_setup": "{appName} Instellen",
|
|
||||||
"please_try_again": "Probeer het opnieuw.",
|
"please_try_again": "Probeer het opnieuw.",
|
||||||
"you_are_about_to_sign_in_to_the_initial_admin_account": "U staat op het punt om in te loggen op het oorspronkelijke beheerdersaccount. Iedereen met deze link heeft toegang tot het account totdat er een passkey is toegevoegd. Stel zo snel mogelijk een passkey in om ongeautoriseerde toegang te voorkomen.",
|
|
||||||
"continue": "Doorgaan",
|
"continue": "Doorgaan",
|
||||||
"alternative_sign_in": "Alternatieve aanmelding",
|
"alternative_sign_in": "Alternatieve aanmelding",
|
||||||
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "Als u geen toegang hebt tot uw passkeys, kunt u zich op een van de volgende manieren aanmelden.",
|
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "Als u geen toegang hebt tot uw passkeys, kunt u zich op een van de volgende manieren aanmelden.",
|
||||||
@@ -154,7 +153,7 @@
|
|||||||
"actions": "Acties",
|
"actions": "Acties",
|
||||||
"images_updated_successfully": "Afbeeldingen succesvol bijgewerkt",
|
"images_updated_successfully": "Afbeeldingen succesvol bijgewerkt",
|
||||||
"general": "Algemeen",
|
"general": "Algemeen",
|
||||||
"configure_smtp_to_send_emails": "Enable email notifications to alert users when a login is detected from a new device or location.",
|
"configure_smtp_to_send_emails": "Zet e-mailmeldingen aan om mensen te laten weten als iemand inlogt vanaf een nieuw apparaat of een nieuwe plek.",
|
||||||
"ldap": "LDAP",
|
"ldap": "LDAP",
|
||||||
"configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server": "Configureer LDAP-instellingen om gebruikers en groepen vanaf een LDAP-server te synchroniseren.",
|
"configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server": "Configureer LDAP-instellingen om gebruikers en groepen vanaf een LDAP-server te synchroniseren.",
|
||||||
"images": "Afbeeldingen",
|
"images": "Afbeeldingen",
|
||||||
@@ -178,10 +177,10 @@
|
|||||||
"enabled_emails": "Ingeschakelde e-mails",
|
"enabled_emails": "Ingeschakelde e-mails",
|
||||||
"email_login_notification": "E-mail-inlogmelding",
|
"email_login_notification": "E-mail-inlogmelding",
|
||||||
"send_an_email_to_the_user_when_they_log_in_from_a_new_device": "Stuur een e-mail naar de gebruiker wanneer deze zich aanmeldt vanaf een nieuw apparaat.",
|
"send_an_email_to_the_user_when_they_log_in_from_a_new_device": "Stuur een e-mail naar de gebruiker wanneer deze zich aanmeldt vanaf een nieuw apparaat.",
|
||||||
"emai_login_code_requested_by_user": "Email Login Code Requested by User",
|
"emai_login_code_requested_by_user": "E-mail login code aangevraagd door gebruiker",
|
||||||
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Allows users to bypass passkeys by requesting a login code sent to their email. This reduces the security significantly as anyone with access to the user's email can gain entry.",
|
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Hiermee kunnen gebruikers wachtwoorden omzeilen door een inlogcode aan te vragen die naar hun e-mail wordt gestuurd. Dit maakt het een stuk minder veilig, omdat iedereen die toegang heeft tot de e-mail van de gebruiker binnen kan komen.",
|
||||||
"email_login_code_from_admin": "Email Login Code from Admin",
|
"email_login_code_from_admin": "E-mail inlogcode van beheerder",
|
||||||
"allows_an_admin_to_send_a_login_code_to_the_user": "Allows an admin to send a login code to the user via email.",
|
"allows_an_admin_to_send_a_login_code_to_the_user": "Hiermee kan een admin een inlogcode naar de gebruiker mailen.",
|
||||||
"send_test_email": "Test-e-mail verzenden",
|
"send_test_email": "Test-e-mail verzenden",
|
||||||
"application_configuration_updated_successfully": "Toepassingsconfiguratie succesvol bijgewerkt",
|
"application_configuration_updated_successfully": "Toepassingsconfiguratie succesvol bijgewerkt",
|
||||||
"application_name": "Toepassingsnaam",
|
"application_name": "Toepassingsnaam",
|
||||||
@@ -309,67 +308,116 @@
|
|||||||
"background_image": "Achtergrondfoto",
|
"background_image": "Achtergrondfoto",
|
||||||
"language": "Taal",
|
"language": "Taal",
|
||||||
"reset_profile_picture_question": "Profielfoto opnieuw instellen?",
|
"reset_profile_picture_question": "Profielfoto opnieuw instellen?",
|
||||||
"this_will_remove_the_uploaded_image_and_reset_the_profile_picture_to_default": "Dit verwijdert de geüploade afbeelding en de zet de profielfoto terug naar de standaard-profielfoto. Wilt u doorgaan?",
|
"this_will_remove_the_uploaded_image_and_reset_the_profile_picture_to_default": "Hiermee wordt de geüploade afbeelding verwijderd en wordt de profielfoto teruggezet naar de standaardinstelling. Wil je doorgaan?",
|
||||||
"reset": "Reset",
|
"reset": "Opnieuw instellen",
|
||||||
"reset_to_default": "Standaardinstellingen herstellen",
|
"reset_to_default": "Standaardinstellingen herstellen",
|
||||||
"profile_picture_has_been_reset": "Profielfoto is gereset. Het kan enkele minuten duren voordat de wijzigingen zichtbaar zijn.",
|
"profile_picture_has_been_reset": "Profielfoto is gereset. Het kan enkele minuten duren voordat de wijzigingen zichtbaar zijn.",
|
||||||
"select_the_language_you_want_to_use": "Selecteer de taal die u wilt gebruiken. Sommige talen zijn mogelijk niet volledig vertaald.",
|
"select_the_language_you_want_to_use": "Kies de taal die je wilt gebruiken. Let op: sommige teksten worden automatisch vertaald en kunnen onnauwkeurig zijn.",
|
||||||
|
"contribute_to_translation": "Als je een fout vindt, kun je altijd helpen met de vertaling op <link href='https://crowdin.com/project/pocket-id'>Crowdin</link>.",
|
||||||
"personal": "Persoonlijk",
|
"personal": "Persoonlijk",
|
||||||
"global": "Globaal",
|
"global": "Globaal",
|
||||||
"all_users": "Alle gebruikers",
|
"all_users": "Alle gebruikers",
|
||||||
"all_events": "Alle activiteiten",
|
"all_events": "Alle activiteiten",
|
||||||
"all_clients": "Alle clients",
|
"all_clients": "Alle clients",
|
||||||
|
"all_locations": "Alle locaties",
|
||||||
"global_audit_log": "Algemeen audit logboek",
|
"global_audit_log": "Algemeen audit logboek",
|
||||||
"see_all_account_activities_from_the_last_3_months": "Bekijk alle gebruikersactiviteit van de afgelopen 3 maanden.",
|
"see_all_account_activities_from_the_last_3_months": "Bekijk alle gebruikersactiviteit van de afgelopen 3 maanden.",
|
||||||
"token_sign_in": "Token Sign In",
|
"token_sign_in": "Inloggen met token",
|
||||||
"client_authorization": "Client autorisatie",
|
"client_authorization": "Client autorisatie",
|
||||||
"new_client_authorization": "Nieuwe clientautorisatie",
|
"new_client_authorization": "Nieuwe clientautorisatie",
|
||||||
"disable_animations": "Disable Animations",
|
"disable_animations": "Animatie uitzetten",
|
||||||
"turn_off_ui_animations": "Turn off all animations throughout the Admin UI.",
|
"turn_off_ui_animations": "Zet alle animaties in de gebruikersinterface uit.",
|
||||||
"user_disabled": "Account Disabled",
|
"user_disabled": "Account uitgeschakeld",
|
||||||
"disabled_users_cannot_log_in_or_use_services": "Disabled users cannot log in or use services.",
|
"disabled_users_cannot_log_in_or_use_services": "Gebruikers met een handicap kunnen niet inloggen of diensten gebruiken.",
|
||||||
"user_disabled_successfully": "User has been disabled successfully.",
|
"user_disabled_successfully": "Je bent nu uitgelogd.",
|
||||||
"user_enabled_successfully": "User has been enabled successfully.",
|
"user_enabled_successfully": "Je bent nu aangemeld.",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
"disable_firstname_lastname": "Disable {firstName} {lastName}",
|
"disable_firstname_lastname": "{firstName} {lastName}uitschakelen",
|
||||||
"are_you_sure_you_want_to_disable_this_user": "Are you sure you want to disable this user? They will not be able to log in or access any services.",
|
"are_you_sure_you_want_to_disable_this_user": "Weet je zeker dat je deze gebruiker wilt uitschakelen? Ze kunnen dan niet meer inloggen of diensten gebruiken.",
|
||||||
"ldap_soft_delete_users": "Keep disabled users from LDAP.",
|
"ldap_soft_delete_users": "Voorkom dat gebruikers met een handicap toegang krijgen tot LDAP.",
|
||||||
"ldap_soft_delete_users_description": "When enabled, users removed from LDAP will be disabled rather than deleted from the system.",
|
"ldap_soft_delete_users_description": "Als dit is ingeschakeld, worden gebruikers die uit LDAP worden verwijderd, uitgeschakeld in plaats van uit het systeem verwijderd.",
|
||||||
"login_code_email_success": "The login code has been sent to the user.",
|
"login_code_email_success": "De inlogcode is naar je gestuurd.",
|
||||||
"send_email": "Send Email",
|
"send_email": "E-mail sturen",
|
||||||
"show_code": "Show Code",
|
"show_code": "Code tonen",
|
||||||
"callback_url_description": "URL(s) provided by your client. Will be automatically added if left blank. Wildcards (*) are supported, but best avoided for better security.",
|
"callback_url_description": "URL's die je klant heeft gegeven. Als je dit leeg laat, worden ze automatisch toegevoegd. Je kunt jokertekens (*) gebruiken, maar voor de veiligheid kun je die beter niet doen.",
|
||||||
"logout_callback_url_description": "URL(s) provided by your client for logout. Wildcards (*) are supported, but best avoided for better security.",
|
"logout_callback_url_description": "URL's die je klant heeft gegeven om uit te loggen. Je kunt jokertekens (*) gebruiken, maar dat is niet zo'n goed idee voor de veiligheid.",
|
||||||
"api_key_expiration": "API Key Expiration",
|
"api_key_expiration": "API-sleutel verloopt",
|
||||||
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Send an email to the user when their API key is about to expire.",
|
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Stuur een mailtje naar de gebruiker als hun API-sleutel bijna afloopt.",
|
||||||
"authorize_device": "Authorize Device",
|
"authorize_device": "Apparaat autoriseren",
|
||||||
"the_device_has_been_authorized": "The device has been authorized.",
|
"the_device_has_been_authorized": "Het apparaat is goedgekeurd.",
|
||||||
"enter_code_displayed_in_previous_step": "Enter the code that was displayed in the previous step.",
|
"enter_code_displayed_in_previous_step": "Voer de code in die je in de vorige stap hebt gezien.",
|
||||||
"authorize": "Authorize",
|
"authorize": "Autoriseren",
|
||||||
"federated_client_credentials": "Federated Client Credentials",
|
"federated_client_credentials": "Federatieve clientreferenties",
|
||||||
"federated_client_credentials_description": "Using federated client credentials, you can authenticate OIDC clients using JWT tokens issued by third-party authorities.",
|
"federated_client_credentials_description": "Met federatieve clientreferenties kun je OIDC-clients verifiëren met JWT-tokens die zijn uitgegeven door andere instanties.",
|
||||||
"add_federated_client_credential": "Add Federated Client Credential",
|
"add_federated_client_credential": "Federatieve clientreferenties toevoegen",
|
||||||
"add_another_federated_client_credential": "Add another federated client credential",
|
"add_another_federated_client_credential": "Voeg nog een federatieve clientreferentie toe",
|
||||||
"oidc_allowed_group_count": "Allowed Group Count",
|
"oidc_allowed_group_count": "Toegestaan aantal groepen",
|
||||||
"unrestricted": "Unrestricted",
|
"unrestricted": "Onbeperkt",
|
||||||
"show_advanced_options": "Show Advanced Options",
|
"show_advanced_options": "Geavanceerde opties weergeven",
|
||||||
"hide_advanced_options": "Hide Advanced Options",
|
"hide_advanced_options": "Verberg geavanceerde opties",
|
||||||
"oidc_data_preview": "OIDC Data Preview",
|
"oidc_data_preview": "OIDC-gegevensvoorbeeld",
|
||||||
"preview_the_oidc_data_that_would_be_sent_for_different_users": "Preview the OIDC data that would be sent for different users",
|
"preview_the_oidc_data_that_would_be_sent_for_different_users": "Bekijk een voorbeeld van de OIDC-gegevens die voor verschillende gebruikers zouden worden verzonden.",
|
||||||
"id_token": "ID Token",
|
"id_token": "ID-token",
|
||||||
"access_token": "Access Token",
|
"access_token": "Toegangstoken",
|
||||||
"userinfo": "Userinfo",
|
"userinfo": "Gebruikersinfo",
|
||||||
"id_token_payload": "ID Token Payload",
|
"id_token_payload": "ID-token payload",
|
||||||
"access_token_payload": "Access Token Payload",
|
"access_token_payload": "Toegangstoken-payload",
|
||||||
"userinfo_endpoint_response": "Userinfo Endpoint Response",
|
"userinfo_endpoint_response": "Gebruikersinfo Eindpuntrespons",
|
||||||
"copy": "Copy",
|
"copy": "Kopieer",
|
||||||
"no_preview_data_available": "No preview data available",
|
"no_preview_data_available": "Geen voorbeeldgegevens beschikbaar",
|
||||||
"copy_all": "Copy All",
|
"copy_all": "Alles kopiëren",
|
||||||
"preview": "Preview",
|
"preview": "Voorbeeld",
|
||||||
"preview_for_user": "Preview for {name} ({email})",
|
"preview_for_user": "Voorbeeld van {name} ({email})",
|
||||||
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Preview the OIDC data that would be sent for this user",
|
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Bekijk een voorbeeld van de OIDC-gegevens die voor deze gebruiker zouden worden verzonden.",
|
||||||
"show": "Show",
|
"show": "Laten zien",
|
||||||
"select_an_option": "Select an option",
|
"select_an_option": "Kies een optie",
|
||||||
"select_user": "Select User",
|
"select_user": "Gebruiker kiezen",
|
||||||
"error": "Error"
|
"error": "Fout",
|
||||||
|
"select_an_accent_color_to_customize_the_appearance_of_pocket_id": "Kies een accentkleur om hoe Pocket ID eruitziet aan te passen.",
|
||||||
|
"accent_color": "Accentkleur",
|
||||||
|
"custom_accent_color": "Aangepaste accentkleur",
|
||||||
|
"custom_accent_color_description": "Voer een eigen kleur in met een geldige CSS-kleurcode (bijvoorbeeld hex, rgb, hsl).",
|
||||||
|
"color_value": "Kleurwaarde",
|
||||||
|
"apply": "Solliciteren",
|
||||||
|
"signup_token": "Aanmeldingstoken",
|
||||||
|
"create_a_signup_token_to_allow_new_user_registration": "Maak een aanmeldingstoken aan om nieuwe gebruikers te laten registreren.",
|
||||||
|
"usage_limit": "Gebruikslimiet",
|
||||||
|
"number_of_times_token_can_be_used": "Hoe vaak je het aanmeldingstoken kunt gebruiken.",
|
||||||
|
"expires": "Verloopt",
|
||||||
|
"signup": "Aanmelden",
|
||||||
|
"signup_requires_valid_token": "Je hebt een geldige registratietoken nodig om een account aan te maken.",
|
||||||
|
"validating_signup_token": "Inlogtoken checken",
|
||||||
|
"go_to_login": "Ga naar inloggen",
|
||||||
|
"signup_to_appname": "Meld je aan voor {appName}",
|
||||||
|
"create_your_account_to_get_started": "Maak je account aan om te beginnen.",
|
||||||
|
"initial_account_creation_description": "Maak een account aan om te beginnen. Je kunt later een wachtwoord instellen.",
|
||||||
|
"setup_your_passkey": "Stel je passkey in",
|
||||||
|
"create_a_passkey_to_securely_access_your_account": "Maak een toegangscode aan om veilig toegang te krijgen tot je account. Dit wordt je belangrijkste manier om in te loggen.",
|
||||||
|
"skip_for_now": "Voor nu even overslaan",
|
||||||
|
"account_created": "Account aangemaakt",
|
||||||
|
"enable_user_signups": "Gebruikersregistratie inschakelen",
|
||||||
|
"enable_user_signups_description": "Of de functie voor gebruikersregistratie moet worden ingeschakeld.",
|
||||||
|
"user_signups_are_disabled": "Je kunt nu niet aanmelden.",
|
||||||
|
"create_signup_token": "Aanmeldingstoken maken",
|
||||||
|
"view_active_signup_tokens": "Actieve aanmeldingstokens bekijken",
|
||||||
|
"manage_signup_tokens": "Aanmeldingstokens beheren",
|
||||||
|
"view_and_manage_active_signup_tokens": "Bekijk en beheer actieve aanmeldingstokens.",
|
||||||
|
"signup_token_deleted_successfully": "Aanmeldingstoken succesvol verwijderd.",
|
||||||
|
"expired": "Verlopen",
|
||||||
|
"used_up": "Opgebruikt",
|
||||||
|
"active": "Actief",
|
||||||
|
"usage": "Gebruik",
|
||||||
|
"created": "Gemaakt",
|
||||||
|
"token": "Token",
|
||||||
|
"loading": "Bezig met laden",
|
||||||
|
"delete_signup_token": "Registratietoken verwijderen",
|
||||||
|
"are_you_sure_you_want_to_delete_this_signup_token": "Weet je zeker dat je dit aanmeldingstoken wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.",
|
||||||
|
"signup_disabled_description": "Gebruikersregistraties zijn helemaal uitgeschakeld. Alleen beheerders kunnen nieuwe gebruikersaccounts aanmaken.",
|
||||||
|
"signup_with_token": "Aanmelden met token",
|
||||||
|
"signup_with_token_description": "Je kunt je alleen aanmelden met een geldige aanmeldtoken die door een beheerder is aangemaakt.",
|
||||||
|
"signup_open": "Open inschrijving",
|
||||||
|
"signup_open_description": "Iedereen kan zonder beperkingen een nieuw account aanmaken.",
|
||||||
|
"of": "van",
|
||||||
|
"skip_passkey_setup": "Pas de instellingen voor de toegangssleutel over",
|
||||||
|
"skip_passkey_setup_description": "Het is echt een aanrader om een wachtwoord in te stellen, want zonder dat word je uit je account gegooid zodra de sessie afloopt."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
"my_account": "Moje konto",
|
"my_account": "Moje konto",
|
||||||
"logout": "Wyloguj się",
|
"logout": "Wyloguj się",
|
||||||
"confirm": "Potwierdź",
|
"confirm": "Potwierdź",
|
||||||
|
"docs": "Dokumenty",
|
||||||
"key": "Klucz",
|
"key": "Klucz",
|
||||||
"value": "Wartość",
|
"value": "Wartość",
|
||||||
"remove_custom_claim": "Usuń niestandardowy atrybut",
|
"remove_custom_claim": "Usuń niestandardowy atrybut",
|
||||||
@@ -64,11 +65,9 @@
|
|||||||
"do_you_want_to_sign_out_of_pocketid_with_the_account": "Czy chcesz się wylogować z Pocket ID z konta <b>{username}</b>?",
|
"do_you_want_to_sign_out_of_pocketid_with_the_account": "Czy chcesz się wylogować z Pocket ID z konta <b>{username}</b>?",
|
||||||
"sign_in_to_appname": "Zaloguj się do {appName}",
|
"sign_in_to_appname": "Zaloguj się do {appName}",
|
||||||
"please_try_to_sign_in_again": "Spróbuj zalogować się ponownie.",
|
"please_try_to_sign_in_again": "Spróbuj zalogować się ponownie.",
|
||||||
"authenticate_yourself_with_your_passkey_to_access_the_admin_panel": "Uwierzytelnij się swoim kluczem, aby uzyskać dostęp do panelu administracyjnego.",
|
"authenticate_with_passkey_to_access_account": "Uwierzytelnij się za pomocą klucza dostępowego, aby uzyskać dostęp do konta.",
|
||||||
"authenticate": "Uwierzytelnij",
|
"authenticate": "Uwierzytelnij",
|
||||||
"appname_setup": "Konfiguracja {appName}",
|
|
||||||
"please_try_again": "Spróbuj ponownie.",
|
"please_try_again": "Spróbuj ponownie.",
|
||||||
"you_are_about_to_sign_in_to_the_initial_admin_account": "Zaraz zalogujesz się na początkowe konto administratora. Każdy z tym linkiem ma dostęp do konta, dopóki nie zostanie dodany klucz. Dodaj klucz jak najszybciej, aby zapobiec nieautoryzowanemu dostępowi.",
|
|
||||||
"continue": "Kontynuuj",
|
"continue": "Kontynuuj",
|
||||||
"alternative_sign_in": "Alternatywne logowanie",
|
"alternative_sign_in": "Alternatywne logowanie",
|
||||||
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "Jeśli nie masz dostępu do swojego klucza, możesz zalogować się, używając jednej z następujących metod.",
|
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "Jeśli nie masz dostępu do swojego klucza, możesz zalogować się, używając jednej z następujących metod.",
|
||||||
@@ -179,7 +178,7 @@
|
|||||||
"email_login_notification": "Powiadomienie o logowaniu przez e-mail",
|
"email_login_notification": "Powiadomienie o logowaniu przez e-mail",
|
||||||
"send_an_email_to_the_user_when_they_log_in_from_a_new_device": "Wyślij e-mail do użytkownika, gdy zaloguje się z nowego urządzenia.",
|
"send_an_email_to_the_user_when_they_log_in_from_a_new_device": "Wyślij e-mail do użytkownika, gdy zaloguje się z nowego urządzenia.",
|
||||||
"emai_login_code_requested_by_user": "Kod logowania e-mailem zażądany przez użytkownika",
|
"emai_login_code_requested_by_user": "Kod logowania e-mailem zażądany przez użytkownika",
|
||||||
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Pozwól użytkownikom zalogować się za pomocą kodu logowania wysłanego na ich e-mail. Znacząco obniża to bezpieczeństwo, ponieważ każdy, kto ma dostęp do e-maila użytkownika, może uzyskać dostęp.",
|
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Umożliwia użytkownikom ominięcie kluczy dostępu poprzez wysłanie kodu logowania na ich adres e-mail. Znacznie obniża to poziom bezpieczeństwa, ponieważ dostęp do konta może uzyskać każda osoba mająca dostęp do poczty e-mail użytkownika.",
|
||||||
"email_login_code_from_admin": "Kod logowania e-mailem od administratora",
|
"email_login_code_from_admin": "Kod logowania e-mailem od administratora",
|
||||||
"allows_an_admin_to_send_a_login_code_to_the_user": "Pozwala administratorowi wysłać kod logowania do użytkownika za pomocą e-maila.",
|
"allows_an_admin_to_send_a_login_code_to_the_user": "Pozwala administratorowi wysłać kod logowania do użytkownika za pomocą e-maila.",
|
||||||
"send_test_email": "Wyślij testowy e-mail",
|
"send_test_email": "Wyślij testowy e-mail",
|
||||||
@@ -309,23 +308,25 @@
|
|||||||
"background_image": "Obraz tła",
|
"background_image": "Obraz tła",
|
||||||
"language": "Język",
|
"language": "Język",
|
||||||
"reset_profile_picture_question": "Zresetować zdjęcie profilowe?",
|
"reset_profile_picture_question": "Zresetować zdjęcie profilowe?",
|
||||||
"this_will_remove_the_uploaded_image_and_reset_the_profile_picture_to_default": "To usunie przesłany obraz i zresetuje zdjęcie profilowe do domyślnego. Czy chcesz kontynuować?",
|
"this_will_remove_the_uploaded_image_and_reset_the_profile_picture_to_default": "Spowoduje to usunięcie przesłanego zdjęcia i przywrócenie domyślnego zdjęcia profilowego. Czy chcesz kontynuować?",
|
||||||
"reset": "Zresetuj",
|
"reset": "Zresetuj",
|
||||||
"reset_to_default": "Zresetuj do domyślnych",
|
"reset_to_default": "Zresetuj do domyślnych",
|
||||||
"profile_picture_has_been_reset": "Zdjęcie profilowe zostało zresetowane. Może to potrwać kilka minut.",
|
"profile_picture_has_been_reset": "Zdjęcie profilowe zostało zresetowane. Może to potrwać kilka minut.",
|
||||||
"select_the_language_you_want_to_use": "Wybierz język, którego chcesz używać. Niektóre języki mogą nie być w pełni przetłumaczone.",
|
"select_the_language_you_want_to_use": "Wybierz język, którego chcesz używać. Pamiętaj, że niektóre fragmenty tekstu mogą zostać automatycznie przetłumaczone i mogą zawierać nieścisłości.",
|
||||||
|
"contribute_to_translation": "Jeśli znajdziesz błąd, możesz wziąć udział w tłumaczeniu na <link href='https://crowdin.com/project/pocket-id'>Crowdin</link>.",
|
||||||
"personal": "Osobiste",
|
"personal": "Osobiste",
|
||||||
"global": "Globalne",
|
"global": "Globalne",
|
||||||
"all_users": "Wszyscy użytkownicy",
|
"all_users": "Wszyscy użytkownicy",
|
||||||
"all_events": "Wszystkie wydarzenia",
|
"all_events": "Wszystkie wydarzenia",
|
||||||
"all_clients": "Wszyscy klienci",
|
"all_clients": "Wszyscy klienci",
|
||||||
|
"all_locations": "Wszystkie lokalizacje",
|
||||||
"global_audit_log": "Globalny dziennik audytu",
|
"global_audit_log": "Globalny dziennik audytu",
|
||||||
"see_all_account_activities_from_the_last_3_months": "Zobacz wszystkie działania użytkowników z ostatnich 3 miesięcy.",
|
"see_all_account_activities_from_the_last_3_months": "Zobacz wszystkie działania użytkowników z ostatnich 3 miesięcy.",
|
||||||
"token_sign_in": "Logowanie za pomocą tokena",
|
"token_sign_in": "Logowanie za pomocą tokena",
|
||||||
"client_authorization": "Autoryzacja klienta",
|
"client_authorization": "Autoryzacja klienta",
|
||||||
"new_client_authorization": "Nowa autoryzacja klienta",
|
"new_client_authorization": "Nowa autoryzacja klienta",
|
||||||
"disable_animations": "Wyłącz animacje",
|
"disable_animations": "Wyłącz animacje",
|
||||||
"turn_off_ui_animations": "Wyłącz wszystkie animacje w całym interfejsie administracyjnym.",
|
"turn_off_ui_animations": "Wyłącz animacje w całym interfejsie użytkownika.",
|
||||||
"user_disabled": "Konto wyłączone",
|
"user_disabled": "Konto wyłączone",
|
||||||
"disabled_users_cannot_log_in_or_use_services": "Wyłączone konta użytkowników nie mogą się logować ani korzystać z usług.",
|
"disabled_users_cannot_log_in_or_use_services": "Wyłączone konta użytkowników nie mogą się logować ani korzystać z usług.",
|
||||||
"user_disabled_successfully": "Sukces! Konto zostało wyłączone.",
|
"user_disabled_successfully": "Sukces! Konto zostało wyłączone.",
|
||||||
@@ -338,38 +339,85 @@
|
|||||||
"login_code_email_success": "Kod logowania został wysłany do użytkownika.",
|
"login_code_email_success": "Kod logowania został wysłany do użytkownika.",
|
||||||
"send_email": "Wyślij e-mail",
|
"send_email": "Wyślij e-mail",
|
||||||
"show_code": "Pokaż kod",
|
"show_code": "Pokaż kod",
|
||||||
"callback_url_description": "URL(s) provided by your client. Will be automatically added if left blank. Wildcards (*) are supported, but best avoided for better security.",
|
"callback_url_description": "Adresy URL podane przez klienta. Zostaną automatycznie dodane, jeśli pole pozostanie puste. Obsługiwane są symbole wieloznaczne (*), ale dla większego bezpieczeństwa najlepiej ich unikać.",
|
||||||
"logout_callback_url_description": "URL(s) provided by your client for logout. Wildcards (*) are supported, but best avoided for better security.",
|
"logout_callback_url_description": "Adresy URL podane przez klienta do wylogowania. Obsługiwane są symbole wieloznaczne (*), ale dla większego bezpieczeństwa najlepiej ich unikać.",
|
||||||
"api_key_expiration": "Wygaszenie klucza API",
|
"api_key_expiration": "Wygaszenie klucza API",
|
||||||
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Wyślij e-mail do użytkownika, gdy jego klucz API ma wygasnąć.",
|
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Wyślij e-mail do użytkownika, gdy jego klucz API ma wygasnąć.",
|
||||||
"authorize_device": "Autoryzuj urządzenie",
|
"authorize_device": "Autoryzuj urządzenie",
|
||||||
"the_device_has_been_authorized": "Urządzenie zostało autoryzowane.",
|
"the_device_has_been_authorized": "Urządzenie zostało autoryzowane.",
|
||||||
"enter_code_displayed_in_previous_step": "Wprowadź kod wyświetlony w poprzednim kroku.",
|
"enter_code_displayed_in_previous_step": "Wprowadź kod wyświetlony w poprzednim kroku.",
|
||||||
"authorize": "Autoryzuj",
|
"authorize": "Autoryzuj",
|
||||||
"federated_client_credentials": "Federated Client Credentials",
|
"federated_client_credentials": "Połączone poświadczenia klienta",
|
||||||
"federated_client_credentials_description": "Using federated client credentials, you can authenticate OIDC clients using JWT tokens issued by third-party authorities.",
|
"federated_client_credentials_description": "Korzystając z połączonych poświadczeń klienta, możecie uwierzytelnić klientów OIDC za pomocą tokenów JWT wydanych przez zewnętrzne organy.",
|
||||||
"add_federated_client_credential": "Add Federated Client Credential",
|
"add_federated_client_credential": "Dodaj poświadczenia klienta federacyjnego",
|
||||||
"add_another_federated_client_credential": "Add another federated client credential",
|
"add_another_federated_client_credential": "Dodaj kolejne poświadczenia klienta federacyjnego",
|
||||||
"oidc_allowed_group_count": "Allowed Group Count",
|
"oidc_allowed_group_count": "Dopuszczalna liczba grup",
|
||||||
"unrestricted": "Unrestricted",
|
"unrestricted": "Bez ograniczeń",
|
||||||
"show_advanced_options": "Show Advanced Options",
|
"show_advanced_options": "Pokaż opcje zaawansowane",
|
||||||
"hide_advanced_options": "Hide Advanced Options",
|
"hide_advanced_options": "Ukryj opcje zaawansowane",
|
||||||
"oidc_data_preview": "OIDC Data Preview",
|
"oidc_data_preview": "Podgląd danych OIDC",
|
||||||
"preview_the_oidc_data_that_would_be_sent_for_different_users": "Preview the OIDC data that would be sent for different users",
|
"preview_the_oidc_data_that_would_be_sent_for_different_users": "Podgląd danych OIDC, które zostaną wysłane dla różnych użytkowników",
|
||||||
"id_token": "ID Token",
|
"id_token": "Token identyfikacyjny",
|
||||||
"access_token": "Access Token",
|
"access_token": "Token dostępu",
|
||||||
"userinfo": "Userinfo",
|
"userinfo": "Informacje o użytkowniku",
|
||||||
"id_token_payload": "ID Token Payload",
|
"id_token_payload": "Ładunek tokenu identyfikacyjnego",
|
||||||
"access_token_payload": "Access Token Payload",
|
"access_token_payload": "Dane ładunku tokenu dostępu",
|
||||||
"userinfo_endpoint_response": "Userinfo Endpoint Response",
|
"userinfo_endpoint_response": "Odpowiedź punktu końcowego informacji o użytkowniku",
|
||||||
"copy": "Copy",
|
"copy": "Kopiuj",
|
||||||
"no_preview_data_available": "No preview data available",
|
"no_preview_data_available": "Brak dostępnych danych podglądu",
|
||||||
"copy_all": "Copy All",
|
"copy_all": "Skopiuj wszystko",
|
||||||
"preview": "Preview",
|
"preview": "Podgląd",
|
||||||
"preview_for_user": "Preview for {name} ({email})",
|
"preview_for_user": "Zapowiedź książki „ {name} ” ({email})",
|
||||||
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Preview the OIDC data that would be sent for this user",
|
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Wyświetl podgląd danych OIDC, które zostaną wysłane dla tego użytkownika.",
|
||||||
"show": "Show",
|
"show": "Pokaż",
|
||||||
"select_an_option": "Select an option",
|
"select_an_option": "Wybierz opcję",
|
||||||
"select_user": "Select User",
|
"select_user": "Wybierz użytkownika",
|
||||||
"error": "Error"
|
"error": "Błąd",
|
||||||
|
"select_an_accent_color_to_customize_the_appearance_of_pocket_id": "Wybierz kolor akcentujący, aby dostosować wygląd Pocket ID.",
|
||||||
|
"accent_color": "Kolor akcentujący",
|
||||||
|
"custom_accent_color": "Niestandardowy kolor akcentujący",
|
||||||
|
"custom_accent_color_description": "Wprowadź niestandardowy kolor, używając prawidłowych formatów kolorów CSS (np. hex, rgb, hsl).",
|
||||||
|
"color_value": "Wartość koloru",
|
||||||
|
"apply": "Zastosuj",
|
||||||
|
"signup_token": "Token rejestracyjny",
|
||||||
|
"create_a_signup_token_to_allow_new_user_registration": "Utwórz token rejestracji, aby umożliwić rejestrację nowych użytkowników.",
|
||||||
|
"usage_limit": "Limit użytkowania",
|
||||||
|
"number_of_times_token_can_be_used": "Liczba przypadków, w których można użyć tokenu rejestracji.",
|
||||||
|
"expires": "Wygasają",
|
||||||
|
"signup": "Zarejestruj się",
|
||||||
|
"signup_requires_valid_token": "Aby utworzyć konto, wymagany jest ważny token rejestracyjny.",
|
||||||
|
"validating_signup_token": "Weryfikacja tokenu rejestracji",
|
||||||
|
"go_to_login": "Przejdź do logowania",
|
||||||
|
"signup_to_appname": "Zarejestruj się na stronie {appName}",
|
||||||
|
"create_your_account_to_get_started": "Załóż konto, aby rozpocząć.",
|
||||||
|
"initial_account_creation_description": "Aby rozpocząć, utwórz konto. Klucz dostępu będzie można skonfigurować później.",
|
||||||
|
"setup_your_passkey": "Skonfiguruj swój klucz dostępu",
|
||||||
|
"create_a_passkey_to_securely_access_your_account": "Utwórz klucz dostępu, aby uzyskać bezpieczny dostęp do swojego konta. Będzie to główny sposób logowania.",
|
||||||
|
"skip_for_now": "Pomiń na razie",
|
||||||
|
"account_created": "Konto utworzone",
|
||||||
|
"enable_user_signups": "Włącz rejestrację użytkowników",
|
||||||
|
"enable_user_signups_description": "Czy funkcja rejestracji użytkowników powinna być włączona.",
|
||||||
|
"user_signups_are_disabled": "Rejestracja użytkowników jest obecnie wyłączona.",
|
||||||
|
"create_signup_token": "Utwórz token rejestracji",
|
||||||
|
"view_active_signup_tokens": "Wyświetl aktywne tokeny rejestracji",
|
||||||
|
"manage_signup_tokens": "Zarządzaj tokenami rejestracji",
|
||||||
|
"view_and_manage_active_signup_tokens": "Wyświetlaj aktywne tokeny rejestracji i zarządzaj nimi.",
|
||||||
|
"signup_token_deleted_successfully": "Token rejestracji został pomyślnie usunięty.",
|
||||||
|
"expired": "Wygasło",
|
||||||
|
"used_up": "Zużyte",
|
||||||
|
"active": "Aktywny",
|
||||||
|
"usage": "Zastosowanie",
|
||||||
|
"created": "Stworzone",
|
||||||
|
"token": "Token",
|
||||||
|
"loading": "Ładowanie",
|
||||||
|
"delete_signup_token": "Usuń token rejestracji",
|
||||||
|
"are_you_sure_you_want_to_delete_this_signup_token": "Czy na pewno chcesz usunąć ten token rejestracji? Tego działania nie można cofnąć.",
|
||||||
|
"signup_disabled_description": "Rejestracja użytkowników jest całkowicie wyłączona. Tylko administratorzy mogą tworzyć nowe konta użytkowników.",
|
||||||
|
"signup_with_token": "Zarejestruj się za pomocą tokenu",
|
||||||
|
"signup_with_token_description": "Użytkownicy mogą zarejestrować się wyłącznie przy użyciu ważnego tokenu rejestracyjnego utworzonego przez administratora.",
|
||||||
|
"signup_open": "Otwórz rejestrację",
|
||||||
|
"signup_open_description": "Każdy może utworzyć nowe konto bez żadnych ograniczeń.",
|
||||||
|
"of": "z",
|
||||||
|
"skip_passkey_setup": "Pomiń konfigurację klucza dostępu",
|
||||||
|
"skip_passkey_setup_description": "Zdecydowanie zalecamy skonfigurowanie klucza dostępu, ponieważ bez niego utracisz dostęp do konta zaraz po wygaśnięciu sesji."
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user