Skip to content

refactor(auth): secure non-OAuth2 admin credentials with keyring-first flow and runtime JWT key - #1161

Open
nbmaiti wants to merge 2 commits into
mainfrom
jwt_auth_keystore
Open

refactor(auth): secure non-OAuth2 admin credentials with keyring-first flow and runtime JWT key#1161
nbmaiti wants to merge 2 commits into
mainfrom
jwt_auth_keystore

Conversation

@nbmaiti

@nbmaiti nbmaiti commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR implements secure credential handling for non-OAuth2 Console deployments, aligned with issue #1067.

It centralizes admin secret handling, prioritizes keyring-backed storage, removes default JWT key placeholders from config templates, and ensures runtime JWT key generation when no explicit key is configured.

Related Issue

What Changed

Admin credential flow (non-OAuth2)

  • Centralized admin credential logic into a dedicated secret-store module.
  • Added keyring-first credential resolution with fallback order:
    1. keyring
    2. .env
    3. environment variables
    4. config.yml
    5. interactive prompt
  • Added admin CLI workflows:
    • --add-admin
    • --remove-admin
    • --show-admin (username only; password is never displayed)
  • On successful keyring save, admin credentials are scrubbed from config.yml.
  • If keyring save fails after interactive input, user confirmation is required before persisting fallback values to config.

Password security

  • Admin passwords are normalized to bcrypt hashes before persistence.
  • Login path now verifies credentials using bcrypt hash comparison (no plaintext equality check).

JWT key behavior

  • Removed default placeholder JWT key values from templates.
  • AUTH_JWT_KEY is no longer treated as required.
  • If no JWT key is configured, Console generates a runtime-only key in memory at startup.

Files Changed

  • .env.example
  • cmd/app/main.go
  • cmd/app/main_test.go
  • cmd/app/secret_store.go
  • config/config.go
  • config/config.yml
  • config/config_test.go
  • internal/controller/httpapi/v1/login.go
  • internal/controller/httpapi/v1/login_test.go

Validation

Executed:

  • go test ./config
  • go test ./cmd/app ./internal/controller/httpapi/v1

All passed.

Notes

  • No infrastructure/docker-compose changes are included in this PR.
  • Remote secret store (for example Vault-backed admin/JWT secret retrieval) remains

@nbmaiti
nbmaiti requested a review from Copilot July 27, 2026 15:58
@nbmaiti
nbmaiti requested a review from a team as a code owner July 27, 2026 15:58
@codecov

codecov Bot commented Jul 27, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 49.04215% with 133 lines in your changes missing coverage. Please review.
✅ Project coverage is 50.13%. Comparing base (7ccfa79) to head (d27d09d).

Files with missing lines Patch % Lines
cmd/app/secret_store.go 51.61% 88 Missing and 17 partials ⚠️
config/config.go 33.33% 21 Missing and 3 partials ⚠️
cmd/app/main.go 33.33% 2 Missing and 2 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1161      +/-   ##
==========================================
- Coverage   50.16%   50.13%   -0.04%     
==========================================
  Files         147      148       +1     
  Lines       13574    13830     +256     
==========================================
+ Hits         6810     6933     +123     
- Misses       6172     6283     +111     
- Partials      592      614      +22     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@nbmaiti
nbmaiti force-pushed the jwt_auth_keystore branch from a827972 to d29099e Compare July 27, 2026 16:02

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR strengthens non-OAuth2 authentication by moving admin credential handling into a keyring-first flow, hashing admin passwords with bcrypt, and ensuring the JWT signing key is generated at runtime when not explicitly configured.

Changes:

  • Introduces a centralized admin secret-store flow (keyring → .env → env vars → config.yml → prompt) plus CLI helpers for managing stored admin credentials.
  • Switches basic-auth verification from plaintext comparison to bcrypt hash verification.
  • Removes placeholder JWT keys from templates and generates an in-memory JWT key at startup when none is configured.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
.env.example Removes placeholder auth secrets and documents keystore-first resolution.
cmd/app/main.go Wires admin CLI handling and replaces legacy admin password setup with the new credential flow.
cmd/app/main_test.go Adds unit tests for the new secret-store resolution, CLI commands, and hashing behavior.
cmd/app/secret_store.go Implements keyring-first admin credential resolution, interactive prompting, and CLI workflows.
config/config.go Makes AUTH_JWT_KEY optional and generates a runtime JWT key when missing; adds helpers to clear/persist admin creds.
config/config.yml Removes default admin/JWT placeholders and adds guidance comments for secure configuration.
config/config_test.go Adds tests around runtime JWT key behavior.
internal/controller/httpapi/v1/login.go Uses bcrypt hash comparison for basic-auth login verification.
internal/controller/httpapi/v1/login_test.go Updates tests to use bcrypt-hashed admin password.
Comments suppressed due to low confidence (3)

internal/controller/httpapi/v1/login.go:83

  • The || short-circuit means bcrypt is only evaluated when the username matches, which makes username enumeration possible via response-time differences (bcrypt is much slower than a simple string compare). Compute the password check first so the handler always does the bcrypt work regardless of username match.
	if creds.Username != lr.Config.AdminUsername || bcrypt.CompareHashAndPassword([]byte(lr.Config.AdminPassword), []byte(creds.Password)) != nil {
		c.JSON(http.StatusUnauthorized, gin.H{errorKey: "invalid credentials", messageKey: "Incorrect Username and/or Password!"})

		return
	}

cmd/app/secret_store.go:174

  • If only one of the two keystore writes succeeds, the keystore can end up in a partial state that later causes confusing credential resolution (e.g., username from keyring + password from another source). Consider a best-effort rollback when either write fails.
	usernameSaveErr := keyringStore.SetKeyValue(keyringAdminUsername, cfg.AdminUsername)
	passwordSaveErr := keyringStore.SetKeyValue(keyringAdminPassword, cfg.AdminPassword)

	if usernameSaveErr == nil && passwordSaveErr == nil {
		// Credentials safely in keystore; scrub any plaintext from config.yml.
		if err := config.ClearAdminCredentials(); err != nil {
			log.Printf("Warning: failed to clear admin credentials from config.yml: %v", err)
		} else {
			log.Print("Admin credentials cleared from config.yml (stored in keystore).")
		}

		return
	}

cmd/app/main_test.go:183

  • This test uses t.Setenv, which mutates process-global environment variables and should not run in parallel. Add an explicit //nolint:paralleltest (as used elsewhere in this repo) to avoid paralleltest linter failures.
func TestResolveAdminCredentialsFromSources_FallbackToDotEnvThenConfig(t *testing.T) {
	t.Setenv(authAdminUsernameEnv, "")
	t.Setenv(authAdminPasswordEnv, "")

Comment thread cmd/app/secret_store.go Outdated
Comment thread cmd/app/secret_store.go Outdated
Comment thread cmd/app/secret_store.go
Comment thread config/config_test.go
Comment thread cmd/app/main_test.go
@nbmaiti
nbmaiti force-pushed the jwt_auth_keystore branch 3 times, most recently from 4ee2bf3 to f914325 Compare July 28, 2026 02:59
@nbmaiti
nbmaiti requested a review from Copilot July 28, 2026 06:10
@nbmaiti nbmaiti self-assigned this Jul 28, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 11 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (4)

cmd/app/main_test.go:164

  • This test uses t.Setenv but doesn't call t.Parallel(). With paralleltest enabled, new tests are expected to either call t.Parallel() or explicitly opt out. Since environment mutation isn't safe to run in parallel, add a //nolint:paralleltest on the test declaration (per CLAUDE.md:245).
func TestResolveAdminCredentialsFromSources_PriorityOrder(t *testing.T) {
	t.Setenv(authAdminUsernameEnv, "env-user")
	t.Setenv(authAdminSecretEnv, "env-pass")

cmd/app/main_test.go:183

  • This test uses t.Setenv but doesn't call t.Parallel(). With paralleltest enabled, new tests should either call t.Parallel() or explicitly opt out. Since environment mutation isn't safe to run in parallel, add a //nolint:paralleltest on the test declaration (per CLAUDE.md:245).
func TestResolveAdminCredentialsFromSources_FallbackToDotEnvThenConfig(t *testing.T) {
	t.Setenv(authAdminUsernameEnv, "")
	t.Setenv(authAdminSecretEnv, "")

cmd/app/secret_store.go:178

  • handleAdminCredentials runs even when OAuth2 is configured (auth.clientId set). In that mode Console doesn't use basic-auth admin credentials, but this function can still prompt/exit on EOF in non-interactive environments, breaking OAuth2 deployments unnecessarily. Skip admin credential resolution when OAuth2 is enabled (same condition used in login.go: ClientID != "").
func handleAdminCredentials(cfg *config.Config) {
	if cfg.Disabled {
		log.Print("Auth is disabled; skipping admin credential resolution.")

		return

go.mod:74

  • go.mod marks golang.org/x/term as indirect, but it's imported directly (cmd/app/secret_store.go). This usually indicates go.mod wasn't tidied; leaving it indirect can cause churn on the next go mod tidy run.
	golang.org/x/term v0.45.0 // indirect

Comment thread config/config.go
Comment on lines +338 to +342
var configPathFlag string
if f := flag.Lookup("config"); f != nil {
configPathFlag = f.Value.String()
}

@nbmaiti nbmaiti added the bug Something isn't working label Jul 28, 2026
@nbmaiti nbmaiti moved this to In Progress in Sprint Planning Jul 29, 2026
@nbmaiti nbmaiti moved this to Q3 2025 in Backlog and Roadmap Jul 29, 2026
@nbmaiti nbmaiti removed the status in Sprint Planning Jul 29, 2026
@nbmaiti nbmaiti moved this to In Progress in Sprint Planning Jul 29, 2026
@nbmaiti
nbmaiti force-pushed the jwt_auth_keystore branch from f914325 to f108807 Compare July 29, 2026 03:33
@nbmaiti
nbmaiti force-pushed the jwt_auth_keystore branch from f108807 to fa6e135 Compare August 13, 2026 09:29
@sudhir-intc
sudhir-intc requested a lite review from Copilot August 14, 2026 10:48

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 11 changed files in this pull request and generated no new comments.

Suppressed comments (4)

cmd/app/main_test.go:164

  • This test uses t.Setenv and intentionally does not call t.Parallel(), but this file otherwise follows the repo’s paralleltest convention (tests either call t.Parallel() or add //nolint:paralleltest). Add an explicit //nolint:paralleltest to avoid lint failures for a missing t.Parallel() call.
func TestResolveAdminCredentialsFromSources_PriorityOrder(t *testing.T) {
	t.Setenv(authAdminUsernameEnv, "env-user")
	t.Setenv(authAdminSecretEnv, "env-pass")

cmd/app/main_test.go:183

  • Same as above: this test uses t.Setenv and does not call t.Parallel(). Add //nolint:paralleltest so the paralleltest linter doesn’t report a missing t.Parallel() in this file.
func TestResolveAdminCredentialsFromSources_FallbackToDotEnvThenConfig(t *testing.T) {
	t.Setenv(authAdminUsernameEnv, "")
	t.Setenv(authAdminSecretEnv, "")

cmd/app/secret_store.go:86

  • The PR description lists an --add-admin workflow, but the CLI parsing here only recognizes --remove-admin and --show-admin (and the error message also references only those). This is a discrepancy: either implement --add-admin (e.g., prompt for username/password, store to keyring, clear config.yml, then exit) or update the PR description/docs to match the actual supported commands.
func selectedAdminCommand(args []string) string {
	selected := 0
	command := ""

	if hasArg(args, "--remove-admin") {
		selected++
		command = "remove"
	}

	if hasArg(args, "--show-admin") {
		selected++
		command = "show"
	}

cmd/app/main.go:79

  • main() now uses handleAdminCredentials, but the old handleAdminPassword flow (random password + SaveAdminPassword to config.yml) still exists in this file and is only referenced by tests. Keeping two admin-credential mechanisms around (one of which persists plaintext) is confusing and risks accidentally reintroducing the insecure path later. Consider removing handleAdminPassword and its tests (or making it call into the new secret-store logic).
	l := logger.New(cfg.Level)

	handleEncryptionKey(cfg)
	handleAdminCredentials(cfg)

@sudhir-intc

sudhir-intc commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

I am yet to review the code, but did give a try with your PR and when I run console with the following I see console is hung, this could be due to keyring being locked or some other issue on my setup.

APP_DISABLE_CIRA=false LOG_LEVEL=debug HTTP_PORT=8181 HTTP_HOST=10.190.213.14 AUTH_ADMIN_USERNAME=admin AUTH_ADMIN_PASSWORD= APP_ENCRYPTION_KEY= ./console_linux_x64
2026/08/14 16:24:21 migrate: environment variable not declared: DB_URL -- using embedded database
2026/08/14 16:24:21 DB path : /home/ledgepark/.config/device-management-toolkit/console.db
2026/08/14 16:24:21 Root certificate loaded from local files
2026/08/14 16:24:21 Web server certificate loaded from local files
2026/08/14 16:24:21 Encryption key loaded from environment

So you bring some initial check on keyring accessibility with some time-out and exit with the message. Something like this

2026/08/14 16:44:26 migrate: environment variable not declared: DB_URL -- using embedded database
2026/08/14 16:44:26 DB path : /home/ledgepark/.config/device-management-toolkit/console.db
2026/08/14 16:44:26 Root certificate loaded from local files
2026/08/14 16:44:26 Web server certificate loaded from local files
2026/08/14 16:44:26 Encryption key loaded from environment
2026/08/14 16:44:28 Warning: keyring access timed out after 2s; keyring seems to be locked or unavaialble.

@sudhir-intc

Copy link
Copy Markdown
Contributor

./console_linux_x64 --help does not show the options of --show-admin, --remove-admin

@nbmaiti
nbmaiti force-pushed the jwt_auth_keystore branch from fa6e135 to 81bdcd3 Compare August 17, 2026 03:45
@sudhir-intc sudhir-intc changed the title fix(auth): secure non-OAuth2 admin credentials with keyring-first flow and runtime JWT key refactor(auth): secure non-OAuth2 admin credentials with keyring-first flow and runtime JWT key Aug 17, 2026

@divneil-intel divneil-intel left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe the credential and JWT workflow needs further discussion before merging.

My concerns are:

  • config.yml is user configured, but the code rewrites it to remove the admin username and password. In my opinion, the code should open config.yml in read-only mode.
  • bcrypt hash is stored in the OS keyring, instead could be stored in a protected file. The container based deployment would work seamlessly with it.
  • .env is also parsed with this PR, making it's usage at-par with config.yml, thus duplicating the purpose of it
  • It looks inconsistent to have --show-admin and --remove-admin without implementing --add-admin
  • For AUTH_JWT_KEY I believe we should move away from generating keys if not provided as it should be user owned.

Since, it looks a new user flow is getting added, so, I propose the below categorization of phases which could be expanded further:

Provisioning

  • Admin enrollment
  • JWT provisioning
  • and so on

Runtime

  • Admin login: the credentials are never persisted in clear. bcrypt hash is used to compare the incoming credentials.
  • The runtime always uses the data but never creates it, this makes the separation of duties apparent and flow more cleaner.

@nbmaiti

nbmaiti commented Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

I believe the credential and JWT workflow needs further discussion before merging.

My concerns are:

  • config.yml is user configured, but the code rewrites it to remove the admin username and password. In my opinion, the code should open config.yml in read-only mode.
  • bcrypt hash is stored in the OS keyring, instead could be stored in a protected file. The container based deployment would work seamlessly with it.
  • .env is also parsed with this PR, making it's usage at-par with config.yml, thus duplicating the purpose of it
  • It looks inconsistent to have --show-admin and --remove-admin without implementing --add-admin
  • For AUTH_JWT_KEY I believe we should move away from generating keys if not provided as it should be user owned.

Since, it looks a new user flow is getting added, so, I propose the below categorization of phases which could be expanded further:

Provisioning

  • Admin enrollment
  • JWT provisioning
  • and so on

Runtime

  • Admin login: the credentials are never persisted in clear. bcrypt hash is used to compare the incoming credentials.
  • The runtime always uses the data but never creates it, this makes the separation of duties apparent and flow more cleaner.

Please look into secret creds ADR , please provide feedback if you have

Migrate the admin username/password out of config.yml and env vars
into the OS keyring on startup, with .env and config.yml as fallback
sources, per the centralize-secret-storage ADR (standalone mode).

Signed-off-by: Nabendu Maiti <nabendu.bikash.maiti@intel.com>
Hash admin passwords with bcrypt before they are written to the
keystore, and compare bcrypt hashes on login instead of plaintext.

Signed-off-by: Nabendu Maiti <nabendu.bikash.maiti@intel.com>
@nbmaiti
nbmaiti force-pushed the jwt_auth_keystore branch from 81bdcd3 to d27d09d Compare August 18, 2026 06:03

@rsdmike rsdmike left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On the right track. Don't think it makes sense to have any user management cli flags, those need to be removed. that opens a door we currently dont have open right now. If they need to rotate the credential they can do so via their appropriate OS credential store or their deployment secret provider, they shouldnt want or need console to do it and we dont want to be in that workflow.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

Status: Q3 2025
Status: In Progress

Development

Successfully merging this pull request may close these issues.

Provide secure credential storage for non-OAuth2 Console deployments

5 participants