diff --git a/.github/workflows/pr-path-guard.yml b/.github/workflows/pr-path-guard.yml index 37f8518d38..3722d87c7d 100644 --- a/.github/workflows/pr-path-guard.yml +++ b/.github/workflows/pr-path-guard.yml @@ -9,6 +9,7 @@ on: jobs: ensure-no-translator-changes: + name: ensure-no-translator-changes runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/pr-test-build.yml b/.github/workflows/pr-test-build.yml index 337d3f1375..86b2f91d55 100644 --- a/.github/workflows/pr-test-build.yml +++ b/.github/workflows/pr-test-build.yml @@ -31,3 +31,368 @@ jobs: steps: - name: Skip build for migrated router compatibility branch run: echo "Skipping compile step for migrated router compatibility branch." + + go-ci: + name: go-ci + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - name: Run full tests with baseline + run: | + mkdir -p target + go test -json ./... > target/test-baseline.json + go test ./... > target/test-baseline.txt + - name: Upload baseline artifact + uses: actions/upload-artifact@v4 + with: + name: go-test-baseline + path: | + target/test-baseline.json + target/test-baseline.txt + if-no-files-found: error + + quality-ci: + name: quality-ci + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - name: Install golangci-lint + run: | + if ! command -v golangci-lint >/dev/null 2>&1; then + go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 + fi + - name: Install staticcheck + run: | + if ! command -v staticcheck >/dev/null 2>&1; then + go install honnef.co/go/tools/cmd/staticcheck@latest + fi + - name: Install Task + uses: arduino/setup-task@v2 + with: + version: 3.x + repo-token: ${{ secrets.GITHUB_TOKEN }} + - name: Run CI quality gates + env: + QUALITY_DIFF_RANGE: "${{ github.event.pull_request.base.sha }}...${{ github.sha }}" + ENABLE_STATICCHECK: "1" + run: task quality:ci + + quality-staged-check: + name: quality-staged-check + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - name: Install golangci-lint + run: | + if ! command -v golangci-lint >/dev/null 2>&1; then + go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 + fi + - name: Install Task + uses: arduino/setup-task@v2 + with: + version: 3.x + repo-token: ${{ secrets.GITHUB_TOKEN }} + - name: Check staged/diff files in PR range + env: + QUALITY_DIFF_RANGE: "${{ github.event.pull_request.base.sha }}...${{ github.sha }}" + run: task quality:fmt-staged:check + + fmt-check: + name: fmt-check + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - name: Install Task + uses: arduino/setup-task@v2 + with: + version: 3.x + repo-token: ${{ secrets.GITHUB_TOKEN }} + - name: Verify formatting + run: task quality:fmt:check + + golangci-lint: + name: golangci-lint + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - name: Install golangci-lint + run: | + if ! command -v golangci-lint >/dev/null 2>&1; then + go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 + fi + - name: Run golangci-lint + run: | + golangci-lint run ./... + + route-lifecycle: + name: route-lifecycle + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - name: Run route lifecycle tests + run: | + go test -run 'TestServer_' ./pkg/llmproxy/api + + provider-smoke-matrix: + name: provider-smoke-matrix + if: ${{ vars.CLIPROXY_PROVIDER_SMOKE_CASES != '' }} + runs-on: ubuntu-latest + env: + CLIPROXY_PROVIDER_SMOKE_CASES: ${{ vars.CLIPROXY_PROVIDER_SMOKE_CASES }} + CLIPROXY_SMOKE_EXPECT_SUCCESS: ${{ vars.CLIPROXY_SMOKE_EXPECT_SUCCESS }} + CLIPROXY_SMOKE_WAIT_FOR_READY: "1" + CLIPROXY_BASE_URL: "http://127.0.0.1:8317" + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - name: Build cliproxy proxy + run: go build -o cliproxyapi++ ./cmd/server + - name: Run proxy in background + run: | + ./cliproxyapi++ --config config.example.yaml > /tmp/cliproxy-smoke.log 2>&1 & + echo $! > /tmp/cliproxy-smoke.pid + sleep 1 + env: + CLIPROXY_BASE_URL: "${{ env.CLIPROXY_BASE_URL }}" + - name: Run provider smoke matrix + run: | + ./scripts/provider-smoke-matrix.sh + - name: Stop proxy + if: always() + run: | + if [ -f /tmp/cliproxy-smoke.pid ]; then + kill "$(cat /tmp/cliproxy-smoke.pid)" || true + fi + wait || true + + provider-smoke-matrix-cheapest: + name: provider-smoke-matrix-cheapest + runs-on: ubuntu-latest + env: + CLIPROXY_SMOKE_EXPECT_SUCCESS: "0" + CLIPROXY_SMOKE_WAIT_FOR_READY: "1" + CLIPROXY_BASE_URL: "http://127.0.0.1:8317" + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - name: Build cliproxy proxy + run: go build -o cliproxyapi++ ./cmd/server + - name: Run proxy in background + run: | + ./cliproxyapi++ --config config.example.yaml > /tmp/cliproxy-smoke.log 2>&1 & + echo $! > /tmp/cliproxy-smoke.pid + sleep 1 + - name: Run provider smoke matrix (cheapest aliases) + run: ./scripts/provider-smoke-matrix-cheapest.sh + - name: Stop proxy + if: always() + run: | + if [ -f /tmp/cliproxy-smoke.pid ]; then + kill "$(cat /tmp/cliproxy-smoke.pid)" || true + fi + wait || true + + test-smoke: + name: test-smoke + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - name: Install Task + uses: arduino/setup-task@v2 + with: + version: 3.x + repo-token: ${{ secrets.GITHUB_TOKEN }} + - name: Run startup and control-plane smoke tests + run: task test:smoke + + pre-release-config-compat-smoke: + name: pre-release-config-compat-smoke + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - name: Install Task + uses: arduino/setup-task@v2 + with: + version: 3.x + repo-token: ${{ secrets.GITHUB_TOKEN }} + - name: Validate config compatibility path + run: | + task quality:release-lint + + distributed-critical-paths: + name: distributed-critical-paths + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - name: Run targeted critical-path checks + run: ./.github/scripts/check-distributed-critical-paths.sh + + changelog-scope-classifier: + name: changelog-scope-classifier + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Detect change scopes + run: | + mkdir -p target + if [ "${{ github.base_ref }}" = "" ]; then + base_ref="HEAD~1" + else + base_ref="origin/${{ github.base_ref }}" + fi + if git rev-parse --verify "${base_ref}" >/dev/null 2>&1; then + true + else + git fetch origin "${{ github.base_ref }}" --depth=1 || true + fi + if [ "${{ github.event_name }}" = "pull_request" ]; then + git fetch origin "${{ github.base_ref }}" + changed_files="$(git diff --name-only "${base_ref}...${{ github.sha }}")" + else + changed_files="$(git diff --name-only HEAD~1...HEAD)" + fi + + if [ -z "${changed_files}" ]; then + echo "No changed files detected; scope=none" + echo "scope=none" >> "$GITHUB_ENV" + echo "scope=none" > target/changelog-scope.txt + exit 0 + fi + + scope="none" + if echo "${changed_files}" | grep -qE '(^|/)pkg/(auth|config|runtime|api|usage)/|(^|/)sdk/(access|auth|cliproxy)/'; then + scope="routing" + elif echo "${changed_files}" | grep -qE '(^|/)docs/'; then + scope="docs" + elif echo "${changed_files}" | grep -qE '(^|/)security|policy|oauth|token|auth'; then + scope="security" + fi + echo "Detected changelog scope: ${scope}" + echo "scope=${scope}" >> "$GITHUB_ENV" + echo "scope=${scope}" > target/changelog-scope.txt + - name: Upload changelog scope artifact + uses: actions/upload-artifact@v4 + with: + name: changelog-scope + path: target/changelog-scope.txt + + docs-build: + name: docs-build + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: docs/package.json + - name: Build docs + working-directory: docs + run: | + npm install + npm run docs:build + + ci-summary: + name: ci-summary + runs-on: ubuntu-latest + needs: + - quality-ci + - quality-staged-check + - go-ci + - fmt-check + - golangci-lint + - route-lifecycle + - test-smoke + - pre-release-config-compat-smoke + - distributed-critical-paths + - provider-smoke-matrix + - provider-smoke-matrix-cheapest + - changelog-scope-classifier + - docs-build + if: always() + steps: + - name: Summarize PR CI checks + run: | + echo "### cliproxyapi++ PR CI summary" >> "$GITHUB_STEP_SUMMARY" + echo "- quality-ci: ${{ needs.quality-ci.result }}" >> "$GITHUB_STEP_SUMMARY" + echo "- quality-staged-check: ${{ needs.quality-staged-check.result }}" >> "$GITHUB_STEP_SUMMARY" + echo "- go-ci: ${{ needs.go-ci.result }}" >> "$GITHUB_STEP_SUMMARY" + echo "- fmt-check: ${{ needs.fmt-check.result }}" >> "$GITHUB_STEP_SUMMARY" + echo "- golangci-lint: ${{ needs.golangci-lint.result }}" >> "$GITHUB_STEP_SUMMARY" + echo "- route-lifecycle: ${{ needs.route-lifecycle.result }}" >> "$GITHUB_STEP_SUMMARY" + echo "- test-smoke: ${{ needs.test-smoke.result }}" >> "$GITHUB_STEP_SUMMARY" + echo "- pre-release-config-compat-smoke: ${{ needs.pre-release-config-compat-smoke.result }}" >> "$GITHUB_STEP_SUMMARY" + echo "- distributed-critical-paths: ${{ needs.distributed-critical-paths.result }}" >> "$GITHUB_STEP_SUMMARY" + echo "- provider-smoke-matrix: ${{ needs.provider-smoke-matrix.result }}" >> "$GITHUB_STEP_SUMMARY" + echo "- provider-smoke-matrix-cheapest: ${{ needs.provider-smoke-matrix-cheapest.result }}" >> "$GITHUB_STEP_SUMMARY" + echo "- changelog-scope-classifier: ${{ needs.changelog-scope-classifier.result }}" >> "$GITHUB_STEP_SUMMARY" + echo "- docs-build: ${{ needs.docs-build.result }}" >> "$GITHUB_STEP_SUMMARY" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 612da871d6..b386d18263 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,13 +9,13 @@ By participating in this project, you agree to abide by our [Code of Conduct](CO ## How Can I Contribute? ### Reporting Bugs -- Use the [Bug Report](https://github.com/KooshaPari/cliproxyapi-plusplus/issues/new?template=bug_report.md) template. +- Use the [Bug Report](https://github.com/kooshapari/cliproxyapi-plusplus/issues/new?template=bug_report.md) template. - Provide a clear and descriptive title. - Describe the exact steps to reproduce the problem. ### Suggesting Enhancements -- Check the [Issues](https://github.com/KooshaPari/cliproxyapi-plusplus/issues) to see if the enhancement has already been suggested. -- Use the [Feature Request](https://github.com/KooshaPari/cliproxyapi-plusplus/issues/new?template=feature_request.md) template. +- Check the [Issues](https://github.com/kooshapari/cliproxyapi-plusplus/issues) to see if the enhancement has already been suggested. +- Use the [Feature Request](https://github.com/kooshapari/cliproxyapi-plusplus/issues/new?template=feature_request.md) template. ### Pull Requests 1. Fork the repo and create your branch from `main`. @@ -25,8 +25,8 @@ By participating in this project, you agree to abide by our [Code of Conduct](CO 5. Make sure your code lints (`golangci-lint run`). #### Which repository to use? -- **Third-party provider support**: Submit your PR directly to [KooshaPari/cliproxyapi-plusplus](https://github.com/KooshaPari/cliproxyapi-plusplus). -- **Core logic improvements**: If the change is not specific to a third-party provider, please propose it to the [mainline project](https://github.com/router-for-me/CLIProxyAPI) first. +- **Third-party provider support**: Submit your PR directly to [kooshapari/cliproxyapi-plusplus](https://github.com/kooshapari/cliproxyapi-plusplus). +- **Core logic improvements**: If the change is not specific to a third-party provider, please propose it to the [mainline project](https://github.com/kooshapari/cliproxyapi) first. ## Governance diff --git a/README.md b/README.md index 7eb43039d3..02c362664b 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# CLIProxyAPI++ +# cliproxyapi++ Agent-native, multi-provider OpenAI-compatible proxy for production and local model routing. diff --git a/cmd/boardsync/main.go b/cmd/boardsync/main.go index 38e75eec7e..93cbcae123 100644 --- a/cmd/boardsync/main.go +++ b/cmd/boardsync/main.go @@ -21,8 +21,8 @@ const ( ) var repos = []string{ - "router-for-me/CLIProxyAPIPlus", - "router-for-me/CLIProxyAPI", + "kooshapari/cliproxyapi-plusplus", + "kooshapari/cliproxyapi", } type sourceItem struct { @@ -256,7 +256,7 @@ func loadSources(tmpDir string) ([]sourceItem, map[string]int, error) { Body: shrink(strFromAny(it["body"]), 1200), } out = append(out, s) - if strings.HasSuffix(repo, "CLIProxyAPIPlus") { + if strings.HasSuffix(repo, "cliproxyapi-plusplus") { stats["issues_plus"]++ } else { stats["issues_core"]++ @@ -282,7 +282,7 @@ func loadSources(tmpDir string) ([]sourceItem, map[string]int, error) { Body: shrink(strFromAny(it["body"]), 1200), } out = append(out, s) - if strings.HasSuffix(repo, "CLIProxyAPIPlus") { + if strings.HasSuffix(repo, "cliproxyapi-plusplus") { stats["prs_plus"]++ } else { stats["prs_core"]++ @@ -308,7 +308,7 @@ func loadSources(tmpDir string) ([]sourceItem, map[string]int, error) { Body: shrink(d.BodyText, 1200), } out = append(out, s) - if strings.HasSuffix(repo, "CLIProxyAPIPlus") { + if strings.HasSuffix(repo, "cliproxyapi-plusplus") { stats["discussions_plus"]++ } else { stats["discussions_core"]++ @@ -516,9 +516,9 @@ func writeProjectImportCSV(path string, board []boardItem) error { func writeBoardMarkdown(path string, board []boardItem, bj boardJSON) error { var buf bytes.Buffer now := time.Now().Format("2006-01-02") - buf.WriteString("# CLIProxyAPI Ecosystem 2000-Item Execution Board\n\n") + buf.WriteString("# cliproxyapi++ Ecosystem 2000-Item Execution Board\n\n") fmt.Fprintf(&buf, "- Generated: %s\n", now) - buf.WriteString("- Scope: `router-for-me/CLIProxyAPIPlus` + `router-for-me/CLIProxyAPI` Issues, PRs, Discussions\n") + buf.WriteString("- Scope: `kooshapari/cliproxyapi-plusplus` + `kooshapari/cliproxyapi` Issues, PRs, Discussions\n") buf.WriteString("- Objective: Implementation-ready backlog (up to 2000), including CLI extraction, bindings/API integration, docs quickstarts, and dev-runtime refresh\n\n") buf.WriteString("## Coverage\n") keys := []string{"generated_items", "sources_total_unique", "issues_plus", "issues_core", "prs_plus", "prs_core", "discussions_plus", "discussions_core"} diff --git a/docs/github-ownership-guard.md b/docs/github-ownership-guard.md index 7d16edd679..4c9c6b2435 100644 --- a/docs/github-ownership-guard.md +++ b/docs/github-ownership-guard.md @@ -8,13 +8,13 @@ Use this guard before any scripted GitHub mutation (issue/PR/comment operations) It returns non-zero for non-owned repos: -- allowed: `KooshaPari` +- allowed: `kooshapari` - allowed: `atoms-tech` Example for a source URL: ```bash -./scripts/github-owned-guard.sh https://github.com/router-for-me/CLIProxyAPI/pull/1699 +./scripts/github-owned-guard.sh https://github.com/kooshapari/cliproxyapi-plusplus/pull/1699 ``` Example for current git origin: diff --git a/docs/sdk-access.md b/docs/sdk-access.md index 343c851b4f..c871a915c1 100644 --- a/docs/sdk-access.md +++ b/docs/sdk-access.md @@ -1,16 +1,16 @@ # @sdk/access SDK Reference -The `github.com/router-for-me/CLIProxyAPI/v6/sdk/access` package centralizes inbound request authentication for the proxy. It offers a lightweight manager that chains credential providers, so servers can reuse the same access control logic inside or outside the CLI runtime. +The `github.com/kooshapari/cliproxyapi-plusplus/v6/sdk/access` package centralizes inbound request authentication for the proxy. It offers a lightweight manager that chains credential providers, so servers can reuse the same access control logic inside or outside the CLI runtime. ## Importing ```go import ( - sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access" + sdkaccess "github.com/kooshapari/cliproxyapi-plusplus/v6/sdk/access" ) ``` -Add the module with `go get github.com/router-for-me/CLIProxyAPI/v6/sdk/access`. +Add the module with `go get github.com/kooshapari/cliproxyapi-plusplus/v6/sdk/access`. ## Provider Registry @@ -76,7 +76,7 @@ To consume a provider shipped in another Go module, import it for its registrati ```go import ( _ "github.com/acme/xplatform/sdk/access/providers/partner" // registers partner-token - sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access" + sdkaccess "github.com/kooshapari/cliproxyapi-plusplus/v6/sdk/access" ) ``` @@ -146,7 +146,7 @@ Register any custom providers (typically via blank imports) before calling `Buil When configuration changes, refresh any config-backed providers and then reset the manager's provider chain: ```go -// configaccess is github.com/router-for-me/CLIProxyAPI/v6/internal/access/config_access +// configaccess is github.com/kooshapari/cliproxyapi-plusplus/v6/internal/access/config_access configaccess.Register(&newCfg.SDKConfig) accessManager.SetProviders(sdkaccess.RegisteredProviders()) ``` diff --git a/docs/sdk-advanced.md b/docs/sdk-advanced.md index 3a9d3e5004..5ab4832d6c 100644 --- a/docs/sdk-advanced.md +++ b/docs/sdk-advanced.md @@ -24,8 +24,8 @@ import ( "context" "net/http" - coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" - clipexec "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor" + coreauth "github.com/kooshapari/cliproxyapi-plusplus/v6/sdk/cliproxy/auth" + clipexec "github.com/kooshapari/cliproxyapi-plusplus/v6/sdk/cliproxy/executor" ) type Executor struct{} @@ -82,7 +82,7 @@ package myprov import ( "context" - sdktr "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator" + sdktr "github.com/kooshapari/cliproxyapi-plusplus/v6/sdk/translator" ) const ( diff --git a/docs/sdk-usage.md b/docs/sdk-usage.md index 55e7d5f9a7..2eb1b26d11 100644 --- a/docs/sdk-usage.md +++ b/docs/sdk-usage.md @@ -5,7 +5,7 @@ The `sdk/cliproxy` module exposes the proxy as a reusable Go library so external ## Install & Import ```bash -go get github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy +go get github.com/kooshapari/cliproxyapi-plusplus/v6/sdk/cliproxy ``` ```go @@ -14,8 +14,8 @@ import ( "errors" "time" - "github.com/router-for-me/CLIProxyAPI/v6/internal/config" - "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy" + "github.com/kooshapari/cliproxyapi-plusplus/v6/sdk/config" + "github.com/kooshapari/cliproxyapi-plusplus/v6/sdk/cliproxy" ) ``` diff --git a/go.mod b/go.mod index 64c9d4eebc..b6219a5355 100644 --- a/go.mod +++ b/go.mod @@ -38,6 +38,8 @@ require ( modernc.org/sqlite v1.46.1 ) +replace github.com/KooshaPari/phenotype-go-auth => ./third_party/phenotype-go-auth + require ( cloud.google.com/go/compute/metadata v0.3.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect @@ -111,5 +113,3 @@ require ( modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect ) - -replace github.com/KooshaPari/phenotype-go-auth => ../../../template-commons/phenotype-go-auth diff --git a/pkg/llmproxy/api/handlers/management/config_basic.go b/pkg/llmproxy/api/handlers/management/config_basic.go index 41f57d1bd5..898490cfe3 100644 --- a/pkg/llmproxy/api/handlers/management/config_basic.go +++ b/pkg/llmproxy/api/handlers/management/config_basic.go @@ -19,7 +19,7 @@ import ( ) const ( - latestReleaseURL = "https://api.github.com/repos/KooshaPari/cliproxyapi-plusplus/releases/latest" + latestReleaseURL = "https://api.github.com/repos/kooshapari/cliproxyapi-plusplus/releases/latest" latestReleaseUserAgent = "cliproxyapi++" ) diff --git a/third_party/phenotype-go-auth/README.md b/third_party/phenotype-go-auth/README.md new file mode 100644 index 0000000000..ccafc4f6a5 --- /dev/null +++ b/third_party/phenotype-go-auth/README.md @@ -0,0 +1,50 @@ +# phenotype-go-auth + +Shared Go module for authentication and token management across Phenotype services. + +## Features + +- **TokenStorage Interface**: Generic interface for OAuth2 token persistence +- **BaseTokenStorage**: Base implementation with common token fields +- **PKCE Support**: RFC 7636 compliant PKCE code generation +- **OAuth2 Server**: Local HTTP server for handling OAuth callbacks +- **Token Management**: Load, save, and clear token data securely + +## Installation + +```bash +go get github.com/KooshaPari/phenotype-go-auth +``` + +## Quick Start + +```go +import "github.com/KooshaPari/phenotype-go-auth" + +// Create token storage +storage := auth.NewBaseTokenStorage("/path/to/token.json") + +// Load from file +if err := storage.Load(); err != nil { + log.Fatal(err) +} + +// Generate PKCE codes for OAuth +codes, err := auth.GeneratePKCECodes() +if err != nil { + log.Fatal(err) +} + +// Start OAuth callback server +server := auth.NewOAuthServer(8080) +if err := server.Start(); err != nil { + log.Fatal(err) +} + +// Wait for callback +result, err := server.WaitForCallback(5 * time.Minute) +``` + +## License + +MIT diff --git a/third_party/phenotype-go-auth/go.mod b/third_party/phenotype-go-auth/go.mod new file mode 100644 index 0000000000..1d48f79d76 --- /dev/null +++ b/third_party/phenotype-go-auth/go.mod @@ -0,0 +1,5 @@ +module github.com/KooshaPari/phenotype-go-auth + +go 1.22 + +require github.com/sirupsen/logrus v1.9.3 diff --git a/third_party/phenotype-go-auth/oauth.go b/third_party/phenotype-go-auth/oauth.go new file mode 100644 index 0000000000..88f01b5624 --- /dev/null +++ b/third_party/phenotype-go-auth/oauth.go @@ -0,0 +1,337 @@ +// Package auth provides shared OAuth utilities for Phenotype services. +package auth + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "net" + "net/http" + "sync" + "time" + + log "github.com/sirupsen/logrus" +) + +// PKCECodes holds the PKCE code verifier and challenge pair. +type PKCECodes struct { + // CodeVerifier is the cryptographically random string sent in the token request. + CodeVerifier string + + // CodeChallenge is the SHA256 hash of the code verifier, sent in the authorization request. + CodeChallenge string +} + +// GeneratePKCECodes generates a PKCE code verifier and challenge pair +// following RFC 7636 specifications for OAuth 2.0 PKCE extension. +// This provides additional security for the OAuth flow by ensuring that +// only the client that initiated the request can exchange the authorization code. +// +// Returns: +// - *PKCECodes: A struct containing the code verifier and challenge +// - error: An error if the generation fails, nil otherwise +func GeneratePKCECodes() (*PKCECodes, error) { + // Generate code verifier: 43-128 characters, URL-safe + codeVerifier, err := generateCodeVerifier() + if err != nil { + return nil, fmt.Errorf("failed to generate code verifier: %w", err) + } + + // Generate code challenge using S256 method + codeChallenge := generateCodeChallenge(codeVerifier) + + return &PKCECodes{ + CodeVerifier: codeVerifier, + CodeChallenge: codeChallenge, + }, nil +} + +// generateCodeVerifier creates a cryptographically random string +// of 128 characters using URL-safe base64 encoding +func generateCodeVerifier() (string, error) { + // Generate 96 random bytes (will result in 128 base64 characters) + bytes := make([]byte, 96) + _, err := rand.Read(bytes) + if err != nil { + return "", fmt.Errorf("failed to generate random bytes: %w", err) + } + + // Encode to URL-safe base64 without padding + return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(bytes), nil +} + +// generateCodeChallenge creates a SHA256 hash of the code verifier +// and encodes it using URL-safe base64 encoding without padding +func generateCodeChallenge(codeVerifier string) string { + hash := sha256.Sum256([]byte(codeVerifier)) + return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(hash[:]) +} + +// OAuthServer handles the local HTTP server for OAuth callbacks. +// It listens for the authorization code response from the OAuth provider +// and captures the necessary parameters to complete the authentication flow. +type OAuthServer struct { + // server is the underlying HTTP server instance + server *http.Server + // port is the port number on which the server listens + port int + // resultChan is a channel for sending OAuth results + resultChan chan *OAuthResult + // errorChan is a channel for sending OAuth errors + errorChan chan error + // mu is a mutex for protecting server state + mu sync.Mutex + // running indicates whether the server is currently running + running bool +} + +// OAuthResult contains the result of the OAuth callback. +// It holds either the authorization code and state for successful authentication +// or an error message if the authentication failed. +type OAuthResult struct { + // Code is the authorization code received from the OAuth provider + Code string + // State is the state parameter used to prevent CSRF attacks + State string + // Error contains any error message if the OAuth flow failed + Error string +} + +// NewOAuthServer creates a new OAuth callback server. +// It initializes the server with the specified port and creates channels +// for handling OAuth results and errors. +// +// Parameters: +// - port: The port number on which the server should listen +// +// Returns: +// - *OAuthServer: A new OAuthServer instance +func NewOAuthServer(port int) *OAuthServer { + return &OAuthServer{ + port: port, + resultChan: make(chan *OAuthResult, 1), + errorChan: make(chan error, 1), + } +} + +// Start starts the OAuth callback server. +// It sets up the HTTP handlers for the callback and success endpoints, +// and begins listening on the specified port. +// +// Returns: +// - error: An error if the server fails to start +func (s *OAuthServer) Start() error { + s.mu.Lock() + defer s.mu.Unlock() + + if s.running { + return fmt.Errorf("server is already running") + } + + // Check if port is available + if !s.isPortAvailable() { + return fmt.Errorf("port %d is already in use", s.port) + } + + mux := http.NewServeMux() + mux.HandleFunc("/callback", s.handleCallback) + mux.HandleFunc("/success", s.handleSuccess) + + s.server = &http.Server{ + Addr: fmt.Sprintf(":%d", s.port), + Handler: mux, + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + } + + s.running = true + + // Start server in goroutine + go func() { + if err := s.server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + s.errorChan <- fmt.Errorf("server failed to start: %w", err) + } + }() + + // Give server a moment to start + time.Sleep(100 * time.Millisecond) + + return nil +} + +// Stop gracefully stops the OAuth callback server. +// It performs a graceful shutdown of the HTTP server with a timeout. +// +// Parameters: +// - ctx: The context for controlling the shutdown process +// +// Returns: +// - error: An error if the server fails to stop gracefully +func (s *OAuthServer) Stop(ctx context.Context) error { + s.mu.Lock() + defer s.mu.Unlock() + + if !s.running || s.server == nil { + return nil + } + + log.Debug("Stopping OAuth callback server") + + // Create a context with timeout for shutdown + shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + err := s.server.Shutdown(shutdownCtx) + s.running = false + s.server = nil + + return err +} + +// WaitForCallback waits for the OAuth callback with a timeout. +// It blocks until either an OAuth result is received, an error occurs, +// or the specified timeout is reached. +// +// Parameters: +// - timeout: The maximum time to wait for the callback +// +// Returns: +// - *OAuthResult: The OAuth result if successful +// - error: An error if the callback times out or an error occurs +func (s *OAuthServer) WaitForCallback(timeout time.Duration) (*OAuthResult, error) { + select { + case result := <-s.resultChan: + return result, nil + case err := <-s.errorChan: + return nil, err + case <-time.After(timeout): + return nil, fmt.Errorf("timeout waiting for OAuth callback") + } +} + +// handleCallback handles the OAuth callback endpoint. +// It extracts the authorization code and state from the callback URL, +// validates the parameters, and sends the result to the waiting channel. +// +// Parameters: +// - w: The HTTP response writer +// - r: The HTTP request +func (s *OAuthServer) handleCallback(w http.ResponseWriter, r *http.Request) { + log.Debug("Received OAuth callback") + + // Validate request method + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Extract parameters + query := r.URL.Query() + code := query.Get("code") + state := query.Get("state") + errorParam := query.Get("error") + + // Validate required parameters + if errorParam != "" { + log.Errorf("OAuth error received: %s", errorParam) + result := &OAuthResult{ + Error: errorParam, + } + s.sendResult(result) + http.Error(w, fmt.Sprintf("OAuth error: %s", errorParam), http.StatusBadRequest) + return + } + + if code == "" { + log.Error("No authorization code received") + result := &OAuthResult{ + Error: "no_code", + } + s.sendResult(result) + http.Error(w, "No authorization code received", http.StatusBadRequest) + return + } + + if state == "" { + log.Error("No state parameter received") + result := &OAuthResult{ + Error: "no_state", + } + s.sendResult(result) + http.Error(w, "No state parameter received", http.StatusBadRequest) + return + } + + // Send successful result + result := &OAuthResult{ + Code: code, + State: state, + } + s.sendResult(result) + + // Redirect to success page + http.Redirect(w, r, "/success", http.StatusFound) +} + +// handleSuccess handles the success page endpoint. +// It serves a user-friendly response indicating that authentication was successful. +// +// Parameters: +// - w: The HTTP response writer +// - r: The HTTP request +func (s *OAuthServer) handleSuccess(w http.ResponseWriter, r *http.Request) { + log.Debug("Serving success page") + + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusOK) + + successMsg := "Authentication successful! You can now close this window and return to your CLI." + _, err := w.Write([]byte(successMsg)) + if err != nil { + log.Errorf("Failed to write success page: %v", err) + } +} + +// sendResult sends the OAuth result to the waiting channel. +// It ensures that the result is sent without blocking the handler. +// +// Parameters: +// - result: The OAuth result to send +func (s *OAuthServer) sendResult(result *OAuthResult) { + select { + case s.resultChan <- result: + log.Debug("OAuth result sent to channel") + default: + log.Warn("OAuth result channel is full, result dropped") + } +} + +// isPortAvailable checks if the specified port is available. +// It attempts to listen on the port to determine availability. +// +// Returns: +// - bool: True if the port is available, false otherwise +func (s *OAuthServer) isPortAvailable() bool { + addr := fmt.Sprintf(":%d", s.port) + listener, err := net.Listen("tcp", addr) + if err != nil { + return false + } + defer func() { + _ = listener.Close() + }() + return true +} + +// IsRunning returns whether the server is currently running. +// +// Returns: +// - bool: True if the server is running, false otherwise +func (s *OAuthServer) IsRunning() bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.running +} diff --git a/third_party/phenotype-go-auth/token.go b/third_party/phenotype-go-auth/token.go new file mode 100644 index 0000000000..aec431b44d --- /dev/null +++ b/third_party/phenotype-go-auth/token.go @@ -0,0 +1,237 @@ +// Package auth provides shared authentication and token management functionality +// for Phenotype services. It includes token storage interfaces, token persistence, +// and OAuth2 helper utilities. +package auth + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +// TokenStorage defines the interface for token persistence and retrieval. +// Implementations should handle secure storage of OAuth2 tokens and related metadata. +type TokenStorage interface { + // Load reads the token from storage (typically a file). + // Returns the token data or an error if loading fails. + Load() error + + // Save writes the token to storage (typically a file). + // Returns an error if saving fails. + Save() error + + // Clear removes the token from storage. + // Returns an error if clearing fails. + Clear() error + + // GetAccessToken returns the current access token. + GetAccessToken() string + + // GetRefreshToken returns the current refresh token. + GetRefreshToken() string + + // GetIDToken returns the current ID token. + GetIDToken() string + + // GetEmail returns the email associated with this token. + GetEmail() string + + // GetType returns the provider type (e.g., "claude", "github-copilot"). + GetType() string + + // GetMetadata returns arbitrary metadata associated with this token. + GetMetadata() map[string]any + + // SetMetadata allows external callers to inject metadata before saving. + SetMetadata(meta map[string]any) +} + +// BaseTokenStorage provides a shared implementation of token storage +// with common fields used across all OAuth2 providers. +type BaseTokenStorage struct { + // IDToken is the JWT ID token containing user claims and identity information. + IDToken string `json:"id_token"` + + // AccessToken is the OAuth2 access token used for authenticating API requests. + AccessToken string `json:"access_token"` + + // RefreshToken is used to obtain new access tokens when the current one expires. + RefreshToken string `json:"refresh_token"` + + // LastRefresh is the timestamp of the last token refresh operation. + LastRefresh string `json:"last_refresh"` + + // Email is the email address associated with this token. + Email string `json:"email"` + + // Type indicates the authentication provider type (e.g., "claude", "github-copilot"). + Type string `json:"type"` + + // Expire is the timestamp when the current access token expires. + Expire string `json:"expired"` + + // Metadata holds arbitrary key-value pairs injected via hooks. + // It is not exported to JSON directly to allow flattening during serialization. + Metadata map[string]any `json:"-"` + + // filePath is the path where the token is stored. + filePath string +} + +// NewBaseTokenStorage creates a new BaseTokenStorage instance with the given file path. +// +// Parameters: +// - filePath: The full path where the token file should be saved/loaded +// +// Returns: +// - *BaseTokenStorage: A new BaseTokenStorage instance +func NewBaseTokenStorage(filePath string) *BaseTokenStorage { + return &BaseTokenStorage{ + filePath: filePath, + Metadata: make(map[string]any), + } +} + +// Load reads the token from the file path. +// Returns an error if the operation fails or the file does not exist. +func (ts *BaseTokenStorage) Load() error { + filePath := strings.TrimSpace(ts.filePath) + if filePath == "" { + return fmt.Errorf("token file path is empty") + } + + data, err := os.ReadFile(filePath) + if err != nil { + return fmt.Errorf("failed to read token file: %w", err) + } + + if err = json.Unmarshal(data, ts); err != nil { + return fmt.Errorf("failed to parse token file: %w", err) + } + + return nil +} + +// Save writes the token to the file path. +// Creates the necessary directory structure if it doesn't exist. +func (ts *BaseTokenStorage) Save() error { + filePath := strings.TrimSpace(ts.filePath) + if filePath == "" { + return fmt.Errorf("token file path is empty") + } + + // Ensure directory exists + if err := os.MkdirAll(filepath.Dir(filePath), 0700); err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } + + // Merge metadata into the token data for JSON serialization + data := ts.toJSONMap() + + // Write to file + jsonData, err := json.MarshalIndent(data, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal token: %w", err) + } + + if err := os.WriteFile(filePath, jsonData, 0600); err != nil { + return fmt.Errorf("failed to write token file: %w", err) + } + + return nil +} + +// Clear removes the token file. +// Returns nil if the file doesn't exist. +func (ts *BaseTokenStorage) Clear() error { + filePath := strings.TrimSpace(ts.filePath) + if filePath == "" { + return fmt.Errorf("token file path is empty") + } + + if err := os.Remove(filePath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to remove token file: %w", err) + } + + return nil +} + +// GetAccessToken returns the access token. +func (ts *BaseTokenStorage) GetAccessToken() string { + return ts.AccessToken +} + +// GetRefreshToken returns the refresh token. +func (ts *BaseTokenStorage) GetRefreshToken() string { + return ts.RefreshToken +} + +// GetIDToken returns the ID token. +func (ts *BaseTokenStorage) GetIDToken() string { + return ts.IDToken +} + +// GetEmail returns the email. +func (ts *BaseTokenStorage) GetEmail() string { + return ts.Email +} + +// GetType returns the provider type. +func (ts *BaseTokenStorage) GetType() string { + return ts.Type +} + +// GetMetadata returns the metadata. +func (ts *BaseTokenStorage) GetMetadata() map[string]any { + return ts.Metadata +} + +// SetMetadata allows external callers to inject metadata into the storage before saving. +func (ts *BaseTokenStorage) SetMetadata(meta map[string]any) { + ts.Metadata = meta +} + +// UpdateLastRefresh updates the LastRefresh timestamp to the current time. +func (ts *BaseTokenStorage) UpdateLastRefresh() { + ts.LastRefresh = time.Now().UTC().Format(time.RFC3339) +} + +// IsExpired checks if the token has expired based on the Expire timestamp. +func (ts *BaseTokenStorage) IsExpired() bool { + if ts.Expire == "" { + return false + } + + expireTime, err := time.Parse(time.RFC3339, ts.Expire) + if err != nil { + return false + } + + return time.Now().After(expireTime) +} + +// toJSONMap converts the token storage to a map for JSON serialization, +// merging in any metadata. +func (ts *BaseTokenStorage) toJSONMap() map[string]any { + result := map[string]any{ + "id_token": ts.IDToken, + "access_token": ts.AccessToken, + "refresh_token": ts.RefreshToken, + "last_refresh": ts.LastRefresh, + "email": ts.Email, + "type": ts.Type, + "expired": ts.Expire, + } + + // Merge metadata into the result + for key, value := range ts.Metadata { + if key != "" { + result[key] = value + } + } + + return result +}