Skip to content

fix(auth): support importing external_idp credentials and Kiro IDE cache for Azure tenant SSO - #1

Open
0xharryriddle wants to merge 10 commits into
zsecducna:mainfrom
0xharryriddle:feat/import-json-for-azure-tenant-sso
Open

fix(auth): support importing external_idp credentials and Kiro IDE cache for Azure tenant SSO#1
0xharryriddle wants to merge 10 commits into
zsecducna:mainfrom
0xharryriddle:feat/import-json-for-azure-tenant-sso

Conversation

@0xharryriddle

Copy link
Copy Markdown

Summary

This PR builds on top of feat/azure-tenant-sso from PR Quorinex#131 and fixes several follow-up issues around importing Microsoft 365 / Entra ID external_idp credentials into Kiro-Go.

It adds support for:

  • importing CLIProxyAPI_*.json files generated by helper tooling
  • accepting both snake_case and camelCase credential JSON
  • preserving external IdP refresh metadata during import
  • zero-touch Docker import via data/imports/
  • importing directly from the local Kiro IDE AWS SSO cache
  • portable Docker cache mounting for Linux and macOS
  • avoiding Docker port mismatches caused by stale data/config.json

This PR is intended as a support/fix layer for Quorinex#131, not a replacement for it.

Why

PR Quorinex#131 adds the main Microsoft 365 / Entra ID hosted Enterprise SSO flow.

However, real-world usage exposed additional issues:

  1. external_idp helper JSON could not be imported reliably because fields like token_endpoint, issuer_url, scopes, and profile_arn were not preserved through the import path.
  2. Importing credentials by editing config.json manually could be overwritten by the running app's in-memory config.
  3. Docker could publish host port 8080 while the app listened on a stale configured port like 8081, making localhost:8080 unreachable.
  4. The Import from Kiro IDE (this host) path failed inside Docker because the container runs as root and looked for the cache at /root/.aws/sso/cache/kiro-auth-token.json.
  5. A host-user-specific Docker cache mount is not portable across Linux and macOS, so the cache mount now uses a $HOME-based directory mount.

Changes

1. Normalize helper JSON imports

Adds proxy/cli_json_import.go.

The import path now accepts both helper-native snake_case and app-native camelCase fields, including:

token_endpoint / tokenEndpoint
issuer_url / issuerUrl
profile_arn / profileArn
refresh_token / refreshToken
access_token / accessToken
client_id / clientId
auth_method / authMethod

For external_idp imports, the normalizer preserves refresh-critical fields:

tokenEndpoint
issuerUrl
clientId
scopes
profileArn

This fixes the issue where imported Microsoft 365 credentials failed refresh because the IdP token endpoint/client ID were dropped.

2. Shared import core

Refactors credential importing around a shared importOne path.

This ensures all import mechanisms behave consistently:

  • existing /auth/import-credentials
  • new /auth/import-cli-json
  • Docker watcher imports
  • Kiro IDE cache imports

external_idp credentials now go through the same mandatory refresh/persistence path as interactive SSO credentials.

3. New helper JSON import endpoint

Adds:

POST /admin/api/auth/import-cli-json

Supports:

  • one JSON object
  • array of objects
  • { "files": [...] }
  • { "accounts": [...] }
  • concatenated/newline-separated JSON objects

This makes CLIProxyAPI_*.json files directly importable.

4. Admin UI support

Adds UI support in the Enterprise SSO modal for:

  • selecting one or more CLIProxyAPI_*.json files
  • pasting JSON manually
  • importing directly from the Kiro IDE cache on this host

New UI action:

Import from Kiro IDE (this host)

Locale strings added for English and Chinese.

5. Zero-touch Docker import watcher

Adds an optional watcher controlled by:

KIRO_IMPORT_WATCH=1

Docker Compose enables it by default.

The watcher scans:

data/imports/

and imports matching credential files automatically.

Processed files are moved to:

data/imports/processed/
data/imports/failed/

Failed imports get a .error.txt sidecar.

This avoids manual config.json edits and prevents races with the running app's in-memory config.

6. Kiro IDE cache import

Adds:

POST /admin/api/auth/import-ide-cache

This reads the local Kiro IDE AWS SSO cache:

~/.aws/sso/cache/kiro-auth-token.json

The cache is camelCase and maps directly onto the existing credential decoder.

The stale expiresAt in the IDE cache is intentionally ignored; import performs a mandatory refresh and persists the fresh upstream expiry.

7. Portable Docker support for IDE cache import

Docker cannot use the host user's ~ automatically because the app runs as root inside the container.

This PR mounts the host AWS SSO cache directory into the container:

${KIRO_AWS_SSO_CACHE_DIR:-${HOME}/.aws/sso/cache}:/host-aws-sso-cache:ro

and sets:

KIRO_IDE_CACHE=/host-aws-sso-cache/kiro-auth-token.json

This works across:

  • Linux: /home/<user>/.aws/sso/cache
  • macOS: /Users/<user>/.aws/sso/cache

If needed, users can override:

KIRO_AWS_SSO_CACHE_DIR=/custom/path/to/sso/cache docker compose up -d

8. Docker port mismatch fix

Docker Compose now forces the in-container app listen port to match the published mapping:

PORT=8080
HOST=0.0.0.0

This prevents stale data/config.json values like 8081 from making:

http://localhost:8080/admin

unreachable.

The host port is still customizable:

KIRO_PORT=9090 docker compose up -d

9. Configurable binary port/host

Adds runtime overrides:

./kiro-go -port 9090
./kiro-go -host 0.0.0.0 -port 9090
PORT=9090 ./kiro-go
HOST=0.0.0.0 PORT=9090 ./kiro-go

Precedence:

CLI flag > env var > config.json

Security Notes

  • Passwords are never persisted or sent upstream.
  • Email/password input is not used for headless ROPC.
  • M365 / Entra ID tenants commonly enforce MFA / Conditional Access, so refresh relies on the OAuth refresh token minted by the hosted SSO / IDE flow.
  • The Docker IDE cache mount is read-only.
  • The Kiro IDE cache is read server-side only.

Docker Usage

Default:

mkdir -p data
docker compose up -d

Open:

http://localhost:8080/admin

Different host port:

KIRO_PORT=9090 docker compose up -d

Open:

http://localhost:9090/admin

Import helper JSON automatically:

mkdir -p data/imports
cp CLIProxyAPI_*.json data/imports/

Import from Kiro IDE cache:

Admin UI -> Enterprise SSO - Microsoft 365 -> Import from Kiro IDE (this host)

If the AWS SSO cache is in a custom location:

KIRO_AWS_SSO_CACHE_DIR=/custom/path/to/sso/cache docker compose up -d

Verification

Ad-hoc verification was run locally because the live Docker container owns the normal app port.

Verified:

  • go build ./...
  • go vet ./...
  • focused IDE-cache tests:
    • TestReadIdeCacheCredentialExternalIdp
    • TestReadIdeCacheCredentialMissingFile
    • TestReadIdeCacheCredentialNoRefreshToken
    • TestReadIdeCacheCredentialExternalIdpMissingEndpoint
    • TestIdeCachePathPrecedence
  • JSON locale files parse successfully
  • Docker Compose config renders successfully
  • Docker container starts successfully
  • Admin page returns 200 OK
  • Docker container can see the mounted Kiro IDE cache at:
    • /host-aws-sso-cache/kiro-auth-token.json

apiImportCredentials previously dropped the external_idp refresh material
(token_endpoint/issuer_url/scopes/profile_arn) and read camelCase only, so a
CLIProxyAPI_*.json minted by kiro-login-helper.py could never be imported by
API — refreshExternalIdpToken hard-fails without token_endpoint+client_id.

- Add proxy/cli_json_import.go: single normalizer accepting snake_case (helper
  native) and camelCase, auth-method normalization + inference, provider
  defaulting (external_idp -> AzureAD), us-east-1 default.
- Refactor apiImportCredentials to share an importOne core; carry external_idp
  fields into the mandatory refresh and the persisted account, mirroring the
  account apiPollKiroSso writes for an interactive login. profileArn falls back
  to the helper-provided ARN (external_idp refresh returns none by design),
  else resolved lazily on first use.
- Add POST /admin/api/auth/import-cli-json: ingest one/many helper docs (object,
  array, {files|accounts} wrapper, or concatenated text) with per-item results.
- Email stored as label only; password never persisted or sent upstream (D5).
- Tests (compiled, not run): normalizer table tests, external_idp happy path via
  httptest IdP token server, missing-token_endpoint 400, cli-json batch.
- README import section + env vars; CHANGELOG; plan doc.
D3: data/imports/ watcher (KIRO_IMPORT_WATCH, opt-in; on in docker-compose)
auto-ingests CLIProxyAPI_*.json via importOne -> config.AddAccount, moving
handled files to processed/ or failed/ with an .error.txt sidecar; dedupes by
refresh token / email+authMethod.
D4: Enterprise SSO modal file picker + paste box posting to /auth/import-cli-json;
EN/ZH locale strings.
docker-compose: KIRO_IMPORT_WATCH=1 + ./data/imports mount.
… env

Precedence: CLI flag > env > config.json. docker-compose honors KIRO_PORT
for the published host port. Lets the proxy run on a port other than 8080
without editing config.json.
zsecducna pushed a commit that referenced this pull request Jul 14, 2026
The refreshLocks comment claimed the per-account lock 'replaces' the handler-level tokenRefreshMu, but tokenRefreshMu is still active at handler.go:2148 (request-path refresh) and is part of the documented global lock order at account.go:250 (tokenRefreshMu -> p.mu -> refreshLockFor -> cfgLock). Reworded to 'reduces the global contention tokenRefreshMu imposed' + explicit note that tokenRefreshMu still guards the request-path refresh and is intentionally kept (removal = risky concurrency change violating the documented lock order, out of bug-fix scope). Closes Task 1 minor #1. Comment-only.
@zsecducna

Copy link
Copy Markdown
Owner

Reviewed — the feature and its security posture look good (import endpoints are admin-gated, the auto-import watcher is opt-in via KIRO_IMPORT_WATCH and traversal-safe, host/port flags preserve the existing bind, testdata is sanitized). Holding merge on one blocker: 3 of the PR's own watcher tests fail.

--- FAIL: TestImportWatcherProcessesValidFile
--- FAIL: TestImportWatcherMovesInvalidFileToFailed
--- FAIL: TestImportWatcherSkipsDuplicate

Cause: scanImportDir skips files whose mtime is younger than importMinFileAgeSeconds (2s) — a deliberate anti-race guard against importing a half-written file. But the tests write a fixture and call scanImportDir immediately, so the file is ~0s old and always skipped -> 0 imported, nothing moved to processed//failed/.

Fix is test-only (impl is correct): backdate the fixture mtime after writing, e.g.

old := time.Now().Add(-3 * time.Second)
os.Chtimes(path, old, old)

in the test helper, so the file clears the min-age gate deterministically.

(The 2 translator failures on this branch — TestClaudeToolResultMixedTextAndImage, TestOpenAIToolResultImageCarriedWhenFollowedByUser — are pre-existing on main and unrelated to this PR.)

Once the watcher tests are green I'll resolve the conflicts (handler.go, web/app.js, en/zh locales, import_credentials_test.go) and merge into feat/azure-tenant-sso.

ariushieu pushed a commit to ariushieu/Kiro-Go that referenced this pull request Jul 15, 2026
Add DoS guard, per-key limits, Force Model, proxy pool failover + EN/…
@zsecducna

Copy link
Copy Markdown
Owner

@0xharryriddle following up on my earlier review — this is still blocked, and I don't see any new commits since fd06da3 (2026-06-29). Two things need doing before I can merge:

1. The 3 watcher tests still fail

Re-confirmed just now on the current head:

--- FAIL: TestImportWatcherProcessesValidFile      (expected 1 account imported, got 0)
--- FAIL: TestImportWatcherMovesInvalidFileToFailed
--- FAIL: TestImportWatcherSkipsDuplicate

The implementation is correct — this is a test-only issue. scanImportDir skips files younger than importMinFileAgeSeconds (2s) to avoid importing a half-written file (proxy/import_watcher.go:104). But the tests write a fixture and scan immediately, so the file is ~0s old and always skipped → 0 imported, nothing moved to processed//failed/.

Fix is to backdate the fixture mtime after writing it, so it clears the min-age gate deterministically:

old := time.Now().Add(-3 * time.Second)
if err := os.Chtimes(path, old, old); err != nil {
    t.Fatal(err)
}

2. Please retarget the PR base to main

Right now the base is feat/azure-tenant-sso, which is fully merged into main (0 ahead, 62 behind) — merging into it wouldn't reach anything. The PR is also CONFLICTING against that base. Please retarget onto main and resolve the conflicts (they're in proxy/handler.go, web/app.js, web/locales/en.json, web/locales/zh.json, and proxy/import_credentials_test.go).

Once tests are green against main, I'll re-review and merge. The feature and security posture already looked good on my last pass — this is purely the test fix + rebase.

The three watcher tests wrote a fixture and called scanImportDir in the same
instant, so every file was ~0s old and always skipped by the
importMinFileAgeSeconds (2s) "still being written" guard in
proxy/import_watcher.go:104 — 0 accounts imported and nothing moved to
processed/ or failed/.

Fix is test-only; the implementation guard is correct. Add backdateForWatcher()
which os.Chtimes the fixture to now-(importMinFileAgeSeconds+1)s so it clears
the gate deterministically, and call it from writeHelperFile and the malformed
-file fixture.

Fixes TestImportWatcherProcessesValidFile,
TestImportWatcherMovesInvalidFileToFailed, TestImportWatcherSkipsDuplicate.
Retargets this PR onto main and resolves the conflicts in proxy/handler.go,
proxy/import_credentials_test.go, web/app.js and the en/zh locales.

main landed its own external_idp import hardening while this branch was open,
so the two sides overlapped in apiImportCredentials. Resolved as a union rather
than picking a side: main's security posture is kept and moved INTO this
branch's shared importOne funnel, so every import path (the legacy
/auth/credentials endpoint, /auth/import-cli-json, /auth/import-ide-cache and
the data/imports watcher) now inherits it instead of only the one endpoint:

- external_idp tokenEndpoint/issuerUrl are validated against the IdP host
  allow-list before any refresh POST, so an untrusted credential JSON can no
  longer point the refresh at an internal or attacker-controlled host.
- Azure endpoints/scopes are derived from userId or the accessToken JWT issuer,
  so Kiro Account Manager exports and bare blobs import without tokenEndpoint.
- Trust-on-import: an external_idp credential carrying an Azure AD JWT with a
  real exp is persisted verbatim, so re-importing the same JSON does not consume
  (rotate) the refresh token. Everything else keeps refresh-at-import.
- A pasted record's account id is reused when free, else regenerated, so
  re-importing a backup updates instead of duplicating.
- 1 MiB body cap on /auth/credentials retained from main.

importCredentialRequest gains ID/UserID (decoded from id/userId/user_id) to
carry the two new inputs through the normalizer.

Tests: both sides' suites are kept. main's normalizeImportAuthMethod table,
allow-list rejection, derivation and trust-on-import tests all pass, as do this
branch's missing-token_endpoint and cli-json batch tests. The three external_idp
tests that POST at an httptest server now install the no-op validator hook, since
importOne enforces the allow-list on every path and http+127.0.0.1 is rejected
by design.

go build ./... and go vet ./... clean; 246 tests pass. The 2 remaining failures
(TestClaudeToolResultMixedTextAndImage,
TestOpenAIToolResultImageCarriedWhenFollowedByUser) reproduce identically on a
clean origin/main worktree and are unrelated to this PR.
@0xharryriddle
0xharryriddle changed the base branch from feat/azure-tenant-sso to main July 25, 2026 05:19
Retargets this branch onto the PR's real base (zsecducna/Kiro-Go main),
which had advanced 102 commits past the stale fork main.

Conflict resolutions (union, no feature dropped):

proxy/handler.go
- RequestLog: take upstream's struct (adds ApiKeyID).
- apiImportCredentials: keep the body-read + decodeImportRequest front
  end (so snake_case helper JSON still imports), then upstream's
  api_key short-circuit branch, then importOne for the OAuth path.
- Fold main's external_idp hardening INTO importOne rather than leaving
  it inline in the handler, so all four import paths (API, cli-json,
  IDE cache, directory watcher) inherit the IdP endpoint allow-list,
  userId/JWT tenant derivation, trust-on-import, and id preservation.
  Previously only the one handler had them.

proxy/cli_json_import.go
- importCredentialRequest gains ID, UserID and KiroApiKey (+ raw
  decoding for both casings) to feed the merged logic.
- normalizeAuthMethod learns api_key.
- Region default no longer applied to api_key: those accounts are never
  refreshed, so the key is probed for its real region; a us-east-1
  placeholder would be validated as the answer and restore an EU key as
  a permanently-403ing pool slot.
- firstNonEmpty -> firstNonBlank: upstream added its own firstNonEmpty
  in bedrock_eventstream.go with different (non-trimming) semantics.

web/app.js, web/locales/{en,zh}.json
- Both sides were pure additions; kept both (528 keys each, JSON valid).

Full suite: 416 passed. The 2 remaining failures are a pre-existing
t.TempDir cleanup race ("directory not empty") that also reproduces on
pristine upstream/main, landing on a different random test each run.
@0xharryriddle

Copy link
Copy Markdown
Author

@zsecducna Thanks for the precise diagnosis on both points — both are done and pushed.

1. Watcher tests: fixed (test-only, as you called it)

Your read was exactly right: the fixture was ~0s old and always failed the
importMinFileAgeSeconds gate at proxy/import_watcher.go:104. The implementation
was correct and is untouched.

Fixed via a backdateForWatcher helper in import_watcher_test.go that backdates the
fixture after writing it. One small deviation from your snippet: the offset is derived
from the constant rather than hardcoded to 3s —

old := time.Now().Add(-(importMinFileAgeSeconds + 1) * time.Second)

so the tests won't silently start skipping again if that gate is ever retuned.

All three pass: TestImportWatcherProcessesValidFile,
TestImportWatcherMovesInvalidFileToFailed, TestImportWatcherSkipsDuplicate.

2. Retargeted to main — and it needed more than a conflict resolution

Base is now main, and GitHub reports MERGEABLE. The branch is 10 ahead / 0 behind
main.

Worth flagging: my fork's main was 102 commits stale against this repo, so I merged
the real upstream base. That surfaced something more than textual conflicts — main had
independently implemented its own external_idp import path carrying security work my
branch did not have:

  • SSRF allow-listing of tokenEndpoint / issuerUrl
  • Azure tenant derivation from userId or the access-token JWT
  • trust-on-import for pasted Azure AD JWTs
  • a 1MB request body cap

Rather than pick a side, I merged them: that hardening now lives inside the shared
importOne funnel
, so all four import paths (API, cli-json, IDE cache, and the
watcher) inherit the allow-list — previously only the one endpoint on main was gated.
Your newer api_key (ksk_...) import branch is preserved ahead of the OAuth path.

Three things I'd ask you to look at specifically

a) firstNonEmpty collision. main has one that does not trim; mine did. I
renamed mine to firstNonBlank rather than collapsing them, since unifying would have
silently changed trimming behaviour for the bedrock callers.

b) api_key region default — this was a real bug my branch would have introduced.
My normalizer applied a blanket us-east-1 default, which broke
TestApiImportCredentialsApiKeyDiscoversRegion. That default is actively harmful for
api_key accounts: they never re-probe, so a placeholder region gets validated as the
answer and an EU key restores as a permanently-403ing pool slot. The default is now
skipped for api_key and left to the probe.

c) importOne is now the single enforcement point for validation, allow-listing,
tenant derivation, and the refresh-vs-trust-on-import decision. That is the main
semantic change in handler.go and the part most worth a second pair of eyes.

Test status

416 passing. The two translator failures you'd flagged as pre-existing
(TestClaudeToolResultMixedTextAndImage,
TestOpenAIToolResultImageCarriedWhenFollowedByUser) now pass — the fix for them was
among the upstream commits I picked up.

One pre-existing flake you should know about

The full suite intermittently reports 1–2 failures of the form:

TempDir RemoveAll cleanup: unlinkat /tmp/TestXxx.../001: directory not empty

It lands on a different random test each run, and it's a cleanup race rather than an
assertion failure — a background goroutine writes into the temp dir after the test body
returns. I confirmed it reproduces on pristine main in a clean worktree, so it is
unrelated to this PR and I have not touched it. It will make CI flaky though, so it
probably deserves its own issue.

Commits: a3a6a1c (test fix), e3008f3 + f8a8e91 (merges).

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants