Mirror of https://github.com/roostorg/coop github.com/roostorg/coop
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Allow rotation of Signing Key by users (#69)

* Allow rotation of Signing Key by users

* code review changes

* use logError for structure errors here

authored by

Juan Mrad and committed by
GitHub
4df5f858 3d73fc32

+663 -52
+18
client/src/graphql/apiKeyQueries.ts
··· 40 40 } 41 41 } 42 42 `; 43 + 44 + export const ROTATE_WEBHOOK_SIGNING_KEY_MUTATION = gql` 45 + mutation RotateWebhookSigningKey { 46 + rotateWebhookSigningKey { 47 + ... on RotateWebhookSigningKeySuccessResponse { 48 + publicSigningKey 49 + } 50 + ... on RotateWebhookSigningKeyError { 51 + title 52 + status 53 + type 54 + detail 55 + pointer 56 + requestId 57 + } 58 + } 59 + } 60 + `;
+103
client/src/graphql/generated.ts
··· 2259 2259 readonly requestDemo?: Maybe<Scalars['Boolean']>; 2260 2260 readonly resetPassword: Scalars['Boolean']; 2261 2261 readonly rotateApiKey: GQLRotateApiKeyResponse; 2262 + readonly rotateWebhookSigningKey: GQLRotateWebhookSigningKeyResponse; 2262 2263 readonly runRetroaction?: Maybe<GQLRunRetroactionResponse>; 2263 2264 readonly sendPasswordReset: Scalars['Boolean']; 2264 2265 readonly setAllUserStrikeThresholds: GQLSetAllUserStrikeThresholdsSuccessResponse; ··· 3608 3609 readonly record: GQLApiKey; 3609 3610 }; 3610 3611 3612 + export type GQLRotateWebhookSigningKeyError = GQLError & { 3613 + readonly __typename: 'RotateWebhookSigningKeyError'; 3614 + readonly detail?: Maybe<Scalars['String']>; 3615 + readonly pointer?: Maybe<Scalars['String']>; 3616 + readonly requestId?: Maybe<Scalars['String']>; 3617 + readonly status: Scalars['Int']; 3618 + readonly title: Scalars['String']; 3619 + readonly type: ReadonlyArray<Scalars['String']>; 3620 + }; 3621 + 3622 + export type GQLRotateWebhookSigningKeyResponse = 3623 + | GQLRotateWebhookSigningKeyError 3624 + | GQLRotateWebhookSigningKeySuccessResponse; 3625 + 3626 + export type GQLRotateWebhookSigningKeySuccessResponse = { 3627 + readonly __typename: 'RotateWebhookSigningKeySuccessResponse'; 3628 + readonly publicSigningKey: Scalars['String']; 3629 + }; 3630 + 3611 3631 export type GQLRoutingRule = { 3612 3632 readonly __typename: 'RoutingRule'; 3613 3633 readonly conditionSet: GQLConditionSet; ··· 4765 4785 readonly lastUsedAt?: string | null; 4766 4786 readonly createdBy?: string | null; 4767 4787 }; 4788 + }; 4789 + }; 4790 + 4791 + export type GQLRotateWebhookSigningKeyMutationVariables = Exact<{ 4792 + [key: string]: never; 4793 + }>; 4794 + 4795 + export type GQLRotateWebhookSigningKeyMutation = { 4796 + readonly __typename: 'Mutation'; 4797 + readonly rotateWebhookSigningKey: 4798 + | { 4799 + readonly __typename: 'RotateWebhookSigningKeyError'; 4800 + readonly title: string; 4801 + readonly status: number; 4802 + readonly type: ReadonlyArray<string>; 4803 + readonly detail?: string | null; 4804 + readonly pointer?: string | null; 4805 + readonly requestId?: string | null; 4806 + } 4807 + | { 4808 + readonly __typename: 'RotateWebhookSigningKeySuccessResponse'; 4809 + readonly publicSigningKey: string; 4768 4810 }; 4769 4811 }; 4770 4812 ··· 24855 24897 GQLRotateApiKeyMutation, 24856 24898 GQLRotateApiKeyMutationVariables 24857 24899 >; 24900 + export const GQLRotateWebhookSigningKeyDocument = gql` 24901 + mutation RotateWebhookSigningKey { 24902 + rotateWebhookSigningKey { 24903 + ... on RotateWebhookSigningKeySuccessResponse { 24904 + publicSigningKey 24905 + } 24906 + ... on RotateWebhookSigningKeyError { 24907 + title 24908 + status 24909 + type 24910 + detail 24911 + pointer 24912 + requestId 24913 + } 24914 + } 24915 + } 24916 + `; 24917 + export type GQLRotateWebhookSigningKeyMutationFn = Apollo.MutationFunction< 24918 + GQLRotateWebhookSigningKeyMutation, 24919 + GQLRotateWebhookSigningKeyMutationVariables 24920 + >; 24921 + 24922 + /** 24923 + * __useGQLRotateWebhookSigningKeyMutation__ 24924 + * 24925 + * To run a mutation, you first call `useGQLRotateWebhookSigningKeyMutation` within a React component and pass it any options that fit your needs. 24926 + * When your component renders, `useGQLRotateWebhookSigningKeyMutation` returns a tuple that includes: 24927 + * - A mutate function that you can call at any time to execute the mutation 24928 + * - An object with fields that represent the current status of the mutation's execution 24929 + * 24930 + * @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; 24931 + * 24932 + * @example 24933 + * const [gqlRotateWebhookSigningKeyMutation, { data, loading, error }] = useGQLRotateWebhookSigningKeyMutation({ 24934 + * variables: { 24935 + * }, 24936 + * }); 24937 + */ 24938 + export function useGQLRotateWebhookSigningKeyMutation( 24939 + baseOptions?: Apollo.MutationHookOptions< 24940 + GQLRotateWebhookSigningKeyMutation, 24941 + GQLRotateWebhookSigningKeyMutationVariables 24942 + >, 24943 + ) { 24944 + const options = { ...defaultOptions, ...baseOptions }; 24945 + return Apollo.useMutation< 24946 + GQLRotateWebhookSigningKeyMutation, 24947 + GQLRotateWebhookSigningKeyMutationVariables 24948 + >(GQLRotateWebhookSigningKeyDocument, options); 24949 + } 24950 + export type GQLRotateWebhookSigningKeyMutationHookResult = ReturnType< 24951 + typeof useGQLRotateWebhookSigningKeyMutation 24952 + >; 24953 + export type GQLRotateWebhookSigningKeyMutationResult = 24954 + Apollo.MutationResult<GQLRotateWebhookSigningKeyMutation>; 24955 + export type GQLRotateWebhookSigningKeyMutationOptions = 24956 + Apollo.BaseMutationOptions< 24957 + GQLRotateWebhookSigningKeyMutation, 24958 + GQLRotateWebhookSigningKeyMutationVariables 24959 + >; 24858 24960 export const GQLHashBanksDocument = gql` 24859 24961 query HashBanks { 24860 24962 hashBanks { ··· 37174 37276 }, 37175 37277 Mutation: { 37176 37278 RotateApiKey: 'RotateApiKey', 37279 + RotateWebhookSigningKey: 'RotateWebhookSigningKey', 37177 37280 CreateHashBank: 'CreateHashBank', 37178 37281 UpdateHashBank: 'UpdateHashBank', 37179 37282 DeleteHashBank: 'DeleteHashBank',
+172 -7
client/src/webpages/settings/ApiAuthenticationSettings.tsx
··· 5 5 import { Textarea } from '@/coop-ui/Textarea'; 6 6 import { Tooltip, TooltipContent, TooltipTrigger } from '@/coop-ui/Tooltip'; 7 7 import { Heading, Text } from '@/coop-ui/Typography'; 8 - import { useGQLApiAuthQuery, useGQLRotateApiKeyMutation } from '../../graphql/generated'; 8 + import { 9 + useGQLApiAuthQuery, 10 + useGQLRotateApiKeyMutation, 11 + useGQLRotateWebhookSigningKeyMutation, 12 + } from '../../graphql/generated'; 9 13 import { Clipboard, Eye, EyeClosed, RotateCcw } from 'lucide-react'; 10 14 import { useState } from 'react'; 11 15 import { Helmet } from 'react-helmet-async'; ··· 20 24 const ApiAuthenticationSettings = () => { 21 25 const { data, loading, error, refetch } = useGQLApiAuthQuery(); 22 26 const [rotateApiKey] = useGQLRotateApiKeyMutation(); 27 + const [rotateWebhookSigningKey] = useGQLRotateWebhookSigningKeyMutation(); 23 28 const [apiKeyVisible, setApiKeyVisible] = useState(false); 24 29 const [showRotationDialog, setShowRotationDialog] = useState(false); 25 30 const [isRotating, setIsRotating] = useState(false); 26 31 const [newApiKey, setNewApiKey] = useState<string | null>(null); 27 32 const [rotationError, setRotationError] = useState<string | null>(null); 33 + const [showWebhookKeyRotationDialog, setShowWebhookKeyRotationDialog] = 34 + useState(false); 35 + const [isRotatingWebhookKey, setIsRotatingWebhookKey] = useState(false); 36 + const [newWebhookSigningKey, setNewWebhookSigningKey] = useState< 37 + string | null 38 + >(null); 39 + const [webhookKeyRotationError, setWebhookKeyRotationError] = useState< 40 + string | null 41 + >(null); 42 + const [webhookKeyCopied, setWebhookKeyCopied] = useState(false); 28 43 const navigate = useNavigate(); 29 44 30 45 if (loading) { ··· 32 47 } 33 48 34 49 if (error) { 35 - return <div />; 50 + const message = 51 + error.graphQLErrors?.[0]?.message ?? 52 + error.message ?? 53 + 'Failed to load API key settings'; 54 + return ( 55 + <div className="flex flex-col gap-4 max-w-xl"> 56 + <Heading size="2XL">API Keys</Heading> 57 + <div className="p-4 bg-red-50 border border-red-200 rounded-lg"> 58 + <Text size="SM" className="text-red-800"> 59 + {message} 60 + </Text> 61 + <Button 62 + variant="outline" 63 + size="sm" 64 + className="mt-3" 65 + onClick={async () => { await refetch(); }} 66 + > 67 + Retry 68 + </Button> 69 + </div> 70 + </div> 71 + ); 36 72 } 37 73 38 74 const requiredPermissions = [GQLUserPermission.ManageOrg]; 39 75 const permissions = data?.me?.permissions; 40 76 if (!userHasPermissions(permissions, requiredPermissions)) { 41 77 navigate('/settings'); 78 + return null; 42 79 } 43 80 44 81 const org = data?.myOrg; 45 82 if (!org) { 46 - throw Error('Missing org'); 83 + return ( 84 + <div className="p-4 bg-amber-50 border border-amber-200 rounded-lg"> 85 + <Text size="SM">Unable to load organization. Please try again.</Text> 86 + <Button variant="outline" size="sm" className="mt-3" onClick={async () => { await refetch(); }}> 87 + Retry 88 + </Button> 89 + </div> 90 + ); 47 91 } 48 92 49 93 const apiKey = data?.apiKey; ··· 90 134 setShowRotationDialog(true); 91 135 }; 92 136 137 + const handleRotateWebhookSigningKey = async () => { 138 + setIsRotatingWebhookKey(true); 139 + setWebhookKeyRotationError(null); 140 + 141 + try { 142 + const result = await rotateWebhookSigningKey(); 143 + 144 + if ( 145 + result.data?.rotateWebhookSigningKey.__typename === 146 + 'RotateWebhookSigningKeySuccessResponse' 147 + ) { 148 + setNewWebhookSigningKey( 149 + result.data.rotateWebhookSigningKey.publicSigningKey, 150 + ); 151 + await refetch(); 152 + } else if ( 153 + result.data?.rotateWebhookSigningKey.__typename === 154 + 'RotateWebhookSigningKeyError' 155 + ) { 156 + setWebhookKeyRotationError( 157 + result.data.rotateWebhookSigningKey.detail ?? 158 + 'Failed to generate new webhook signing key', 159 + ); 160 + } 161 + } catch { 162 + setWebhookKeyRotationError( 163 + 'An error occurred while generating the new webhook signing key', 164 + ); 165 + } finally { 166 + setIsRotatingWebhookKey(false); 167 + setShowWebhookKeyRotationDialog(false); 168 + } 169 + }; 170 + 171 + const confirmWebhookKeyRotation = () => { 172 + setShowWebhookKeyRotationDialog(true); 173 + }; 174 + 175 + const showWebhookDialog = showWebhookKeyRotationDialog; 176 + const onConfirmWebhookRotation = handleRotateWebhookSigningKey; 177 + 93 178 return ( 94 179 <div className="flex flex-col w-3/5 text-start"> 95 180 <Helmet> ··· 229 314 )} 230 315 </div> 231 316 <div className="mb-8"> 232 - <Heading>Webhook Signature Verification Key</Heading> 317 + <div className="flex justify-between items-center mb-2"> 318 + <Heading>Webhook Signature Verification Key</Heading> 319 + <Button 320 + variant="outline" 321 + size="sm" 322 + onClick={confirmWebhookKeyRotation} 323 + disabled={isRotatingWebhookKey} 324 + className="flex items-center gap-2" 325 + > 326 + <RotateCcw className="h-4 w-4" /> 327 + {isRotatingWebhookKey ? 'Generating...' : 'Generate new key'} 328 + </Button> 329 + </div> 233 330 <Text size="SM"> 234 331 This is your webhook signature verification key. We will include a 235 332 signature in every HTTP request we send to you in case you'd like to 236 333 verify that the request is valid and came from Coop. To learn how to 237 334 verify requests with this secret, see our{' '} 238 335 <Link 239 - href="https://docs.coopapi.com/docs/authentication#optional-verifying-incoming-requests-from-coop" 336 + href="https://roostorg.github.io/coop/api_authentication.html#verifying-incoming-requests-from-coop" 240 337 target="_blank" 241 338 > 242 - API Documentation 339 + API Keys and Authentication 243 340 </Link> 244 341 . 245 342 </Text> 246 343 </div> 247 344 345 + {newWebhookSigningKey && ( 346 + <div className="mb-4 p-4 bg-green-50 border border-green-200 rounded-lg"> 347 + <Text size="SM" className="text-green-800 mb-2"> 348 + New webhook signature verification key generated. Copy and store it 349 + securely; future webhook requests will be signed with the new key. 350 + </Text> 351 + <Textarea 352 + className="h-44 font-mono text-sm mb-2" 353 + value={newWebhookSigningKey} 354 + readOnly 355 + /> 356 + <Button 357 + variant="outline" 358 + size="sm" 359 + onClick={() => { 360 + copyText(newWebhookSigningKey); 361 + setWebhookKeyCopied(true); 362 + setTimeout(() => setWebhookKeyCopied(false), 2000); 363 + }} 364 + className="flex items-center gap-2" 365 + > 366 + <Clipboard className="h-4 w-4" /> 367 + {webhookKeyCopied ? 'Copied!' : 'Copy to clipboard'} 368 + </Button> 369 + </div> 370 + )} 371 + 372 + {webhookKeyRotationError && ( 373 + <div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg"> 374 + <Text size="SM" className="text-red-800"> 375 + {webhookKeyRotationError} 376 + </Text> 377 + </div> 378 + )} 379 + 248 380 <div className="flex flex-col gap-2"> 249 381 <Label htmlFor="publicSigningKey">Key</Label> 250 382 <Textarea ··· 270 402 /> 271 403 </div> 272 404 273 - {/* Confirmation Dialog */} 405 + {/* API Key rotation confirmation dialog */} 274 406 {showRotationDialog && ( 275 407 <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> 276 408 <div className="bg-white p-6 rounded-lg max-w-md w-full mx-4"> ··· 296 428 className="bg-red-600 text-white hover:bg-red-700" 297 429 > 298 430 {isRotating ? 'Rotating...' : 'Rotate Key'} 431 + </Button> 432 + </div> 433 + </div> 434 + </div> 435 + )} 436 + 437 + {/* Webhook signing key rotation confirmation dialog */} 438 + {showWebhookDialog && ( 439 + <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> 440 + <div className="bg-white p-6 rounded-lg max-w-md w-full mx-4"> 441 + <Heading size="LG" className="mb-4"> 442 + Generate new webhook verification key 443 + </Heading> 444 + <Text size="SM" className="mb-6"> 445 + This will generate a new webhook signature verification key. The current key will stop 446 + working for verifying new webhook requests. Update your systems with the new key after 447 + generating it. 448 + </Text> 449 + <div className="flex gap-3 justify-end"> 450 + <Button 451 + variant="outline" 452 + onClick={() => setShowWebhookKeyRotationDialog(false)} 453 + disabled={isRotatingWebhookKey} 454 + > 455 + Cancel 456 + </Button> 457 + <Button 458 + variant="outline" 459 + onClick={onConfirmWebhookRotation} 460 + disabled={isRotatingWebhookKey} 461 + className="bg-red-600 text-white hover:bg-red-700" 462 + > 463 + {isRotatingWebhookKey ? 'Generating...' : 'Generate new key'} 299 464 </Button> 300 465 </div> 301 466 </div>
+65
docs/API_AUTHENTICATION.md
··· 1 + # API Keys and Authentication 2 + 3 + ## Sending requests to Coop 4 + 5 + To authenticate the requests you send to Coop, add an HTTP header to every API request with your organization's API key. You can find or manage your API key in **Settings → API Keys** in the Coop UI. 6 + 7 + Format the header as follows: 8 + 9 + ``` 10 + X-API-KEY: <<apiKey>> 11 + Content-Type: application/json 12 + ``` 13 + 14 + You can rotate your API key at any time from the same page. After rotating, update any applications or scripts that use the previous key. 15 + 16 + ## Verifying incoming requests from Coop 17 + 18 + To verify that an incoming request to your Action APIs (or other webhooks) was sent by Coop, you can check the request signature. Coop signs each HTTP request it sends to your endpoints and includes the signature in a header. You use a **webhook signature verification key** (public key) to verify that signature. 19 + 20 + - Your **webhook signature verification key** is shown in **Settings → API Keys** under "Webhook Signature Verification Key". You can generate a new key there when needed; after rotation, update your verification logic with the new public key. 21 + 22 + ### Validating requests with the signature header 23 + 24 + Coop sends the signature in a `Coop-Signature` header (or `coop-signature` depending on the client). To validate an incoming HTTP request: 25 + 26 + 1. **Hash the request body** using SHA-256. Use the raw request body (binary) as the input to the hash. 27 + 2. **Base64-decode** the value in the `Coop-Signature` header to obtain the raw binary signature. 28 + 3. **Verify the signature** using your public key. Coop uses **RSASSA-PKCS1-v1_5** with **SHA-256**: decrypt/verify the signature with your public key and confirm it matches the hash from step 1. Use your language’s crypto library (e.g. Web Crypto, OpenSSL, or standard crypto packages) for RSASSA-PKCS1-v1_5 verification. 29 + 30 + ### Example (JavaScript / Node) 31 + 32 + ```javascript 33 + // Your public signing key in PEM format (from Settings → API Keys) 34 + const pem = `-----BEGIN PUBLIC KEY----- 35 + ...your key... 36 + -----END PUBLIC KEY-----`; 37 + 38 + const pemHeader = "-----BEGIN PUBLIC KEY-----"; 39 + const pemFooter = "-----END PUBLIC KEY-----"; 40 + const publicKeyPem = pem.substring( 41 + pemHeader.length, 42 + pem.length - pemFooter.length 43 + ); 44 + 45 + const publicKeyBuffer = Buffer.from(publicKeyPem, "base64"); 46 + const requestBodyBuffer = Buffer.from(req.body, "utf8"); 47 + const signature = Buffer.from(req.headers["coop-signature"], "base64"); 48 + 49 + const publicKey = await crypto.subtle.importKey( 50 + "spki", 51 + publicKeyBuffer, 52 + { name: "RSASSA-PKCS1-v1_5", hash: { name: "SHA-256" } }, 53 + false, 54 + ["verify"] 55 + ); 56 + 57 + const isValid = await crypto.subtle.verify( 58 + "RSASSA-PKCS1-v1_5", 59 + publicKey, 60 + signature, 61 + requestBodyBuffer 62 + ); 63 + ``` 64 + 65 + Adjust header name (`coop-signature` vs `Coop-Signature`) and body encoding to match how your server receives the request.
+1
docs/SUMMARY.md
··· 7 7 - [Integrating Signals](SIGNALS.md) 8 8 - [Data Warehouse Abstraction](DATA_WAREHOUSE_ABSTRACTION.md) 9 9 - [User Guide](USER_GUIDE.md) 10 + - [API Keys and Authentication](API_AUTHENTICATION.md) 10 11 - [Rules](RULES.md) 11 12 - [Reports](REPORTS.md) 12 13 - [Appeals](APPEALS.md)
+25 -19
server/graphql/datasources/OrgApi.ts
··· 11 11 CoopError, 12 12 ErrorType, 13 13 type ErrorInstanceData, 14 + isCoopErrorOfType, 14 15 } from '../../utils/errors.js'; 15 16 import { WEEK_MS } from '../../utils/time.js'; 16 17 import { ··· 228 229 }; 229 230 } 230 231 232 + /** 233 + * Returns the org's webhook public signing key as PEM. If no key exists yet 234 + * (e.g. org created before this feature), we create and persist one once. 235 + */ 231 236 async getPublicSigningKeyPem(orgId: string) { 237 + let key: CryptoKey; 232 238 try { 233 - const key = await this.signingKeyPairService.getSignatureVerificationInfo( 239 + key = await this.signingKeyPairService.getSignatureVerificationInfo( 234 240 orgId, 235 241 ); 236 - const exported = await crypto.subtle.exportKey('spki', key); 237 - const exportedAsBase64 = b64EncodeArrayBuffer(exported); 238 - return `-----BEGIN PUBLIC KEY-----\n${exportedAsBase64}\n-----END PUBLIC KEY-----`; 239 242 } catch (error) { 240 - // In development, if there's no signing key, generate one on-the-fly 241 - if (process.env.NODE_ENV === 'development') { 242 - try { 243 - // Generate a development signing key for this organization 244 - const devKey = await this.signingKeyPairService.createAndStoreSigningKeys(orgId); 245 - const exported = await crypto.subtle.exportKey('spki', devKey); 246 - const exportedAsBase64 = b64EncodeArrayBuffer(exported); 247 - return `-----BEGIN PUBLIC KEY-----\n${exportedAsBase64}\n-----END PUBLIC KEY-----`; 248 - } catch (devError) { 249 - // If even the dev key generation fails, return a placeholder 250 - // eslint-disable-next-line no-console 251 - console.warn(`Failed to generate dev signing key for org ${orgId}:`, devError); 252 - return 'dev-signing-key-placeholder'; 253 - } 243 + if (isCoopErrorOfType(error, 'SigningKeyPairNotFound')) { 244 + key = await this.signingKeyPairService.createAndStoreSigningKeys(orgId); 245 + } else { 246 + throw error; 254 247 } 255 - throw error; 256 248 } 249 + const exported = await crypto.subtle.exportKey('spki', key); 250 + const exportedAsBase64 = b64EncodeArrayBuffer(exported); 251 + return `-----BEGIN PUBLIC KEY-----\n${exportedAsBase64}\n-----END PUBLIC KEY-----`; 252 + } 253 + 254 + /** 255 + * Rotates the webhook signing key for the org: generates a new key pair, 256 + * overwrites storage, invalidates cache, and returns the new public key as PEM. 257 + */ 258 + async rotateWebhookSigningKey(orgId: string): Promise<string> { 259 + const publicKey = await this.signingKeyPairService.rotateSigningKeys(orgId); 260 + const exported = await crypto.subtle.exportKey('spki', publicKey); 261 + const exportedAsBase64 = b64EncodeArrayBuffer(exported); 262 + return `-----BEGIN PUBLIC KEY-----\n${exportedAsBase64}\n-----END PUBLIC KEY-----`; 257 263 } 258 264 } 259 265
+96
server/graphql/generated.ts
··· 2328 2328 readonly requestDemo?: Maybe<Scalars['Boolean']>; 2329 2329 readonly resetPassword: Scalars['Boolean']; 2330 2330 readonly rotateApiKey: GQLRotateApiKeyResponse; 2331 + readonly rotateWebhookSigningKey: GQLRotateWebhookSigningKeyResponse; 2331 2332 readonly runRetroaction?: Maybe<GQLRunRetroactionResponse>; 2332 2333 readonly sendPasswordReset: Scalars['Boolean']; 2333 2334 readonly setAllUserStrikeThresholds: GQLSetAllUserStrikeThresholdsSuccessResponse; ··· 3677 3678 readonly record: GQLApiKey; 3678 3679 }; 3679 3680 3681 + export type GQLRotateWebhookSigningKeyError = GQLError & { 3682 + readonly __typename?: 'RotateWebhookSigningKeyError'; 3683 + readonly detail?: Maybe<Scalars['String']>; 3684 + readonly pointer?: Maybe<Scalars['String']>; 3685 + readonly requestId?: Maybe<Scalars['String']>; 3686 + readonly status: Scalars['Int']; 3687 + readonly title: Scalars['String']; 3688 + readonly type: ReadonlyArray<Scalars['String']>; 3689 + }; 3690 + 3691 + export type GQLRotateWebhookSigningKeyResponse = 3692 + | GQLRotateWebhookSigningKeyError 3693 + | GQLRotateWebhookSigningKeySuccessResponse; 3694 + 3695 + export type GQLRotateWebhookSigningKeySuccessResponse = { 3696 + readonly __typename?: 'RotateWebhookSigningKeySuccessResponse'; 3697 + readonly publicSigningKey: Scalars['String']; 3698 + }; 3699 + 3680 3700 export type GQLRoutingRule = { 3681 3701 readonly __typename?: 'RoutingRule'; 3682 3702 readonly conditionSet: GQLConditionSet; ··· 5136 5156 | GQLResolversTypes['RecordingJobDecisionFailedError'] 5137 5157 | GQLResolversTypes['ReportingRuleNameExistsError'] 5138 5158 | GQLResolversTypes['RotateApiKeyError'] 5159 + | GQLResolversTypes['RotateWebhookSigningKeyError'] 5139 5160 | GQLResolversTypes['RoutingRuleNameExistsError'] 5140 5161 | GQLResolversTypes['RuleHasRunningBacktestsError'] 5141 5162 | GQLResolversTypes['RuleNameExistsError'] ··· 5539 5560 | GQLResolversTypes['RotateApiKeyError'] 5540 5561 | GQLResolversTypes['RotateApiKeySuccessResponse']; 5541 5562 RotateApiKeySuccessResponse: ResolverTypeWrapper<GQLRotateApiKeySuccessResponse>; 5563 + RotateWebhookSigningKeyError: ResolverTypeWrapper<GQLRotateWebhookSigningKeyError>; 5564 + RotateWebhookSigningKeyResponse: 5565 + | GQLResolversTypes['RotateWebhookSigningKeyError'] 5566 + | GQLResolversTypes['RotateWebhookSigningKeySuccessResponse']; 5567 + RotateWebhookSigningKeySuccessResponse: ResolverTypeWrapper<GQLRotateWebhookSigningKeySuccessResponse>; 5542 5568 RoutingRule: ResolverTypeWrapper<RoutingRuleWithoutVersion>; 5543 5569 RoutingRuleNameExistsError: ResolverTypeWrapper<GQLRoutingRuleNameExistsError>; 5544 5570 RoutingRuleStatus: GQLRoutingRuleStatus; ··· 5978 6004 | GQLResolversParentTypes['RecordingJobDecisionFailedError'] 5979 6005 | GQLResolversParentTypes['ReportingRuleNameExistsError'] 5980 6006 | GQLResolversParentTypes['RotateApiKeyError'] 6007 + | GQLResolversParentTypes['RotateWebhookSigningKeyError'] 5981 6008 | GQLResolversParentTypes['RoutingRuleNameExistsError'] 5982 6009 | GQLResolversParentTypes['RuleHasRunningBacktestsError'] 5983 6010 | GQLResolversParentTypes['RuleNameExistsError'] ··· 6325 6352 | GQLResolversParentTypes['RotateApiKeyError'] 6326 6353 | GQLResolversParentTypes['RotateApiKeySuccessResponse']; 6327 6354 RotateApiKeySuccessResponse: GQLRotateApiKeySuccessResponse; 6355 + RotateWebhookSigningKeyError: GQLRotateWebhookSigningKeyError; 6356 + RotateWebhookSigningKeyResponse: 6357 + | GQLResolversParentTypes['RotateWebhookSigningKeyError'] 6358 + | GQLResolversParentTypes['RotateWebhookSigningKeySuccessResponse']; 6359 + RotateWebhookSigningKeySuccessResponse: GQLRotateWebhookSigningKeySuccessResponse; 6328 6360 RoutingRule: RoutingRuleWithoutVersion; 6329 6361 RoutingRuleNameExistsError: GQLRoutingRuleNameExistsError; 6330 6362 Rule: Rule; ··· 8209 8241 | 'RecordingJobDecisionFailedError' 8210 8242 | 'ReportingRuleNameExistsError' 8211 8243 | 'RotateApiKeyError' 8244 + | 'RotateWebhookSigningKeyError' 8212 8245 | 'RoutingRuleNameExistsError' 8213 8246 | 'RuleHasRunningBacktestsError' 8214 8247 | 'RuleNameExistsError' ··· 10263 10296 ContextType, 10264 10297 RequireFields<GQLMutationRotateApiKeyArgs, 'input'> 10265 10298 >; 10299 + rotateWebhookSigningKey?: Resolver< 10300 + GQLResolversTypes['RotateWebhookSigningKeyResponse'], 10301 + ParentType, 10302 + ContextType 10303 + >; 10266 10304 runRetroaction?: Resolver< 10267 10305 Maybe<GQLResolversTypes['RunRetroactionResponse']>, 10268 10306 ParentType, ··· 12088 12126 __isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>; 12089 12127 }; 12090 12128 12129 + export type GQLRotateWebhookSigningKeyErrorResolvers< 12130 + ContextType = Context, 12131 + ParentType extends 12132 + GQLResolversParentTypes['RotateWebhookSigningKeyError'] = GQLResolversParentTypes['RotateWebhookSigningKeyError'], 12133 + > = { 12134 + detail?: Resolver< 12135 + Maybe<GQLResolversTypes['String']>, 12136 + ParentType, 12137 + ContextType 12138 + >; 12139 + pointer?: Resolver< 12140 + Maybe<GQLResolversTypes['String']>, 12141 + ParentType, 12142 + ContextType 12143 + >; 12144 + requestId?: Resolver< 12145 + Maybe<GQLResolversTypes['String']>, 12146 + ParentType, 12147 + ContextType 12148 + >; 12149 + status?: Resolver<GQLResolversTypes['Int'], ParentType, ContextType>; 12150 + title?: Resolver<GQLResolversTypes['String'], ParentType, ContextType>; 12151 + type?: Resolver< 12152 + ReadonlyArray<GQLResolversTypes['String']>, 12153 + ParentType, 12154 + ContextType 12155 + >; 12156 + __isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>; 12157 + }; 12158 + 12159 + export type GQLRotateWebhookSigningKeyResponseResolvers< 12160 + ContextType = Context, 12161 + ParentType extends 12162 + GQLResolversParentTypes['RotateWebhookSigningKeyResponse'] = GQLResolversParentTypes['RotateWebhookSigningKeyResponse'], 12163 + > = { 12164 + __resolveType: TypeResolveFn< 12165 + 'RotateWebhookSigningKeyError' | 'RotateWebhookSigningKeySuccessResponse', 12166 + ParentType, 12167 + ContextType 12168 + >; 12169 + }; 12170 + 12171 + export type GQLRotateWebhookSigningKeySuccessResponseResolvers< 12172 + ContextType = Context, 12173 + ParentType extends 12174 + GQLResolversParentTypes['RotateWebhookSigningKeySuccessResponse'] = GQLResolversParentTypes['RotateWebhookSigningKeySuccessResponse'], 12175 + > = { 12176 + publicSigningKey?: Resolver< 12177 + GQLResolversTypes['String'], 12178 + ParentType, 12179 + ContextType 12180 + >; 12181 + __isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>; 12182 + }; 12183 + 12091 12184 export type GQLRoutingRuleResolvers< 12092 12185 ContextType = Context, 12093 12186 ParentType extends ··· 14030 14123 RotateApiKeyError?: GQLRotateApiKeyErrorResolvers<ContextType>; 14031 14124 RotateApiKeyResponse?: GQLRotateApiKeyResponseResolvers<ContextType>; 14032 14125 RotateApiKeySuccessResponse?: GQLRotateApiKeySuccessResponseResolvers<ContextType>; 14126 + RotateWebhookSigningKeyError?: GQLRotateWebhookSigningKeyErrorResolvers<ContextType>; 14127 + RotateWebhookSigningKeyResponse?: GQLRotateWebhookSigningKeyResponseResolvers<ContextType>; 14128 + RotateWebhookSigningKeySuccessResponse?: GQLRotateWebhookSigningKeySuccessResponseResolvers<ContextType>; 14033 14129 RoutingRule?: GQLRoutingRuleResolvers<ContextType>; 14034 14130 RoutingRuleNameExistsError?: GQLRoutingRuleNameExistsErrorResolvers<ContextType>; 14035 14131 Rule?: GQLRuleResolvers<ContextType>;
+83 -3
server/graphql/modules/apiKey.ts
··· 1 1 import { AuthenticationError } from 'apollo-server-express'; 2 + 3 + import { ErrorType, CoopError } from '../../utils/errors.js'; 4 + import { logErrorJson } from '../../utils/logging.js'; 2 5 import { gqlErrorResult, gqlSuccessResult } from '../utils/gqlResult.js'; 3 - import { ErrorType, CoopError } from '../../utils/errors.js'; 6 + 7 + /** Context shape required by rotateWebhookSigningKey (avoids importing resolvers). */ 8 + type RotateWebhookSigningKeyContext = { 9 + getUser: () => { 10 + orgId: string; 11 + getPermissions: () => readonly string[]; 12 + } | null | undefined; 13 + dataSources: { 14 + orgAPI: { rotateWebhookSigningKey: (orgId: string) => Promise<string> }; 15 + }; 16 + }; 4 17 5 18 const typeDefs = /* GraphQL */ ` 6 19 type ApiKey { ··· 34 47 description: String 35 48 } 36 49 50 + type RotateWebhookSigningKeySuccessResponse { 51 + publicSigningKey: String! 52 + } 53 + 54 + type RotateWebhookSigningKeyError implements Error { 55 + title: String! 56 + status: Int! 57 + type: [String!]! 58 + pointer: String 59 + detail: String 60 + requestId: String 61 + } 62 + 63 + union RotateWebhookSigningKeyResponse = 64 + RotateWebhookSigningKeySuccessResponse 65 + | RotateWebhookSigningKeyError 66 + 37 67 type Query { 38 68 apiKey: String! 39 69 } 40 70 41 71 type Mutation { 42 72 rotateApiKey(input: RotateApiKeyInput!): RotateApiKeyResponse! 73 + rotateWebhookSigningKey: RotateWebhookSigningKeyResponse! 43 74 } 44 75 `; 45 76 ··· 65 96 if (!user || !user.orgId) { 66 97 throw new AuthenticationError('User must be authenticated'); 67 98 } 99 + if (!user.getPermissions().includes('MANAGE_ORG')) { 100 + throw new AuthenticationError( 101 + 'User does not have permission to rotate the API key', 102 + ); 103 + } 68 104 69 105 try { 70 106 const { apiKey, record } = await context.services.ApiKeyService.rotateApiKey( ··· 90 126 'RotateApiKeySuccessResponse' 91 127 ); 92 128 } catch (error) { 129 + // Resolvers do not receive a request-scoped logger; use logErrorJson for structured server-side logging. 130 + // eslint-disable-next-line no-restricted-syntax -- see comment above 131 + logErrorJson({ message: 'Failed to rotate API key', error }); 93 132 return gqlErrorResult( 94 133 new CoopError({ 95 134 status: 500, 96 135 type: [ErrorType.InternalServerError], 97 136 title: 'Failed to rotate API key', 98 - detail: error instanceof Error ? error.message : 'An error occurred while rotating the API key', 137 + detail: 'An error occurred while rotating the API key', 99 138 name: 'InternalServerError', 100 139 shouldErrorSpan: true, 101 - }) 140 + }), 141 + ); 142 + } 143 + }, 144 + async rotateWebhookSigningKey( 145 + _: unknown, 146 + __: Record<string, never>, 147 + context: RotateWebhookSigningKeyContext, 148 + ) { 149 + const user = context.getUser(); 150 + if (!user || !user.orgId) { 151 + throw new AuthenticationError('User must be authenticated'); 152 + } 153 + if (!user.getPermissions().includes('MANAGE_ORG')) { 154 + throw new AuthenticationError( 155 + 'User does not have permission to rotate the webhook signing key', 156 + ); 157 + } 158 + 159 + try { 160 + const publicSigningKey = 161 + await context.dataSources.orgAPI.rotateWebhookSigningKey(user.orgId); 162 + return gqlSuccessResult( 163 + { publicSigningKey }, 164 + 'RotateWebhookSigningKeySuccessResponse', 165 + ); 166 + } catch (error) { 167 + // Resolvers do not receive a request-scoped logger; use logErrorJson for structured server-side logging. 168 + // eslint-disable-next-line no-restricted-syntax -- see comment above 169 + logErrorJson({ 170 + message: 'Failed to rotate webhook signing key', 171 + error, 172 + }); 173 + return gqlErrorResult( 174 + new CoopError({ 175 + status: 500, 176 + type: [ErrorType.InternalServerError], 177 + title: 'Failed to rotate webhook signing key', 178 + detail: 'An error occurred while rotating the webhook signing key', 179 + name: 'InternalServerError', 180 + shouldErrorSpan: true, 181 + }), 102 182 ); 103 183 } 104 184 },
+8
server/lib/cache/Cache.ts
··· 266 266 return this.dataStore.store(entriesWithTimes); 267 267 } 268 268 269 + /** 270 + * Deletes all stored entries for the given resource id. Used for cache 271 + * invalidation (e.g. when a signing key is rotated). 272 + */ 273 + public async delete(id: Id): Promise<void> { 274 + return this.dataStore.delete(id); 275 + } 276 + 269 277 public async close(timeout?: number) { 270 278 this.closed = true; 271 279 return this.dataStore.close(timeout);
+7 -3
server/services/signingKeyPairService/postgresSigningKeyPairStorage.ts
··· 1 1 import crypto from 'node:crypto'; 2 2 import type { Kysely } from 'kysely'; 3 3 4 - import { jsonStringify } from '../../utils/encoding.js'; 4 + import { jsonParse, jsonStringify, type JsonOf } from '../../utils/encoding.js'; 5 5 import { CoopError, ErrorType } from '../../utils/errors.js'; 6 6 import { type CombinedPg } from '../combinedDbTypes.js'; 7 7 import { ··· 79 79 }); 80 80 } 81 81 82 - // eslint-disable-next-line no-restricted-syntax 83 - const keyData: JWTCryptoKeyPairWithAlgorithm = JSON.parse(result.key_data); 82 + const keyData: JWTCryptoKeyPairWithAlgorithm = 83 + typeof result.key_data === 'string' 84 + ? jsonParse( 85 + result.key_data as JsonOf<JWTCryptoKeyPairWithAlgorithm>, 86 + ) 87 + : (result.key_data as JWTCryptoKeyPairWithAlgorithm); 84 88 const { privateKeyWithAlgorithm, publicKeyWithAlgorithm } = keyData; 85 89 86 90 return {
+36 -15
server/services/signingKeyPairService/secretsManagerSigningKeyPairStorage.ts
··· 2 2 import { 3 3 CreateSecretCommand, 4 4 GetSecretValueCommand, 5 + PutSecretValueCommand, 6 + ResourceNotFoundException, 5 7 SecretsManagerClient, 6 8 } from '@aws-sdk/client-secrets-manager'; 7 9 ··· 44 46 crypto.subtle.exportKey('jwk', keyPair.privateKey), 45 47 crypto.subtle.exportKey('jwk', keyPair.publicKey), 46 48 ]); 47 - await this.client.send( 48 - new CreateSecretCommand({ 49 - Name: this.getSecretIdForKeyId(keyId), 50 - Description: `Public + private key pair used to sign webhook requests for org with ID ${keyId.orgId}`, 51 - SecretString: jsonStringify<JWTCryptoKeyPairWithAlgorithm>({ 52 - publicKeyWithAlgorithm: { 53 - key: publicKey, 54 - algorithm: keyPair.publicKey.algorithm, 55 - }, 56 - privateKeyWithAlgorithm: { 57 - key: privateKey, 58 - algorithm: keyPair.privateKey.algorithm, 59 - }, 49 + const secretString = jsonStringify<JWTCryptoKeyPairWithAlgorithm>({ 50 + publicKeyWithAlgorithm: { 51 + key: publicKey, 52 + algorithm: keyPair.publicKey.algorithm, 53 + }, 54 + privateKeyWithAlgorithm: { 55 + key: privateKey, 56 + algorithm: keyPair.privateKey.algorithm, 57 + }, 58 + }); 59 + const secretId = this.getSecretIdForKeyId(keyId); 60 + 61 + try { 62 + await this.client.send( 63 + new GetSecretValueCommand({ SecretId: secretId }), 64 + ); 65 + await this.client.send( 66 + new PutSecretValueCommand({ 67 + SecretId: secretId, 68 + SecretString: secretString, 60 69 }), 61 - }), 62 - ); 70 + ); 71 + } catch (err) { 72 + if (err instanceof ResourceNotFoundException) { 73 + await this.client.send( 74 + new CreateSecretCommand({ 75 + Name: secretId, 76 + Description: `Public + private key pair used to sign webhook requests for org with ID ${keyId.orgId}`, 77 + SecretString: secretString, 78 + }), 79 + ); 80 + } else { 81 + throw err; 82 + } 83 + } 63 84 } 64 85 65 86 private async fetchKeyPair(keyId: SigningKeyId): Promise<CryptoKeyPair> {
+43 -5
server/services/signingKeyPairService/signingKeyPairService.ts
··· 6 6 7 7 /** 8 8 * This service generates + stores key pairs for request signing, which it can 9 - * then retrieve. 9 + * then retrieve. It operates under the assumption that the resulting key will 10 + * be stored somewhere, and then retrieved from storage for signing requests or 11 + * to show to the user in the UI. 10 12 * 11 - * It operates under the assumption that the resulting key will be stored 12 - * somewhere, and then retrieved from storage for signing requests or to show to 13 - * the user in the UI. 13 + * After rotating, storage (e.g. AWS Secrets Manager) may be eventually 14 + * consistent, so the next read can still return the old key. We cache the new 15 + * public key here briefly so the UI refetch/refresh sees the correct key. 14 16 */ 17 + const ROTATED_KEY_TTL_MS = 10_000; 18 + const recentlyRotatedPublicKeys = new Map< 19 + string, 20 + { key: CryptoKey; expiresAt: number } 21 + >(); 22 + 15 23 class SigningKeyPairService { 16 24 private fetchPrivateKey: Cached<(key: SigningKeyId) => Promise<CryptoKey>>; 17 25 ··· 51 59 } 52 60 53 61 /** 54 - * Returns the same public CryptoKey as `createAndStoreSigningKeys` 62 + * Generates a new key pair, overwrites the stored pair for the org, and 63 + * invalidates any cached private key so the new key is used for signing. 64 + * Use this to rotate the webhook signature verification key. 65 + * 66 + * @param orgId The org for which to rotate the key pair. 67 + * @returns The new public key (for exporting to PEM and showing once to the user). 68 + */ 69 + public async rotateSigningKeys(orgId: string) { 70 + const keyPair = await this.createSigningKeys(); 71 + await this.store.storeKeyPair({ orgId }, keyPair); 72 + if (this.fetchPrivateKey.invalidate) { 73 + await this.fetchPrivateKey.invalidate({ orgId }); 74 + } 75 + recentlyRotatedPublicKeys.set(orgId, { 76 + key: keyPair.publicKey, 77 + expiresAt: Date.now() + ROTATED_KEY_TTL_MS, 78 + }); 79 + return keyPair.publicKey; 80 + } 81 + 82 + /** 83 + * Returns the public key for verification. If we just rotated for this org, 84 + * we return the new key from memory so the next read is correct even when 85 + * storage (e.g. AWS Secrets Manager) is eventually consistent. 55 86 */ 56 87 public async getSignatureVerificationInfo(orgId: string) { 88 + const entry = recentlyRotatedPublicKeys.get(orgId); 89 + if (entry) { 90 + if (Date.now() < entry.expiresAt) { 91 + return entry.key; 92 + } 93 + recentlyRotatedPublicKeys.delete(orgId); 94 + } 57 95 return this.store.fetchPublicKey({ orgId }); 58 96 } 59 97
+6
server/utils/caching.ts
··· 47 47 directives?: ConsumerDirectives, 48 48 ): Promise<ReadonlyDeep<CachedContentType>>; 49 49 close(): Promise<void>; 50 + /** Invalidates the cached value for the given key. Used when the source data has been replaced (e.g. key rotation). */ 51 + invalidate?(key: KeyType): Promise<void>; 50 52 }; 51 53 52 54 /** ··· 147 149 } 148 150 149 151 exposedGet.close = async () => getWithCache.cache.close(); 152 + exposedGet.invalidate = async (key: KeyType) => { 153 + const cacheKey = keyGeneration.toString(key); 154 + await getWithCache.cache.delete(cacheKey); 155 + }; 150 156 return exposedGet; 151 157 } 152 158