diff --git a/.cspell.json b/.cspell.json index 9a578de..4de95cc 100644 --- a/.cspell.json +++ b/.cspell.json @@ -4,22 +4,33 @@ "words": [ "aquasecurity", "artipacked", + "binname", + "binpath", "cimd", "clidocs", "coverprofile", "cpuprof", + "credstore", "ehthumbs", + "esac", + "fprintln", "goarch", + "gobin", "gofmt", "golangci", - "goreleaser", + "goos", + "gopath", "kics", "ldflags", "lfx", "lfxv", "linuxfoundation", "memprof", + "mgechev", + "mktemp", + "pipefail", "techdocs", + "trimpath", "urfave", "zizmor" ], diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index 69e362a..04ba185 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -11,9 +11,15 @@ name: Publish Tagged Release permissions: {} jobs: - goreleaser: - name: GoReleaser - runs-on: ubuntu-latest + release: + name: Build and upload release artifacts + runs-on: macos-latest + # Runs on macos-latest (rather than ubuntu-latest) so darwin binaries + # can be built natively with CGO_ENABLED=1: keyring's KeychainBackend + # is compiled only under the "darwin && cgo" build tag, which a + # cross-compiled, CGO_ENABLED=0 darwin build can't satisfy. linux and + # windows binaries are still pure cross-compiles (CGO_ENABLED=0, no + # cgo-only backends there), which works the same from any host OS. permissions: contents: write @@ -34,19 +40,82 @@ jobs: # into a release build (zizmor cache-poisoning audit). cache: false - - name: Run GoReleaser - uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6.4.0 - with: - # Constrained to v2 (the action's documented default) so a - # future GoReleaser v3 release doesn't silently break old tags. - version: "~> v2" - args: release --clean + - name: Build and archive release binaries + run: | + set -euo pipefail + + name="lfx-cli" + version="${GITHUB_REF_NAME#v}" + # Reproducible builds: pin every archived file's mtime to the + # commit timestamp (rather than build/checkout time), and strip + # build-path information from the binary. actions/checkout sets + # file mtimes to checkout time, not the original commit time (git + # doesn't track per-file mtimes at all), so LICENSE/README need + # pinning here too, not just the binary. + # https://reproducible-builds.org/docs/archives/ + commit_epoch="$(git log -1 --format=%ct)" + mtime="$(date -u -r "${commit_epoch}" +%Y%m%d%H%M.%S)" + touch -t "${mtime}" LICENSE LICENSE-docs README.md + + # goos, goarch, cgo_enabled + targets=( + "linux amd64 0" + "linux arm64 0" + "windows amd64 0" + "darwin amd64 1" + "darwin arm64 1" + ) + + for target in "${targets[@]}"; do + read -r goos goarch cgo_enabled <<< "${target}" + + workdir="$(mktemp -d)" + binname="lfx" + [ "${goos}" = "windows" ] && binname="lfx.exe" + binpath="${workdir}/${binname}" + + GOOS="${goos}" GOARCH="${goarch}" CGO_ENABLED="${cgo_enabled}" go build \ + -trimpath \ + -ldflags="-s -w -X main.version=${version}" \ + -o "${binpath}" ./cmd/lfx + touch -t "${mtime}" "${binpath}" + + if [ "${goos}" = "windows" ]; then + archive="${name}_${goos}_${goarch}.zip" + zip -X -j "${archive}" "${binpath}" LICENSE LICENSE-docs README.md + else + archive="${name}_${goos}_${goarch}.tar.gz" + # Pipe through gzip -n rather than tar's built-in -z: bsdtar's + # gzip writer embeds the current wall-clock time in the gzip + # header regardless of the archived files' mtimes, which + # would otherwise make the archive bytes differ on every run + # despite the pinned mtimes above. + tar -cf - -C "${workdir}" "${binname}" -C "${GITHUB_WORKSPACE}" LICENSE LICENSE-docs README.md \ + | gzip -n > "${archive}" + fi + + shasum -a 256 "${archive}" >> checksums.txt + rm -rf "${workdir}" + done + + - name: Upload release artifacts env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + + gh release upload "${GITHUB_REF_NAME}" \ + lfx-cli_linux_amd64.tar.gz \ + lfx-cli_linux_arm64.tar.gz \ + lfx-cli_windows_amd64.zip \ + lfx-cli_darwin_amd64.tar.gz \ + lfx-cli_darwin_arm64.tar.gz \ + checksums.txt \ + --repo "${GITHUB_REPOSITORY}" --clobber publish-docs: name: Publish CLI docs to gh-pages - needs: goreleaser + needs: release runs-on: ubuntu-latest permissions: diff --git a/.goreleaser.yaml b/.goreleaser.yaml deleted file mode 100644 index 81533d8..0000000 --- a/.goreleaser.yaml +++ /dev/null @@ -1,52 +0,0 @@ -# Copyright The Linux Foundation and each contributor to LFX. -# SPDX-License-Identifier: MIT - -version: 2 - -before: - hooks: - - go mod tidy - -builds: - - id: lfx - main: ./cmd/lfx - binary: lfx - env: - - CGO_ENABLED=0 - goos: - - linux - - darwin - - windows - goarch: - - amd64 - - arm64 - ignore: - - goos: windows - goarch: arm64 - ldflags: - - -s -w -X main.version={{.Version}} - -archives: - - id: lfx - formats: [tar.gz] - format_overrides: - - goos: windows - formats: [zip] - name_template: >- - {{ .ProjectName }}_{{ .Os }}_{{ .Arch }} - -checksum: - name_template: "checksums.txt" - -changelog: - sort: asc - filters: - exclude: - - "^docs:" - - "^test:" - - "^ci:" - -release: - github: - owner: linuxfoundation - name: lfx-cli diff --git a/AGENTS.md b/AGENTS.md index 02bc6c7..c761719 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,8 +18,15 @@ CLI (`lfx auth login` → `lfx auth token`). subcommand routing - **Docs generation**: [`urfave/cli-docs/v3`](https://github.com/urfave/cli-docs) for LLM/agent-friendly Markdown reference docs (`lfx docs`) -- **Release automation**: [GoReleaser](https://goreleaser.com/) for - multi-arch binary builds published to GitHub Releases +- **Release automation**: a single `release-tag.yml` GitHub Actions job + (running on `macos-latest`) cross-compiles linux/windows binaries and + natively builds cgo-enabled darwin binaries, then uploads all archives + to the GitHub Release +- **Output**: user-facing command output uses plain `fmt.Println`/ + `fmt.Fprintln`, not `log/slog`. This is intentional: unlike the + JSON-structured `slog` logging convention used by LFX's long-running + services, `lfx` is an interactive CLI with no log aggregator consuming + its output. ## Architecture Overview @@ -29,7 +36,8 @@ lfx-cli/ │ └── lfx/ # Main application entry point ├── internal/ │ └── commands/ # CLI subcommand implementations -├── .goreleaser.yaml # Multi-arch release build configuration +├── .github/workflows/ +│ └── release-tag.yml # Multi-arch release build/upload ├── go.mod # Go module definition ├── Makefile # Build automation ├── README.md # User documentation @@ -85,7 +93,6 @@ make fmt # Format code make vet # Run go vet make lint # Run golangci-lint (if installed) make revive # Run revive (if installed) -make goreleaser-check # Validate .goreleaser.yaml (if goreleaser installed) make check # Run all of the above ``` @@ -202,7 +209,7 @@ explicitly instructed. Do **not** create or push git tags manually. Instead, use the GitHub Releases UI (or `gh` CLI) to create a release; GitHub will create the tag automatically, and the **Publish Tagged Release** GitHub Actions workflow -will run GoReleaser to build and publish multi-arch binaries. +will build and upload multi-arch binaries to it. ```bash # Determine the next version by inspecting the latest tag. @@ -225,5 +232,7 @@ release binaries may be missing even though the GitHub Release exists. the established pattern 2. **Package Comments**: Every new `*.go` file must include the same `// Package ...` doc comment as the rest of its package -3. **Code Quality**: Run `make check` before commits -4. **Documentation**: Update README.md for user-facing changes +3. **Dependencies**: Run `go get -u ./... && go mod tidy` before every PR to + keep dependencies current +4. **Code Quality**: Run `make check` before commits +5. **Documentation**: Update README.md for user-facing changes diff --git a/Makefile b/Makefile index 5586ab1..2ad8f2c 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ # Copyright The Linux Foundation and each contributor to LFX. # SPDX-License-Identifier: MIT -.PHONY: all build clean check fmt vet lint revive goreleaser-check test test-coverage run deps install-tools megalinter help +.PHONY: all build clean check fmt vet lint revive test test-coverage run deps install-tools megalinter help # Build variables BINARY_NAME=lfx @@ -41,7 +41,7 @@ clean: # the read-only checks run; those are safe to parallelize with each other. check: $(MAKE) fmt - $(MAKE) vet lint revive goreleaser-check + $(MAKE) vet lint revive # Format Go code fmt: @@ -71,15 +71,6 @@ revive: echo "revive not installed, skipping..."; \ fi -# Validate the GoReleaser config (if available) -goreleaser-check: - @echo "Validating .goreleaser.yaml..." - @if command -v goreleaser >/dev/null 2>&1; then \ - goreleaser check; \ - else \ - echo "goreleaser not installed, skipping..."; \ - fi - # Run tests test: @echo "Running tests..." @@ -106,12 +97,11 @@ deps: install-tools: @echo "Installing development tools..." go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest - go install github.com/goreleaser/goreleaser/v2@latest go install github.com/mgechev/revive@latest @gobin="$$(go env GOPATH)/bin"; \ case ":$$PATH:" in \ *":$$gobin:"*) ;; \ - *) echo "Warning: $$gobin is not in your PATH; installed tools (golangci-lint, goreleaser, revive) won't be found. Add it to your PATH, e.g.: export PATH=\"$$PATH:$$gobin\"" ;; \ + *) echo "Warning: $$gobin is not in your PATH; installed tools (golangci-lint, revive) won't be found. Add it to your PATH, e.g.: export PATH=\"$$PATH:$$gobin\"" ;; \ esac # Run MegaLinter locally via Docker (matches CI Go flavor at v9.6.0). @@ -131,7 +121,6 @@ help: @echo " vet - Run go vet" @echo " lint - Run golangci-lint" @echo " revive - Run revive" - @echo " goreleaser-check - Validate .goreleaser.yaml" @echo " test - Run tests" @echo " test-coverage - Run tests with coverage report" @echo " run - Build and run the CLI (pass args via ARGS=...)" diff --git a/README.md b/README.md index 7d4dcc4..44f4b9d 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,20 @@ lfx auth logout lfx api ``` +Credentials (refresh token, cached access token) are stored in your +operating system's credential store by default (macOS Keychain, Windows +Credential Manager, Linux Secret Service/KWallet/`pass`). Pass +`--insecure-storage` to any `auth` subcommand to instead store credentials +in a plain, unencrypted, owner-only file, at the cost of weaker protection +for the stored tokens. On Windows, this owner-only mode relies on inherited +directory permissions rather than a real ACL, since Go's `Chmod(0600)` maps +to the read-only attribute there rather than restricting access to the +current user. + +```bash +lfx auth login --insecure-storage +``` + Run `lfx --help` or `lfx --help` for full details on any command. > **Note:** This project is under active development. Authentication and API diff --git a/go.mod b/go.mod index 991cf0b..7199b68 100644 --- a/go.mod +++ b/go.mod @@ -5,11 +5,20 @@ module github.com/linuxfoundation/lfx-cli go 1.26.5 require ( + github.com/99designs/keyring v1.2.2 github.com/urfave/cli-docs/v3 v3.1.0 github.com/urfave/cli/v3 v3.10.1 ) require ( - github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect + github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect + github.com/danieljoos/wincred v1.2.3 // indirect + github.com/dvsekhvalnov/jose2go v1.8.0 // indirect + github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // indirect + github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c // indirect + github.com/mtibben/percent v0.2.1 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/term v0.45.0 // indirect ) diff --git a/go.sum b/go.sum index b0ce4fa..77c7dc3 100644 --- a/go.sum +++ b/go.sum @@ -1,16 +1,43 @@ -github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= -github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 h1:/vQbFIOMbk2FiG/kXiLl8BRyzTWDw7gX/Hz7Dd5eDMs= +github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4/go.mod h1:hN7oaIRCjzsZ2dE+yG5k+rsdt3qcwykqK6HVGcKwsw4= +github.com/99designs/keyring v1.2.2 h1:pZd3neh/EmUzWONb35LxQfvuY7kiSXAq3HQd97+XBn0= +github.com/99designs/keyring v1.2.2/go.mod h1:wes/FrByc8j7lFOAGLGSNEg8f/PaI3cgTBqhFkHUrPk= +github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= +github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= +github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dvsekhvalnov/jose2go v1.8.0 h1:LqkkVKAlHFfH9LOEl5fe4p/zL02OhWE7pCufMBG2jLA= +github.com/dvsekhvalnov/jose2go v1.8.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB/mPZadG+mhXU= +github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 h1:ZpnhV/YsD2/4cESfV5+Hoeu/iUR3ruzNvZ+yQfO03a0= +github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4= +github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c h1:6rhixN/i8ZofjG1Y75iExal34USq5p+wiN1tpie8IrU= +github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c/go.mod h1:NMPJylDgVpX0MLRlPy15sqSwOFv/U1GZ2m21JhFfek0= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/mtibben/percent v0.2.1 h1:5gssi8Nqo8QU/r2pynCm+hBQHpkB/uNK7BJCFogWdzs= +github.com/mtibben/percent v0.2.1/go.mod h1:KG9uO+SZkUp+VkRHsCdYQV3XSZrrSpR3O9ibNBTZrns= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/urfave/cli-docs/v3 v3.1.0 h1:Sa5xm19IpE5gpm6tZzXdfjdFxn67PnEsE4dpXF7vsKw= github.com/urfave/cli-docs/v3 v3.1.0/go.mod h1:59d+5Hz1h6GSGJ10cvcEkbIe3j233t4XDqI72UIx7to= github.com/urfave/cli/v3 v3.10.1 h1:7Kx9H50hrHbRbyxgO1KP6/BcbiGRz0uYh5YyQ30JEEY= github.com/urfave/cli/v3 v3.10.1/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b h1:QRR6H1YWRnHb4Y/HeNFCTJLFVxaq6wH4YuVdsUOr75U= +gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/commands/auth.go b/internal/commands/auth.go index 2092839..407629c 100644 --- a/internal/commands/auth.go +++ b/internal/commands/auth.go @@ -11,15 +11,32 @@ import ( "github.com/urfave/cli/v3" ) +// insecureStorageFlagName is the auth command group's flag controlling +// whether credentials bypass the system keychain in favor of a plain, +// unencrypted file. +const insecureStorageFlagName = "insecure-storage" + // NewAuthCommand builds the `lfx auth` command group with its subcommands. // -// All subcommands are currently stubs; real implementations land in -// LFXV2-2513 (Auth0 client), LFXV2-2514 (keychain storage), LFXV2-2515 -// (login flow), and LFXV2-2516 (token command). +// The --insecure-storage flag is shared by all subcommands and controls +// whether credentials bypass the system keychain in favor of credstore's +// plain (unencrypted) file fallback, e.g. for headless/CI use. +// +// The login, token, status, and logout actions are currently stubs; real +// implementations land in LFXV2-2515 (login flow) and LFXV2-2516 (token +// command), at which point they'll build a credstore.Store via +// credstore.New(credstore.Options{Insecure: +// cmd.Bool(insecureStorageFlagName)}). func NewAuthCommand() *cli.Command { return &cli.Command{ Name: "auth", Usage: "Manage authentication with the LFX platform", + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: insecureStorageFlagName, + Usage: "Store credentials in a plain (unencrypted) file instead of the system keychain", + }, + }, Commands: []*cli.Command{ newAuthLoginCommand(), newAuthTokenCommand(), diff --git a/internal/credstore/credstore.go b/internal/credstore/credstore.go new file mode 100644 index 0000000..660c015 --- /dev/null +++ b/internal/credstore/credstore.go @@ -0,0 +1,401 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +// Package credstore provides secure storage for LFX CLI credentials and +// non-sensitive device state. +// +// Secrets (refresh token, cached access token and its expiry) are stored in +// the operating system's credential store via github.com/99designs/keyring: +// macOS Keychain, Windows Credential Manager, Linux Secret Service/KWallet, +// or the `pass` password store. keyring's own encrypted-file backend is +// deliberately excluded from this list: it isn't a real system keychain, and +// its Remove behavior doesn't match keyring.ErrKeyNotFound (see +// keyringSecrets.Delete). If none of the allowed backends are available, +// New returns an error rather than silently falling back to a file. +// +// When --insecure-storage is passed explicitly, the system keychain is +// bypassed entirely in favor of a plain (unencrypted), owner-only (0600 on +// POSIX; on Windows, Go's Chmod maps 0600 to the read-only attribute +// instead of a real ACL, so confidentiality there relies on the file's +// inherited directory permissions) JSON file under the state directory. +// This is intended for headless/CI use where no passphrase prompt is +// acceptable, and is deliberately less secure than the keyring-backed +// storage. +// +// Non-sensitive state (device ID, IdP domain used at login) is always stored +// as plain JSON under the XDG state directory (~/.local/state/lfx-cli/ by +// default), per XDG Base Directory conventions for mutable runtime state. +package credstore + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/99designs/keyring" +) + +// ErrNotFound is returned by Load methods when no value has been stored yet. +var ErrNotFound = errors.New("credstore: not found") + +// serviceName identifies this application's entries within shared credential +// stores (e.g. the macOS Keychain "service" attribute). +const serviceName = "lfx-cli" + +// credentialsKey is the keyring item key under which Credentials are stored. +const credentialsKey = "credentials" + +// stateFileName is the name of the plain-JSON file holding non-secret device +// state within the state directory. +const stateFileName = "state.json" + +// insecureCredentialsFileName is the name of the plain (unencrypted) JSON +// file used to store credentials when --insecure-storage bypasses the +// system keychain. +const insecureCredentialsFileName = "credentials.json" + +// systemBackends is the set of keyring backends considered real system +// credential stores. Notably excludes: +// - keyring.FileBackend: it isn't backed by a real system keychain, and +// its Remove behavior on a missing key doesn't match +// keyring.ErrKeyNotFound (see keyringSecrets.Delete). Explicit +// file-based storage is only available via --insecure-storage +// (plainFileSecrets), never as an automatic fallback. +// - keyring.KeyCtlBackend: its opener requires a non-empty Config.KeyCtlScope +// ("user", "session", "process", or "thread"); with the zero value we +// leave it at, Open always fails to open this backend and silently +// skips it, so including it here would be a no-op. +var systemBackends = []keyring.BackendType{ + keyring.SecretServiceBackend, + keyring.KeychainBackend, + keyring.KWalletBackend, + keyring.WinCredBackend, + keyring.PassBackend, +} + +// Credentials holds the secrets needed to authenticate with the LFX +// platform: the long-lived Auth0 refresh token, and an optional cached +// access token with its expiry. +type Credentials struct { + RefreshToken string `json:"refresh_token"` + AccessToken string `json:"access_token,omitempty"` + AccessTokenExpiry time.Time `json:"access_token_expiry,omitempty"` +} + +// ValidAccessToken reports whether the cached access token is present and +// not yet expired, allowing for a small clock-skew buffer. +func (c Credentials) ValidAccessToken() bool { + const skew = 30 * time.Second + return c.AccessToken != "" && time.Now().Add(skew).Before(c.AccessTokenExpiry) +} + +// DeviceState holds non-sensitive information persisted between CLI +// invocations so that commands like `lfx auth token` don't need to +// re-specify the IdP domain used at login. +type DeviceState struct { + DeviceID string `json:"device_id"` + IDPDomain string `json:"idp_domain,omitempty"` +} + +// Store is the credential storage abstraction used by the auth commands. +type Store interface { + // SaveCredentials persists secrets to the system keychain (or, with + // Options.Insecure, the plain-file backend). + SaveCredentials(creds Credentials) error + // LoadCredentials returns the persisted secrets, or ErrNotFound if none + // have been saved. + LoadCredentials() (Credentials, error) + // DeleteCredentials removes any persisted secrets. It is a no-op if + // none exist. + DeleteCredentials() error + + // SaveDeviceState persists non-sensitive device state as plain JSON. + SaveDeviceState(state DeviceState) error + // LoadDeviceState returns the persisted device state, or ErrNotFound if + // none has been saved. + LoadDeviceState() (DeviceState, error) +} + +// Options configures a Store returned by New. +type Options struct { + // Insecure bypasses the system keychain in favor of a plain, owner-only + // (0600; see writeOwnerOnlyFile for the Windows caveat) JSON file. + // Intended for headless/CI use where a passphrase prompt is + // unacceptable. Corresponds to the CLI's --insecure-storage + // flag. + Insecure bool + + // StateDir overrides the computed state directory. Intended for tests; + // leave empty to use $XDG_STATE_HOME/lfx-cli (or ~/.local/state/lfx-cli + // if $XDG_STATE_HOME is unset). + StateDir string +} + +// secretsBackend abstracts over the two ways Credentials can be persisted: +// the system keychain (via keyring.Keyring), or a plain file when insecure +// storage is requested. +type secretsBackend interface { + Save(creds Credentials) error + Load() (Credentials, error) + Delete() error +} + +// store is the default Store implementation, backed by a secretsBackend for +// credentials and a plain JSON file for non-secret device state. +type store struct { + secrets secretsBackend + stateDir string +} + +// New builds a Store using the given Options. +func New(opts Options) (Store, error) { + stateDir, err := resolveStateDir(opts.StateDir) + if err != nil { + return nil, fmt.Errorf("credstore: resolve state dir: %w", err) + } + + var secrets secretsBackend + if opts.Insecure { + secrets = &plainFileSecrets{ + path: filepath.Join(stateDir, insecureCredentialsFileName), + } + } else { + kr, err := keyring.Open(keyring.Config{ + ServiceName: serviceName, + // The pass backend namespaces entries via PassPrefix, not + // ServiceName; without it, our generic credentialsKey would be + // stored as a top-level "credentials" entry in the user's + // password store, risking collisions with unrelated tools. + PassPrefix: serviceName, + AllowedBackends: systemBackends, + KeychainTrustApplication: true, + }) + if err != nil { + return nil, fmt.Errorf("credstore: open keyring (use --insecure-storage as a fallback): %w", err) + } + secrets = &keyringSecrets{keyring: kr} + } + + return &store{secrets: secrets, stateDir: stateDir}, nil +} + +// resolveStateDir determines the directory used for non-secret device state +// and the insecure-storage credentials file, creating it if necessary. +func resolveStateDir(override string) (string, error) { + dir := override + if dir == "" { + // XDG Base Directory Specification requires $XDG_STATE_HOME to be an + // absolute path; per spec, relative values (and thus the variable) + // must be ignored, falling back to the documented default. + if xdgState := os.Getenv("XDG_STATE_HOME"); filepath.IsAbs(xdgState) { + dir = filepath.Join(xdgState, "lfx-cli") + } else { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("determine home directory: %w", err) + } + dir = filepath.Join(home, ".local", "state", "lfx-cli") + } + } + + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", fmt.Errorf("create state directory %q: %w", dir, err) + } + + return dir, nil +} + +// writeOwnerOnlyFile writes data to path in place, creating it with +// owner-only (0600) permissions if it doesn't already exist. path is +// exclusively created and managed by this CLI, so there's no other writer +// to race against and no need for a temp-file-plus-rename swap: that would +// only introduce problems here, since os.Rename isn't guaranteed atomic on +// Windows, and on SELinux systems a renamed-in file can pick up the temp +// file's security context instead of the destination's. The accepted +// tradeoff is that a crash or write error between truncation and the write +// completing can leave the file empty or partially written, requiring the +// user to re-authenticate; for a local single-writer state file, that's a +// simpler and more predictable failure mode than the complexity a +// crash-safe rename would add. +// +// The 0600 mode is requested only at creation time (per os.OpenFile +// semantics); if path already exists with looser permissions (e.g. a user +// deliberately loosened them), this does not tighten them back. It's the +// user's file to manage once it exists. +// +// On Windows, 0600 does not enforce owner-only access: Go maps it to the +// read-only attribute rather than a real ACL, so confidentiality there +// depends on the file's inherited directory permissions. +func writeOwnerOnlyFile(path string, data []byte) (err error) { + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) + if err != nil { + return err + } + defer func() { + if closeErr := f.Close(); err == nil { + err = closeErr + } + }() + + if _, err = f.Write(data); err != nil { + return err + } + + return f.Sync() +} + +// SaveCredentials implements Store. +func (s *store) SaveCredentials(creds Credentials) error { + return s.secrets.Save(creds) +} + +// LoadCredentials implements Store. +func (s *store) LoadCredentials() (Credentials, error) { + return s.secrets.Load() +} + +// DeleteCredentials implements Store. +func (s *store) DeleteCredentials() error { + return s.secrets.Delete() +} + +// SaveDeviceState implements Store. +func (s *store) SaveDeviceState(state DeviceState) error { + data, err := json.MarshalIndent(state, "", " ") + if err != nil { + return fmt.Errorf("credstore: marshal device state: %w", err) + } + + path := filepath.Join(s.stateDir, stateFileName) + if err := writeOwnerOnlyFile(path, data); err != nil { + return fmt.Errorf("credstore: write device state: %w", err) + } + + return nil +} + +// LoadDeviceState implements Store. +func (s *store) LoadDeviceState() (DeviceState, error) { + path := filepath.Join(s.stateDir, stateFileName) + + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return DeviceState{}, ErrNotFound + } + if err != nil { + return DeviceState{}, fmt.Errorf("credstore: read device state: %w", err) + } + + var state DeviceState + if err := json.Unmarshal(data, &state); err != nil { + return DeviceState{}, fmt.Errorf("credstore: unmarshal device state: %w", err) + } + + return state, nil +} + +// keyringSecrets is a secretsBackend that stores Credentials in a real +// system credential store via keyring.Keyring (see systemBackends). +type keyringSecrets struct { + keyring keyring.Keyring +} + +func (k *keyringSecrets) Save(creds Credentials) error { + data, err := json.Marshal(creds) + if err != nil { + return fmt.Errorf("credstore: marshal credentials: %w", err) + } + + err = k.keyring.Set(keyring.Item{ + Key: credentialsKey, + Data: data, + Label: "LFX CLI credentials", + Description: "Refresh and access tokens for the LFX platform", + }) + if err != nil { + return fmt.Errorf("credstore: save credentials: %w", err) + } + + return nil +} + +func (k *keyringSecrets) Load() (Credentials, error) { + item, err := k.keyring.Get(credentialsKey) + if errors.Is(err, keyring.ErrKeyNotFound) { + return Credentials{}, ErrNotFound + } + if err != nil { + return Credentials{}, fmt.Errorf("credstore: load credentials: %w", err) + } + + var creds Credentials + if err := json.Unmarshal(item.Data, &creds); err != nil { + return Credentials{}, fmt.Errorf("credstore: unmarshal credentials: %w", err) + } + + return creds, nil +} + +func (k *keyringSecrets) Delete() error { + err := k.keyring.Remove(credentialsKey) + // Most backends return keyring.ErrKeyNotFound for a missing key, but + // some (e.g. the pass backend, which shells out to files on disk) may + // instead surface an os.ErrNotExist-wrapping error; treat both as a + // successful no-op per the Store.DeleteCredentials contract. + if err != nil && !errors.Is(err, keyring.ErrKeyNotFound) && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("credstore: delete credentials: %w", err) + } + + return nil +} + +// plainFileSecrets is a secretsBackend that stores Credentials as a plain +// (unencrypted), owner-only (0600) JSON file. Used when --insecure-storage +// is passed, bypassing the system keychain entirely so that no passphrase +// prompt is ever required (e.g. for headless/CI use). This is deliberately +// less secure than keyringSecrets. +type plainFileSecrets struct { + path string +} + +func (p *plainFileSecrets) Save(creds Credentials) error { + data, err := json.MarshalIndent(creds, "", " ") + if err != nil { + return fmt.Errorf("credstore: marshal credentials: %w", err) + } + + if err := writeOwnerOnlyFile(p.path, data); err != nil { + return fmt.Errorf("credstore: write credentials: %w", err) + } + + return nil +} + +func (p *plainFileSecrets) Load() (Credentials, error) { + data, err := os.ReadFile(p.path) + if errors.Is(err, os.ErrNotExist) { + return Credentials{}, ErrNotFound + } + if err != nil { + return Credentials{}, fmt.Errorf("credstore: read credentials: %w", err) + } + + var creds Credentials + if err := json.Unmarshal(data, &creds); err != nil { + return Credentials{}, fmt.Errorf("credstore: unmarshal credentials: %w", err) + } + + return creds, nil +} + +func (p *plainFileSecrets) Delete() error { + err := os.Remove(p.path) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("credstore: delete credentials: %w", err) + } + + return nil +}