Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ MEILI_URL=http://meilisearch:7700
MEILI_MASTER_KEY=change-me

GITHUB_OAUTH_CLIENT_ID=replace-with-public-client-id
GITHUB_OAUTH_CLIENT_SECRET=replace-with-oauth-client-secret
GITHUB_WEBHOOK_SECRET=replace-with-random-secret
DEVICE_ID_PEPPER=replace-with-random-secret
ADMIN_TOKEN=replace-with-random-secret
Expand Down
57 changes: 54 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ permissions:
jobs:
verify:
runs-on: ubuntu-latest
timeout-minutes: 15
timeout-minutes: 60
env:
GITHUB_CLIENT_ID: Ov23lim8WhLjeUMqvuMj
GITHUB_ROCK_BACKEND_URL: ""
steps:
- uses: actions/checkout@v7
- uses: actions/setup-java@v5
Expand All @@ -34,7 +37,7 @@ jobs:
docker compose config --quiet
test -s LICENSE
test -s web/index.html
- name: Build and test
- name: Build and test backend
id: gradle-build
continue-on-error: true
shell: bash
Expand All @@ -57,10 +60,58 @@ jobs:
path: build/libs/github-rock-backend.jar
if-no-files-found: error
retention-days: 14
- name: Enforce Gradle result
- name: Enforce backend Gradle result
if: steps.gradle-build.outcome == 'failure'
run: exit 1

- name: Check out companion Android main branch
uses: actions/checkout@v7
with:
repository: Sayanthrock-Developer/GitHub-Rock
ref: main
path: android-client
- name: Switch to JDK 17 for Android
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: "17"
- uses: android-actions/setup-android@v3
- name: Generate Android Gradle wrapper
working-directory: android-client
run: gradle wrapper --gradle-version 8.13
- name: Android unit tests
working-directory: android-client
run: |
set -o pipefail
./gradlew testDebugUnitTest --stacktrace 2>&1 | tee unit-tests.log
- name: Compile Compose UI tests
working-directory: android-client
run: |
set -o pipefail
./gradlew assembleDebugAndroidTest --stacktrace 2>&1 | tee ui-tests.log
- name: Android lint
working-directory: android-client
run: ./gradlew lintDebug --stacktrace
- name: Build debug APK
working-directory: android-client
run: ./gradlew assembleDebug --stacktrace
- name: Verify release APK
working-directory: android-client
run: ./gradlew assembleRelease --stacktrace
Comment thread
SayanthRock marked this conversation as resolved.
- name: Upload Android verification
if: always()
uses: actions/upload-artifact@v7
with:
name: github-rock-android-integration
path: |
android-client/app/build/outputs/apk/debug/*.apk
android-client/app/build/reports/tests/
android-client/app/build/reports/lint-results-debug.html
android-client/unit-tests.log
android-client/ui-tests.log
if-no-files-found: warn
retention-days: 7

docker:
runs-on: ubuntu-latest
timeout-minutes: 20
Expand Down
20 changes: 16 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# GitHub Rock Backend

Production-oriented Kotlin/Ktor backend foundation for the **GitHub Rock** Android developer control centre.
Production-oriented Kotlin/Ktor backend for the **GitHub Rock** Android developer control centre.

> The Android app remains usable without this service. Direct GitHub repository, issue, pull-request, workflow, release, and download actions should continue to use the user's GitHub authorization whenever possible.
> The Android app remains usable without this service. Direct GitHub repository, issue, pull-request, workflow, release, and download actions continue to use the user's GitHub authorization whenever possible.

## Included in v0.1

Expand All @@ -11,7 +11,8 @@ Production-oriented Kotlin/Ktor backend foundation for the **GitHub Rock** Andro
- Redis connectivity
- Meilisearch connectivity
- Public health and runtime configuration endpoints
- Stateless GitHub OAuth Device Flow proxy
- Stateless GitHub OAuth Device Flow start, poll, and refresh proxy
- Android-aligned OAuth scopes, including native follow/unfollow support
- HMAC-SHA256 GitHub webhook verification
- Webhook replay protection using delivery IDs
- Structured JSON errors
Expand All @@ -36,15 +37,26 @@ Production-oriented Kotlin/Ktor backend foundation for the **GitHub Rock** Andro
| GET | `/v1/config` | Public app version and feature flags |
| POST | `/v1/auth/device/start` | Start GitHub Device Flow |
| POST | `/v1/auth/device/poll` | Poll GitHub Device Flow |
| POST | `/v1/auth/device/refresh` | Refresh an expiring GitHub OAuth token |
| POST | `/v1/github/webhooks` | Verify and accept GitHub webhooks |

See [`docs/API.md`](docs/API.md).

## Connect the Android app

1. Deploy this repository behind HTTPS.
2. Configure `GITHUB_OAUTH_CLIENT_ID` and the server-only `GITHUB_OAUTH_CLIENT_SECRET`.
3. Verify `/v1/health` and `/v1/config`.
4. In GitHub Rock, open **Profile → About → App information → GitHub Rock Backend connection**.
5. Enter the deployed HTTPS base URL and run the connection test.

The Android app can also receive the endpoint at build time through `GITHUB_ROCK_BACKEND_URL`. The OAuth client secret must never be copied into the Android repository, `local.properties`, GitHub Actions variables, or an APK.

## Run locally

```bash
cp .env.example .env
# Fill GITHUB_OAUTH_CLIENT_ID and GITHUB_WEBHOOK_SECRET
# Fill GITHUB_OAUTH_CLIENT_ID, GITHUB_OAUTH_CLIENT_SECRET, and GITHUB_WEBHOOK_SECRET
bash scripts/start-local.sh
```

Expand Down
38 changes: 36 additions & 2 deletions docs/API.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,50 @@
# API v1

## `GET /v1/health`

Reports the backend, PostgreSQL, Redis, and Meilisearch state.

## `GET /v1/config`

Returns public app-version and feature-availability metadata. It never returns secrets.

GitHub Rock validates this response before using backend-assisted authentication. The Android client requires `apiVersion=v1`, rejects maintenance mode, checks `minSupportedAppVersion`, and verifies the required OAuth feature flag. When the backend is unavailable or incompatible, the app uses its direct-GitHub fallback when a public OAuth client ID is present.

Important mobile flags:

- `oauthDeviceProxy` — Device Flow start and poll are available.
- `oauthRefreshProxy` — expiring OAuth tokens can refresh through the backend.

## `POST /v1/auth/device/start`
Starts GitHub Device Flow using the configured public OAuth client ID.

Starts GitHub Device Flow using the configured public OAuth client ID. The backend requests the same scopes as the Android app:

```text
repo workflow read:user user:email read:org notifications user:follow
```

Returns `503 oauth_unavailable` when OAuth is not configured.

## `POST /v1/auth/device/poll`
Body: `{ "device_code": "..." }`. Returns `pending`, `slow_down`, `authorized`, `expired`, `denied`, or `error`.

Body:

```json
{ "device_code": "..." }
```

Returns `pending`, `slow_down`, `authorized`, `expired`, `denied`, or `error`. Authorized responses preserve access-token expiry and refresh-token metadata returned by GitHub.

## `POST /v1/auth/device/refresh`

Body:

```json
{ "refresh_token": "..." }
```

Exchanges an expiring refresh token through GitHub using the server-only OAuth client secret. Returns `503 oauth_refresh_unavailable` when the secret is not configured. Tokens are proxied to the requesting Android client and are not stored by this service.

## `POST /v1/github/webhooks`

Requires valid `X-Hub-Signature-256`, `X-GitHub-Delivery`, and `X-GitHub-Event` headers. Payloads are capped at 1 MiB. Delivery IDs are persisted for replay protection; full payloads are not stored.
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ data class AppConfig(
val meiliUrl: String,
val meiliMasterKey: String,
val githubOauthClientId: String,
val githubOauthClientSecret: String,
val githubWebhookSecret: String,
val deviceIdPepper: String,
val adminToken: String,
Expand All @@ -32,6 +33,7 @@ data class AppConfig(
"MEILI_URL" to meiliUrl,
"MEILI_MASTER_KEY" to meiliMasterKey,
"GITHUB_OAUTH_CLIENT_ID" to githubOauthClientId,
"GITHUB_OAUTH_CLIENT_SECRET" to githubOauthClientSecret,
"GITHUB_WEBHOOK_SECRET" to githubWebhookSecret,
"DEVICE_ID_PEPPER" to deviceIdPepper,
"ADMIN_TOKEN" to adminToken,
Expand Down Expand Up @@ -59,6 +61,7 @@ data class AppConfig(
meiliUrl = env["MEILI_URL"] ?: "http://localhost:7700",
meiliMasterKey = env["MEILI_MASTER_KEY"] ?: "githubrock-dev-key",
githubOauthClientId = env["GITHUB_OAUTH_CLIENT_ID"] ?: "",
githubOauthClientSecret = env["GITHUB_OAUTH_CLIENT_SECRET"] ?: "",
githubWebhookSecret = env["GITHUB_WEBHOOK_SECRET"] ?: "",
deviceIdPepper = env["DEVICE_ID_PEPPER"] ?: "",
adminToken = env["ADMIN_TOKEN"] ?: "",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,17 @@ data class DeviceStartResponse(
@Serializable
data class DevicePollRequest(@SerialName("device_code") val deviceCode: String)

@Serializable
data class TokenRefreshRequest(@SerialName("refresh_token") val refreshToken: String)

@Serializable
data class GitHubTokenResponse(
@SerialName("access_token") val accessToken: String? = null,
@SerialName("token_type") val tokenType: String? = null,
val scope: String? = null,
@SerialName("expires_in") val expiresIn: Long? = null,
@SerialName("refresh_token") val refreshToken: String? = null,
@SerialName("refresh_token_expires_in") val refreshTokenExpiresIn: Long? = null,
val error: String? = null,
@SerialName("error_description") val errorDescription: String? = null,
val interval: Int? = null,
Expand All @@ -54,6 +60,9 @@ data class DevicePollResponse(
@SerialName("access_token") val accessToken: String? = null,
@SerialName("token_type") val tokenType: String? = null,
val scope: String? = null,
@SerialName("expires_in") val expiresIn: Long? = null,
@SerialName("refresh_token") val refreshToken: String? = null,
@SerialName("refresh_token_expires_in") val refreshTokenExpiresIn: Long? = null,
val message: String? = null,
val interval: Int? = null,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import com.sayanthrock.githubrock.backend.config.AppConfig
import com.sayanthrock.githubrock.backend.model.DevicePollRequest
import com.sayanthrock.githubrock.backend.model.ErrorResponse
import com.sayanthrock.githubrock.backend.model.PublicConfigResponse
import com.sayanthrock.githubrock.backend.model.TokenRefreshRequest
import com.sayanthrock.githubrock.backend.model.WebhookAcceptedResponse
import com.sayanthrock.githubrock.backend.security.WebhookVerifier
import com.sayanthrock.githubrock.backend.service.GitHubDeviceFlowService
Expand Down Expand Up @@ -51,7 +52,8 @@ fun Application.configureRoutes() {
latestAppVersion = config.latestAppVersion,
maintenanceMode = config.maintenanceMode,
features = mapOf(
"oauthDeviceProxy" to config.githubOauthClientId.isNotBlank(),
"oauthDeviceProxy" to deviceFlowService.isConfigured,
"oauthRefreshProxy" to deviceFlowService.isRefreshConfigured,
"webhooks" to config.githubWebhookSecret.isNotBlank(),
"repositoryCache" to false,
"buildMonitoring" to false,
Expand All @@ -62,11 +64,38 @@ fun Application.configureRoutes() {
}

route("/auth/device") {
post("/start") { call.respond(deviceFlowService.start()) }
post("/start") {
if (!deviceFlowService.isConfigured) {
call.respond(
HttpStatusCode.ServiceUnavailable,
ErrorResponse("oauth_unavailable", "GitHub OAuth Device Flow is not configured"),
)
return@post
}
call.respond(deviceFlowService.start())
Comment thread
SayanthRock marked this conversation as resolved.
}
post("/poll") {
if (!deviceFlowService.isConfigured) {
call.respond(
HttpStatusCode.ServiceUnavailable,
ErrorResponse("oauth_unavailable", "GitHub OAuth Device Flow is not configured"),
)
return@post
}
val request = call.receive<DevicePollRequest>()
call.respond(deviceFlowService.poll(request.deviceCode))
}
post("/refresh") {
if (!deviceFlowService.isRefreshConfigured) {
call.respond(
HttpStatusCode.ServiceUnavailable,
ErrorResponse("oauth_refresh_unavailable", "GitHub OAuth token refresh is not configured"),
)
return@post
}
val request = call.receive<TokenRefreshRequest>()
call.respond(deviceFlowService.refresh(request.refreshToken))
}
}

post("/github/webhooks") {
Expand Down
Loading
Loading