diff --git a/packages/api/src/services/auth-github-login-stream.ts b/packages/api/src/services/auth-github-login-stream.ts index 08958ab4..eb06ee91 100644 --- a/packages/api/src/services/auth-github-login-stream.ts +++ b/packages/api/src/services/auth-github-login-stream.ts @@ -4,15 +4,21 @@ 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" @@ -20,7 +26,7 @@ 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 @@ -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, @@ -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 => { - 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 @@ -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)) diff --git a/packages/api/tests/auth-github-login-stream.test.ts b/packages/api/tests/auth-github-login-stream.test.ts index 015556a6..d4b275b1 100644 --- a/packages/api/tests/auth-github-login-stream.test.ts +++ b/packages/api/tests/auth-github-login-stream.test.ts @@ -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" diff --git a/packages/app/src/lib/usecases/auth-github.ts b/packages/app/src/lib/usecases/auth-github.ts index 41c55a15..5d674f5d 100644 --- a/packages/app/src/lib/usecases/auth-github.ts +++ b/packages/app/src/lib/usecases/auth-github.ts @@ -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" @@ -86,28 +91,6 @@ const listGithubTokens = (envText: string): ReadonlyArray => })) .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 => { - 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 = ( envGlobalPath: string, run: (context: EnvContext) => Effect.Effect @@ -144,7 +127,8 @@ const validateGithubTokenEntry = ( label: entry.label, token: entry.token, status: validation.status, - login: validation.login + login: validation.login, + oauthScopes: validation.oauthScopes })) ) @@ -200,6 +184,36 @@ const runGithubLogin = ( (exitCode) => new CommandFailedError({ command: "gh auth login --web", exitCode }) ) +const runGithubRemoveDeleteRepoScope = ( + cwd: string, + accountPath: string +): Effect.Effect => + 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 => + 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 + }) + ) + const retryGithubLogin = ( effect: Effect.Effect ): Effect.Effect => @@ -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)) @@ -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)) diff --git a/packages/app/src/lib/usecases/github-scope-policy.ts b/packages/app/src/lib/usecases/github-scope-policy.ts new file mode 100644 index 00000000..cbb5cfaa --- /dev/null +++ b/packages/app/src/lib/usecases/github-scope-policy.ts @@ -0,0 +1,62 @@ +/* jscpd:ignore-start */ +export const defaultGithubScopes: ReadonlyArray = ["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 => { + 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 | 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 | null): boolean => + scopes?.some((scope) => isGithubRepositoryDeleteScope(scope)) ?? false +/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/usecases/github-token-validation.ts b/packages/app/src/lib/usecases/github-token-validation.ts index fd71cc6b..f544242e 100644 --- a/packages/app/src/lib/usecases/github-token-validation.ts +++ b/packages/app/src/lib/usecases/github-token-validation.ts @@ -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" @@ -21,6 +24,7 @@ export type GithubTokenValidationStatus = "valid" | "invalid" | "unknown" export type GithubTokenValidationResult = { readonly status: GithubTokenValidationStatus readonly login: string | null + readonly oauthScopes: ReadonlyArray | null } const GithubUserSchema: Schema.Schema = Schema.Struct({ @@ -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 => @@ -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 @@ -69,17 +74,20 @@ export const validateGithubToken = (token: string): Effect.Effect => })) .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 => { - 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 = ( envGlobalPath: string, run: (context: EnvContext) => Effect.Effect @@ -143,7 +126,8 @@ const validateGithubTokenEntry = ( label: entry.label, token: entry.token, status: validation.status, - login: validation.login + login: validation.login, + oauthScopes: validation.oauthScopes })) ) @@ -199,6 +183,39 @@ const runGithubLogin = ( (exitCode) => new CommandFailedError({ command: "gh auth login --web", exitCode }) ) +export const runGithubRemoveDeleteRepoScope = ( + cwd: string, + accountPath: string +): Effect.Effect => + 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 }) + ) + +export const rejectGithubTokenWithRepositoryDeleteScope = (token: string): Effect.Effect => + validateGithubToken(token).pipe( + Effect.flatMap((validation) => { + if (validation.status === "invalid") { + return Effect.fail(new AuthError({ message: githubInvalidTokenMessage })) + } + if (validation.status === "unknown" || validation.oauthScopes === null) { + return Effect.fail(new AuthError({ message: githubUnverifiedTokenScopesMessage })) + } + return hasGithubRepositoryDeleteScope(validation.oauthScopes) + ? Effect.fail(new AuthError({ message: githubForbiddenDeleteRepoScopeMessage })) + : Effect.void + }) + ) + const retryGithubLogin = ( effect: Effect.Effect ): Effect.Effect => @@ -242,7 +259,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)) @@ -270,6 +290,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)) diff --git a/packages/lib/src/usecases/github-scope-policy.ts b/packages/lib/src/usecases/github-scope-policy.ts new file mode 100644 index 00000000..1f536722 --- /dev/null +++ b/packages/lib/src/usecases/github-scope-policy.ts @@ -0,0 +1,60 @@ +export const defaultGithubScopes: ReadonlyArray = Object.freeze(["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 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 => { + 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 | 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 | null): boolean => + scopes?.some((scope) => isGithubRepositoryDeleteScope(scope)) ?? false diff --git a/packages/lib/src/usecases/github-token-validation.ts b/packages/lib/src/usecases/github-token-validation.ts index b24a47cf..f65d9419 100644 --- a/packages/lib/src/usecases/github-token-validation.ts +++ b/packages/lib/src/usecases/github-token-validation.ts @@ -1,7 +1,10 @@ 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" @@ -20,6 +23,7 @@ export type GithubTokenValidationStatus = "valid" | "invalid" | "unknown" export type GithubTokenValidationResult = { readonly status: GithubTokenValidationStatus readonly login: string | null + readonly oauthScopes: ReadonlyArray | null } const GithubUserSchema: Schema.Schema = Schema.Struct({ @@ -29,7 +33,8 @@ const GithubUserJsonSchema = Schema.parseJson(GithubUserSchema) const unknownGithubTokenValidationResult = (): GithubTokenValidationResult => ({ status: "unknown", - login: null + login: null, + oauthScopes: null }) const decodeGithubUserLogin = (input: string): string | null => @@ -45,8 +50,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 @@ -68,17 +73,20 @@ export const validateGithubToken = (token: string): Effect.Effect( }) ) +const withPatchedFetch = ( + fetchImpl: typeof globalThis.fetch, + effect: Effect.Effect +): Effect.Effect => + Effect.acquireUseRelease( + Effect.sync(() => { + const previous = globalThis.fetch + globalThis.fetch = fetchImpl + return previous + }), + () => effect, + (previous) => + Effect.sync(() => { + globalThis.fetch = previous + }) + ) + const withWorkingDirectory = ( cwd: string, effect: Effect.Effect @@ -145,6 +167,19 @@ const makeFakeExecutor = ( return CommandExecutor.makeExecutor(start) } +const githubUserResponse = (scopes: string | null): Response => { + const headers: Record = { + "content-type": "application/json" + } + if (scopes !== null) { + headers["x-oauth-scopes"] = scopes + } + return new Response(JSON.stringify({ login: "octocat" }), { + status: 200, + headers + }) +} + describe("auth container paths", () => { it.effect("pins gh auth login and token reads to the same writable config dir", () => withTempDir((root) => @@ -154,35 +189,50 @@ describe("auth container paths", () => { const accountPath = `${root}/.docker-git/.orch/auth/gh/default` const recorded: Array = [] const executor = makeFakeExecutor(recorded) + const fetchMock = vi.fn(() => + Effect.runPromise(Effect.succeed(githubUserResponse("repo, workflow, read:org"))) + ) yield* _( - withPatchedEnv( - { - HOME: root, - DOCKER_GIT_STATE_AUTO_SYNC: "0" - }, - withWorkingDirectory( - root, - authGithubLogin({ - _tag: "AuthGithubLogin", - label: null, - token: null, - scopes: null, - envGlobalPath: ".docker-git/.orch/env/global.env" - }).pipe(Effect.provideService(CommandExecutor.CommandExecutor, executor)) + withPatchedFetch( + fetchMock, + withPatchedEnv( + { + HOME: root, + DOCKER_GIT_STATE_AUTO_SYNC: "0" + }, + withWorkingDirectory( + root, + authGithubLogin({ + _tag: "AuthGithubLogin", + label: null, + token: null, + scopes: null, + envGlobalPath: ".docker-git/.orch/env/global.env" + }).pipe(Effect.provideService(CommandExecutor.CommandExecutor, executor)) + ) ) ) ) - const loginCommand = recorded.find((entry) => + const loginIndex = recorded.findIndex((entry) => isDockerRunFor(entry, "docker-git-auth-gh:latest", ["auth", "login"]) ) - const tokenCommand = recorded.find((entry) => + const refreshIndex = recorded.findIndex((entry) => + isDockerRunFor(entry, "docker-git-auth-gh:latest", ["auth", "refresh"]) + ) + const tokenIndex = recorded.findIndex((entry) => isDockerRunFor(entry, "docker-git-auth-gh:latest", ["auth", "token"]) ) + const loginCommand = recorded[loginIndex] + const refreshCommand = recorded[refreshIndex] + const tokenCommand = recorded[tokenIndex] expect(loginCommand).toBeDefined() + expect(refreshCommand).toBeDefined() expect(tokenCommand).toBeDefined() + expect(refreshIndex).toBeGreaterThan(loginIndex) + expect(tokenIndex).toBeGreaterThan(refreshIndex) expect( includesArgsInOrder(loginCommand?.args ?? [], [ "-v", @@ -193,7 +243,24 @@ describe("auth container paths", () => { "GH_CONFIG_DIR=/gh-auth", "docker-git-auth-gh:latest", "auth", - "login" + "login", + "--scopes", + "repo,workflow,read:org" + ]) + ).toBe(true) + expect( + includesArgsInOrder(refreshCommand?.args ?? [], [ + "-v", + `${accountPath}:/gh-auth`, + "-e", + "BROWSER=echo", + "-e", + "GH_CONFIG_DIR=/gh-auth", + "docker-git-auth-gh:latest", + "auth", + "refresh", + "--remove-scopes", + "delete_repo" ]) ).toBe(true) expect( @@ -210,6 +277,205 @@ describe("auth container paths", () => { const envText = yield* _(fs.readFileString(envPath)) expect(envText).toContain("GITHUB_TOKEN=test-gh-token") + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + ).pipe(Effect.provide(NodeContext.layer))) + + it.effect("filters delete_repo from requested scopes before GitHub web login", () => + withTempDir((root) => + Effect.gen(function*(_) { + const recorded: Array = [] + const executor = makeFakeExecutor(recorded) + const fetchMock = vi.fn(() => + Effect.runPromise(Effect.succeed(githubUserResponse("repo, workflow"))) + ) + + yield* _( + withPatchedFetch( + fetchMock, + withPatchedEnv( + { + HOME: root, + DOCKER_GIT_STATE_AUTO_SYNC: "0" + }, + withWorkingDirectory( + root, + authGithubLogin({ + _tag: "AuthGithubLogin", + label: null, + token: null, + scopes: "repo,DELETE_REPO workflow", + envGlobalPath: ".docker-git/.orch/env/global.env" + }).pipe(Effect.provideService(CommandExecutor.CommandExecutor, executor)) + ) + ) + ) + ) + + const loginCommand = recorded.find((entry) => + isDockerRunFor(entry, "docker-git-auth-gh:latest", ["auth", "login"]) + ) + + expect(loginCommand).toBeDefined() + expect(includesArgsInOrder(loginCommand?.args ?? [], ["--scopes", "repo,workflow"])).toBe(true) + }) + ).pipe(Effect.provide(NodeContext.layer))) + + it.effect("does not persist a generated token when GitHub reports delete_repo", () => + withTempDir((root) => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const envPath = `${root}/.docker-git/.orch/env/global.env` + const recorded: Array = [] + const executor = makeFakeExecutor(recorded) + const fetchMock = vi.fn(() => + Effect.runPromise(Effect.succeed(githubUserResponse("repo, delete_repo"))) + ) + + const failure = yield* _( + withPatchedFetch( + fetchMock, + withPatchedEnv( + { + HOME: root, + DOCKER_GIT_STATE_AUTO_SYNC: "0" + }, + withWorkingDirectory( + root, + authGithubLogin({ + _tag: "AuthGithubLogin", + label: null, + token: null, + scopes: null, + envGlobalPath: ".docker-git/.orch/env/global.env" + }).pipe( + Effect.provideService(CommandExecutor.CommandExecutor, executor), + Effect.flip + ) + ) + ) + ) + ) + + expect(failure._tag).toBe("AuthError") + expect(failure.message).toBe(githubForbiddenDeleteRepoScopeMessage) + expect(yield* _(fs.exists(envPath))).toBe(false) + }) + ).pipe(Effect.provide(NodeContext.layer))) + + it.effect("does not persist a generated token when GitHub omits OAuth scopes", () => + withTempDir((root) => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const envPath = `${root}/.docker-git/.orch/env/global.env` + const recorded: Array = [] + const executor = makeFakeExecutor(recorded) + const fetchMock = vi.fn(() => + Effect.runPromise(Effect.succeed(githubUserResponse(null))) + ) + + const failure = yield* _( + withPatchedFetch( + fetchMock, + withPatchedEnv( + { + HOME: root, + DOCKER_GIT_STATE_AUTO_SYNC: "0" + }, + withWorkingDirectory( + root, + authGithubLogin({ + _tag: "AuthGithubLogin", + label: null, + token: null, + scopes: null, + envGlobalPath: ".docker-git/.orch/env/global.env" + }).pipe( + Effect.provideService(CommandExecutor.CommandExecutor, executor), + Effect.flip + ) + ) + ) + ) + ) + + expect(failure._tag).toBe("AuthError") + expect(failure.message).toBe(githubUnverifiedTokenScopesMessage) + expect(yield* _(fs.exists(envPath))).toBe(false) + }) + ).pipe(Effect.provide(NodeContext.layer))) + + it.effect("rejects a manual token when GitHub reports delete_repo", () => + withTempDir((root) => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const envPath = `${root}/.docker-git/.orch/env/global.env` + const fetchMock = vi.fn(() => + Effect.runPromise(Effect.succeed(githubUserResponse("repo, delete_repo"))) + ) + + const failure = yield* _( + withPatchedFetch( + fetchMock, + withPatchedEnv( + { + HOME: root, + DOCKER_GIT_STATE_AUTO_SYNC: "0" + }, + withWorkingDirectory( + root, + authGithubLogin({ + _tag: "AuthGithubLogin", + label: null, + token: "manual-token", + scopes: null, + envGlobalPath: ".docker-git/.orch/env/global.env" + }).pipe(Effect.flip) + ) + ) + ) + ) + + expect(failure._tag).toBe("AuthError") + expect(failure.message).toBe(githubForbiddenDeleteRepoScopeMessage) + expect(yield* _(fs.exists(envPath))).toBe(false) + }) + ).pipe(Effect.provide(NodeContext.layer))) + + it.effect("rejects a manual token when GitHub omits OAuth scopes", () => + withTempDir((root) => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const envPath = `${root}/.docker-git/.orch/env/global.env` + const fetchMock = vi.fn(() => + Effect.runPromise(Effect.succeed(githubUserResponse(null))) + ) + + const failure = yield* _( + withPatchedFetch( + fetchMock, + withPatchedEnv( + { + HOME: root, + DOCKER_GIT_STATE_AUTO_SYNC: "0" + }, + withWorkingDirectory( + root, + authGithubLogin({ + _tag: "AuthGithubLogin", + label: null, + token: "manual-token", + scopes: null, + envGlobalPath: ".docker-git/.orch/env/global.env" + }).pipe(Effect.flip) + ) + ) + ) + ) + + expect(failure._tag).toBe("AuthError") + expect(failure.message).toBe(githubUnverifiedTokenScopesMessage) + expect(yield* _(fs.exists(envPath))).toBe(false) }) ).pipe(Effect.provide(NodeContext.layer))) diff --git a/packages/lib/tests/usecases/github-scope-policy.test.ts b/packages/lib/tests/usecases/github-scope-policy.test.ts new file mode 100644 index 00000000..8fb0356f --- /dev/null +++ b/packages/lib/tests/usecases/github-scope-policy.test.ts @@ -0,0 +1,67 @@ +import fc from "fast-check" +import { describe, expect, it } from "vitest" + +import { + defaultGithubScopes, + hasGithubRepositoryDeleteScope, + normalizeGithubScopes, + parseGithubOauthScopesHeader +} from "../../src/usecases/github-scope-policy.js" + +describe("github scope policy", () => { + const deleteRepoScopeArbitrary = fc.constantFrom("delete_repo", "DELETE_REPO", "Delete_Repo", " delete_repo ") + const separatorArbitrary = fc.constantFrom(",", " ", "\n", "\t", ", ", " , ") + const scopeTokenArbitrary = fc.oneof(fc.string(), deleteRepoScopeArbitrary) + const scopeListInputArbitrary = fc.array(scopeTokenArbitrary, { maxLength: 20 }).chain((scopes) => + fc.array(separatorArbitrary, { minLength: scopes.length, maxLength: scopes.length }).map((separators) => + scopes.map((scope, index) => `${scope}${separators[index] ?? ""}`).join("") + ) + ) + const nullableScopeInputArbitrary = fc.oneof(fc.constant(null), fc.constant(undefined), fc.string(), scopeListInputArbitrary) + const forbiddenOnlyInputArbitrary = fc.array(deleteRepoScopeArbitrary, { minLength: 1, maxLength: 20 }).chain((scopes) => + fc.array(separatorArbitrary, { minLength: scopes.length, maxLength: scopes.length }).map((separators) => + scopes.map((scope, index) => `${scope}${separators[index] ?? ""}`).join("") + ) + ) + + it("preserves default safe scopes", () => { + expect(normalizeGithubScopes(null)).toEqual(defaultGithubScopes) + expect(normalizeGithubScopes("")).toEqual(defaultGithubScopes) + }) + + it("accepts comma and space separated scopes", () => { + expect(normalizeGithubScopes("repo,workflow read:org")).toEqual(["repo", "workflow", "read:org"]) + }) + + it("removes delete_repo case-insensitively", () => { + expect(normalizeGithubScopes("repo,DELETE_REPO workflow delete_repo")).toEqual(["repo", "workflow"]) + }) + + it("falls back to defaults when every requested scope is forbidden", () => { + expect(normalizeGithubScopes("delete_repo DELETE_REPO")).toEqual(defaultGithubScopes) + }) + + it("preserves the no-delete-repo invariant for arbitrary scope inputs", () => { + fc.assert( + fc.property(nullableScopeInputArbitrary, (input) => { + expect( + normalizeGithubScopes(input).some((scope) => scope.trim().toLowerCase() === "delete_repo") + ).toBe(false) + }) + ) + fc.assert( + fc.property(forbiddenOnlyInputArbitrary, (input) => { + expect(normalizeGithubScopes(input)).toEqual(defaultGithubScopes) + }) + ) + }) + + it("parses GitHub OAuth scope headers and detects repository deletion", () => { + expect(parseGithubOauthScopesHeader(null)).toBe(null) + expect(parseGithubOauthScopesHeader(undefined)).toBe(null) + expect(parseGithubOauthScopesHeader("repo, workflow, delete_repo")).toEqual(["repo", "workflow", "delete_repo"]) + expect(hasGithubRepositoryDeleteScope(["repo", "DELETE_REPO"])).toBe(true) + expect(hasGithubRepositoryDeleteScope(["repo", "workflow"])).toBe(false) + expect(hasGithubRepositoryDeleteScope(null)).toBe(false) + }) +})