Skip to content
Draft
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
47 changes: 26 additions & 21 deletions packages/api/src/services/auth-github-login-stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,29 @@ import * as FileSystem from "@effect/platform/FileSystem"
import * as Path from "@effect/platform/Path"
import { defaultTemplateConfig } from "@effect-template/lib/core/template-defaults"
import { buildDockerAuthArgs, resolveDockerVolumeHostPath, runDockerAuthCapture } from "@effect-template/lib/shell/docker-auth"
import type { AuthError } from "@effect-template/lib/shell/errors"
import { CommandFailedError } from "@effect-template/lib/shell/errors"
import {
rejectGithubTokenWithRepositoryDeleteScope,
runGithubRemoveDeleteRepoScope
} from "@effect-template/lib/usecases/auth-github"
import { buildDockerAuthSpec, normalizeAccountLabel } from "@effect-template/lib/usecases/auth-helpers"
import { ensureEnvFile, readEnvText, upsertEnvKey } from "@effect-template/lib/usecases/env-file"
import { ensureGhAuthImage, ghAuthDir, ghAuthRoot, ghImageName } from "@effect-template/lib/usecases/github-auth-image"
import { normalizeGithubScopes } from "@effect-template/lib/usecases/github-scope-policy"
import { resolvePathFromCwd } from "@effect-template/lib/usecases/path-helpers"
import { autoSyncState } from "@effect-template/lib/usecases/state-repo"
import { ensureStateDotDockerGitRepo } from "@effect-template/lib/usecases/state-repo-github"
import { migrateLegacyOrchLayout } from "@effect-template/lib/usecases/auth-sync"
import { Effect, Logger, Runtime } from "effect"
import { Effect, Logger, Match, Runtime } from "effect"
import * as Stream from "effect/Stream"
import { spawn, type ChildProcess } from "node:child_process"

import type { GithubAuthLoginRequest } from "../api/contracts.js"
import { ApiBadRequestError, ApiInternalError } from "../api/errors.js"

type GithubRuntime = FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor
type GithubSetupError = CommandFailedError | PlatformError
type GithubSetupError = AuthError | CommandFailedError | PlatformError

type PreparedGithubLogin = {
readonly cwd: string
Expand All @@ -36,7 +42,6 @@ const githubLoginStreamSuccessMarker = "__DOCKER_GIT_GITHUB_LOGIN_STATUS__:ok"
const githubLoginStreamErrorMarkerPrefix = "__DOCKER_GIT_GITHUB_LOGIN_STATUS__:error:"
const githubTokenKey = "GITHUB_TOKEN"
const githubTokenPrefix = "GITHUB_TOKEN__"
const defaultGithubScopes = "repo,workflow,read:org"

const ensureGithubOrchLayout = (
cwd: string,
Expand Down Expand Up @@ -71,25 +76,22 @@ const buildGithubTokenKey = (label: string | null): string => {

const labelFromKey = (key: string): string => key.startsWith(githubTokenPrefix) ? key.slice(githubTokenPrefix.length) : "default"

const normalizeGithubScopes = (value: string | null | undefined): ReadonlyArray<string> => {
const raw = value?.trim() ?? ""
const input = raw.length === 0 ? defaultGithubScopes : raw
const scopes = input
.split(/[,\s]+/g)
.map((scope) => scope.trim())
.filter((scope) => scope.length > 0 && scope !== "delete_repo")
return scopes.length === 0 ? defaultGithubScopes.split(",") : scopes
}

const toApiError = (error: GithubSetupError): ApiBadRequestError | ApiInternalError =>
error._tag === "CommandFailedError"
? new ApiBadRequestError({
message: `${error.command} failed (exit ${error.exitCode}).`
})
: new ApiInternalError({
message: String(error),
cause: error
})
Match.value(error).pipe(
Match.tag("AuthError", (authError) =>
new ApiBadRequestError({
message: authError.message
})),
Match.tag("CommandFailedError", (commandError) =>
new ApiBadRequestError({
message: `${commandError.command} failed (exit ${commandError.exitCode}).`
})),
Match.orElse((platformError) =>
new ApiInternalError({
message: String(platformError),
cause: platformError
}))
)

const prepareGithubLogin = (
request: GithubAuthLoginRequest
Expand Down Expand Up @@ -216,7 +218,10 @@ const finalizeGithubLogin = (
Effect.gen(function*(_) {
const fs = yield* _(FileSystem.FileSystem)
const path = yield* _(Path.Path)
yield* _(Effect.log("Removing repository delete scope from GH auth token..."))
yield* _(runGithubRemoveDeleteRepoScope(prepared.cwd, prepared.accountPath).pipe(Effect.mapError(toApiError)))
const token = yield* _(resolveGithubToken(prepared.cwd, prepared.accountPath).pipe(Effect.mapError(toApiError)))
yield* _(rejectGithubTokenWithRepositoryDeleteScope(token).pipe(Effect.mapError(toApiError)))
yield* _(ensureEnvFile(fs, path, prepared.envPath).pipe(Effect.mapError(toApiError)))
yield* _(persistGithubToken(fs, prepared.envPath, prepared.key, token).pipe(Effect.mapError(toApiError)))
yield* _(ensureStateDotDockerGitRepo(token))
Expand Down
10 changes: 10 additions & 0 deletions packages/api/tests/auth-github-login-stream.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,16 @@ describe("GitHub auth login stream", () => {
expect(output.indexOf("State dir ready")).toBeLessThan(output.indexOf("GitHub login completed."))
})

it("renders repository delete scope refresh output before the success marker", () => {
const output = renderGithubPostLoginOutput([
"Removing repository delete scope from GH auth token..."
], "ok")

expect(output).toContain("Removing repository delete scope")
expect(output).toContain("__DOCKER_GIT_GITHUB_LOGIN_STATUS__:ok")
expect(output.indexOf("Removing repository delete scope")).toBeLessThan(output.indexOf("GitHub login completed."))
})

it("renders post-login failure details before the failure marker", () => {
const output = renderGithubPostLoginOutput([
"GitHub login finished in browser, but post-login sync failed: git fetch failed"
Expand Down
68 changes: 43 additions & 25 deletions packages/app/src/lib/usecases/auth-github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,17 @@ import type { AuthGithubLoginCommand, AuthGithubLogoutCommand, AuthGithubStatusC
import { defaultTemplateConfig } from "../core/domain.js"
import { trimLeftChar, trimRightChar } from "../core/strings.js"
import { runDockerAuth, runDockerAuthCapture } from "../shell/docker-auth.js"
import type { AuthError } from "../shell/errors.js"
import { CommandFailedError } from "../shell/errors.js"
import { AuthError, CommandFailedError } from "../shell/errors.js"
import { buildDockerAuthSpec, normalizeAccountLabel } from "./auth-helpers.js"
import { migrateLegacyOrchLayout } from "./auth-sync.js"
import { ensureEnvFile, parseEnvEntries, readEnvText, removeEnvKey, upsertEnvKey } from "./env-file.js"
import { ensureGhAuthImage, ghAuthDir, ghAuthRoot, ghImageName } from "./github-auth-image.js"
import {
githubForbiddenDeleteRepoScopeMessage,
githubUnverifiedTokenScopesMessage,
hasGithubRepositoryDeleteScope,
normalizeGithubScopes
} from "./github-scope-policy.js"
import type { GithubTokenValidationResult } from "./github-token-validation.js"
import { validateGithubToken } from "./github-token-validation.js"
import { resolvePathFromCwd } from "./path-helpers.js"
Expand Down Expand Up @@ -86,28 +91,6 @@ const listGithubTokens = (envText: string): ReadonlyArray<GithubTokenEntry> =>
}))
.filter((entry) => entry.token.trim().length > 0)

const defaultGithubScopes = "repo,workflow,read:org"

// CHANGE: normalize GitHub scopes for gh auth login
// WHY: ensure required scopes are requested without delete_repo
// QUOTE(ТЗ): "Передай все нужные скопы"
// REF: user-request-2026-02-05-gh-scopes
// SOURCE: n/a
// FORMAT THEOREM: ∀s: normalize(s) -> scopes(s) ⊆ required
// PURITY: CORE
// EFFECT: n/a
// INVARIANT: empty input yields default scopes
// COMPLEXITY: O(n) where n = |scopes|
const normalizeGithubScopes = (value: string | null | undefined): ReadonlyArray<string> => {
const raw = value?.trim() ?? ""
const input = raw.length === 0 ? defaultGithubScopes : raw
const scopes = input
.split(/[,\s]+/g)
.map((scope) => scope.trim())
.filter((scope) => scope.length > 0 && scope !== "delete_repo")
return scopes.length === 0 ? defaultGithubScopes.split(",") : scopes
}

const withEnvContext = <A, E, R>(
envGlobalPath: string,
run: (context: EnvContext) => Effect.Effect<A, E, FileSystem.FileSystem | R>
Expand Down Expand Up @@ -144,7 +127,8 @@ const validateGithubTokenEntry = (
label: entry.label,
token: entry.token,
status: validation.status,
login: validation.login
login: validation.login,
oauthScopes: validation.oauthScopes
}))
)

Expand Down Expand Up @@ -200,6 +184,36 @@ const runGithubLogin = (
(exitCode) => new CommandFailedError({ command: "gh auth login --web", exitCode })
)

const runGithubRemoveDeleteRepoScope = (
cwd: string,
accountPath: string
): Effect.Effect<void, CommandFailedError | PlatformError, CommandExecutor.CommandExecutor> =>
runDockerAuth(
buildDockerAuthSpec({
cwd,
image: ghImageName,
hostPath: accountPath,
containerPath: ghAuthDir,
env: ["BROWSER=echo", `GH_CONFIG_DIR=${ghAuthDir}`],
args: ["auth", "refresh", "-h", "github.com", "--remove-scopes", "delete_repo"],
interactive: false
}),
[0],
(exitCode) => new CommandFailedError({ command: "gh auth refresh --remove-scopes delete_repo", exitCode })
)

const rejectGithubTokenWithRepositoryDeleteScope = (token: string): Effect.Effect<void, AuthError> =>
validateGithubToken(token).pipe(
Effect.flatMap((validation) => {
if (validation.status !== "valid" || validation.oauthScopes === null) {
return Effect.fail(new AuthError({ message: githubUnverifiedTokenScopesMessage }))
}
return hasGithubRepositoryDeleteScope(validation.oauthScopes)
? Effect.fail(new AuthError({ message: githubForbiddenDeleteRepoScopeMessage }))
: Effect.void
})
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const retryGithubLogin = (
effect: Effect.Effect<void, CommandFailedError | PlatformError, CommandExecutor.CommandExecutor>
): Effect.Effect<void, CommandFailedError | PlatformError, CommandExecutor.CommandExecutor> =>
Expand Down Expand Up @@ -243,7 +257,10 @@ const runGithubInteractiveLogin = (
yield* _(ensureGhAuthImage(fs, path, cwd, "gh auth"))
yield* _(Effect.log(`Starting GH auth login in container (scopes: ${scopes.join(", ")})...`))
yield* _(retryGithubLogin(runGithubLogin(cwd, accountPath, scopes)))
yield* _(Effect.log("Removing repository delete scope from GH auth token..."))
yield* _(runGithubRemoveDeleteRepoScope(cwd, accountPath))
const resolved = yield* _(resolveGithubTokenFromGh(cwd, accountPath))
yield* _(rejectGithubTokenWithRepositoryDeleteScope(resolved))
yield* _(ensureEnvFile(fs, path, envPath))
const key = buildGithubTokenKey(command.label)
yield* _(persistGithubToken(fs, envPath, key, resolved))
Expand Down Expand Up @@ -271,6 +288,7 @@ export const authGithubLogin = (
const key = buildGithubTokenKey(command.label)
const label = labelFromKey(key)
if (token.length > 0) {
yield* _(rejectGithubTokenWithRepositoryDeleteScope(token))
yield* _(ensureEnvFile(fs, path, envPath))
yield* _(persistGithubToken(fs, envPath, key, token))
yield* _(ensureStateDotDockerGitRepo(token))
Expand Down
62 changes: 62 additions & 0 deletions packages/app/src/lib/usecases/github-scope-policy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/* jscpd:ignore-start */
export const defaultGithubScopes: ReadonlyArray<string> = ["repo", "workflow", "read:org"]
export const githubRepositoryDeleteScope = "delete_repo"
export const githubForbiddenDeleteRepoScopeMessage = [
"GitHub auth token includes forbidden OAuth scope: delete_repo.",
"Repository deletion is not allowed for docker-git tokens. The token was not stored."
].join("\n")
export const githubUnverifiedTokenScopesMessage = [
"Unable to verify GitHub token OAuth scopes.",
"The token was not stored because docker-git could not confirm repository deletion is disabled."
].join("\n")

const scopeSeparator = /[,\s]+/g

const normalizeScopeForComparison = (scope: string): string => scope.trim().toLowerCase()

const isGithubRepositoryDeleteScope = (scope: string): boolean =>
normalizeScopeForComparison(scope) === githubRepositoryDeleteScope

// CHANGE: centralize GitHub OAuth scope normalization
// WHY: every app auth surface must request useful scopes while excluding repository deletion
// QUOTE(user): "Generated GitHub tokens must not be able to delete repositories."
// REF: issue-288
// SOURCE: n/a
// FORMAT THEOREM: forall input: delete_repo notin normalizeGithubScopes(input)
// PURITY: CORE
// EFFECT: n/a
// INVARIANT: empty or all-forbidden input falls back to default safe scopes
// COMPLEXITY: O(n) where n = |input scopes|
export const normalizeGithubScopes = (value: string | null | undefined): ReadonlyArray<string> => {
const raw = value?.trim() ?? ""
const input = raw.length === 0 ? defaultGithubScopes.join(",") : raw
const scopes = input
.split(scopeSeparator)
.map((scope) => scope.trim())
.filter((scope) => scope.length > 0 && !isGithubRepositoryDeleteScope(scope))
return scopes.length === 0 ? defaultGithubScopes : scopes
}

// CHANGE: parse GitHub's X-OAuth-Scopes response header
// WHY: persisted tokens must be checked against the effective scopes granted by GitHub
// QUOTE(user): "Generated GitHub tokens must not be able to delete repositories."
// REF: issue-288
// SOURCE: n/a
// FORMAT THEOREM: forall header: parse(header) = granted OAuth scopes or empty
// PURITY: CORE
// EFFECT: n/a
// INVARIANT: absent header remains unknown; empty header is a known empty scope set
// COMPLEXITY: O(n) where n = |header|
export const parseGithubOauthScopesHeader = (value: string | null | undefined): ReadonlyArray<string> | null => {
if (value === null || value === undefined) {
return null
}
return value
.split(scopeSeparator)
.map((scope) => scope.trim())
.filter((scope) => scope.length > 0)
}

export const hasGithubRepositoryDeleteScope = (scopes: ReadonlyArray<string> | null): boolean =>
scopes?.some((scope) => isGithubRepositoryDeleteScope(scope)) ?? false
/* jscpd:ignore-end */
Comment thread
coderabbitai[bot] marked this conversation as resolved.
20 changes: 14 additions & 6 deletions packages/app/src/lib/usecases/github-token-validation.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
/* jscpd:ignore-start */
import { FetchHttpClient, HttpClient } from "@effect/platform"
import * as Headers from "@effect/platform/Headers"
import * as ParseResult from "@effect/schema/ParseResult"
import * as Schema from "@effect/schema/Schema"
import { Effect, Either } from "effect"
import { Effect, Either, Option } from "effect"

import { parseGithubOauthScopesHeader } from "./github-scope-policy.js"

const githubTokenValidationUrl = "https://api.github.com/user"

Expand All @@ -21,6 +24,7 @@ export type GithubTokenValidationStatus = "valid" | "invalid" | "unknown"
export type GithubTokenValidationResult = {
readonly status: GithubTokenValidationStatus
readonly login: string | null
readonly oauthScopes: ReadonlyArray<string> | null
}

const GithubUserSchema: Schema.Schema<GithubUser> = Schema.Struct({
Expand All @@ -30,7 +34,8 @@ const GithubUserJsonSchema = Schema.parseJson(GithubUserSchema)

const unknownGithubTokenValidationResult = (): GithubTokenValidationResult => ({
status: "unknown",
login: null
login: null,
oauthScopes: null
})

const decodeGithubUserLogin = (input: string): string | null =>
Expand All @@ -46,8 +51,8 @@ const mapGithubTokenValidationStatus = (status: number): GithubTokenValidationSt
return status >= 200 && status < 300 ? "valid" : "unknown"
}

// CHANGE: validate GitHub token and decode the authenticated account login on success
// WHY: auth status and create preflight must share one live GitHub validation boundary
// CHANGE: validate GitHub token and decode the authenticated account login/scopes on success
// WHY: auth status, create preflight, and auth persistence must share one live GitHub validation boundary
// QUOTE(ТЗ): "status проверял валидность токена и если он валидный то писал бы кто овнер"
// REF: user-request-2026-03-19-github-token-status-owner
// SOURCE: n/a
Expand All @@ -69,17 +74,20 @@ export const validateGithubToken = (token: string): Effect.Effect<GithubTokenVal
)

const status = mapGithubTokenValidationStatus(response.status)
const oauthScopes = parseGithubOauthScopesHeader(Option.getOrNull(Headers.get(response.headers, "x-oauth-scopes")))
if (status !== "valid") {
return {
status,
login: null
login: null,
oauthScopes
} satisfies GithubTokenValidationResult
}

const body = yield* _(response.text)
return {
status,
login: decodeGithubUserLogin(body)
login: decodeGithubUserLogin(body),
oauthScopes
} satisfies GithubTokenValidationResult
}).pipe(
Effect.provide(FetchHttpClient.layer),
Expand Down
Loading
Loading