Skip to content

feat(sts): GetCallerIdentity + real configure-aws-credentials integration test - #126

Open
alukach wants to merge 3 commits into
mainfrom
worktree-aws-action-integration-test
Open

feat(sts): GetCallerIdentity + real configure-aws-credentials integration test#126
alukach wants to merge 3 commits into
mainfrom
worktree-aws-action-integration-test

Conversation

@alukach

@alukach alukach commented Jul 23, 2026

Copy link
Copy Markdown
Member

What I'm changing

aws-actions/configure-aws-credentials is the flagship consumer of the STS endpoint, but it could never work against the proxy: after AssumeRoleWithWebIdentity it issues an unconditional, SigV4-signed GetCallerIdentity call to validate the credentials before exporting them (no opt-out; 12 retries then the step fails). multistore-sts only parsed AssumeRoleWithWebIdentity, so that call fell through unhandled.

This PR implements GetCallerIdentity (closes #127) and adds an integration test that runs the real action end to end and proves the credentials it exports can write.

How I did it

STS crate — GetCallerIdentity (#127)

  • caller_identity (new module): authenticates the call against the sealed session token — recovers the minted credentials from x-amz-security-token, checks the auth-header access key matches, and verifies the SigV4 signature with the proxy's own verify_sigv4_signature. Payload hash comes from x-amz-content-sha256 when present, else recomputed from the collected form body (AWS SDKs sign STS POSTs over the body hash, often without the header). Verifies over the raw signing path so the trailing slash AWS SDK JS v3 appends (/.sts -> /.sts/) is honored, not normalized.
  • request::is_get_caller_identity detects the action in a query string or form body.
  • responses::build_caller_identity_response emits STS-shaped XML. Account is the fabricated SYNTHETIC_ACCOUNT_ID (000000000000) — the proxy has no real AWS account — used consistently in the assumed-role ARN. build_sts_error_response now maps SignatureDoesNotMatch/ExpiredCredentials to STS-shaped 403s.
  • route_handler: dispatches GetCallerIdentity ahead of the assume-role exchange; with_sts registers both /.sts and /.sts/ (adds a Clone bound) so SDK-JS callers reach the handler.

Integration test — the real action

  • ci.yml: the integration job now runs aws-actions/configure-aws-credentials@v6 (SHA-pinned) with sts-endpoint at the proxy, then aws s3 cp round-trips an object in private-uploads with the exported credentials. The action's own GetCallerIdentity validation must pass for the step to succeed.
  • wrangler.integration.toml: adds a role keyed by the full ARN arn:aws:iam::000000000000:role/github-actions (write scope), since the action sends role-to-assume verbatim as the RoleArn and get_role matches role_id exactly.
  • test_integration.py: adds test_get_caller_identity (botocore signs a real GetCallerIdentity over the assumed creds) and test_get_caller_identity_rejects_static_credentials; rescopes the earlier form-POST test to test_sdk_form_post_assume_role_grants_write_access.

Docs: auth/proxy-auth.md (GetCallerIdentity, synthetic account, trailing-slash rule, configure-aws-credentials workflow example), reference/operations.md, architecture/crate-layout.md.

Test plan

  • cargo test -p multistore-sts (35 pass), cargo fmt, cargo clippy --fix
  • cargo check --all-targets, cargo check -p multistore-cf-workers --target wasm32-unknown-unknown
  • integration CI job: real action assume-role -> GetCallerIdentity validation -> aws s3 cp write, plus the two new OIDC-gated pytest cases. The signed GetCallerIdentity happy-path is verified in CI (botocore + the action), not locally — locally I audited the exact inputs handed to verify_sigv4_signature.

🤖 Generated with Claude Code

PR #112 made /.sts a drop-in AssumeRoleWithWebIdentity target for AWS
SDK STS clients, which send parameters in a form-encoded POST body
rather than the query string. aws-actions/configure-aws-credentials
relies on that path. The integration suite only exercised the
query-string GET, so a regression in form-body parsing would go unnoticed.

- Extract XML->creds parsing into `_parse_sts_credentials`, shared by both
  the existing query GET helper and the new form-POST helper.
- Add `assume_role_form_post`, reproducing the exact wire request the action
  emits (application/x-www-form-urlencoded body via requests `data=`).
- Add `test_configure_aws_credentials_action_grants_write_access`: assume the
  github-actions role that way and prove a PUT/GET round-trip on
  private-uploads succeeds. Runs in the OIDC-gated class (id-token: write CI).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@claude

claude Bot commented Jul 23, 2026

Copy link
Copy Markdown

Claude finished @alukach's task in 1m 37s —— View job


✅ No blocking issues — safe to merge.

Reviewed crates/sts/src/{caller_identity,request,responses,route_handler,lib}.rs, the CI workflow addition, wrangler.integration.toml, and test_integration.py against origin/main.

Notes (non-blocking)

  • crates/sts/src/caller_identity.rs:75-77: the payload hash falls back to re-hashing form_body when x-amz-content-sha256 is absent. This is safe — a forged/mismatched hash just fails signature verification since the real client signed over the true hash — but worth double-checking against a GET request where AWS SDKs sometimes omit the header and send an empty body; the test suite already covers the happy path via test_get_caller_identity, and missing_session_token_is_access_denied/missing_token_key_is_internal_error cover the auth-failure branches, so this is well-exercised.
  • crates/sts/src/responses.rs:109-124: source_identity is interpolated into the Arn/UserId format strings, but since these are struct fields serialized via quick_xml/serde, quick_xml escapes them on output — confirmed this isn't a raw-XML-concat injection risk.
  • route_handler.rs:84-92: trailing-slash dual registration is guarded against matchit's duplicate-route panic when path already ends in /. Correct.
  • Confirmed TokenKey::unseal (pre-existing, sealed_token.rs:99-101) already rejects expired credentials before caller_identity.rs ever builds a response, so there's no separate expiry check needed in the new code.

Simplify (ponytail)

Nothing to flag — hex is an existing workspace dependency newly wired into the sts crate (not a new external dependency), and caller_identity.rs reuses the proxy's existing verify_sigv4_signature/parse_sigv4_auth machinery rather than reimplementing SigV4. No speculative abstractions or dead flexibility found in this diff.


💰 Estimated review cost: $0.77 · 1m36s · 22 turns

@github-actions github-actions Bot added the test label Jul 23, 2026
alukach and others added 2 commits July 22, 2026 17:25
`aws-actions/configure-aws-credentials` (and other AWS tooling) validates
freshly assumed credentials by issuing an unconditional, SigV4-signed
`GetCallerIdentity` call before exporting them. multistore-sts only parsed
`AssumeRoleWithWebIdentity`, so the action could never succeed against the
proxy — it would retry GetCallerIdentity 12 times and then fail the step.
Closes #127.

- **`caller_identity`** (new module) — `handle_get_caller_identity` authenticates
  the call against the sealed session token: it recovers the minted credentials
  from `x-amz-security-token`, checks the auth-header access key matches, and
  verifies the SigV4 signature with the proxy's own `verify_sigv4_signature`
  over the recovered secret. AWS SDKs sign STS POSTs over the SHA-256 of the
  form body, usually without an `x-amz-content-sha256` header, so the payload
  hash is taken from that header when present and recomputed from the collected
  body otherwise. Verification runs over the raw signing path so the trailing
  slash AWS SDK JS v3 appends (`/.sts` -> `/.sts/`) is honored, not normalized.
- **`request::is_get_caller_identity`** — detects the action in a query string or
  form body (the two places AWS SDKs put STS parameters).
- **`responses`** — `build_caller_identity_response` emits STS-shaped
  `GetCallerIdentityResponse` XML. `Account` is the fabricated
  `SYNTHETIC_ACCOUNT_ID` (`000000000000`) — the proxy has no real AWS account —
  used consistently in the assumed-role ARN so the identity is coherent.
  `build_sts_error_response` now maps `SignatureDoesNotMatch`/`ExpiredCredentials`
  to STS-shaped 403s instead of a generic 500.
- **`route_handler`** — `StsHandler` dispatches GetCallerIdentity (authenticated,
  needs the full request) ahead of the unauthenticated assume-role exchange.
  `with_sts` now registers both `/.sts` and `/.sts/` (adding a `Clone` bound on
  the config) so SDK-JS callers, which hit the trailing-slash path, reach the
  handler.
- **docs** — `auth/proxy-auth.md` documents GetCallerIdentity, the synthetic
  account, the trailing-slash rule, and a `configure-aws-credentials` workflow
  example; `reference/operations.md` and `architecture/crate-layout.md` updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Python suite only reproduced the action's wire requests. Now that the
proxy serves GetCallerIdentity (#127), run the actual action end to end and
prove the credentials it exports can write.

- **ci.yml** — after pytest, the `integration` job runs
  `aws-actions/configure-aws-credentials@v6` (SHA-pinned) with `sts-endpoint`
  pointed at the proxy, then `aws s3 cp` uploads/downloads/deletes an object in
  `private-uploads` using the exported credentials. The action's own mandatory
  GetCallerIdentity validation must pass for the step to succeed, so this covers
  the full assume-role -> validate -> use flow.
- **wrangler.integration.toml** — adds a role keyed by the full ARN
  `arn:aws:iam::000000000000:role/github-actions` (write scope on
  private-uploads). The action sends `role-to-assume` verbatim as the STS
  RoleArn and `get_role` matches `role_id` exactly, so the config key must be
  the full ARN; account `000000000000` matches the synthetic GetCallerIdentity
  account.
- **test_integration.py** — adds `test_get_caller_identity` (botocore signs a
  real GetCallerIdentity over the assumed creds; asserts the synthetic account
  and ARN) and `test_get_caller_identity_rejects_static_credentials` (no session
  token -> 403). Rescopes the earlier form-POST test to
  `test_sdk_form_post_assume_role_grants_write_access`, since the headline
  "action works" claim is now proven by the real action in CI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@alukach alukach changed the title test(integration): assert configure-aws-credentials grants write access feat(sts): GetCallerIdentity + real configure-aws-credentials integration test Jul 23, 2026
@github-actions

Copy link
Copy Markdown

📖 Docs preview deployed to https://multistore-docs-pr-126.development-seed.workers.dev

  • Date: 2026-07-23T00:25:37Z
  • Commit: 77915ef

@github-actions github-actions Bot added feat and removed test labels Jul 23, 2026
@github-actions

Copy link
Copy Markdown

🚀 Latest commit deployed to https://multistore-proxy-pr-126.development-seed.workers.dev

  • Date: 2026-07-23T00:25:37Z
  • Commit: 77915ef

@alukach
alukach marked this pull request as ready for review July 27, 2026 19:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

multistore-sts: implement GetCallerIdentity (unblocks aws-actions/configure-aws-credentials)

1 participant