From 27ca713cd4d413ff368fe2f1db1af638e4b68db7 Mon Sep 17 00:00:00 2001 From: "Alessandro (Ale) Segala" <43508+ItalyPaleAle@users.noreply.github.com> Date: Tue, 3 Mar 2026 18:53:36 -0800 Subject: [PATCH 01/12] fix: one-time-access-token route should get user ID from URL only (#1358) --- backend/internal/common/errors.go | 14 +++++++++ .../internal/controller/user_controller.go | 21 ++++++++++--- backend/internal/dto/one_time_access_dto.go | 3 +- .../service/one_time_access_service.go | 30 +++++++++++++++++-- frontend/src/lib/services/user-service.ts | 2 +- 5 files changed, 60 insertions(+), 10 deletions(-) diff --git a/backend/internal/common/errors.go b/backend/internal/common/errors.go index 9a0b41b0..eb5e57ed 100644 --- a/backend/internal/common/errors.go +++ b/backend/internal/common/errors.go @@ -139,6 +139,20 @@ func (e *TooManyRequestsError) Error() string { } func (e *TooManyRequestsError) HttpStatusCode() int { return http.StatusTooManyRequests } +type UserIdNotProvidedError struct{} + +func (e *UserIdNotProvidedError) Error() string { + return "User id not provided" +} +func (e *UserIdNotProvidedError) HttpStatusCode() int { return http.StatusBadRequest } + +type UserNotFoundError struct{} + +func (e *UserNotFoundError) Error() string { + return "User not found" +} +func (e *UserNotFoundError) HttpStatusCode() int { return http.StatusNotFound } + type ClientIdOrSecretNotProvidedError struct{} func (e *ClientIdOrSecretNotProvidedError) Error() string { diff --git a/backend/internal/controller/user_controller.go b/backend/internal/controller/user_controller.go index 78a5563c..b38eeabc 100644 --- a/backend/internal/controller/user_controller.go +++ b/backend/internal/controller/user_controller.go @@ -4,6 +4,7 @@ import ( "net/http" "time" + "github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/utils/cookie" "github.com/gin-gonic/gin" @@ -322,22 +323,34 @@ func (uc *UserController) updateCurrentUserProfilePictureHandler(c *gin.Context) func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context, own bool) { var input dto.OneTimeAccessTokenCreateDto - if err := c.ShouldBindJSON(&input); err != nil { + err := c.ShouldBindJSON(&input) + if err != nil { _ = c.Error(err) return } - var ttl time.Duration + var ( + userID string + ttl time.Duration + ) if own { - input.UserID = c.GetString("userID") + // Get user ID from context and force the default TTL + userID = c.GetString("userID") ttl = defaultOneTimeAccessTokenDuration } else { + // Get user ID from URL parameter, and optional TTL from body + userID = c.Param("id") ttl = input.TTL.Duration if ttl <= 0 { ttl = defaultOneTimeAccessTokenDuration } } - token, err := uc.oneTimeAccessService.CreateOneTimeAccessToken(c.Request.Context(), input.UserID, ttl) + if userID == "" { + _ = c.Error(&common.UserIdNotProvidedError{}) + return + } + + token, err := uc.oneTimeAccessService.CreateOneTimeAccessToken(c.Request.Context(), userID, ttl) if err != nil { _ = c.Error(err) return diff --git a/backend/internal/dto/one_time_access_dto.go b/backend/internal/dto/one_time_access_dto.go index 336def70..a99dc5ac 100644 --- a/backend/internal/dto/one_time_access_dto.go +++ b/backend/internal/dto/one_time_access_dto.go @@ -3,8 +3,7 @@ package dto import "github.com/pocket-id/pocket-id/backend/internal/utils" type OneTimeAccessTokenCreateDto struct { - UserID string `json:"userId"` - TTL utils.JSONDuration `json:"ttl" binding:"ttl"` + TTL utils.JSONDuration `json:"ttl" binding:"ttl"` } type OneTimeAccessEmailAsUnauthenticatedUserDto struct { diff --git a/backend/internal/service/one_time_access_service.go b/backend/internal/service/one_time_access_service.go index a9d80d80..1b84f498 100644 --- a/backend/internal/service/one_time_access_service.go +++ b/backend/internal/service/one_time_access_service.go @@ -79,7 +79,7 @@ func (s *OneTimeAccessService) requestOneTimeAccessEmailInternal(ctx context.Con tx.Rollback() }() - user, err := s.userService.GetUser(ctx, userID) + user, err := s.userService.getUserInternal(ctx, userID, tx) if err != nil { return nil, err } @@ -131,8 +131,32 @@ func (s *OneTimeAccessService) requestOneTimeAccessEmailInternal(ctx context.Con } func (s *OneTimeAccessService) CreateOneTimeAccessToken(ctx context.Context, userID string, ttl time.Duration) (token string, err error) { - token, _, err = s.createOneTimeAccessTokenInternal(ctx, userID, ttl, false, s.db) - return token, err + tx := s.db.Begin() + defer func() { + tx.Rollback() + }() + + // Load the user to ensure it exists + _, err = s.userService.getUserInternal(ctx, userID, tx) + if errors.Is(err, gorm.ErrRecordNotFound) { + return "", &common.UserNotFoundError{} + } else if err != nil { + return "", err + } + + // Create the one-time access token + token, _, err = s.createOneTimeAccessTokenInternal(ctx, userID, ttl, false, tx) + if err != nil { + return "", err + } + + // Commit + err = tx.Commit().Error + if err != nil { + return "", err + } + + return token, nil } func (s *OneTimeAccessService) createOneTimeAccessTokenInternal(ctx context.Context, userID string, ttl time.Duration, withDeviceToken bool, tx *gorm.DB) (token string, deviceToken *string, err error) { diff --git a/frontend/src/lib/services/user-service.ts b/frontend/src/lib/services/user-service.ts index e577d513..cbf68241 100644 --- a/frontend/src/lib/services/user-service.ts +++ b/frontend/src/lib/services/user-service.ts @@ -72,7 +72,7 @@ export default class UserService extends APIService { }; createOneTimeAccessToken = async (userId: string = 'me', ttl?: string | number) => { - const res = await this.api.post(`/users/${userId}/one-time-access-token`, { userId, ttl }); + const res = await this.api.post(`/users/${userId}/one-time-access-token`, { ttl }); return res.data.token; }; From 34890235ba8c2d856e3a121fdf59fe9d627e8596 Mon Sep 17 00:00:00 2001 From: Ken Watanabe <51844896+dorakemon@users.noreply.github.com> Date: Sun, 8 Mar 2026 00:59:25 +0900 Subject: [PATCH 02/12] Merge commit from fork --- backend/internal/service/oidc_service.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/internal/service/oidc_service.go b/backend/internal/service/oidc_service.go index 1d04c8d1..9d0b7ff0 100644 --- a/backend/internal/service/oidc_service.go +++ b/backend/internal/service/oidc_service.go @@ -404,7 +404,7 @@ func (s *OidcService) createTokenFromAuthorizationCode(ctx context.Context, inpu } } - if authorizationCodeMetaData.ClientID != input.ClientID && authorizationCodeMetaData.ExpiresAt.ToTime().Before(time.Now()) { + if authorizationCodeMetaData.ClientID != input.ClientID || authorizationCodeMetaData.ExpiresAt.ToTime().Before(time.Now()) { return CreatedTokens{}, &common.OidcInvalidAuthorizationCodeError{} } From 1d0681706523d75061a7590c23c7a261679ade63 Mon Sep 17 00:00:00 2001 From: Elias Schneider Date: Sat, 7 Mar 2026 17:15:01 +0100 Subject: [PATCH 03/12] chore(translations): update translations via Crowdin (#1352) --- frontend/messages/cs.json | 1 + frontend/messages/da.json | 5 +++-- frontend/messages/de.json | 1 + frontend/messages/es.json | 1 + frontend/messages/et.json | 3 ++- frontend/messages/fi.json | 1 + frontend/messages/fr.json | 1 + frontend/messages/it.json | 1 + frontend/messages/ja.json | 1 + frontend/messages/ko.json | 1 + frontend/messages/nl.json | 1 + frontend/messages/no.json | 1 + frontend/messages/pl.json | 1 + frontend/messages/pt-BR.json | 1 + frontend/messages/ru.json | 1 + frontend/messages/sv.json | 1 + frontend/messages/tr.json | 1 + frontend/messages/uk.json | 1 + frontend/messages/vi.json | 1 + frontend/messages/zh-CN.json | 1 + frontend/messages/zh-TW.json | 1 + 21 files changed, 24 insertions(+), 3 deletions(-) diff --git a/frontend/messages/cs.json b/frontend/messages/cs.json index 8bf78ad7..643e230c 100644 --- a/frontend/messages/cs.json +++ b/frontend/messages/cs.json @@ -365,6 +365,7 @@ "enter_code_displayed_in_previous_step": "Zadejte kód, který byl zobrazen v předchozím kroku.", "authorize": "Autorizovat", "federated_client_credentials": "Údaje o klientovi ve federaci", + "federated_client_credentials_description": "Federované klientské přihlašovací údaje umožňují ověřování klientů OIDC bez správy dlouhodobých tajných klíčů. Využívají tokeny JWT vydané třetími stranami pro klientská tvrzení, např. tokeny identity pracovního zatížení.", "add_federated_client_credential": "Přidat údaje federovaného klienta", "add_another_federated_client_credential": "Přidat dalšího federovaného klienta", "oidc_allowed_group_count": "Počet povolených skupin", diff --git a/frontend/messages/da.json b/frontend/messages/da.json index 5d2b3336..baa18220 100644 --- a/frontend/messages/da.json +++ b/frontend/messages/da.json @@ -356,7 +356,7 @@ "login_code_email_success": "Loginkoden er sendt til brugeren.", "send_email": "Send e-mail", "show_code": "Vis kode", - "callback_url_description": "URL(er) angivet af din klient. Tilføjes automatisk, hvis feltet efterlades tomt. Jokertegn understøttes.", + "callback_url_description": "URL(er) angivet af din klient. Tilføjes automatisk, hvis feltet efterlades tomt. Wildcards understøttes.", "logout_callback_url_description": "URL(er) angivet af din klient til logout. Wildcards understøttes.", "api_key_expiration": "Udløb af API-nøgle", "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.", @@ -365,6 +365,7 @@ "enter_code_displayed_in_previous_step": "Indtast koden, der blev vist i det forrige trin.", "authorize": "Godkend", "federated_client_credentials": "Federated klientlegitimationsoplysninger", + "federated_client_credentials_description": "Federerede klientlegitimationsoplysninger gør det muligt at autentificere OIDC-klienter uden at skulle administrere langvarige hemmeligheder. De udnytter JWT-tokens udstedt af tredjepartsmyndigheder til klientpåstande, f.eks. identitetstokens for arbejdsbelastning.", "add_federated_client_credential": "Tilføj federated klientlegitimation", "add_another_federated_client_credential": "Tilføj endnu en federated klientlegitimation", "oidc_allowed_group_count": "Tilladt antal grupper", @@ -445,7 +446,7 @@ "no_apps_available": "Ingen apps tilgængelige", "contact_your_administrator_for_app_access": "Kontakt din administrator for at få adgang til applikationer.", "launch": "Start", - "client_launch_url": "Kundens lancerings-URL", + "client_launch_url": "Start-URL til klient", "client_launch_url_description": "Den URL, der åbnes, når en bruger starter appen fra siden Mine apps.", "client_name_description": "Navnet på den klient, der vises i Pocket ID-brugergrænsefladen.", "revoke_access": "Tilbagekald adgang", diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 79885fcc..64d1eb48 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -365,6 +365,7 @@ "enter_code_displayed_in_previous_step": "Gib den Code ein, der im vorherigen Schritt angezeigt wurde.", "authorize": "Autorisieren", "federated_client_credentials": "Federated Client Credentials", + "federated_client_credentials_description": "Mit föderierten Client-Anmeldeinfos kann man OIDC-Clients authentifizieren, ohne sich um langlebige Geheimnisse kümmern zu müssen. Sie nutzen JWT-Token, die von Drittanbietern für Client-Assertions ausgestellt werden, z. B. Workload-Identitätstoken.", "add_federated_client_credential": "Föderierte Client-Anmeldeinfos hinzufügen", "add_another_federated_client_credential": "Weitere Anmeldeinformationen für einen Verbundclient hinzufügen", "oidc_allowed_group_count": "Erlaubte Gruppenanzahl", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index ac6fdd78..13f863be 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -365,6 +365,7 @@ "enter_code_displayed_in_previous_step": "Introduce el código que se mostró en el paso anterior.", "authorize": "Autorizar", "federated_client_credentials": "Credenciales de cliente federadas", + "federated_client_credentials_description": "Las credenciales de cliente federadas permiten autenticar clientes OIDC sin gestionar secretos de larga duración. Aprovechan los tokens JWT emitidos por autoridades externas para las afirmaciones de los clientes, por ejemplo, tokens de identidad de carga de trabajo.", "add_federated_client_credential": "Añadir credenciales de cliente federado", "add_another_federated_client_credential": "Añadir otra credencial de cliente federado", "oidc_allowed_group_count": "Recuento de grupos permitidos", diff --git a/frontend/messages/et.json b/frontend/messages/et.json index d3aedc7c..b4d819f7 100644 --- a/frontend/messages/et.json +++ b/frontend/messages/et.json @@ -1,6 +1,6 @@ { "$schema": "https://inlang.com/schema/inlang-message-format", - "my_account": "My Account", + "my_account": "Minu konto", "logout": "Logout", "confirm": "Confirm", "docs": "Docs", @@ -365,6 +365,7 @@ "enter_code_displayed_in_previous_step": "Enter the code that was displayed in the previous step.", "authorize": "Authorize", "federated_client_credentials": "Federated Client Credentials", + "federated_client_credentials_description": "Föderatiivsed kliendi autentimisandmed võimaldavad OIDC-kliente autentida ilma pikaajalisi salajasi andmeid haldamata. Need kasutavad kolmandate osapoolte poolt väljastatud JWT-tokeneid kliendi kinnituste jaoks, nt töökoormuse identiteeditokeneid.", "add_federated_client_credential": "Add Federated Client Credential", "add_another_federated_client_credential": "Add another federated client credential", "oidc_allowed_group_count": "Allowed Group Count", diff --git a/frontend/messages/fi.json b/frontend/messages/fi.json index 59d1f041..0150a08c 100644 --- a/frontend/messages/fi.json +++ b/frontend/messages/fi.json @@ -365,6 +365,7 @@ "enter_code_displayed_in_previous_step": "Syötä edellisessä vaiheessa näkynyt koodi.", "authorize": "Salli", "federated_client_credentials": "Federoidut asiakastunnukset", + "federated_client_credentials_description": "Yhdistetyt asiakastunnistetiedot mahdollistavat OIDC-asiakkaiden todentamisen ilman pitkäaikaisten salaisuuksien hallintaa. Ne hyödyntävät kolmansien osapuolten viranomaisten myöntämiä JWT-tunnuksia asiakastodistuksiin, esimerkiksi työkuorman tunnistetunnuksiin.", "add_federated_client_credential": "Lisää federoitu asiakastunnus", "add_another_federated_client_credential": "Lisää toinen federoitu asiakastunnus", "oidc_allowed_group_count": "Sallittujen ryhmien määrä", diff --git a/frontend/messages/fr.json b/frontend/messages/fr.json index 9ff785d4..37e82a1d 100644 --- a/frontend/messages/fr.json +++ b/frontend/messages/fr.json @@ -365,6 +365,7 @@ "enter_code_displayed_in_previous_step": "Entrez le code affiché à l'étape précédente.", "authorize": "Autoriser", "federated_client_credentials": "Identifiants client fédérés", + "federated_client_credentials_description": "Les informations d'identification client fédérées permettent d'authentifier les clients OIDC sans avoir à gérer des secrets à long terme. Elles utilisent des jetons JWT émis par des autorités tierces pour les assertions client, par exemple des jetons d'identité de charge de travail.", "add_federated_client_credential": "Ajouter un identifiant client fédéré", "add_another_federated_client_credential": "Ajouter un autre identifiant client fédéré", "oidc_allowed_group_count": "Nombre de groupes autorisés", diff --git a/frontend/messages/it.json b/frontend/messages/it.json index e4337b43..8d35ba97 100644 --- a/frontend/messages/it.json +++ b/frontend/messages/it.json @@ -365,6 +365,7 @@ "enter_code_displayed_in_previous_step": "Inserisci il codice visualizzato nel passaggio precedente.", "authorize": "Autorizza", "federated_client_credentials": "Identità Federate", + "federated_client_credentials_description": "Le credenziali client federate ti permettono di autenticare i client OIDC senza dover gestire segreti a lungo termine. Usano i token JWT rilasciati da autorità terze per le asserzioni dei client, tipo i token di identità del carico di lavoro.", "add_federated_client_credential": "Aggiungi Identità Federata", "add_another_federated_client_credential": "Aggiungi un'altra identità federata", "oidc_allowed_group_count": "Numero Gruppi Consentiti", diff --git a/frontend/messages/ja.json b/frontend/messages/ja.json index 2f480471..abaa2f43 100644 --- a/frontend/messages/ja.json +++ b/frontend/messages/ja.json @@ -365,6 +365,7 @@ "enter_code_displayed_in_previous_step": "前のステップで表示されたコードを入力してください。", "authorize": "Authorize", "federated_client_credentials": "連携クライアントの資格情報", + "federated_client_credentials_description": "フェデレーテッドクライアント認証情報は、長期にわたるシークレットを管理せずにOIDCクライアントを認証することを可能にします。これらは、クライアントアサーション(例:ワークロードIDトークン)のためにサードパーティ機関が発行するJWTトークンを活用します。", "add_federated_client_credential": "Add Federated Client Credential", "add_another_federated_client_credential": "Add another federated client credential", "oidc_allowed_group_count": "許可されたグループ数", diff --git a/frontend/messages/ko.json b/frontend/messages/ko.json index 6ec07029..aecd899c 100644 --- a/frontend/messages/ko.json +++ b/frontend/messages/ko.json @@ -365,6 +365,7 @@ "enter_code_displayed_in_previous_step": "이전 단계에 표시된 코드를 입력하세요.", "authorize": "승인", "federated_client_credentials": "연동 클라이언트 자격 증명", + "federated_client_credentials_description": "연방 클라이언트 자격 증명은 장기 비밀을 관리하지 않고도 OIDC 클라이언트를 인증할 수 있게 합니다. 이는 클라이언트 어설션(예: 워크로드 신원 토큰)을 위해 제3자 기관이 발급한 JWT 토큰을 활용합니다.", "add_federated_client_credential": "연동 클라이언트 자격 증명 추가", "add_another_federated_client_credential": "다른 연동 클라이언트 자격 증명 추가", "oidc_allowed_group_count": "허용된 그룹 수", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index 958da44c..9292c6f2 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -365,6 +365,7 @@ "enter_code_displayed_in_previous_step": "Voer de code in die in de vorige stap werd getoond.", "authorize": "Autoriseren", "federated_client_credentials": "Federatieve clientreferenties", + "federated_client_credentials_description": "Met federatieve klantgegevens kun je OIDC-klanten verifiëren zonder dat je langdurige geheimen hoeft te beheren. Ze gebruiken JWT-tokens die door externe instanties zijn uitgegeven voor klantverklaringen, zoals tokens voor werkbelastingidentiteit.", "add_federated_client_credential": "Federatieve clientreferenties toevoegen", "add_another_federated_client_credential": "Voeg nog een federatieve clientreferentie toe", "oidc_allowed_group_count": "Aantal groepen met toegang", diff --git a/frontend/messages/no.json b/frontend/messages/no.json index 320291e5..5ddff904 100644 --- a/frontend/messages/no.json +++ b/frontend/messages/no.json @@ -365,6 +365,7 @@ "enter_code_displayed_in_previous_step": "Enter the code that was displayed in the previous step.", "authorize": "Authorize", "federated_client_credentials": "Federated Client Credentials", + "federated_client_credentials_description": "Federated client credentials allow authenticating OIDC clients without managing long-lived secrets. They leverage JWT tokens issued by third-party authorities for client assertions, e.g. workload identity tokens.", "add_federated_client_credential": "Add Federated Client Credential", "add_another_federated_client_credential": "Add another federated client credential", "oidc_allowed_group_count": "Allowed Group Count", diff --git a/frontend/messages/pl.json b/frontend/messages/pl.json index be63509c..58a614bd 100644 --- a/frontend/messages/pl.json +++ b/frontend/messages/pl.json @@ -365,6 +365,7 @@ "enter_code_displayed_in_previous_step": "Wprowadź kod wyświetlony w poprzednim kroku.", "authorize": "Autoryzuj", "federated_client_credentials": "Połączone poświadczenia klienta", + "federated_client_credentials_description": "Połączone poświadczenia klienta umożliwiają uwierzytelnianie klientów OIDC bez konieczności zarządzania długotrwałymi sekretami. Wykorzystują one tokeny JWT wydane przez zewnętrzne organy do potwierdzania tożsamości klientów, np. tokeny tożsamości obciążenia.", "add_federated_client_credential": "Dodaj poświadczenia klienta federacyjnego", "add_another_federated_client_credential": "Dodaj kolejne poświadczenia klienta federacyjnego", "oidc_allowed_group_count": "Dopuszczalna liczba grup", diff --git a/frontend/messages/pt-BR.json b/frontend/messages/pt-BR.json index 3d28baec..92eddd2f 100644 --- a/frontend/messages/pt-BR.json +++ b/frontend/messages/pt-BR.json @@ -365,6 +365,7 @@ "enter_code_displayed_in_previous_step": "Digite o código que apareceu na etapa anterior.", "authorize": "Autorizar", "federated_client_credentials": "Credenciais de Cliente Federadas", + "federated_client_credentials_description": "As credenciais federadas do cliente permitem autenticar clientes OIDC sem precisar gerenciar segredos de longa duração. Elas usam tokens JWT emitidos por autoridades terceirizadas para afirmações do cliente, tipo tokens de identidade de carga de trabalho.", "add_federated_client_credential": "Adicionar credencial de cliente federado", "add_another_federated_client_credential": "Adicionar outra credencial de cliente federado", "oidc_allowed_group_count": "Total de grupos permitidos", diff --git a/frontend/messages/ru.json b/frontend/messages/ru.json index 7774a94e..1fd6ce0f 100644 --- a/frontend/messages/ru.json +++ b/frontend/messages/ru.json @@ -365,6 +365,7 @@ "enter_code_displayed_in_previous_step": "Введите код, который был отображен на предыдущем шаге.", "authorize": "Авторизовать", "federated_client_credentials": "Федеративные учетные данные клиента", + "federated_client_credentials_description": "Федеративные учетные данные клиента позволяют аутентифицировать клиентов OIDC без необходимости управления долгосрочными секретами. Они используют токены JWT, выданные сторонними органами для утверждений клиента, например токены идентификации рабочей нагрузки.", "add_federated_client_credential": "Добавить федеративные учетные данные клиента", "add_another_federated_client_credential": "Добавить другие федеративные учетные данные клиента", "oidc_allowed_group_count": "Число разрешенных групп", diff --git a/frontend/messages/sv.json b/frontend/messages/sv.json index 2a46b86f..14d3d481 100644 --- a/frontend/messages/sv.json +++ b/frontend/messages/sv.json @@ -365,6 +365,7 @@ "enter_code_displayed_in_previous_step": "Ange koden som visades i föregående steg.", "authorize": "Godkänn", "federated_client_credentials": "Federerade klientuppgifter", + "federated_client_credentials_description": "Federerade klientautentiseringsuppgifter gör det möjligt att autentisera OIDC-klienter utan att hantera långlivade hemligheter. De utnyttjar JWT-tokens som utfärdats av tredjepartsmyndigheter för klientpåståenden, t.ex. identitetstokens för arbetsbelastning.", "add_federated_client_credential": "Lägg till federerad klientuppgift", "add_another_federated_client_credential": "Lägg till ytterligare en federerad klientuppgift", "oidc_allowed_group_count": "Tillåtet antal grupper", diff --git a/frontend/messages/tr.json b/frontend/messages/tr.json index 608939db..03b37e2e 100644 --- a/frontend/messages/tr.json +++ b/frontend/messages/tr.json @@ -365,6 +365,7 @@ "enter_code_displayed_in_previous_step": "Önceki adımda görüntülenen kodu girin.", "authorize": "Yetkilendir", "federated_client_credentials": "Birleştirilmiş İstemci Kimlik Bilgileri", + "federated_client_credentials_description": "Birleştirilmiş istemci kimlik bilgileri, uzun süreli gizli bilgileri yönetmeden OIDC istemcilerinin kimlik doğrulamasını sağlar. Üçüncü taraf yetkililer tarafından istemci beyanları için verilen JWT belirteçlerini (ör. iş yükü kimlik belirteçleri) kullanır.", "add_federated_client_credential": "Birleştirilmiş İstemci Kimlik Bilgisi Ekle", "add_another_federated_client_credential": "Başka bir birleştirilmiş istemci kimlik bilgisi ekle", "oidc_allowed_group_count": "İzin Verilen Grup Sayısı", diff --git a/frontend/messages/uk.json b/frontend/messages/uk.json index 06b548fe..8efc5223 100644 --- a/frontend/messages/uk.json +++ b/frontend/messages/uk.json @@ -365,6 +365,7 @@ "enter_code_displayed_in_previous_step": "Введіть код, який було показано на попередньому кроці.", "authorize": "Авторизувати", "federated_client_credentials": "Федеративні облікові дані клієнта", + "federated_client_credentials_description": "Федеративні облікові дані клієнта дозволяють автентифікувати клієнтів OIDC без управління довготривалими секретами. Вони використовують токени JWT, видані сторонніми органами для підтвердження клієнтів, наприклад, токени ідентичності робочого навантаження.", "add_federated_client_credential": "Додати федеративний обліковий запис клієнта", "add_another_federated_client_credential": "Додати ще один федеративний обліковий запис клієнта", "oidc_allowed_group_count": "Кількість дозволених груп", diff --git a/frontend/messages/vi.json b/frontend/messages/vi.json index 8d861e5a..c8522860 100644 --- a/frontend/messages/vi.json +++ b/frontend/messages/vi.json @@ -365,6 +365,7 @@ "enter_code_displayed_in_previous_step": "Nhập mã đã hiển thị ở bước trước.", "authorize": "Cho phép", "federated_client_credentials": "Thông Tin Xác Thực Của Federated Clients", + "federated_client_credentials_description": "Thông tin xác thực khách hàng liên kết cho phép xác thực các khách hàng OIDC mà không cần quản lý các khóa bí mật có thời hạn dài. Chúng sử dụng các token JWT do các cơ quan thứ ba cấp để xác thực thông tin của khách hàng, ví dụ như các token danh tính công việc.", "add_federated_client_credential": "Thêm thông tin xác thực cho federated clients", "add_another_federated_client_credential": "Thêm một thông tin xác thực cho federated clients khác", "oidc_allowed_group_count": "Số lượng nhóm được phép", diff --git a/frontend/messages/zh-CN.json b/frontend/messages/zh-CN.json index 99b9a987..87a5a99c 100644 --- a/frontend/messages/zh-CN.json +++ b/frontend/messages/zh-CN.json @@ -365,6 +365,7 @@ "enter_code_displayed_in_previous_step": "输入在上一步中显示的代码", "authorize": "授权", "federated_client_credentials": "联合身份", + "federated_client_credentials_description": "联合客户端凭证允许在无需管理长期密钥的情况下验证OIDC客户端。该机制利用第三方机构签发的JWT令牌来验证客户端声明,例如工作负载身份令牌。", "add_federated_client_credential": "添加联合身份", "add_another_federated_client_credential": "再添加一个联合身份", "oidc_allowed_group_count": "允许的群组数量", diff --git a/frontend/messages/zh-TW.json b/frontend/messages/zh-TW.json index eac55e18..a4c62bc7 100644 --- a/frontend/messages/zh-TW.json +++ b/frontend/messages/zh-TW.json @@ -365,6 +365,7 @@ "enter_code_displayed_in_previous_step": "請輸入上一步顯示的代碼。", "authorize": "授權", "federated_client_credentials": "聯邦身分", + "federated_client_credentials_description": "聯合客戶憑證允許驗證 OIDC 客戶端,無需管理長期存續的機密。此機制利用第三方權威機構發行的 JWT 憑證來驗證客戶端聲明,例如工作負載身分憑證。", "add_federated_client_credential": "增加聯邦身分", "add_another_federated_client_credential": "新增另一組聯邦身分", "oidc_allowed_group_count": "允許的群組數量", From e7bd66d1a77c89dde542b4385ba01dc0d432e434 Mon Sep 17 00:00:00 2001 From: Elias Schneider Date: Sat, 7 Mar 2026 17:51:44 +0100 Subject: [PATCH 04/12] tests: fix wrong seed data --- backend/internal/service/e2etest_service.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/internal/service/e2etest_service.go b/backend/internal/service/e2etest_service.go index 7df15681..6cc76b8a 100644 --- a/backend/internal/service/e2etest_service.go +++ b/backend/internal/service/e2etest_service.go @@ -258,7 +258,7 @@ func (s *TestService) SeedDatabase(baseURL string) error { Nonce: "nonce", ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)), UserID: users[1].ID, - ClientID: oidcClients[2].ID, + ClientID: oidcClients[3].ID, }, } for _, authCode := range authCodes { From f4eb8db50993edacd90e919b39a5c6d9dd4924c7 Mon Sep 17 00:00:00 2001 From: Elias Schneider Date: Sat, 7 Mar 2026 18:00:46 +0100 Subject: [PATCH 05/12] tests: fix wrong seed data in `database.json` --- tests/resources/export/database.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/resources/export/database.json b/tests/resources/export/database.json index 66a67e1d..670d80de 100644 --- a/tests/resources/export/database.json +++ b/tests/resources/export/database.json @@ -53,7 +53,7 @@ "user_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e" }, { - "client_id": "7c21a609-96b5-4011-9900-272b8d31a9d1", + "client_id": "c48232ff-ff65-45ed-ae96-7afa8a9b443b", "code": "federated", "code_challenge": null, "code_challenge_method_sha256": null, From 2f56d16f98685e93ddde35d8923f101ad44ab4af Mon Sep 17 00:00:00 2001 From: "Alessandro (Ale) Segala" <43508+ItalyPaleAle@users.noreply.github.com> Date: Sat, 7 Mar 2026 09:07:26 -0800 Subject: [PATCH 06/12] fix: various fixes in background jobs (#1362) Co-authored-by: Elias Schneider --- backend/internal/job/analytics_job.go | 2 +- backend/internal/job/api_key_expiry_job.go | 8 +- backend/internal/job/db_cleanup_job.go | 31 +++-- backend/internal/job/file_cleanup_job.go | 20 +++- backend/internal/job/geoloite_update_job.go | 2 +- backend/internal/job/ldap_job.go | 6 +- backend/internal/job/scheduler.go | 48 ++++++-- backend/internal/job/scim_job.go | 4 +- backend/internal/service/api_key_service.go | 26 ++-- backend/internal/service/app_lock_service.go | 105 ++++++++++------ .../internal/service/app_lock_service_test.go | 112 ++++++++++++++++++ backend/internal/service/email_service.go | 3 +- backend/internal/service/scheduler.go | 25 ++++ backend/internal/service/scim_service.go | 40 +++---- .../api-key-expiring-soon_html.tmpl | 2 +- .../api-key-expiring-soon_text.tmpl | 2 +- .../postgres/20260304090200_indexes.down.sql | 1 + .../postgres/20260304090200_indexes.up.sql | 6 + .../sqlite/20260304090200_indexes.down.sql | 1 + .../sqlite/20260304090200_indexes.up.sql | 12 ++ .../emails/api-key-expiring-soon.tsx | 2 +- 21 files changed, 343 insertions(+), 115 deletions(-) create mode 100644 backend/internal/service/scheduler.go create mode 100644 backend/resources/migrations/postgres/20260304090200_indexes.down.sql create mode 100644 backend/resources/migrations/postgres/20260304090200_indexes.up.sql create mode 100644 backend/resources/migrations/sqlite/20260304090200_indexes.down.sql create mode 100644 backend/resources/migrations/sqlite/20260304090200_indexes.up.sql diff --git a/backend/internal/job/analytics_job.go b/backend/internal/job/analytics_job.go index f67e2042..c2e658df 100644 --- a/backend/internal/job/analytics_job.go +++ b/backend/internal/job/analytics_job.go @@ -28,7 +28,7 @@ func (s *Scheduler) RegisterAnalyticsJob(ctx context.Context, appConfig *service appConfig: appConfig, httpClient: httpClient, } - return s.RegisterJob(ctx, "SendHeartbeat", gocron.DurationJob(24*time.Hour), jobs.sendHeartbeat, true) + return s.RegisterJob(ctx, "SendHeartbeat", gocron.DurationJob(24*time.Hour), jobs.sendHeartbeat, service.RegisterJobOpts{RunImmediately: true}) } type AnalyticsJob struct { diff --git a/backend/internal/job/api_key_expiry_job.go b/backend/internal/job/api_key_expiry_job.go index 6524089b..2481010e 100644 --- a/backend/internal/job/api_key_expiry_job.go +++ b/backend/internal/job/api_key_expiry_job.go @@ -22,7 +22,7 @@ func (s *Scheduler) RegisterApiKeyExpiryJob(ctx context.Context, apiKeyService * } // Send every day at midnight - return s.RegisterJob(ctx, "ExpiredApiKeyEmailJob", gocron.CronJob("0 0 * * *", false), jobs.checkAndNotifyExpiringApiKeys, false) + return s.RegisterJob(ctx, "ExpiredApiKeyEmailJob", gocron.CronJob("0 0 * * *", false), jobs.checkAndNotifyExpiringApiKeys, service.RegisterJobOpts{}) } func (j *ApiKeyEmailJobs) checkAndNotifyExpiringApiKeys(ctx context.Context) error { @@ -42,7 +42,11 @@ func (j *ApiKeyEmailJobs) checkAndNotifyExpiringApiKeys(ctx context.Context) err } err = j.apiKeyService.SendApiKeyExpiringSoonEmail(ctx, key) if err != nil { - slog.ErrorContext(ctx, "Failed to send expiring API key notification email", slog.String("key", key.ID), slog.Any("error", err)) + slog.ErrorContext(ctx, "Failed to send expiring API key notification email", + slog.String("key", key.ID), + slog.String("user", key.User.ID), + slog.Any("error", err), + ) } } return nil diff --git a/backend/internal/job/db_cleanup_job.go b/backend/internal/job/db_cleanup_job.go index fca96516..4872fae1 100644 --- a/backend/internal/job/db_cleanup_job.go +++ b/backend/internal/job/db_cleanup_job.go @@ -7,28 +7,37 @@ import ( "log/slog" "time" - "github.com/go-co-op/gocron/v2" + backoff "github.com/cenkalti/backoff/v5" "gorm.io/gorm" "github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/model" datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" + "github.com/pocket-id/pocket-id/backend/internal/service" ) func (s *Scheduler) RegisterDbCleanupJobs(ctx context.Context, db *gorm.DB) error { jobs := &DbCleanupJobs{db: db} - // Run every 24 hours (but with some jitter so they don't run at the exact same time), and now - def := gocron.DurationRandomJob(24*time.Hour-2*time.Minute, 24*time.Hour+2*time.Minute) + newBackOff := func() *backoff.ExponentialBackOff { + bo := backoff.NewExponentialBackOff() + bo.Multiplier = 4 + bo.RandomizationFactor = 0.1 + bo.InitialInterval = time.Second + bo.MaxInterval = 45 * time.Second + return bo + } + + // Use exponential backoff for each DB cleanup job so transient query failures are retried automatically rather than causing an immediate job failure return errors.Join( - s.RegisterJob(ctx, "ClearWebauthnSessions", def, jobs.clearWebauthnSessions, true), - s.RegisterJob(ctx, "ClearOneTimeAccessTokens", def, jobs.clearOneTimeAccessTokens, true), - s.RegisterJob(ctx, "ClearSignupTokens", def, jobs.clearSignupTokens, true), - s.RegisterJob(ctx, "ClearEmailVerificationTokens", def, jobs.clearEmailVerificationTokens, true), - s.RegisterJob(ctx, "ClearOidcAuthorizationCodes", def, jobs.clearOidcAuthorizationCodes, true), - s.RegisterJob(ctx, "ClearOidcRefreshTokens", def, jobs.clearOidcRefreshTokens, true), - s.RegisterJob(ctx, "ClearReauthenticationTokens", def, jobs.clearReauthenticationTokens, true), - s.RegisterJob(ctx, "ClearAuditLogs", def, jobs.clearAuditLogs, true), + s.RegisterJob(ctx, "ClearWebauthnSessions", jobDefWithJitter(24*time.Hour), jobs.clearWebauthnSessions, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}), + s.RegisterJob(ctx, "ClearOneTimeAccessTokens", jobDefWithJitter(24*time.Hour), jobs.clearOneTimeAccessTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}), + s.RegisterJob(ctx, "ClearSignupTokens", jobDefWithJitter(24*time.Hour), jobs.clearSignupTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}), + s.RegisterJob(ctx, "ClearEmailVerificationTokens", jobDefWithJitter(24*time.Hour), jobs.clearEmailVerificationTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}), + s.RegisterJob(ctx, "ClearOidcAuthorizationCodes", jobDefWithJitter(24*time.Hour), jobs.clearOidcAuthorizationCodes, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}), + s.RegisterJob(ctx, "ClearOidcRefreshTokens", jobDefWithJitter(24*time.Hour), jobs.clearOidcRefreshTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}), + s.RegisterJob(ctx, "ClearReauthenticationTokens", jobDefWithJitter(24*time.Hour), jobs.clearReauthenticationTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}), + s.RegisterJob(ctx, "ClearAuditLogs", jobDefWithJitter(24*time.Hour), jobs.clearAuditLogs, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}), ) } diff --git a/backend/internal/job/file_cleanup_job.go b/backend/internal/job/file_cleanup_job.go index 2b141dac..71f0dd34 100644 --- a/backend/internal/job/file_cleanup_job.go +++ b/backend/internal/job/file_cleanup_job.go @@ -13,20 +13,26 @@ import ( "gorm.io/gorm" "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/storage" ) func (s *Scheduler) RegisterFileCleanupJobs(ctx context.Context, db *gorm.DB, fileStorage storage.FileStorage) error { jobs := &FileCleanupJobs{db: db, fileStorage: fileStorage} - err := s.RegisterJob(ctx, "ClearUnusedDefaultProfilePictures", gocron.DurationJob(24*time.Hour), jobs.clearUnusedDefaultProfilePictures, false) + var errs []error + errs = append(errs, + s.RegisterJob(ctx, "ClearUnusedDefaultProfilePictures", gocron.DurationJob(24*time.Hour), jobs.clearUnusedDefaultProfilePictures, service.RegisterJobOpts{}), + ) // Only necessary for file system storage if fileStorage.Type() == storage.TypeFileSystem { - err = errors.Join(err, s.RegisterJob(ctx, "ClearOrphanedTempFiles", gocron.DurationJob(12*time.Hour), jobs.clearOrphanedTempFiles, true)) + errs = append(errs, + s.RegisterJob(ctx, "ClearOrphanedTempFiles", gocron.DurationJob(12*time.Hour), jobs.clearOrphanedTempFiles, service.RegisterJobOpts{RunImmediately: true}), + ) } - return err + return errors.Join(errs...) } type FileCleanupJobs struct { @@ -68,7 +74,8 @@ func (j *FileCleanupJobs) clearUnusedDefaultProfilePictures(ctx context.Context) // If these initials aren't used by any user, delete the file if _, ok := initialsInUse[initials]; !ok { filePath := path.Join(defaultPicturesDir, filename) - if err := j.fileStorage.Delete(ctx, filePath); err != nil { + err = j.fileStorage.Delete(ctx, filePath) + if err != nil { slog.ErrorContext(ctx, "Failed to delete unused default profile picture", slog.String("path", filePath), slog.Any("error", err)) } else { filesDeleted++ @@ -95,8 +102,9 @@ func (j *FileCleanupJobs) clearOrphanedTempFiles(ctx context.Context) error { return nil } - if err := j.fileStorage.Delete(ctx, p.Path); err != nil { - slog.ErrorContext(ctx, "Failed to delete temp file", slog.String("path", p.Path), slog.Any("error", err)) + rErr := j.fileStorage.Delete(ctx, p.Path) + if rErr != nil { + slog.ErrorContext(ctx, "Failed to delete temp file", slog.String("path", p.Path), slog.Any("error", rErr)) return nil } deleted++ diff --git a/backend/internal/job/geoloite_update_job.go b/backend/internal/job/geoloite_update_job.go index 65353757..7b163a8d 100644 --- a/backend/internal/job/geoloite_update_job.go +++ b/backend/internal/job/geoloite_update_job.go @@ -23,7 +23,7 @@ func (s *Scheduler) RegisterGeoLiteUpdateJobs(ctx context.Context, geoLiteServic jobs := &GeoLiteUpdateJobs{geoLiteService: geoLiteService} // Run every 24 hours (and right away) - return s.RegisterJob(ctx, "UpdateGeoLiteDB", gocron.DurationJob(24*time.Hour), jobs.updateGoeLiteDB, true) + return s.RegisterJob(ctx, "UpdateGeoLiteDB", gocron.DurationJob(24*time.Hour), jobs.updateGoeLiteDB, service.RegisterJobOpts{RunImmediately: true}) } func (j *GeoLiteUpdateJobs) updateGoeLiteDB(ctx context.Context) error { diff --git a/backend/internal/job/ldap_job.go b/backend/internal/job/ldap_job.go index 33646860..1547d954 100644 --- a/backend/internal/job/ldap_job.go +++ b/backend/internal/job/ldap_job.go @@ -4,8 +4,6 @@ import ( "context" "time" - "github.com/go-co-op/gocron/v2" - "github.com/pocket-id/pocket-id/backend/internal/service" ) @@ -17,8 +15,8 @@ type LdapJobs struct { func (s *Scheduler) RegisterLdapJobs(ctx context.Context, ldapService *service.LdapService, appConfigService *service.AppConfigService) error { jobs := &LdapJobs{ldapService: ldapService, appConfigService: appConfigService} - // Register the job to run every hour - return s.RegisterJob(ctx, "SyncLdap", gocron.DurationJob(time.Hour), jobs.syncLdap, true) + // Register the job to run every hour (with some jitter) + return s.RegisterJob(ctx, "SyncLdap", jobDefWithJitter(time.Hour), jobs.syncLdap, service.RegisterJobOpts{RunImmediately: true}) } func (j *LdapJobs) syncLdap(ctx context.Context) error { diff --git a/backend/internal/job/scheduler.go b/backend/internal/job/scheduler.go index 2a48c2a8..2ef2019b 100644 --- a/backend/internal/job/scheduler.go +++ b/backend/internal/job/scheduler.go @@ -5,9 +5,13 @@ import ( "errors" "fmt" "log/slog" + "time" + backoff "github.com/cenkalti/backoff/v5" "github.com/go-co-op/gocron/v2" "github.com/google/uuid" + + "github.com/pocket-id/pocket-id/backend/internal/service" ) type Scheduler struct { @@ -33,16 +37,12 @@ func (s *Scheduler) RemoveJob(name string) error { if job.Name() == name { err := s.scheduler.RemoveJob(job.ID()) if err != nil { - errs = append(errs, fmt.Errorf("failed to unqueue job %q with ID %q: %w", name, job.ID().String(), err)) + errs = append(errs, fmt.Errorf("failed to dequeue job %q with ID %q: %w", name, job.ID().String(), err)) } } } - if len(errs) > 0 { - return errors.Join(errs...) - } - - return nil + return errors.Join(errs...) } // Run the scheduler. @@ -64,7 +64,29 @@ func (s *Scheduler) Run(ctx context.Context) error { return nil } -func (s *Scheduler) RegisterJob(ctx context.Context, name string, def gocron.JobDefinition, job func(ctx context.Context) error, runImmediately bool, extraOptions ...gocron.JobOption) error { +func (s *Scheduler) RegisterJob(ctx context.Context, name string, def gocron.JobDefinition, jobFn func(ctx context.Context) error, opts service.RegisterJobOpts) error { + // If a BackOff strategy is provided, wrap the job with retry logic + if opts.BackOff != nil { + origJob := jobFn + jobFn = func(ctx context.Context) error { + _, err := backoff.Retry( + ctx, + func() (struct{}, error) { + return struct{}{}, origJob(ctx) + }, + backoff.WithBackOff(opts.BackOff), + backoff.WithNotify(func(err error, d time.Duration) { + slog.WarnContext(ctx, "Job failed, retrying", + slog.String("name", name), + slog.Any("error", err), + slog.Duration("retryIn", d), + ) + }), + ) + return err + } + } + jobOptions := []gocron.JobOption{ gocron.WithContext(ctx), gocron.WithName(name), @@ -91,13 +113,13 @@ func (s *Scheduler) RegisterJob(ctx context.Context, name string, def gocron.Job ), } - if runImmediately { + if opts.RunImmediately { jobOptions = append(jobOptions, gocron.JobOption(gocron.WithStartImmediately())) } - jobOptions = append(jobOptions, extraOptions...) + jobOptions = append(jobOptions, opts.ExtraOptions...) - _, err := s.scheduler.NewJob(def, gocron.NewTask(job), jobOptions...) + _, err := s.scheduler.NewJob(def, gocron.NewTask(jobFn), jobOptions...) if err != nil { return fmt.Errorf("failed to register job %q: %w", name, err) @@ -105,3 +127,9 @@ func (s *Scheduler) RegisterJob(ctx context.Context, name string, def gocron.Job return nil } + +func jobDefWithJitter(interval time.Duration) gocron.JobDefinition { + const jitter = 5 * time.Minute + + return gocron.DurationRandomJob(interval-jitter, interval+jitter) +} diff --git a/backend/internal/job/scim_job.go b/backend/internal/job/scim_job.go index 1ea8ee96..5c4336f6 100644 --- a/backend/internal/job/scim_job.go +++ b/backend/internal/job/scim_job.go @@ -16,8 +16,8 @@ type ScimJobs struct { func (s *Scheduler) RegisterScimJobs(ctx context.Context, scimService *service.ScimService) error { jobs := &ScimJobs{scimService: scimService} - // Register the job to run every hour - return s.RegisterJob(ctx, "SyncScim", gocron.DurationJob(time.Hour), jobs.SyncScim, true) + // Register the job to run every hour (with some jitter) + return s.RegisterJob(ctx, "SyncScim", gocron.DurationJob(time.Hour), jobs.SyncScim, service.RegisterJobOpts{RunImmediately: true}) } func (j *ScimJobs) SyncScim(ctx context.Context) error { diff --git a/backend/internal/service/api_key_service.go b/backend/internal/service/api_key_service.go index 3c29d6e9..cb409ec5 100644 --- a/backend/internal/service/api_key_service.go +++ b/backend/internal/service/api_key_service.go @@ -3,6 +3,7 @@ package service import ( "context" "errors" + "fmt" "time" datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" @@ -205,36 +206,33 @@ func (s *ApiKeyService) ListExpiringApiKeys(ctx context.Context, daysAhead int) } func (s *ApiKeyService) SendApiKeyExpiringSoonEmail(ctx context.Context, apiKey model.ApiKey) error { - user := apiKey.User - - if user.ID == "" { - if err := s.db.WithContext(ctx).First(&user, "id = ?", apiKey.UserID).Error; err != nil { - return err - } - } - - if user.Email == nil { + if apiKey.User.Email == nil { return &common.UserEmailNotSetError{} } err := SendEmail(ctx, s.emailService, email.Address{ - Name: user.FullName(), - Email: *user.Email, + Name: apiKey.User.FullName(), + Email: *apiKey.User.Email, }, ApiKeyExpiringSoonTemplate, &ApiKeyExpiringSoonTemplateData{ ApiKeyName: apiKey.Name, ExpiresAt: apiKey.ExpiresAt.ToTime(), - Name: user.FirstName, + Name: apiKey.User.FirstName, }) if err != nil { - return err + return fmt.Errorf("error sending notification email: %w", err) } // Mark the API key as having had an expiration email sent - return s.db.WithContext(ctx). + err = s.db.WithContext(ctx). Model(&model.ApiKey{}). Where("id = ?", apiKey.ID). Update("expiration_email_sent", true). Error + if err != nil { + return fmt.Errorf("error recording expiration sent email in database: %w", err) + } + + return nil } func (s *ApiKeyService) initStaticApiKeyUser(ctx context.Context) (user model.User, err error) { diff --git a/backend/internal/service/app_lock_service.go b/backend/internal/service/app_lock_service.go index 339e41cd..0a309b15 100644 --- a/backend/internal/service/app_lock_service.go +++ b/backend/internal/service/app_lock_service.go @@ -73,7 +73,10 @@ func (lv *lockValue) Unmarshal(raw string) error { // Acquire obtains the lock. When force is true, the lock is stolen from any existing owner. // If the lock is forcefully acquired, it blocks until the previous lock has expired. func (s *AppLockService) Acquire(ctx context.Context, force bool) (waitUntil time.Time, err error) { - tx := s.db.Begin() + tx := s.db.WithContext(ctx).Begin() + if tx.Error != nil { + return time.Time{}, fmt.Errorf("begin lock transaction: %w", tx.Error) + } defer func() { tx.Rollback() }() @@ -174,7 +177,8 @@ func (s *AppLockService) RunRenewal(ctx context.Context) error { case <-ctx.Done(): return nil case <-ticker.C: - if err := s.renew(ctx); err != nil { + err := s.renew(ctx) + if err != nil { return fmt.Errorf("renew lock: %w", err) } } @@ -183,33 +187,43 @@ func (s *AppLockService) RunRenewal(ctx context.Context) error { // Release releases the lock if it is held by this process. func (s *AppLockService) Release(ctx context.Context) error { - opCtx, cancel := context.WithTimeout(ctx, 3*time.Second) - defer cancel() + db, err := s.db.DB() + if err != nil { + return fmt.Errorf("failed to get DB connection: %w", err) + } var query string switch s.db.Name() { case "sqlite": query = ` - DELETE FROM kv - WHERE key = ? - AND json_extract(value, '$.lock_id') = ? - ` +DELETE FROM kv +WHERE key = ? + AND json_extract(value, '$.lock_id') = ? +` case "postgres": query = ` - DELETE FROM kv - WHERE key = $1 - AND value::json->>'lock_id' = $2 - ` +DELETE FROM kv +WHERE key = $1 + AND value::json->>'lock_id' = $2 +` default: return fmt.Errorf("unsupported database dialect: %s", s.db.Name()) } - res := s.db.WithContext(opCtx).Exec(query, lockKey, s.lockID) - if res.Error != nil { - return fmt.Errorf("release lock failed: %w", res.Error) + opCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + + res, err := db.ExecContext(opCtx, query, lockKey, s.lockID) + if err != nil { + return fmt.Errorf("release lock failed: %w", err) } - if res.RowsAffected == 0 { + count, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("failed to count affected rows: %w", err) + } + + if count == 0 { slog.Warn("Application lock not held by this process, cannot release", slog.Int64("process_id", s.processID), slog.String("host_id", s.hostID), @@ -225,6 +239,11 @@ func (s *AppLockService) Release(ctx context.Context) error { // renew tries to renew the lock, retrying up to renewRetries times (sleeping 1s between attempts). func (s *AppLockService) renew(ctx context.Context) error { + db, err := s.db.DB() + if err != nil { + return fmt.Errorf("failed to get DB connection: %w", err) + } + var lastErr error for attempt := 1; attempt <= renewRetries; attempt++ { now := time.Now() @@ -246,42 +265,56 @@ func (s *AppLockService) renew(ctx context.Context) error { switch s.db.Name() { case "sqlite": query = ` - UPDATE kv - SET value = ? - WHERE key = ? - AND json_extract(value, '$.lock_id') = ? - AND json_extract(value, '$.expires_at') > ? - ` +UPDATE kv +SET value = ? +WHERE key = ? + AND json_extract(value, '$.lock_id') = ? + AND json_extract(value, '$.expires_at') > ? +` case "postgres": query = ` - UPDATE kv - SET value = $1 - WHERE key = $2 - AND value::json->>'lock_id' = $3 - AND ((value::json->>'expires_at')::bigint > $4) - ` +UPDATE kv +SET value = $1 +WHERE key = $2 + AND value::json->>'lock_id' = $3 + AND ((value::json->>'expires_at')::bigint > $4) +` default: return fmt.Errorf("unsupported database dialect: %s", s.db.Name()) } opCtx, cancel := context.WithTimeout(ctx, 3*time.Second) - res := s.db.WithContext(opCtx).Exec(query, raw, lockKey, s.lockID, nowUnix) + res, err := db.ExecContext(opCtx, query, raw, lockKey, s.lockID, nowUnix) cancel() - switch { - case res.Error != nil: - lastErr = fmt.Errorf("lock renewal failed: %w", res.Error) - case res.RowsAffected == 0: - // Must be after checking res.Error - return ErrLockLost - default: + // Query succeeded, but may have updated 0 rows + if err == nil { + count, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("failed to count affected rows: %w", err) + } + + // If no rows were updated, we lost the lock + if count == 0 { + return ErrLockLost + } + + // All good slog.Debug("Renewed application lock", slog.Int64("process_id", s.processID), slog.String("host_id", s.hostID), + slog.Duration("duration", time.Since(now)), ) return nil } + // If we're here, we have an error that can be retried + slog.Debug("Application lock renewal attempt failed", + slog.Any("error", err), + slog.Duration("duration", time.Since(now)), + ) + lastErr = fmt.Errorf("lock renewal failed: %w", err) + // Wait before next attempt or cancel if context is done if attempt < renewRetries { select { diff --git a/backend/internal/service/app_lock_service_test.go b/backend/internal/service/app_lock_service_test.go index 95b22f51..8f829dff 100644 --- a/backend/internal/service/app_lock_service_test.go +++ b/backend/internal/service/app_lock_service_test.go @@ -49,6 +49,23 @@ func readLockValue(t *testing.T, db *gorm.DB) lockValue { return value } +func lockDatabaseForWrite(t *testing.T, db *gorm.DB) *gorm.DB { + t.Helper() + + tx := db.Begin() + require.NoError(t, tx.Error) + + // Keep a write transaction open to block other queries. + err := tx.Exec( + `INSERT INTO kv (key, value) VALUES (?, ?) ON CONFLICT(key) DO NOTHING`, + lockKey, + `{"expires_at":0}`, + ).Error + require.NoError(t, err) + + return tx +} + func TestAppLockServiceAcquire(t *testing.T) { t.Run("creates new lock when none exists", func(t *testing.T) { db := testutils.NewDatabaseForTest(t) @@ -99,6 +116,66 @@ func TestAppLockServiceAcquire(t *testing.T) { require.Equal(t, service.hostID, stored.HostID) require.Greater(t, stored.ExpiresAt, time.Now().Unix()) }) + + t.Run("force acquisition returns wait duration when stealing active lock", func(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + service := newTestAppLockService(t, db) + + existing := lockValue{ + ProcessID: 99, + HostID: "other-host", + LockID: "other-lock-id", + ExpiresAt: time.Now().Add(ttl).Unix(), + } + insertLock(t, db, existing) + + waitUntil, err := service.Acquire(context.Background(), true) + require.NoError(t, err) + require.WithinDuration(t, time.Unix(existing.ExpiresAt, 0), waitUntil, time.Second) + }) + + t.Run("force acquisition does not wait when lock id is unchanged", func(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + service := newTestAppLockService(t, db) + + insertLock(t, db, lockValue{ + ProcessID: 99, + HostID: "other-host", + LockID: service.lockID, + ExpiresAt: time.Now().Add(ttl).Unix(), + }) + + waitUntil, err := service.Acquire(context.Background(), true) + require.NoError(t, err) + require.True(t, waitUntil.IsZero()) + }) + + t.Run("returns error when existing lock value is invalid JSON", func(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + service := newTestAppLockService(t, db) + + raw := "this-is-not-json" + err := db.Create(&model.KV{Key: lockKey, Value: &raw}).Error + require.NoError(t, err) + + _, err = service.Acquire(context.Background(), false) + require.ErrorContains(t, err, "decode existing lock value") + }) + + t.Run("returns context deadline exceeded when database is locked", func(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + service := newTestAppLockService(t, db) + + tx := lockDatabaseForWrite(t, db) + defer tx.Rollback() + + ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond) + defer cancel() + + _, err := service.Acquire(ctx, false) + require.ErrorIs(t, err, context.DeadlineExceeded) + require.ErrorContains(t, err, "begin lock transaction") + }) } func TestAppLockServiceRelease(t *testing.T) { @@ -134,6 +211,24 @@ func TestAppLockServiceRelease(t *testing.T) { stored := readLockValue(t, db) require.Equal(t, existing, stored) }) + + t.Run("returns context deadline exceeded when database is locked", func(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + service := newTestAppLockService(t, db) + + _, err := service.Acquire(context.Background(), false) + require.NoError(t, err) + + tx := lockDatabaseForWrite(t, db) + defer tx.Rollback() + + ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond) + defer cancel() + + err = service.Release(ctx) + require.ErrorIs(t, err, context.DeadlineExceeded) + require.ErrorContains(t, err, "release lock failed") + }) } func TestAppLockServiceRenew(t *testing.T) { @@ -186,4 +281,21 @@ func TestAppLockServiceRenew(t *testing.T) { err = service.renew(context.Background()) require.ErrorIs(t, err, ErrLockLost) }) + + t.Run("returns context deadline exceeded when database is locked", func(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + service := newTestAppLockService(t, db) + + _, err := service.Acquire(context.Background(), false) + require.NoError(t, err) + + tx := lockDatabaseForWrite(t, db) + defer tx.Rollback() + + ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond) + defer cancel() + + err = service.renew(ctx) + require.ErrorIs(t, err, context.DeadlineExceeded) + }) } diff --git a/backend/internal/service/email_service.go b/backend/internal/service/email_service.go index 05affa5b..7dffa08c 100644 --- a/backend/internal/service/email_service.go +++ b/backend/internal/service/email_service.go @@ -150,7 +150,8 @@ func SendEmail[V any](ctx context.Context, srv *EmailService, toEmail email.Addr } // Send the email - if err := srv.sendEmailContent(client, toEmail, c); err != nil { + err = srv.sendEmailContent(client, toEmail, c) + if err != nil { return fmt.Errorf("send email content: %w", err) } diff --git a/backend/internal/service/scheduler.go b/backend/internal/service/scheduler.go new file mode 100644 index 00000000..b53f195b --- /dev/null +++ b/backend/internal/service/scheduler.go @@ -0,0 +1,25 @@ +package service + +import ( + "context" + + backoff "github.com/cenkalti/backoff/v5" + "github.com/go-co-op/gocron/v2" +) + +// RegisterJobOpts holds optional configuration for registering a scheduled job. +type RegisterJobOpts struct { + // RunImmediately runs the job immediately after registration. + RunImmediately bool + // ExtraOptions are additional gocron job options. + ExtraOptions []gocron.JobOption + // BackOff is an optional backoff strategy. If non-nil, the job will be wrapped + // with automatic retry logic using the provided backoff on transient failures. + BackOff backoff.BackOff +} + +// Scheduler is an interface for registering and managing background jobs. +type Scheduler interface { + RegisterJob(ctx context.Context, name string, def gocron.JobDefinition, job func(ctx context.Context) error, opts RegisterJobOpts) error + RemoveJob(name string) error +} diff --git a/backend/internal/service/scim_service.go b/backend/internal/service/scim_service.go index 976e2cd5..f21f8be7 100644 --- a/backend/internal/service/scim_service.go +++ b/backend/internal/service/scim_service.go @@ -34,11 +34,6 @@ const scimErrorBodyLimit = 4096 type scimSyncAction int -type Scheduler interface { - RegisterJob(ctx context.Context, name string, def gocron.JobDefinition, job func(ctx context.Context) error, runImmediately bool, extraOptions ...gocron.JobOption) error - RemoveJob(name string) error -} - const ( scimActionNone scimSyncAction = iota scimActionCreated @@ -149,7 +144,7 @@ func (s *ScimService) ScheduleSync() { err := s.scheduler.RegisterJob( context.Background(), jobName, - gocron.OneTimeJob(gocron.OneTimeJobStartDateTime(start)), s.SyncAll, false) + gocron.OneTimeJob(gocron.OneTimeJobStartDateTime(start)), s.SyncAll, RegisterJobOpts{}) if err != nil { slog.Error("Failed to schedule SCIM sync", slog.Any("error", err)) @@ -168,7 +163,8 @@ func (s *ScimService) SyncAll(ctx context.Context) error { errs = append(errs, ctx.Err()) break } - if err := s.SyncServiceProvider(ctx, provider.ID); err != nil { + err = s.SyncServiceProvider(ctx, provider.ID) + if err != nil { errs = append(errs, fmt.Errorf("failed to sync SCIM provider %s: %w", provider.ID, err)) } } @@ -210,26 +206,20 @@ func (s *ScimService) SyncServiceProvider(ctx context.Context, serviceProviderID } var errs []error - var userStats scimSyncStats - var groupStats scimSyncStats // Sync users first, so that groups can reference them - if stats, err := s.syncUsers(ctx, provider, users, &userResources); err != nil { - errs = append(errs, err) - userStats = stats - } else { - userStats = stats - } - - stats, err := s.syncGroups(ctx, provider, groups, groupResources.Resources, userResources.Resources) + userStats, err := s.syncUsers(ctx, provider, users, &userResources) + if err != nil { + errs = append(errs, err) + } + + groupStats, err := s.syncGroups(ctx, provider, groups, groupResources.Resources, userResources.Resources) if err != nil { errs = append(errs, err) - groupStats = stats - } else { - groupStats = stats } if len(errs) > 0 { + err = errors.Join(errs...) slog.WarnContext(ctx, "SCIM sync completed with errors", slog.String("provider_id", provider.ID), slog.Int("error_count", len(errs)), @@ -240,12 +230,14 @@ func (s *ScimService) SyncServiceProvider(ctx context.Context, serviceProviderID slog.Int("groups_updated", groupStats.Updated), slog.Int("groups_deleted", groupStats.Deleted), slog.Duration("duration", time.Since(start)), + slog.Any("error", err), ) - return errors.Join(errs...) + return err } provider.LastSyncedAt = new(datatype.DateTime(time.Now())) - if err := s.db.WithContext(ctx).Save(&provider).Error; err != nil { + err = s.db.WithContext(ctx).Save(&provider).Error + if err != nil { return err } @@ -273,7 +265,7 @@ func (s *ScimService) syncUsers( // Update or create users for _, u := range users { - existing := getResourceByExternalID[dto.ScimUser](u.ID, resourceList.Resources) + existing := getResourceByExternalID(u.ID, resourceList.Resources) action, created, err := s.syncUser(ctx, provider, u, existing) if created != nil && existing == nil { @@ -434,7 +426,7 @@ func (s *ScimService) syncGroup( // Prepare group members members := make([]dto.ScimGroupMember, len(group.Users)) for i, user := range group.Users { - userResource := getResourceByExternalID[dto.ScimUser](user.ID, userResources) + userResource := getResourceByExternalID(user.ID, userResources) if userResource == nil { // Groups depend on user IDs already being provisioned return scimActionNone, fmt.Errorf("cannot sync group %s: user %s is not provisioned in SCIM provider", group.ID, user.ID) diff --git a/backend/resources/email-templates/api-key-expiring-soon_html.tmpl b/backend/resources/email-templates/api-key-expiring-soon_html.tmpl index 8b52a5a0..b9b3bb5c 100644 --- a/backend/resources/email-templates/api-key-expiring-soon_html.tmpl +++ b/backend/resources/email-templates/api-key-expiring-soon_html.tmpl @@ -1 +1 @@ -{{define "root"}}
{{.AppName}}

{{.AppName}}

API Key Expiring Soon

Warning

Hello {{.Data.Name}},
This is a reminder that your API key {{.Data.APIKeyName}} will expire on {{.Data.ExpiresAt.Format "2006-01-02 15:04:05 MST"}}.

Please generate a new API key if you need continued access.

{{end}} \ No newline at end of file +{{define "root"}}
{{.AppName}}

{{.AppName}}

API Key Expiring Soon

Warning

Hello {{.Data.Name}},
This is a reminder that your API key {{.Data.ApiKeyName}} will expire on {{.Data.ExpiresAt.Format "2006-01-02 15:04:05 MST"}}.

Please generate a new API key if you need continued access.

{{end}} \ No newline at end of file diff --git a/backend/resources/email-templates/api-key-expiring-soon_text.tmpl b/backend/resources/email-templates/api-key-expiring-soon_text.tmpl index ae7ba74b..247969d5 100644 --- a/backend/resources/email-templates/api-key-expiring-soon_text.tmpl +++ b/backend/resources/email-templates/api-key-expiring-soon_text.tmpl @@ -6,6 +6,6 @@ API KEY EXPIRING SOON Warning Hello {{.Data.Name}}, -This is a reminder that your API key {{.Data.APIKeyName}} will expire on {{.Data.ExpiresAt.Format "2006-01-02 15:04:05 MST"}}. +This is a reminder that your API key {{.Data.ApiKeyName}} will expire on {{.Data.ExpiresAt.Format "2006-01-02 15:04:05 MST"}}. Please generate a new API key if you need continued access.{{end}} \ No newline at end of file diff --git a/backend/resources/migrations/postgres/20260304090200_indexes.down.sql b/backend/resources/migrations/postgres/20260304090200_indexes.down.sql new file mode 100644 index 00000000..f8e19576 --- /dev/null +++ b/backend/resources/migrations/postgres/20260304090200_indexes.down.sql @@ -0,0 +1 @@ +-- No-op \ No newline at end of file diff --git a/backend/resources/migrations/postgres/20260304090200_indexes.up.sql b/backend/resources/migrations/postgres/20260304090200_indexes.up.sql new file mode 100644 index 00000000..b288f044 --- /dev/null +++ b/backend/resources/migrations/postgres/20260304090200_indexes.up.sql @@ -0,0 +1,6 @@ +CREATE INDEX IF NOT EXISTS idx_webauthn_sessions_expires_at ON webauthn_sessions (expires_at); +CREATE INDEX IF NOT EXISTS idx_one_time_access_tokens_expires_at ON one_time_access_tokens (expires_at); +CREATE INDEX IF NOT EXISTS idx_oidc_authorization_codes_expires_at ON oidc_authorization_codes (expires_at); +CREATE INDEX IF NOT EXISTS idx_oidc_refresh_tokens_expires_at ON oidc_refresh_tokens (expires_at); +CREATE INDEX IF NOT EXISTS idx_reauthentication_tokens_expires_at ON reauthentication_tokens (expires_at); +CREATE INDEX IF NOT EXISTS idx_email_verification_tokens_expires_at ON email_verification_tokens (expires_at); \ No newline at end of file diff --git a/backend/resources/migrations/sqlite/20260304090200_indexes.down.sql b/backend/resources/migrations/sqlite/20260304090200_indexes.down.sql new file mode 100644 index 00000000..f8e19576 --- /dev/null +++ b/backend/resources/migrations/sqlite/20260304090200_indexes.down.sql @@ -0,0 +1 @@ +-- No-op \ No newline at end of file diff --git a/backend/resources/migrations/sqlite/20260304090200_indexes.up.sql b/backend/resources/migrations/sqlite/20260304090200_indexes.up.sql new file mode 100644 index 00000000..f8a32142 --- /dev/null +++ b/backend/resources/migrations/sqlite/20260304090200_indexes.up.sql @@ -0,0 +1,12 @@ +PRAGMA foreign_keys= OFF; +BEGIN; + +CREATE INDEX IF NOT EXISTS idx_webauthn_sessions_expires_at ON webauthn_sessions (expires_at); +CREATE INDEX IF NOT EXISTS idx_one_time_access_tokens_expires_at ON one_time_access_tokens (expires_at); +CREATE INDEX IF NOT EXISTS idx_oidc_authorization_codes_expires_at ON oidc_authorization_codes (expires_at); +CREATE INDEX IF NOT EXISTS idx_oidc_refresh_tokens_expires_at ON oidc_refresh_tokens (expires_at); +CREATE INDEX IF NOT EXISTS idx_reauthentication_tokens_expires_at ON reauthentication_tokens (expires_at); +CREATE INDEX IF NOT EXISTS idx_email_verification_tokens_expires_at ON email_verification_tokens (expires_at); + +COMMIT; +PRAGMA foreign_keys=ON; diff --git a/email-templates/emails/api-key-expiring-soon.tsx b/email-templates/emails/api-key-expiring-soon.tsx index 6ddcc327..2a33987d 100644 --- a/email-templates/emails/api-key-expiring-soon.tsx +++ b/email-templates/emails/api-key-expiring-soon.tsx @@ -40,7 +40,7 @@ ApiKeyExpiringEmail.TemplateProps = { ...sharedTemplateProps, data: { name: "{{.Data.Name}}", - apiKeyName: "{{.Data.APIKeyName}}", + apiKeyName: "{{.Data.ApiKeyName}}", expiresAt: '{{.Data.ExpiresAt.Format "2006-01-02 15:04:05 MST"}}', }, }; From a675d075d1ab9b7ff8160f1cfc35bc0ea1f1980a Mon Sep 17 00:00:00 2001 From: Elias Schneider Date: Sat, 7 Mar 2026 18:34:57 +0100 Subject: [PATCH 07/12] fix: use URL keyboard type for callback URL inputs --- .../settings/admin/oidc-clients/oidc-callback-url-input.svelte | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/src/routes/settings/admin/oidc-clients/oidc-callback-url-input.svelte b/frontend/src/routes/settings/admin/oidc-clients/oidc-callback-url-input.svelte index cc261fa9..8587f943 100644 --- a/frontend/src/routes/settings/admin/oidc-clients/oidc-callback-url-input.svelte +++ b/frontend/src/routes/settings/admin/oidc-clients/oidc-callback-url-input.svelte @@ -32,6 +32,8 @@ aria-invalid={!!error} data-testid={`callback-url-${i + 1}`} type="text" + inputmode="url" + autocomplete="url" bind:value={callbackURLs[i]} />