diff --git a/.github/actions/notify-wire/action.yml b/.github/actions/notify-wire/action.yml new file mode 100644 index 0000000..2b544d7 --- /dev/null +++ b/.github/actions/notify-wire/action.yml @@ -0,0 +1,29 @@ +name: Notify Wire +description: Send a custom message to a Wire conversation through github-app +inputs: + webhook-url: + description: Full github-app Actions endpoint URL for the Wire conversation + required: true + token: + description: GitHub Actions token configured for the Wire conversation + required: true + text: + description: Message text to send + required: true +runs: + using: composite + steps: + - name: Send message to Wire + shell: bash + env: + WIRE_WEBHOOK_URL: ${{ inputs.webhook-url }} + WIRE_ACTIONS_TOKEN: ${{ inputs.token }} + MESSAGE_TEXT: ${{ inputs.text }} + run: | + set -euo pipefail + python3 -c 'import json, os; print(json.dumps({"text": os.environ["MESSAGE_TEXT"]}))' \ + | curl --fail-with-body --silent --show-error \ + -X POST "$WIRE_WEBHOOK_URL" \ + -H "Authorization: Bearer ${WIRE_ACTIONS_TOKEN}" \ + -H "Content-Type: application/json" \ + --data-binary @- diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 8070977..4fb86cc 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -17,7 +17,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: fetch-depth: 1 diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 97c5252..04fa7d8 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -26,7 +26,7 @@ jobs: actions: read # Required for Claude to read CI results on PRs steps: - name: Checkout repository - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: fetch-depth: 1 diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index dd4eb06..9739377 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -8,7 +8,7 @@ jobs: tests: runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c with: distribution: 'zulu' diff --git a/.github/workflows/staging.yml b/.github/workflows/staging.yml index 462c9b3..846da60 100644 --- a/.github/workflows/staging.yml +++ b/.github/workflows/staging.yml @@ -16,7 +16,7 @@ jobs: if: github.actor != 'github-actions[bot]' steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 diff --git a/README.md b/README.md index 33760bf..921f37f 100644 --- a/README.md +++ b/README.md @@ -23,14 +23,16 @@ No license is granted to the Wire trademark and its associated logos, all of whi Here's a list of features included in this project: -| Name | Description | -|------------------------------------------|--------------------------------------------| -| /health | Healthcheck endpoint returning HTTP OK 200. | -| /{conversation_id}/{conversation_domain} | Webhook endpoint. | +| Name | Description | +|--------------------------------------------------------|--------------------------------------------------| +| `/health` | Healthcheck endpoint returning HTTP OK 200. | +| `/{conversation_id}/{conversation_domain}` | Receive signed GitHub repository webhooks. | +| `/actions/{conversation_id}/{conversation_domain}` | Receive workflow-authored GitHub Actions text. | -### GitHub Actions notifications +### Repository webhook notifications -The existing webhook endpoint supports centralized notifications for these GitHub Actions events: +The existing webhook endpoint provides centralized notifications rendered by this service. It supports +these GitHub Actions-related repository webhook events: | GitHub event | Supported action | Notification | |--------------|------------------|--------------| @@ -41,6 +43,43 @@ All completed conclusions are reported, including successful, failed, cancelled, results when GitHub provides them. Other actions, such as `requested`, `queued`, and `in_progress`, are accepted by the endpoint but do not produce a Wire message. +Use `/github webhook help` in the Wire conversation for its URL, secret, and setup instructions. + +### Workflow-authored GitHub Actions notifications + +Use `/github actions help` in the Wire conversation to obtain the complete endpoint URL and Actions +token. Store them as `WIRE_WEBHOOK_URL` and `WIRE_ACTIONS_TOKEN` respectively. + +The action accepts these inputs: + +| Input | Required | Description | +|-------|----------|-------------| +| `webhook-url` | Yes | Complete `/actions/{conversation_id}/{conversation_domain}` URL. | +| `token` | Yes | Dedicated Actions bearer token. | +| `text` | Yes | Complete message text to send without service-side formatting. | + +```yaml +- name: Notify Wire on failure + if: failure() || cancelled() + uses: wireapp/github-app/.github/actions/notify-wire@v1 + with: + webhook-url: ${{ secrets.WIRE_WEBHOOK_URL }} + token: ${{ secrets.WIRE_ACTIONS_TOKEN }} + text: | + CI failed for ${{ github.repository }} on `${{ github.ref_name }}`. + Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} +``` + +## Wire commands + +| Command | Description | +|---------|-------------| +| `/github help` | List available commands and notification approaches. | +| `/github tokens` | Show or create the webhook secret and Actions token. | +| `/github webhook help` | Show repository webhook setup instructions. | +| `/github actions help` | Show the Actions endpoint, token usage, and reusable action example. | + + ## Building & Running To build or run the project, you can use the IDE Run configuration with environment variables. @@ -57,7 +96,6 @@ GHAPP_SERVER_PORT=8083 GHAPP_REDIS_URL=redis://username:password@host:port WIRE_SDK_API_HOST=https://nginz-https.chala.wire.link WIRE_SDK_API_TOKEN=myApiToken -WIRE_SDK_APP_ID=f562e146-dec2-4d85-93c7-7132746b5cca WIRE_SDK_CRYPTOGRAPHY_STORAGE_PASSWORD=myDummyPasswordmyDummyPassword01 ``` diff --git a/build.gradle.kts b/build.gradle.kts index 5713803..3aaeb56 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -118,7 +118,6 @@ tasks { environment("GHAPP_REDIS_URL", "redis://localhost:6379") environment("WIRE_SDK_API_HOST", "https://nginz-https.chala.wire.link") environment("WIRE_SDK_API_TOKEN", "myApiToken") - environment("WIRE_SDK_APP_ID", "f562e146-dec2-4d85-93c7-7132746b5cca") environment("WIRE_SDK_CRYPTOGRAPHY_STORAGE_PASSWORD", "myDummyPasswordmyDummyPassword01") } } diff --git a/docker-compose.yml b/docker-compose.yml index 9b58b0f..83b10e2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,7 +11,6 @@ services: - GHAPP_REDIS_URL=${GHAPP_REDIS_URL} - WIRE_SDK_API_HOST=${WIRE_SDK_API_HOST} - WIRE_SDK_API_TOKEN=${WIRE_SDK_API_TOKEN} - - WIRE_SDK_APP_ID=${WIRE_SDK_APP_ID} - WIRE_SDK_CRYPTOGRAPHY_STORAGE_PASSWORD=${WIRE_SDK_CRYPTOGRAPHY_STORAGE_PASSWORD} ports: - "${GHAPP_SERVER_PORT:-8083}:${GHAPP_SERVER_PORT:-8083}" diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 270125a..9949dec 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,7 +8,7 @@ ktlint-version = "14.2.0" detekt-version = "1.23.8" kotlin-serialization = "1.11.0" koin-version = "4.2.2" -wire-sdk-version = "0.2.1" +wire-sdk-version = "0.3.0" redis-version = "6.7.1.RELEASE" mustache-version = "0.9.14" mockk-version = "1.14.11" diff --git a/helm/githubapp/values-prod.yaml b/helm/githubapp/values-prod.yaml index 7db0cc7..35329e8 100644 --- a/helm/githubapp/values-prod.yaml +++ b/helm/githubapp/values-prod.yaml @@ -17,8 +17,6 @@ ingress: - github-app.apps-prod.wire.com secretName: github-app-tls env: -- name: WIRE_SDK_APP_ID - value: "61c2f1c4-ca8a-4d67-8f05-ca9bb37d85cc" - name: WIRE_SDK_API_HOST value: "https://prod-nginz-https.wire.com" - name: GHAPP_API_HOST diff --git a/helm/githubapp/values.yaml b/helm/githubapp/values.yaml index 1d11802..3de530e 100644 --- a/helm/githubapp/values.yaml +++ b/helm/githubapp/values.yaml @@ -73,8 +73,6 @@ affinity: {} # Environment variables for the application env: - - name: WIRE_SDK_APP_ID - value: "" - name: WIRE_SDK_API_HOST value: "" - name: GHAPP_API_HOST diff --git a/src/main/kotlin/com/wire/github/EventsHandler.kt b/src/main/kotlin/com/wire/github/EventsHandler.kt index 94be414..d150cca 100644 --- a/src/main/kotlin/com/wire/github/EventsHandler.kt +++ b/src/main/kotlin/com/wire/github/EventsHandler.kt @@ -1,13 +1,9 @@ package com.wire.github import com.wire.github.metrics.UsageMetrics -import com.wire.github.util.ENV_VAR_HOST -import com.wire.github.util.SessionIdentifierGenerator -import com.wire.github.util.toStorageKey import com.wire.sdk.WireEventsHandlerSuspending import com.wire.sdk.model.Conversation import com.wire.sdk.model.ConversationMember -import com.wire.sdk.model.QualifiedId import com.wire.sdk.model.WireMessage import io.lettuce.core.api.StatefulRedisConnection import org.koin.core.context.GlobalContext @@ -16,33 +12,33 @@ import org.slf4j.LoggerFactory class EventsHandler : WireEventsHandlerSuspending() { private val logger = LoggerFactory.getLogger(this::class.java) private val redisConnection = GlobalContext.get().get>() - private val storage = redisConnection.sync() private val usageMetrics = GlobalContext.get().get() + private val commandHandler = GitHubCommandHandler( + storage = redisConnection.sync(), + usageMetrics = usageMetrics + ) override suspend fun onTextMessageReceived(wireMessage: WireMessage.Text) { - if (wireMessage.text.equals(HELP_COMMAND, ignoreCase = true)) { - logger.info( - "Event received. Event: TextMessageReceived (HELP command), " + - "conversationId: ${wireMessage.conversationId}, " + - "senderId: ${wireMessage.sender}" - ) - usageMetrics.onHelpCommand() - val message = formatSetupInstructions( - conversationId = wireMessage.conversationId, - secret = storage.get(wireMessage.conversationId.toStorageKey()) - ) + val response = commandHandler.response( + command = wireMessage.text, + conversationId = wireMessage.conversationId + ) ?: return - manager.sendMessage( - message = WireMessage.Text.create( - conversationId = wireMessage.conversationId, - text = message - ) - ) - logger.info( - "Event is processed successfully. Event: TextMessageReceived (HELP command), " + - "conversationId: ${wireMessage.conversationId}" + logger.info( + "Event received. Event: TextMessageReceived (GitHub command), " + + "conversationId: ${wireMessage.conversationId}, " + + "senderId: ${wireMessage.sender}" + ) + manager.sendMessage( + message = WireMessage.Text.create( + conversationId = wireMessage.conversationId, + text = response ) - } + ) + logger.info( + "Event is processed successfully. Event: TextMessageReceived (GitHub command), " + + "conversationId: ${wireMessage.conversationId}" + ) } override suspend fun onAppAddedToConversation( @@ -54,11 +50,12 @@ class EventsHandler : WireEventsHandlerSuspending() { "conversationId: ${conversation.id}" ) usageMetrics.onAppAddedToConversation() + val webhookSetup = commandHandler.webhookHelp(conversationId = conversation.id) val message = buildString { appendLine(WELCOME_TEXT) - appendLine(formatSetupInstructions(conversationId = conversation.id)) + appendLine(webhookSetup) appendLine() - append("Use the `$HELP_COMMAND` command to see the usage again.") + append("Use `${GitHubCommandHandler.HELP_COMMAND}` to see all available commands.") } manager.sendMessage( @@ -73,48 +70,10 @@ class EventsHandler : WireEventsHandlerSuspending() { ) } - private fun formatSetupInstructions( - conversationId: QualifiedId, - secret: String? = null - ): String { - val generatedSecret = secret ?: run { - val generated = SessionIdentifierGenerator.generate() - storage.set(conversationId.toStorageKey(), generated) - generated - } - - val url = String.format( - HOST_URL_PATTERN, - ENV_VAR_HOST, - conversationId.id, - conversationId.domain - ) - - val setupInstructions = String.format( - "Here is how to set me up:\n\n" + - "1. Go to the repository that you would like to connect to\n" + - "2. Go to **Settings / Webhooks / Add webhook**\n" + - "3. Add **Payload URL**: %s\n" + - "4. Set **Content-Type**: application/json\n" + - "5. Set **Secret**: %s\n" + - "6. Select the repository events you want to receive\n" + - "7. For GitHub Actions notifications, select **Workflow runs** and " + - "**Workflow jobs**. Completed results will be sent to this conversation.", - url, - generatedSecret - ) - - return setupInstructions - } - private companion object { - const val HOST_URL_PATTERN = "%s/%s/%s" - const val WELCOME_TEXT = "👋 Hi, I'm GitHub App. Thanks for adding me to the conversation.\n" + "You can use me to receive GitHub notifications in Wire.\n" + "I'm here to help make everyday work a little easier." - - const val HELP_COMMAND = "/github help" } } diff --git a/src/main/kotlin/com/wire/github/GitHubCommandHandler.kt b/src/main/kotlin/com/wire/github/GitHubCommandHandler.kt new file mode 100644 index 0000000..6aea5ef --- /dev/null +++ b/src/main/kotlin/com/wire/github/GitHubCommandHandler.kt @@ -0,0 +1,95 @@ +package com.wire.github + +import com.wire.github.metrics.UsageMetrics +import com.wire.github.util.ENV_VAR_HOST +import com.wire.github.util.ActionsTokenGenerator +import com.wire.github.util.SessionIdentifierGenerator +import com.wire.github.util.toActionsTokenStorageKey +import com.wire.github.util.toStorageKey +import com.wire.sdk.model.QualifiedId +import io.lettuce.core.api.sync.RedisCommands + +internal class GitHubCommandHandler( + private val storage: RedisCommands, + private val usageMetrics: UsageMetrics, + private val host: String = ENV_VAR_HOST, + private val generateSecret: () -> String = SessionIdentifierGenerator::generate, + private val generateActionsToken: () -> String = ActionsTokenGenerator::generate +) { + fun response( + command: String, + conversationId: QualifiedId + ): String? = + when (command.lowercase()) { + HELP_COMMAND -> trackedHelp(help()) + TOKENS_COMMAND -> tokens(conversationId) + WEBHOOK_HELP_COMMAND -> trackedHelp(webhookHelp(conversationId)) + ACTIONS_HELP_COMMAND -> trackedHelp(actionsHelp(conversationId)) + else -> null + } + + fun help(): String = + "GitHub App supports repository webhooks and workflow-authored Actions notifications.\n\n" + + "Available commands:\n" + + "- `$TOKENS_COMMAND` — show or create this conversation's credentials\n" + + "- `$WEBHOOK_HELP_COMMAND` — configure GitHub repository webhooks\n" + + "- `$ACTIONS_HELP_COMMAND` — send custom messages from GitHub Actions" + + private fun tokens(conversationId: QualifiedId): String = + "Conversation credentials:\n\n" + + "GitHub webhook secret:\n`${getOrCreateSecret(conversationId)}`\n\n" + + "GitHub Actions token:\n`${getOrCreateActionsToken(conversationId)}`\n\n" + + "Store these values as secrets. Do not print them in workflow logs." + + fun webhookHelp(conversationId: QualifiedId): String { + val webhookUrl = "$normalizedHost/${conversationId.id}/${conversationId.domain}" + return "GitHub repository webhook setup:\n\n" + + "1. Open **Settings / Webhooks / Add webhook** in the repository\n" + + "2. Set **Payload URL**: $webhookUrl\n" + + "3. Set **Content-Type**: application/json\n" + + "4. Set **Secret**: ${getOrCreateSecret(conversationId)}\n" + + "5. Select the repository events to receive\n" + + "6. For centralized Actions notifications, select **Workflow runs** and " + + "**Workflow jobs**" + } + + private fun actionsHelp(conversationId: QualifiedId): String { + val actionsUrl = "$normalizedHost/actions/${conversationId.id}/${conversationId.domain}" + return "GitHub Actions notification setup:\n\n" + + "Store this URL as `WIRE_WEBHOOK_URL`:\n$actionsUrl\n\n" + + "Store this token as `WIRE_ACTIONS_TOKEN`:\n" + + "${getOrCreateActionsToken(conversationId)}\n\n" + + "Use the reusable action:\n\n" + + "```yaml\n" + + "uses: wireapp/github-app/.github/actions/notify-wire@v1\n" + + "with:\n" + + " webhook-url: \${{ secrets.WIRE_WEBHOOK_URL }}\n" + + " token: \${{ secrets.WIRE_ACTIONS_TOKEN }}\n" + + " text: Message to send to Wire\n" + + "```" + } + + private fun trackedHelp(response: String): String = + response.also { usageMetrics.onHelpCommand() } + + private fun getOrCreateSecret(conversationId: QualifiedId): String = + getOrCreate(conversationId.toStorageKey(), generateSecret) + + private fun getOrCreateActionsToken(conversationId: QualifiedId): String = + getOrCreate(conversationId.toActionsTokenStorageKey(), generateActionsToken) + + private fun getOrCreate( + storageKey: String, + generate: () -> String + ): String = storage.get(storageKey) ?: generate().also { storage.set(storageKey, it) } + + private val normalizedHost: String + get() = host.trimEnd('/') + + companion object { + const val HELP_COMMAND = "/github help" + const val TOKENS_COMMAND = "/github tokens" + const val WEBHOOK_HELP_COMMAND = "/github webhook help" + const val ACTIONS_HELP_COMMAND = "/github actions help" + } +} diff --git a/src/main/kotlin/com/wire/github/Routing.kt b/src/main/kotlin/com/wire/github/Routing.kt index 0f4075e..0d5e678 100644 --- a/src/main/kotlin/com/wire/github/Routing.kt +++ b/src/main/kotlin/com/wire/github/Routing.kt @@ -1,25 +1,34 @@ package com.wire.github import com.wire.github.metrics.UsageMetrics +import com.wire.github.request.model.ActionsNotificationRequest import com.wire.github.response.model.GitHubResponse +import com.wire.github.util.ActionsTokenValidator import com.wire.github.util.KtxSerializer import com.wire.github.util.SignatureValidator import com.wire.github.util.TemplateHandler import com.wire.sdk.WireAppSdk import com.wire.sdk.model.QualifiedId import com.wire.sdk.model.WireMessage +import io.ktor.http.ContentType import io.ktor.http.HttpStatusCode +import io.ktor.http.auth.HttpAuthHeader +import io.ktor.http.auth.parseAuthorizationHeader import io.ktor.serialization.kotlinx.json.json import io.ktor.server.application.Application import io.ktor.server.application.install import io.ktor.server.application.log +import io.ktor.server.application.ApplicationCall import io.ktor.server.plugins.contentnegotiation.ContentNegotiation +import io.ktor.server.request.authorization +import io.ktor.server.request.contentType import io.ktor.server.request.receiveText import io.ktor.server.response.respond import io.ktor.server.response.respondText import io.ktor.server.routing.application import io.ktor.server.routing.get import io.ktor.server.routing.post +import io.ktor.server.routing.Route import io.ktor.server.routing.routing import java.io.IOException import java.util.UUID @@ -35,6 +44,7 @@ fun Application.configureRouting() { } val wireAppSdk = GlobalContext.get().get() + val actionsTokenValidator = GlobalContext.get().get() val signatureValidator = GlobalContext.get().get() val templateHandler = GlobalContext.get().get() val usageMetrics = GlobalContext.get().get() @@ -143,8 +153,114 @@ fun Application.configureRouting() { return@post call.response.status(HttpStatusCode.OK) } + + configureActionsRoute( + wireAppSdk = wireAppSdk, + actionsTokenValidator = actionsTokenValidator + ) } } private const val PARAM_CONVERSATION_ID = "conversationId" private const val PARAM_CONVERSATION_DOMAIN = "conversationDomain" + +private fun Route.configureActionsRoute( + wireAppSdk: WireAppSdk, + actionsTokenValidator: ActionsTokenValidator +) { + post("/actions/{$PARAM_CONVERSATION_ID}/{$PARAM_CONVERSATION_DOMAIN}") { + val conversationId = call.parameters[PARAM_CONVERSATION_ID] + ?: return@post call.respondActionsError( + HttpStatusCode.BadRequest, + "Missing conversation ID" + ) + val conversationDomain = call.parameters[PARAM_CONVERSATION_DOMAIN] + ?: return@post call.respondActionsError( + HttpStatusCode.BadRequest, + "Missing conversation domain" + ) + val conversationUuid = runCatching { UUID.fromString(conversationId) }.getOrNull() + ?: return@post call.respondActionsError( + HttpStatusCode.BadRequest, + "Invalid conversation ID" + ) + val qualifiedConversationId = QualifiedId(conversationUuid, conversationDomain) + + if (!call.request.contentType().match(ContentType.Application.Json)) { + return@post call.respondActionsError( + HttpStatusCode.UnsupportedMediaType, + "Content-Type must be application/json" + ) + } + + val bearerToken = call.bearerToken() + ?: return@post call.respondActionsError( + HttpStatusCode.Unauthorized, + "Bearer authorization is required" + ) + + if (!actionsTokenValidator.isValid(qualifiedConversationId, bearerToken)) { + application.log.warn( + "Invalid Actions token for conversation $conversationId@$conversationDomain" + ) + return@post call.respondActionsError( + HttpStatusCode.Forbidden, + "Invalid conversation credentials" + ) + } + + val request = call.receiveActionsRequest(qualifiedConversationId) + ?: return@post + + wireAppSdk.getApplicationManager().sendMessage( + message = WireMessage.Text.create( + conversationId = qualifiedConversationId, + text = request.text + ) + ) + call.response.status(HttpStatusCode.OK) + } +} + +private suspend fun ApplicationCall.receiveActionsRequest( + conversationId: QualifiedId +): ActionsNotificationRequest? { + val request = try { + KtxSerializer.json.decodeFromString(receiveText()) + } catch (exception: SerializationException) { + application.log.debug( + "Invalid Actions payload for conversation {}", + conversationId, + exception + ) + respondActionsError(HttpStatusCode.BadRequest, "Invalid JSON payload") + return null + } + + return if (request.text.isBlank()) { + respondActionsError(HttpStatusCode.BadRequest, "Text must not be blank") + null + } else { + request + } +} + +private suspend fun ApplicationCall.respondActionsError( + status: HttpStatusCode, + message: String +) { + respondText(text = message, status = status) +} + +private fun ApplicationCall.bearerToken(): String? { + val authorization = request.authorization()?.trim() + val header = authorization + ?.let { runCatching { parseAuthorizationHeader(it) }.getOrNull() } + as? HttpAuthHeader.Single + + return header?.blob?.takeIf { + header.authScheme.equals("Bearer", ignoreCase = true) && + it.isNotEmpty() && + it.none(Char::isWhitespace) + } +} diff --git a/src/main/kotlin/com/wire/github/config/Modules.kt b/src/main/kotlin/com/wire/github/config/Modules.kt index de9b29b..7334835 100644 --- a/src/main/kotlin/com/wire/github/config/Modules.kt +++ b/src/main/kotlin/com/wire/github/config/Modules.kt @@ -2,9 +2,9 @@ package com.wire.github.config import com.wire.github.EventsHandler import com.wire.github.metrics.UsageMetrics +import com.wire.github.util.ActionsTokenValidator import com.wire.github.util.ENV_VAR_API_HOST import com.wire.github.util.ENV_VAR_API_TOKEN -import com.wire.github.util.ENV_VAR_APPLICATION_ID import com.wire.github.util.ENV_VAR_CRYPTOGRAPHY_STORAGE_KEY import com.wire.github.util.ENV_VAR_REDIS_URL import com.wire.github.util.SignatureValidator @@ -24,6 +24,11 @@ val projectModules = module { } } single { SignatureValidator() } + single { + ActionsTokenValidator( + storage = get>().sync() + ) + } single { TemplateHandler() } single { RedisClient.create(ENV_VAR_REDIS_URL) } single> { get().connect() } @@ -33,7 +38,6 @@ val projectModules = module { private fun wireAppSdk(): WireAppSdk = WireAppSdk( - applicationId = ENV_VAR_APPLICATION_ID, apiToken = ENV_VAR_API_TOKEN, apiHost = ENV_VAR_API_HOST, cryptographyStorageKey = ENV_VAR_CRYPTOGRAPHY_STORAGE_KEY.toByteArray(), diff --git a/src/main/kotlin/com/wire/github/request/model/ActionsNotificationRequest.kt b/src/main/kotlin/com/wire/github/request/model/ActionsNotificationRequest.kt new file mode 100644 index 0000000..f31011c --- /dev/null +++ b/src/main/kotlin/com/wire/github/request/model/ActionsNotificationRequest.kt @@ -0,0 +1,8 @@ +package com.wire.github.request.model + +import kotlinx.serialization.Serializable + +@Serializable +data class ActionsNotificationRequest( + val text: String +) diff --git a/src/main/kotlin/com/wire/github/util/ActionsTokenGenerator.kt b/src/main/kotlin/com/wire/github/util/ActionsTokenGenerator.kt new file mode 100644 index 0000000..708f37c --- /dev/null +++ b/src/main/kotlin/com/wire/github/util/ActionsTokenGenerator.kt @@ -0,0 +1,15 @@ +package com.wire.github.util + +import java.security.SecureRandom +import java.util.Base64 + +object ActionsTokenGenerator { + private const val TOKEN_SIZE_BYTES = 32 + private val random = SecureRandom() + + fun generate(): String { + val bytes = ByteArray(TOKEN_SIZE_BYTES) + random.nextBytes(bytes) + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes) + } +} diff --git a/src/main/kotlin/com/wire/github/util/ActionsTokenValidator.kt b/src/main/kotlin/com/wire/github/util/ActionsTokenValidator.kt new file mode 100644 index 0000000..70754b3 --- /dev/null +++ b/src/main/kotlin/com/wire/github/util/ActionsTokenValidator.kt @@ -0,0 +1,22 @@ +package com.wire.github.util + +import com.wire.sdk.model.QualifiedId +import io.lettuce.core.api.sync.RedisCommands +import java.nio.charset.StandardCharsets +import java.security.MessageDigest + +class ActionsTokenValidator( + private val storage: RedisCommands +) { + fun isValid( + conversationId: QualifiedId, + token: String + ): Boolean { + val storageKey = conversationId.toActionsTokenStorageKey() + val storedToken = storage.get(storageKey) ?: return false + return MessageDigest.isEqual( + storedToken.toByteArray(StandardCharsets.UTF_8), + token.toByteArray(StandardCharsets.UTF_8) + ) + } +} diff --git a/src/main/kotlin/com/wire/github/util/EnvironmentVariables.kt b/src/main/kotlin/com/wire/github/util/EnvironmentVariables.kt index 3ae132e..f664c28 100644 --- a/src/main/kotlin/com/wire/github/util/EnvironmentVariables.kt +++ b/src/main/kotlin/com/wire/github/util/EnvironmentVariables.kt @@ -8,8 +8,6 @@ */ package com.wire.github.util -import java.util.UUID - /** * Host Port to be used when setting up Ktor server. */ @@ -44,18 +42,6 @@ val ENV_VAR_REDIS_URL: String = System "redis://localhost:6379" ) -/** - * Application ID received when Onboarding the App. - */ -val ENV_VAR_APPLICATION_ID: UUID = UUID.fromString( - System - .getenv() - .getOrDefault( - "WIRE_SDK_APP_ID", - UUID.randomUUID().toString() - ) -) - /** * API Token received when Onboarding the App. */ diff --git a/src/main/kotlin/com/wire/github/util/Extensions.kt b/src/main/kotlin/com/wire/github/util/Extensions.kt index 684daaf..52cd19a 100644 --- a/src/main/kotlin/com/wire/github/util/Extensions.kt +++ b/src/main/kotlin/com/wire/github/util/Extensions.kt @@ -3,7 +3,10 @@ package com.wire.github.util import com.wire.sdk.model.QualifiedId private const val STORAGE_KEY_PREFIX = "github-app:" +private const val ACTIONS_TOKEN_SUFFIX = ":actions-token" fun QualifiedId.toStorageKey() = "$STORAGE_KEY_PREFIX${this.id}@${this.domain}" fun String.toStorageKey(domain: String) = "$STORAGE_KEY_PREFIX$this@$domain" + +fun QualifiedId.toActionsTokenStorageKey() = "${toStorageKey()}$ACTIONS_TOKEN_SUFFIX" diff --git a/src/test/kotlin/com/wire/github/ApplicationTest.kt b/src/test/kotlin/com/wire/github/ApplicationTest.kt index a776ebe..33bf0f1 100644 --- a/src/test/kotlin/com/wire/github/ApplicationTest.kt +++ b/src/test/kotlin/com/wire/github/ApplicationTest.kt @@ -1,10 +1,12 @@ package com.wire.github import com.wire.github.metrics.UsageMetrics +import com.wire.github.util.ActionsTokenValidator import com.wire.github.util.SignatureValidator import com.wire.github.util.TemplateHandler import com.wire.sdk.WireAppSdk import com.wire.sdk.model.QualifiedId +import com.wire.sdk.model.WireMessage import io.ktor.client.request.get import io.ktor.client.request.post import io.ktor.client.request.setBody @@ -24,6 +26,7 @@ import io.micrometer.prometheusmetrics.PrometheusConfig import io.micrometer.prometheusmetrics.PrometheusMeterRegistry import io.mockk.every import io.mockk.mockk +import io.mockk.slot import io.mockk.verify import java.util.UUID import kotlin.test.AfterTest @@ -49,6 +52,7 @@ class ApplicationTest { modules( module { single { SignatureValidator() } + single { ActionsTokenValidator(storage = mockRedisCommands) } single { TemplateHandler() } single { mockRedisClient } single> { mockRedisConnection } @@ -290,6 +294,64 @@ class ApplicationTest { } } + @Test + fun `given invalid signature, when webhook is posted, then forbidden without sending`() { + val signatureValidator = mockk() + every { signatureValidator.isValid(any(), any(), any(), any()) } returns false + val wireAppSdk = mockk(relaxed = true) + loadKoinModules( + module { + single { signatureValidator } + single { wireAppSdk } + } + ) + + testApplication { + application { configureRouting() } + val response = client.post("/${CONVERSATION_ID.id}/${CONVERSATION_ID.domain}") { + contentType(ContentType.Application.Json) + header("X-GitHub-Event", DUMMY_EVENT) + header("X-Hub-Signature", "sha1=wrong-signature") + header("X-GitHub-Delivery", "delivery") + setBody(DUMMY_PAYLOAD) + } + + assertEquals(HttpStatusCode.Forbidden, response.status) + verify(exactly = 0) { + wireAppSdk.getApplicationManager().sendMessage(message = any()) + } + } + } + + @Test + fun `given malformed payload, when webhook is posted, then bad request without sending`() { + val signatureValidator = mockk() + every { signatureValidator.isValid(any(), any(), any(), any()) } returns true + val wireAppSdk = mockk(relaxed = true) + loadKoinModules( + module { + single { signatureValidator } + single { wireAppSdk } + } + ) + + testApplication { + application { configureRouting() } + val response = client.post("/${CONVERSATION_ID.id}/${CONVERSATION_ID.domain}") { + contentType(ContentType.Application.Json) + header("X-GitHub-Event", DUMMY_EVENT) + header("X-Hub-Signature", "sha1=$DUMMY_SIGNATURE") + header("X-GitHub-Delivery", "delivery") + setBody("{") + } + + assertEquals(HttpStatusCode.BadRequest, response.status) + verify(exactly = 0) { + wireAppSdk.getApplicationManager().sendMessage(message = any()) + } + } + } + @Test fun `given completed GitHub Actions events, when received, then messages are sent`() { val signatureValidator = mockk() @@ -330,6 +392,176 @@ class ApplicationTest { } } + @Test + fun `given valid bearer token, when Actions message is posted, then message is sent`() { + val (actionsTokenValidator, wireAppSdk) = loadActionsDependencies(tokenValid = true) + val sentMessage = slot() + every { + wireAppSdk.getApplicationManager().sendMessage(message = capture(sentMessage)) + } returns UUID.randomUUID() + + testApplication { + application { configureRouting() } + val response = client.post("/actions/${CONVERSATION_ID.id}/${CONVERSATION_ID.domain}") { + contentType(ContentType.parse("application/json; charset=UTF-8")) + header("Authorization", "bearer $ACTIONS_TOKEN") + setBody("""{"text":"CI failed"}""") + } + + assertEquals(HttpStatusCode.OK, response.status) + assertEquals("", response.bodyAsText()) + assertEquals(CONVERSATION_ID, sentMessage.captured.conversationId) + assertEquals("CI failed", sentMessage.captured.text) + verify(exactly = 1) { + actionsTokenValidator.isValid(CONVERSATION_ID, ACTIONS_TOKEN) + } + verify(exactly = 1) { wireAppSdk.getApplicationManager().sendMessage(message = any()) } + } + } + + @Test + fun `Actions message without bearer authorization is unauthorized`() { + listOf( + null, + "Basic $ACTIONS_TOKEN", + "Bearer ", + "Bearer token with-spaces" + ).forEach { authorization -> + val (actionsTokenValidator, wireAppSdk) = loadActionsDependencies(tokenValid = true) + + testApplication { + application { configureRouting() } + val response = client.post( + "/actions/${CONVERSATION_ID.id}/${CONVERSATION_ID.domain}" + ) { + contentType(ContentType.Application.Json) + authorization?.let { header("Authorization", it) } + setBody("""{"text":"CI failed"}""") + } + + assertEquals(HttpStatusCode.Unauthorized, response.status) + assertEquals("Bearer authorization is required", response.bodyAsText()) + verify(exactly = 0) { actionsTokenValidator.isValid(any(), any()) } + verify(exactly = 0) { + wireAppSdk.getApplicationManager().sendMessage(message = any()) + } + } + } + } + + @Test + fun `given wrong bearer token, when Actions message is posted, then forbidden`() { + val (_, wireAppSdk) = loadActionsDependencies(tokenValid = false) + + testApplication { + application { configureRouting() } + val response = client.postActionsMessage() + + assertEquals(HttpStatusCode.Forbidden, response.status) + assertEquals("Invalid conversation credentials", response.bodyAsText()) + verify(exactly = 0) { wireAppSdk.getApplicationManager().sendMessage(message = any()) } + } + } + + @Test + fun `Actions message with invalid text payload is a bad request`() { + listOf("{", "{}", """{"body":"CI failed"}""").forEach { payload -> + val (_, wireAppSdk) = loadActionsDependencies(tokenValid = true) + + testApplication { + application { configureRouting() } + val response = client.postActionsMessage(payload = payload) + + assertEquals(HttpStatusCode.BadRequest, response.status) + assertEquals("Invalid JSON payload", response.bodyAsText()) + verify(exactly = 0) { + wireAppSdk.getApplicationManager().sendMessage(message = any()) + } + } + } + } + + @Test + fun `Actions message with blank text is a bad request`() { + val (_, wireAppSdk) = loadActionsDependencies(tokenValid = true) + + testApplication { + application { configureRouting() } + val response = client.postActionsMessage(payload = """{"text":" "}""") + + assertEquals(HttpStatusCode.BadRequest, response.status) + assertEquals("Text must not be blank", response.bodyAsText()) + verify(exactly = 0) { + wireAppSdk.getApplicationManager().sendMessage(message = any()) + } + } + } + + @Test + fun `Actions message with non-json content type is unsupported`() { + val (actionsTokenValidator, wireAppSdk) = loadActionsDependencies(tokenValid = true) + + testApplication { + application { configureRouting() } + val response = client.post( + "/actions/${CONVERSATION_ID.id}/${CONVERSATION_ID.domain}" + ) { + contentType(ContentType.Text.Plain) + header("Authorization", "Bearer $ACTIONS_TOKEN") + setBody("""{"text":"CI failed"}""") + } + + assertEquals(HttpStatusCode.UnsupportedMediaType, response.status) + assertEquals("Content-Type must be application/json", response.bodyAsText()) + verify(exactly = 0) { actionsTokenValidator.isValid(any(), any()) } + verify(exactly = 0) { + wireAppSdk.getApplicationManager().sendMessage(message = any()) + } + } + } + + @Test + fun `given invalid conversation ID, when Actions message is posted, then bad request`() { + val (actionsTokenValidator, wireAppSdk) = loadActionsDependencies(tokenValid = true) + + testApplication { + application { configureRouting() } + val response = client.post("/actions/not-a-uuid/${CONVERSATION_ID.domain}") { + contentType(ContentType.Application.Json) + header("Authorization", "Bearer $ACTIONS_TOKEN") + setBody("""{"text":"CI failed"}""") + } + + assertEquals(HttpStatusCode.BadRequest, response.status) + assertEquals("Invalid conversation ID", response.bodyAsText()) + verify(exactly = 0) { actionsTokenValidator.isValid(any(), any()) } + verify(exactly = 0) { wireAppSdk.getApplicationManager().sendMessage(message = any()) } + } + } + + private fun loadActionsDependencies( + tokenValid: Boolean + ): Pair { + val actionsTokenValidator = mockk() + every { actionsTokenValidator.isValid(any(), any()) } returns tokenValid + val wireAppSdk = mockk(relaxed = true) + loadKoinModules( + module { + single { actionsTokenValidator } + single { wireAppSdk } + } + ) + return actionsTokenValidator to wireAppSdk + } + + private suspend fun io.ktor.client.HttpClient.postActionsMessage( + payload: String = """{"text":"CI failed"}""" + ) = post("/actions/${CONVERSATION_ID.id}/${CONVERSATION_ID.domain}") { + contentType(ContentType.Application.Json) + header("Authorization", "Bearer $ACTIONS_TOKEN") + setBody(payload) + } + private companion object { val CONVERSATION_ID = QualifiedId( id = UUID.randomUUID(), @@ -338,6 +570,7 @@ class ApplicationTest { const val DUMMY_EVENT = "pull_request" const val DUMMY_SIGNATURE = "dummySignature" const val DUMMY_TEMPLATE = "dummyTemplate" + const val ACTIONS_TOKEN = "actions-token" val DUMMY_PAYLOAD = """ { "action": "created", diff --git a/src/test/kotlin/com/wire/github/GitHubCommandHandlerTest.kt b/src/test/kotlin/com/wire/github/GitHubCommandHandlerTest.kt new file mode 100644 index 0000000..62b2f5d --- /dev/null +++ b/src/test/kotlin/com/wire/github/GitHubCommandHandlerTest.kt @@ -0,0 +1,133 @@ +package com.wire.github + +import com.wire.github.metrics.UsageMetrics +import com.wire.github.util.toActionsTokenStorageKey +import com.wire.github.util.toStorageKey +import com.wire.sdk.model.QualifiedId +import io.lettuce.core.api.sync.RedisCommands +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertNull + +class GitHubCommandHandlerTest { + private val storage = mockk>() + private val usageMetrics = mockk(relaxed = true) + private val handler = GitHubCommandHandler( + storage = storage, + usageMetrics = usageMetrics, + host = "https://github-app.example.com/", + generateSecret = { GENERATED_SECRET }, + generateActionsToken = { GENERATED_ACTIONS_TOKEN } + ) + + @Test + fun `help lists all available commands`() { + val response = handler.response("/GITHUB HELP", CONVERSATION_ID) + + assertContains(response.orEmpty(), GitHubCommandHandler.TOKENS_COMMAND) + assertContains(response.orEmpty(), GitHubCommandHandler.WEBHOOK_HELP_COMMAND) + assertContains(response.orEmpty(), GitHubCommandHandler.ACTIONS_HELP_COMMAND) + verify(exactly = 1) { usageMetrics.onHelpCommand() } + } + + @Test + fun `tokens returns existing webhook secret and Actions token`() { + every { storage.get(CONVERSATION_ID.toStorageKey()) } returns EXISTING_SECRET + every { + storage.get(CONVERSATION_ID.toActionsTokenStorageKey()) + } returns EXISTING_ACTIONS_TOKEN + + val response = handler.response(GitHubCommandHandler.TOKENS_COMMAND, CONVERSATION_ID) + + assertContains(response.orEmpty(), EXISTING_SECRET) + assertContains(response.orEmpty(), EXISTING_ACTIONS_TOKEN) + verify(exactly = 0) { storage.set(any(), any()) } + verify(exactly = 0) { usageMetrics.onHelpCommand() } + } + + @Test + fun `tokens creates and stores both credentials when they are missing`() { + every { storage.get(CONVERSATION_ID.toStorageKey()) } returns null + every { storage.set(CONVERSATION_ID.toStorageKey(), GENERATED_SECRET) } returns "OK" + every { storage.get(CONVERSATION_ID.toActionsTokenStorageKey()) } returns null + every { + storage.set(CONVERSATION_ID.toActionsTokenStorageKey(), GENERATED_ACTIONS_TOKEN) + } returns "OK" + + val response = handler.response(GitHubCommandHandler.TOKENS_COMMAND, CONVERSATION_ID) + + assertContains(response.orEmpty(), GENERATED_SECRET) + assertContains(response.orEmpty(), GENERATED_ACTIONS_TOKEN) + verify(exactly = 1) { storage.set(CONVERSATION_ID.toStorageKey(), GENERATED_SECRET) } + verify(exactly = 1) { + storage.set(CONVERSATION_ID.toActionsTokenStorageKey(), GENERATED_ACTIONS_TOKEN) + } + verify(exactly = 0) { usageMetrics.onHelpCommand() } + } + + @Test + fun `webhook help includes webhook URL and token`() { + every { storage.get(CONVERSATION_ID.toStorageKey()) } returns EXISTING_SECRET + + val response = handler.response(GitHubCommandHandler.WEBHOOK_HELP_COMMAND, CONVERSATION_ID) + + assertContains( + response.orEmpty(), + "https://github-app.example.com/${CONVERSATION_ID.id}/${CONVERSATION_ID.domain}" + ) + assertContains(response.orEmpty(), EXISTING_SECRET) + verify(exactly = 1) { usageMetrics.onHelpCommand() } + } + + @Test + fun `Actions help includes endpoint token and reusable action`() { + every { + storage.get(CONVERSATION_ID.toActionsTokenStorageKey()) + } returns EXISTING_ACTIONS_TOKEN + + val response = handler.response(GitHubCommandHandler.ACTIONS_HELP_COMMAND, CONVERSATION_ID) + + assertContains( + response.orEmpty(), + "https://github-app.example.com/actions/${CONVERSATION_ID.id}/${CONVERSATION_ID.domain}" + ) + assertContains(response.orEmpty(), EXISTING_ACTIONS_TOKEN) + assertContains(response.orEmpty(), "wireapp/github-app/.github/actions/notify-wire@v1") + assertContains(response.orEmpty(), "webhook-url") + assertContains(response.orEmpty(), "token") + verify(exactly = 1) { usageMetrics.onHelpCommand() } + } + + @Test + fun `webhook setup generated for app onboarding is not counted as a help command`() { + every { storage.get(CONVERSATION_ID.toStorageKey()) } returns EXISTING_SECRET + + handler.webhookHelp(CONVERSATION_ID) + + verify(exactly = 0) { usageMetrics.onHelpCommand() } + } + + @Test + fun `old singular token command is ignored`() { + assertNull(handler.response("/github token", CONVERSATION_ID)) + verify(exactly = 0) { usageMetrics.onHelpCommand() } + } + + @Test + fun `unknown command is ignored`() { + assertNull(handler.response("/github unknown", CONVERSATION_ID)) + verify(exactly = 0) { usageMetrics.onHelpCommand() } + } + + private companion object { + val CONVERSATION_ID = QualifiedId(UUID.randomUUID(), "conversation.example.com") + const val EXISTING_SECRET = "existing-secret" + const val GENERATED_SECRET = "generated-secret" + const val EXISTING_ACTIONS_TOKEN = "existing-actions-token" + const val GENERATED_ACTIONS_TOKEN = "generated-actions-token" + } +} diff --git a/src/test/kotlin/com/wire/github/util/ActionsTokenGeneratorTest.kt b/src/test/kotlin/com/wire/github/util/ActionsTokenGeneratorTest.kt new file mode 100644 index 0000000..1ebcfc1 --- /dev/null +++ b/src/test/kotlin/com/wire/github/util/ActionsTokenGeneratorTest.kt @@ -0,0 +1,18 @@ +package com.wire.github.util + +import java.util.Base64 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals + +class ActionsTokenGeneratorTest { + @Test + fun `generates a random URL-safe token from 32 bytes`() { + val firstToken = ActionsTokenGenerator.generate() + val secondToken = ActionsTokenGenerator.generate() + + assertEquals(43, firstToken.length) + assertEquals(32, Base64.getUrlDecoder().decode(firstToken).size) + assertNotEquals(firstToken, secondToken) + } +} diff --git a/src/test/kotlin/com/wire/github/util/ActionsTokenValidatorTest.kt b/src/test/kotlin/com/wire/github/util/ActionsTokenValidatorTest.kt new file mode 100644 index 0000000..6f546db --- /dev/null +++ b/src/test/kotlin/com/wire/github/util/ActionsTokenValidatorTest.kt @@ -0,0 +1,52 @@ +package com.wire.github.util + +import com.wire.sdk.model.QualifiedId +import io.lettuce.core.api.sync.RedisCommands +import io.mockk.every +import io.mockk.mockk +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ActionsTokenValidatorTest { + private val storage = mockk>() + private val validator = ActionsTokenValidator(storage) + + @Test + fun `matching stored token is valid`() { + every { + storage.get(ACTIONS_STORAGE_KEY) + } returns TOKEN + + assertTrue(validator.isValid(CONVERSATION_ID, TOKEN)) + } + + @Test + fun `wrong token is invalid`() { + every { + storage.get(ACTIONS_STORAGE_KEY) + } returns TOKEN + + assertFalse(validator.isValid(CONVERSATION_ID, "wrong-token")) + } + + @Test + fun `missing stored token is invalid`() { + every { + storage.get(ACTIONS_STORAGE_KEY) + } returns null + + assertFalse(validator.isValid(CONVERSATION_ID, TOKEN)) + } + + private companion object { + val CONVERSATION_ID = QualifiedId( + UUID.randomUUID(), + "conversation.example.com" + ) + val ACTIONS_STORAGE_KEY = + "github-app:${CONVERSATION_ID.id}@${CONVERSATION_ID.domain}:actions-token" + const val TOKEN = "actions-token" + } +}