From 811e8772b6d9241f4f1d749bde7fd500efe86ce2 Mon Sep 17 00:00:00 2001 From: Elias Schneider Date: Fri, 9 Jan 2026 12:08:58 +0100 Subject: [PATCH] feat: add option to renew API key (#1214) --- backend/internal/common/errors.go | 7 ++ .../internal/controller/api_key_controller.go | 36 ++++++++++ backend/internal/dto/api_key_dto.go | 4 ++ backend/internal/service/api_key_service.go | 50 ++++++++++++++ backend/internal/service/e2etest_service.go | 31 ++++++--- frontend/messages/en.json | 4 ++ .../lib/components/form/date-picker.svelte | 27 ++++---- frontend/src/lib/services/api-key-service.ts | 7 ++ .../settings/admin/api-keys/+page.svelte | 2 +- .../admin/api-keys/api-key-dialog.svelte | 4 +- .../admin/api-keys/api-key-form.svelte | 1 + .../admin/api-keys/api-key-list.svelte | 49 +++++++++++++- .../admin/api-keys/renew-api-key-modal.svelte | 52 +++++++++++++++ tests/data.ts | 9 ++- tests/resources/export/database.json | 13 +++- tests/specs/api-key.spec.ts | 66 ++++++++++++++++--- 16 files changed, 321 insertions(+), 41 deletions(-) create mode 100644 frontend/src/routes/settings/admin/api-keys/renew-api-key-modal.svelte diff --git a/backend/internal/common/errors.go b/backend/internal/common/errors.go index 9647dbd4..c2bd43d6 100644 --- a/backend/internal/common/errors.go +++ b/backend/internal/common/errors.go @@ -266,6 +266,13 @@ func (e *APIKeyNotFoundError) Error() string { } func (e *APIKeyNotFoundError) HttpStatusCode() int { return http.StatusUnauthorized } +type APIKeyNotExpiredError struct{} + +func (e *APIKeyNotExpiredError) Error() string { + return "API Key is not expired yet" +} +func (e *APIKeyNotExpiredError) HttpStatusCode() int { return http.StatusBadRequest } + type APIKeyExpirationDateError struct{} func (e *APIKeyExpirationDateError) Error() string { diff --git a/backend/internal/controller/api_key_controller.go b/backend/internal/controller/api_key_controller.go index 0a10aec1..3b364682 100644 --- a/backend/internal/controller/api_key_controller.go +++ b/backend/internal/controller/api_key_controller.go @@ -30,6 +30,7 @@ func NewApiKeyController(group *gin.RouterGroup, authMiddleware *middleware.Auth { apiKeyGroup.GET("", uc.listApiKeysHandler) apiKeyGroup.POST("", uc.createApiKeyHandler) + apiKeyGroup.POST("/:id/renew", uc.renewApiKeyHandler) apiKeyGroup.DELETE("/:id", uc.revokeApiKeyHandler) } } @@ -101,6 +102,41 @@ func (c *ApiKeyController) createApiKeyHandler(ctx *gin.Context) { }) } +// renewApiKeyHandler godoc +// @Summary Renew API key +// @Description Renew an existing API key by ID +// @Tags API Keys +// @Param id path string true "API Key ID" +// @Success 200 {object} dto.ApiKeyResponseDto "Renewed API key with new token" +// @Router /api/api-keys/{id}/renew [post] +func (c *ApiKeyController) renewApiKeyHandler(ctx *gin.Context) { + userID := ctx.GetString("userID") + apiKeyID := ctx.Param("id") + + var input dto.ApiKeyRenewDto + if err := dto.ShouldBindWithNormalizedJSON(ctx, &input); err != nil { + _ = ctx.Error(err) + return + } + + apiKey, token, err := c.apiKeyService.RenewApiKey(ctx.Request.Context(), userID, apiKeyID, input.ExpiresAt.ToTime()) + if err != nil { + _ = ctx.Error(err) + return + } + + var apiKeyDto dto.ApiKeyDto + if err := dto.MapStruct(apiKey, &apiKeyDto); err != nil { + _ = ctx.Error(err) + return + } + + ctx.JSON(http.StatusOK, dto.ApiKeyResponseDto{ + ApiKey: apiKeyDto, + Token: token, + }) +} + // revokeApiKeyHandler godoc // @Summary Revoke API key // @Description Revoke (delete) an existing API key by ID diff --git a/backend/internal/dto/api_key_dto.go b/backend/internal/dto/api_key_dto.go index 15ce27ea..e1920b67 100644 --- a/backend/internal/dto/api_key_dto.go +++ b/backend/internal/dto/api_key_dto.go @@ -10,6 +10,10 @@ type ApiKeyCreateDto struct { ExpiresAt datatype.DateTime `json:"expiresAt" binding:"required"` } +type ApiKeyRenewDto struct { + ExpiresAt datatype.DateTime `json:"expiresAt" binding:"required"` +} + type ApiKeyDto struct { ID string `json:"id"` Name string `json:"name"` diff --git a/backend/internal/service/api_key_service.go b/backend/internal/service/api_key_service.go index 42c5ce39..97562697 100644 --- a/backend/internal/service/api_key_service.go +++ b/backend/internal/service/api_key_service.go @@ -72,6 +72,56 @@ func (s *ApiKeyService) CreateApiKey(ctx context.Context, userID string, input d return apiKey, token, nil } +func (s *ApiKeyService) RenewApiKey(ctx context.Context, userID, apiKeyID string, expiration time.Time) (model.ApiKey, string, error) { + // Check if expiration is in the future + if !expiration.After(time.Now()) { + return model.ApiKey{}, "", &common.APIKeyExpirationDateError{} + } + + tx := s.db.Begin() + defer tx.Rollback() + + var apiKey model.ApiKey + err := tx. + WithContext(ctx). + Model(&model.ApiKey{}). + Where("id = ? AND user_id = ?", apiKeyID, userID). + First(&apiKey). + Error + + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return model.ApiKey{}, "", &common.APIKeyNotFoundError{} + } + return model.ApiKey{}, "", err + } + + // Only allow renewal if the key has already expired + if apiKey.ExpiresAt.ToTime().After(time.Now()) { + return model.ApiKey{}, "", &common.APIKeyNotExpiredError{} + } + + // Generate a secure random API key + token, err := utils.GenerateRandomAlphanumericString(32) + if err != nil { + return model.ApiKey{}, "", err + } + + apiKey.Key = utils.CreateSha256Hash(token) + apiKey.ExpiresAt = datatype.DateTime(expiration) + + err = tx.WithContext(ctx).Save(&apiKey).Error + if err != nil { + return model.ApiKey{}, "", err + } + + if err := tx.Commit().Error; err != nil { + return model.ApiKey{}, "", err + } + + return apiKey, token, nil +} + func (s *ApiKeyService) RevokeApiKey(ctx context.Context, userID, apiKeyID string) error { var apiKey model.ApiKey err := s.db. diff --git a/backend/internal/service/e2etest_service.go b/backend/internal/service/e2etest_service.go index 1be77436..7cb98530 100644 --- a/backend/internal/service/e2etest_service.go +++ b/backend/internal/service/e2etest_service.go @@ -354,17 +354,30 @@ func (s *TestService) SeedDatabase(baseURL string) error { return err } - apiKey := model.ApiKey{ - Base: model.Base{ - ID: "5f1fa856-c164-4295-961e-175a0d22d725", + apiKeys := []model.ApiKey{ + { + Base: model.Base{ + ID: "5f1fa856-c164-4295-961e-175a0d22d725", + }, + Name: "Test API Key", + Key: "6c34966f57ef2bb7857649aff0e7ab3ad67af93c846342ced3f5a07be8706c20", + UserID: users[0].ID, + ExpiresAt: datatype.DateTime(time.Now().Add(30 * 24 * time.Hour)), + }, + { + Base: model.Base{ + ID: "98900330-7a7b-48fe-881b-2cc6ad049976", + }, + Name: "Expired API Key", + Key: "141ff8ac9db640ba93630099de83d0ead8e7ac673e3a7d31b4fd7ff2252e6389", + UserID: users[0].ID, + ExpiresAt: datatype.DateTime(time.Now().Add(-20 * 24 * time.Hour)), }, - Name: "Test API Key", - Key: "6c34966f57ef2bb7857649aff0e7ab3ad67af93c846342ced3f5a07be8706c20", - UserID: users[0].ID, - ExpiresAt: datatype.DateTime(time.Now().Add(30 * 24 * time.Hour)), } - if err := tx.Create(&apiKey).Error; err != nil { - return err + for _, apiKey := range apiKeys { + if err := tx.Create(&apiKey).Error; err != nil { + return err + } } signupTokens := []model.SignupToken{ diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 39d0587c..18a9d9bf 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -506,6 +506,10 @@ "issuer_url": "Issuer URL", "smtp_field_required_when_other_provided": "Required when any SMTP setting is provided", "smtp_field_required_when_email_enabled": "Required when email notifications are enabled", + "renew": "Renew", + "renew_api_key": "Renew API Key", + "renew_api_key_description": "Renewing the API key will generate a new key. Make sure to update any integrations using this key.", + "api_key_renewed": "API key renewed", "app_config_home_page": "Home Page", "app_config_home_page_description": "The page users are redirected to after signing in." } diff --git a/frontend/src/lib/components/form/date-picker.svelte b/frontend/src/lib/components/form/date-picker.svelte index e2723265..fc0627ce 100644 --- a/frontend/src/lib/components/form/date-picker.svelte +++ b/frontend/src/lib/components/form/date-picker.svelte @@ -31,19 +31,6 @@ return new CalendarDate(d.getFullYear(), d.getMonth() + 1, d.getDate()); } - $effect(() => { - if (calendarDisplayDate) { - const newExternalDate = calendarDisplayDate.toDate(getLocalTimeZone()); - if (!value || value.getTime() !== newExternalDate.getTime()) { - value = newExternalDate; - } - } else { - if (value !== undefined) { - value = undefined; - } - } - }); - $effect(() => { if (value) { const newInternalCalendarDate = dateToCalendarDate(value); @@ -59,6 +46,17 @@ function handleCalendarInteraction(newDateValue?: DateValue) { open = false; + calendarDisplayDate = newDateValue as CalendarDate | undefined; + if (calendarDisplayDate) { + const newExternalDate = calendarDisplayDate.toDate(getLocalTimeZone()); + if (!value || value.getTime() !== newExternalDate.getTime()) { + value = newExternalDate; + } + } else { + if (value !== undefined) { + value = undefined; + } + } } const df = new DateFormatter(getLocale(), { @@ -89,8 +87,7 @@ calendarDisplayDate, (newValue) => handleCalendarInteraction(newValue)} initialFocus /> diff --git a/frontend/src/lib/services/api-key-service.ts b/frontend/src/lib/services/api-key-service.ts index 681a23b3..c30bf984 100644 --- a/frontend/src/lib/services/api-key-service.ts +++ b/frontend/src/lib/services/api-key-service.ts @@ -13,6 +13,13 @@ export default class ApiKeyService extends APIService { return res.data as ApiKeyResponse; }; + renew = async (id: string, expiresAt: Date): Promise => { + const res = await this.api.post(`/api-keys/${id}/renew`, { + expiresAt + }); + return res.data as ApiKeyResponse; + }; + revoke = async (id: string): Promise => { await this.api.delete(`/api-keys/${id}`); }; diff --git a/frontend/src/routes/settings/admin/api-keys/+page.svelte b/frontend/src/routes/settings/admin/api-keys/+page.svelte index 91ab3efd..06317a69 100644 --- a/frontend/src/routes/settings/admin/api-keys/+page.svelte +++ b/frontend/src/routes/settings/admin/api-keys/+page.svelte @@ -76,4 +76,4 @@ - + diff --git a/frontend/src/routes/settings/admin/api-keys/api-key-dialog.svelte b/frontend/src/routes/settings/admin/api-keys/api-key-dialog.svelte index fd0715f7..b52ba02b 100644 --- a/frontend/src/routes/settings/admin/api-keys/api-key-dialog.svelte +++ b/frontend/src/routes/settings/admin/api-keys/api-key-dialog.svelte @@ -6,8 +6,10 @@ import type { ApiKeyResponse } from '$lib/types/api-key.type'; let { + title, apiKeyResponse = $bindable() }: { + title: string; apiKeyResponse: ApiKeyResponse | null; } = $props(); @@ -21,7 +23,7 @@ e.preventDefault()}> - {m.api_key_created()} + {title} {m.for_security_reasons_this_key_will_only_be_shown_once()} diff --git a/frontend/src/routes/settings/admin/api-keys/api-key-form.svelte b/frontend/src/routes/settings/admin/api-keys/api-key-form.svelte index 09518f2b..0c09ff87 100644 --- a/frontend/src/routes/settings/admin/api-keys/api-key-form.svelte +++ b/frontend/src/routes/settings/admin/api-keys/api-key-form.svelte @@ -19,6 +19,7 @@ // Set default expiration to 30 days from now const defaultExpiry = new Date(); defaultExpiry.setDate(defaultExpiry.getDate() + 30); + defaultExpiry.setHours(0, 0, 0, 0); const apiKey = { name: '', diff --git a/frontend/src/routes/settings/admin/api-keys/api-key-list.svelte b/frontend/src/routes/settings/admin/api-keys/api-key-list.svelte index 656306a5..7bfc08e8 100644 --- a/frontend/src/routes/settings/admin/api-keys/api-key-list.svelte +++ b/frontend/src/routes/settings/admin/api-keys/api-key-list.svelte @@ -7,13 +7,17 @@ AdvancedTableColumn, CreateAdvancedTableActions } from '$lib/types/advanced-table.type'; - import type { ApiKey } from '$lib/types/api-key.type'; + import type { ApiKey, ApiKeyResponse } from '$lib/types/api-key.type'; import { axiosErrorToast } from '$lib/utils/error-util'; - import { LucideBan } from '@lucide/svelte'; + import { LucideBan, LucideRefreshCcw, LucideTriangleAlert } from '@lucide/svelte'; import { toast } from 'svelte-sonner'; + import ApiKeyDialog from './api-key-dialog.svelte'; + import RenewApiKeyModal from './renew-api-key-modal.svelte'; const apiKeyService = new ApiKeyService(); + let apiKeyToRenew: ApiKey | null = $state(null); + let renewedApiKey: ApiKeyResponse | null = $state(null); let tableRef: AdvancedTable; export function refresh() { @@ -35,7 +39,7 @@ label: m.expires_at(), column: 'expiresAt', sortable: true, - value: (item) => formatDate(item.expiresAt) + cell: ExpirationCell }, { label: m.last_used(), @@ -53,6 +57,13 @@ ]; const actions: CreateAdvancedTableActions = (apiKey) => [ + { + label: m.renew(), + icon: LucideRefreshCcw, + variant: 'primary', + hidden: new Date(apiKey.expiresAt) > new Date(), + onClick: (apiKey) => (apiKeyToRenew = apiKey) + }, { label: m.revoke(), icon: LucideBan, @@ -61,6 +72,21 @@ } ]; + async function renewApiKey(expirationDate: Date) { + if (!apiKeyToRenew) return; + + await apiKeyService + .renew(apiKeyToRenew.id, expirationDate) + .then(async (response) => { + renewedApiKey = response; + await refresh(); + apiKeyToRenew = null; + }) + .catch((e) => { + axiosErrorToast(e); + }); + } + function revokeApiKey(apiKey: ApiKey) { openConfirmDialog({ title: m.revoke_api_key(), @@ -84,6 +110,20 @@ } +{#snippet ExpirationCell({ item }: { item: ApiKey })} + {@const expired = new Date(item.expiresAt) <= new Date()} + {formatDate(item.expiresAt)} + {#if expired} + + {/if} + +{/snippet} + + + + diff --git a/frontend/src/routes/settings/admin/api-keys/renew-api-key-modal.svelte b/frontend/src/routes/settings/admin/api-keys/renew-api-key-modal.svelte new file mode 100644 index 00000000..30ad47fa --- /dev/null +++ b/frontend/src/routes/settings/admin/api-keys/renew-api-key-modal.svelte @@ -0,0 +1,52 @@ + + + + e.preventDefault()}> + + {m.renew_api_key()} + + {m.renew_api_key_description()} + + + + {m.expiration()} + + + + + + + + + diff --git a/tests/data.ts b/tests/data.ts index 1c234ef2..c47af384 100644 --- a/tests/data.ts +++ b/tests/data.ts @@ -96,7 +96,14 @@ export const apiKeys = [ { id: '5f1fa856-c164-4295-961e-175a0d22d725', key: '6c34966f57ef2bb7857649aff0e7ab3ad67af93c846342ced3f5a07be8706c20', - name: 'Test API Key' + name: 'Test API Key', + expired: false + }, + { + id: '98900330-7a7b-48fe-881b-2cc6ad049976', + key: '141ff8ac9db640ba93630099de83d0ead8e7ac673e3a7d31b4fd7ff2252e6389', + name: 'Expired API Key', + expired: true } ]; diff --git a/tests/resources/export/database.json b/tests/resources/export/database.json index ed60f745..7232df29 100644 --- a/tests/resources/export/database.json +++ b/tests/resources/export/database.json @@ -1,9 +1,20 @@ { "provider": "sqlite", - "version": 20251229173100, + "version": 20260106140900, "tableOrder": ["users", "user_groups", "oidc_clients", "signup_tokens"], "tables": { "api_keys": [ + { + "created_at": "2025-12-21T19:12:03Z", + "description": null, + "expiration_email_sent": false, + "expires_at": "2025-12-25T12:00:00Z", + "key": "141ff8ac9db640ba93630099de83d0ead8e7ac673e3a7d31b4fd7ff2252e6389", + "id": "98900330-7a7b-48fe-881b-2cc6ad049976", + "last_used_at": null, + "name": "Expired API Key", + "user_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e" + }, { "created_at": "2025-11-25T12:39:02Z", "description": null, diff --git a/tests/specs/api-key.spec.ts b/tests/specs/api-key.spec.ts index 2543b428..57c70ff4 100644 --- a/tests/specs/api-key.spec.ts +++ b/tests/specs/api-key.spec.ts @@ -1,5 +1,5 @@ // frontend/tests/api-key.spec.ts -import { expect, test } from '@playwright/test'; +import { expect, Page, test } from '@playwright/test'; import { apiKeys } from '../data'; import { cleanupBackend } from '../utils/cleanup.util'; @@ -19,15 +19,7 @@ test.describe('API Key Management', () => { // Choose the date const currentDate = new Date(); - await page.getByRole('button', { name: 'Select a date' }).click(); - await page.getByLabel('Select year').click(); - // Select the next year - await page.getByRole('option', { name: (currentDate.getFullYear() + 1).toString() }).click(); - // Select the first day of the month - await page - .getByRole('button', { name: /([A-Z][a-z]+), ([A-Z][a-z]+) 1, (\d{4})/ }) - .first() - .click(); + await selectDate(page, currentDate.getFullYear() + 1, currentDate.getMonth(), 1); // Submit the form await page.getByRole('button', { name: 'Save' }).click(); @@ -51,6 +43,30 @@ test.describe('API Key Management', () => { await expect(page.getByRole('cell', { name }).first()).toContainText(name); }); + test('Renew API key', async ({ page }) => { + const apiKey = apiKeys[1]; + + await page + .getByRole('row', { name: apiKey.name }) + .getByRole('button', { name: 'Toggle menu' }) + .click(); + + await page.getByRole('menuitem', { name: 'Renew' }).click(); + + // Choose the date + const currentDate = new Date(); + await selectDate(page, currentDate.getFullYear() + 1, currentDate.getMonth(), 1); + + await page.getByRole('button', { name: 'Renew' }).click(); + + await expect(page.getByRole('heading', { name: 'API key renewed' })).toBeVisible(); + + // Verify the new expiration date is shown + const row = page.getByRole('row', { name: apiKey.name }); + const expectedDate = new Date(currentDate.getFullYear() + 1, currentDate.getMonth(), 1); + await expect(row.getByRole('cell', { name: expectedDate.toLocaleString() })).toBeVisible(); + }); + test('Revoke API key', async ({ page }) => { const apiKey = apiKeys[0]; @@ -70,3 +86,33 @@ test.describe('API Key Management', () => { await expect(page.getByRole('cell', { name: apiKey.name })).not.toBeVisible(); }); }); + +async function selectDate(page: Page, year: number, month: number, day: number) { + // Open the date picker + await page.getByRole('button', { name: 'Select a date' }).click(); + // Select the year + await page.getByLabel('Select year').click(); + await page.getByRole('option', { name: year.toString() }).click(); + // Select the month and day + const monthNames = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December' + ]; + const monthName = monthNames[month]; + await page.getByRole('button', { name: 'Select month' }).click(); + await page.getByRole('option', { name: monthName }).click(); + + await page + .getByRole('button', { name: new RegExp(`([A-Z][a-z]+), ([A-Z][a-z]+) ${day}, (\\d{4})`) }).first() + .click(); +}