From 4df5f858f37d42b8a6fd702b48ec8c0d38c01073 Mon Sep 17 00:00:00 2001 From: Juan Mrad Date: Thu, 19 Feb 2026 17:12:10 -0600 Subject: [PATCH] Allow rotation of Signing Key by users (#69) * Allow rotation of Signing Key by users * code review changes * use logError for structure errors here --- client/src/graphql/apiKeyQueries.ts | 18 ++ client/src/graphql/generated.ts | 103 ++++++++++ .../settings/ApiAuthenticationSettings.tsx | 179 +++++++++++++++++- docs/API_AUTHENTICATION.md | 65 +++++++ docs/SUMMARY.md | 1 + server/graphql/datasources/OrgApi.ts | 44 +++-- server/graphql/generated.ts | 96 ++++++++++ server/graphql/modules/apiKey.ts | 86 ++++++++- server/lib/cache/Cache.ts | 8 + .../postgresSigningKeyPairStorage.ts | 10 +- .../secretsManagerSigningKeyPairStorage.ts | 51 +++-- .../signingKeyPairService.ts | 48 ++++- server/utils/caching.ts | 6 + 13 files changed, 663 insertions(+), 52 deletions(-) create mode 100644 docs/API_AUTHENTICATION.md diff --git a/client/src/graphql/apiKeyQueries.ts b/client/src/graphql/apiKeyQueries.ts index 6f05f70..bdb442b 100644 --- a/client/src/graphql/apiKeyQueries.ts +++ b/client/src/graphql/apiKeyQueries.ts @@ -40,3 +40,21 @@ export const ROTATE_API_KEY_MUTATION = gql` } } `; + +export const ROTATE_WEBHOOK_SIGNING_KEY_MUTATION = gql` + mutation RotateWebhookSigningKey { + rotateWebhookSigningKey { + ... on RotateWebhookSigningKeySuccessResponse { + publicSigningKey + } + ... on RotateWebhookSigningKeyError { + title + status + type + detail + pointer + requestId + } + } + } +`; diff --git a/client/src/graphql/generated.ts b/client/src/graphql/generated.ts index 3c5e4ba..5937b81 100644 --- a/client/src/graphql/generated.ts +++ b/client/src/graphql/generated.ts @@ -2259,6 +2259,7 @@ export type GQLMutation = { readonly requestDemo?: Maybe; readonly resetPassword: Scalars['Boolean']; readonly rotateApiKey: GQLRotateApiKeyResponse; + readonly rotateWebhookSigningKey: GQLRotateWebhookSigningKeyResponse; readonly runRetroaction?: Maybe; readonly sendPasswordReset: Scalars['Boolean']; readonly setAllUserStrikeThresholds: GQLSetAllUserStrikeThresholdsSuccessResponse; @@ -3608,6 +3609,25 @@ export type GQLRotateApiKeySuccessResponse = { readonly record: GQLApiKey; }; +export type GQLRotateWebhookSigningKeyError = GQLError & { + readonly __typename: 'RotateWebhookSigningKeyError'; + readonly detail?: Maybe; + readonly pointer?: Maybe; + readonly requestId?: Maybe; + readonly status: Scalars['Int']; + readonly title: Scalars['String']; + readonly type: ReadonlyArray; +}; + +export type GQLRotateWebhookSigningKeyResponse = + | GQLRotateWebhookSigningKeyError + | GQLRotateWebhookSigningKeySuccessResponse; + +export type GQLRotateWebhookSigningKeySuccessResponse = { + readonly __typename: 'RotateWebhookSigningKeySuccessResponse'; + readonly publicSigningKey: Scalars['String']; +}; + export type GQLRoutingRule = { readonly __typename: 'RoutingRule'; readonly conditionSet: GQLConditionSet; @@ -4768,6 +4788,28 @@ export type GQLRotateApiKeyMutation = { }; }; +export type GQLRotateWebhookSigningKeyMutationVariables = Exact<{ + [key: string]: never; +}>; + +export type GQLRotateWebhookSigningKeyMutation = { + readonly __typename: 'Mutation'; + readonly rotateWebhookSigningKey: + | { + readonly __typename: 'RotateWebhookSigningKeyError'; + readonly title: string; + readonly status: number; + readonly type: ReadonlyArray; + readonly detail?: string | null; + readonly pointer?: string | null; + readonly requestId?: string | null; + } + | { + readonly __typename: 'RotateWebhookSigningKeySuccessResponse'; + readonly publicSigningKey: string; + }; +}; + export type GQLHashBanksQueryVariables = Exact<{ [key: string]: never }>; export type GQLHashBanksQuery = { @@ -24855,6 +24897,66 @@ export type GQLRotateApiKeyMutationOptions = Apollo.BaseMutationOptions< GQLRotateApiKeyMutation, GQLRotateApiKeyMutationVariables >; +export const GQLRotateWebhookSigningKeyDocument = gql` + mutation RotateWebhookSigningKey { + rotateWebhookSigningKey { + ... on RotateWebhookSigningKeySuccessResponse { + publicSigningKey + } + ... on RotateWebhookSigningKeyError { + title + status + type + detail + pointer + requestId + } + } + } +`; +export type GQLRotateWebhookSigningKeyMutationFn = Apollo.MutationFunction< + GQLRotateWebhookSigningKeyMutation, + GQLRotateWebhookSigningKeyMutationVariables +>; + +/** + * __useGQLRotateWebhookSigningKeyMutation__ + * + * To run a mutation, you first call `useGQLRotateWebhookSigningKeyMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useGQLRotateWebhookSigningKeyMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [gqlRotateWebhookSigningKeyMutation, { data, loading, error }] = useGQLRotateWebhookSigningKeyMutation({ + * variables: { + * }, + * }); + */ +export function useGQLRotateWebhookSigningKeyMutation( + baseOptions?: Apollo.MutationHookOptions< + GQLRotateWebhookSigningKeyMutation, + GQLRotateWebhookSigningKeyMutationVariables + >, +) { + const options = { ...defaultOptions, ...baseOptions }; + return Apollo.useMutation< + GQLRotateWebhookSigningKeyMutation, + GQLRotateWebhookSigningKeyMutationVariables + >(GQLRotateWebhookSigningKeyDocument, options); +} +export type GQLRotateWebhookSigningKeyMutationHookResult = ReturnType< + typeof useGQLRotateWebhookSigningKeyMutation +>; +export type GQLRotateWebhookSigningKeyMutationResult = + Apollo.MutationResult; +export type GQLRotateWebhookSigningKeyMutationOptions = + Apollo.BaseMutationOptions< + GQLRotateWebhookSigningKeyMutation, + GQLRotateWebhookSigningKeyMutationVariables + >; export const GQLHashBanksDocument = gql` query HashBanks { hashBanks { @@ -37174,6 +37276,7 @@ export const namedOperations = { }, Mutation: { RotateApiKey: 'RotateApiKey', + RotateWebhookSigningKey: 'RotateWebhookSigningKey', CreateHashBank: 'CreateHashBank', UpdateHashBank: 'UpdateHashBank', DeleteHashBank: 'DeleteHashBank', diff --git a/client/src/webpages/settings/ApiAuthenticationSettings.tsx b/client/src/webpages/settings/ApiAuthenticationSettings.tsx index 1ad0d7a..18ce9c3 100644 --- a/client/src/webpages/settings/ApiAuthenticationSettings.tsx +++ b/client/src/webpages/settings/ApiAuthenticationSettings.tsx @@ -5,7 +5,11 @@ import { Link } from '@/coop-ui/Link'; import { Textarea } from '@/coop-ui/Textarea'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/coop-ui/Tooltip'; import { Heading, Text } from '@/coop-ui/Typography'; -import { useGQLApiAuthQuery, useGQLRotateApiKeyMutation } from '../../graphql/generated'; +import { + useGQLApiAuthQuery, + useGQLRotateApiKeyMutation, + useGQLRotateWebhookSigningKeyMutation, +} from '../../graphql/generated'; import { Clipboard, Eye, EyeClosed, RotateCcw } from 'lucide-react'; import { useState } from 'react'; import { Helmet } from 'react-helmet-async'; @@ -20,11 +24,22 @@ import { userHasPermissions } from '../../routing/permissions'; const ApiAuthenticationSettings = () => { const { data, loading, error, refetch } = useGQLApiAuthQuery(); const [rotateApiKey] = useGQLRotateApiKeyMutation(); + const [rotateWebhookSigningKey] = useGQLRotateWebhookSigningKeyMutation(); const [apiKeyVisible, setApiKeyVisible] = useState(false); const [showRotationDialog, setShowRotationDialog] = useState(false); const [isRotating, setIsRotating] = useState(false); const [newApiKey, setNewApiKey] = useState(null); const [rotationError, setRotationError] = useState(null); + const [showWebhookKeyRotationDialog, setShowWebhookKeyRotationDialog] = + useState(false); + const [isRotatingWebhookKey, setIsRotatingWebhookKey] = useState(false); + const [newWebhookSigningKey, setNewWebhookSigningKey] = useState< + string | null + >(null); + const [webhookKeyRotationError, setWebhookKeyRotationError] = useState< + string | null + >(null); + const [webhookKeyCopied, setWebhookKeyCopied] = useState(false); const navigate = useNavigate(); if (loading) { @@ -32,18 +47,47 @@ const ApiAuthenticationSettings = () => { } if (error) { - return
; + const message = + error.graphQLErrors?.[0]?.message ?? + error.message ?? + 'Failed to load API key settings'; + return ( +
+ API Keys +
+ + {message} + + +
+
+ ); } const requiredPermissions = [GQLUserPermission.ManageOrg]; const permissions = data?.me?.permissions; if (!userHasPermissions(permissions, requiredPermissions)) { navigate('/settings'); + return null; } const org = data?.myOrg; if (!org) { - throw Error('Missing org'); + return ( +
+ Unable to load organization. Please try again. + +
+ ); } const apiKey = data?.apiKey; @@ -90,6 +134,47 @@ const ApiAuthenticationSettings = () => { setShowRotationDialog(true); }; + const handleRotateWebhookSigningKey = async () => { + setIsRotatingWebhookKey(true); + setWebhookKeyRotationError(null); + + try { + const result = await rotateWebhookSigningKey(); + + if ( + result.data?.rotateWebhookSigningKey.__typename === + 'RotateWebhookSigningKeySuccessResponse' + ) { + setNewWebhookSigningKey( + result.data.rotateWebhookSigningKey.publicSigningKey, + ); + await refetch(); + } else if ( + result.data?.rotateWebhookSigningKey.__typename === + 'RotateWebhookSigningKeyError' + ) { + setWebhookKeyRotationError( + result.data.rotateWebhookSigningKey.detail ?? + 'Failed to generate new webhook signing key', + ); + } + } catch { + setWebhookKeyRotationError( + 'An error occurred while generating the new webhook signing key', + ); + } finally { + setIsRotatingWebhookKey(false); + setShowWebhookKeyRotationDialog(false); + } + }; + + const confirmWebhookKeyRotation = () => { + setShowWebhookKeyRotationDialog(true); + }; + + const showWebhookDialog = showWebhookKeyRotationDialog; + const onConfirmWebhookRotation = handleRotateWebhookSigningKey; + return (
@@ -229,22 +314,69 @@ const ApiAuthenticationSettings = () => { )}
- Webhook Signature Verification Key +
+ Webhook Signature Verification Key + +
This is your webhook signature verification key. We will include a signature in every HTTP request we send to you in case you'd like to verify that the request is valid and came from Coop. To learn how to verify requests with this secret, see our{' '} - API Documentation + API Keys and Authentication .
+ {newWebhookSigningKey && ( +
+ + New webhook signature verification key generated. Copy and store it + securely; future webhook requests will be signed with the new key. + +