Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .cargo/mutants.toml
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ exclude_re = [
# `a_builder_without_a_transport_is_refused_when_there_is_no_bundled_one`.
'stack-auth/src/error\.rs:\d+:\d+: replace RequestError::is_no_transport -> bool with false$',
'stack-auth/src/auto_strategy\.rs:150:9: replace AutoStrategy::detect_inner -> Result<Self, AuthError> with Ok\(Default::default\(\)\)$',
'stack-auth/src/token_store\.rs:(258|266):9: replace <impl TokenStore for TokenStoreFn<L, S>>::(load|save)',
'stack-auth/src/token_store\.rs:(261|269):9: replace <impl TokenStore for TokenStoreFn<L, S>>::(load|save)',
# stack-auth — equivalent.
'stack-auth/src/(access_key|oidc)_refresher\.rs:\d+:\d+: replace <impl Refresher for \w+(<P>)?>::try_credential -> Option<Self::Credential> with Some\(Default::default\(\)\)$',
'stack-auth/src/error\.rs:\d+:\d+: replace AuthErrorKind::payload -> serde_json::Map<String, serde_json::Value> with Default::default\(\)$',
Expand Down
5 changes: 5 additions & 0 deletions .changeset/cancelled-auth-refresh.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@cipherstash/auth': patch
---

Keep native token refreshes running when their caller is cancelled, so rotated device-session credentials are saved and subsequent requests can still authenticate. Close a refresh-waiter notification race, and give the caller that started a refresh the new token rather than the expiring one it replaced.
37 changes: 37 additions & 0 deletions .github/workflows/_test-auth-refresh-soak.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: Auth refresh cancellation soak

on:
workflow_call:
inputs:
long:
type: boolean
default: false

permissions:
contents: read

jobs:
soak:
runs-on: blacksmith-16vcpu-ubuntu-2204
timeout-minutes: 30
env:
MISE_ENV: test
NEXTEST_PROFILE: ci
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4
with:
version: 2026.4.0
install: true
working_directory: .
cache: true
cache_key_prefix: mise-v1-ubuntu-2204
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
- name: Short seeded soak
if: ${{ !inputs.long }}
run: cargo nextest run --locked -p stack-auth --all-features -E 'test(cancellation_soak_short)' --success-output final
- name: Long seeded soak
if: inputs.long
run: mise run test:soak:stack-auth
30 changes: 29 additions & 1 deletion .github/workflows/release-plz.yml
Original file line number Diff line number Diff line change
Expand Up @@ -225,9 +225,37 @@ jobs:
id: armed
run: node scripts/eql-pipeline-armed.mjs crates

# Gated on the switch so an unarmed line spends nothing on the soak. It
# still runs on every armed push, before release-crates' preflight decides
# whether any crate is unpublished, so a failed soak holds stack-kms and
# stack-encrypt too.
auth-refresh-soak:

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.

Follow-up (non-blocking). This job runs on every push that matches the path filter. It runs before release-crates checks whether a crate version is unpublished. A soak that fails or times out also stops the stack-kms, stack-encrypt and stack-encrypt-derive releases, which do not depend on the refresh engine for publication. Gate the job on needs.crates-armed.outputs.armed == 'true'. Better, move the unpublished-version preflight into its own job, and run the soak only when stack-auth has an unpublished version.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Partly done in b8df909. auth-refresh-soak now needs crates-armed and has the same armed == 'true' condition as release-crates. eql-pipeline-armed.test.mjs lists it as a gated job. I did not move the preflight into its own job. Then release-crates would need !cancelled() or similar to run past a skipped soak, and auth-refresh-soak-ci.test.mjs forbids that on the publish job. So a failed soak still holds stack-kms and stack-encrypt. The commit message says so.

name: Auth refresh cancellation soak before publishing
needs: [crates-armed]
if: needs.crates-armed.outputs.armed == 'true'
runs-on: ubuntu-latest
timeout-minutes: 60
env:
MISE_ENV: test
NEXTEST_PROFILE: ci
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
# Release workflows never restore caches. The PR workflow runs the
# same mise task with caches; only toolchain setup differs here.
- uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4
with:
version: 2026.4.0
install: true
working_directory: .
cache: false
- name: Long seeded soak
run: mise run test:soak:stack-auth

release-crates:
name: "Release the stack-* crates"
needs: [crates-armed]
needs: [crates-armed, auth-refresh-soak]
if: needs.crates-armed.outputs.armed == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
Expand Down
50 changes: 50 additions & 0 deletions .github/workflows/tests-auth-refresh.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
name: Tests (auth refresh cancellation)

# The short run covers every PR, including Version Packages. The longer run
# also covers release PRs and changes to the engine, refreshers or toolchain.
on:
pull_request: {}
workflow_dispatch: {}

permissions:
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
short:
uses: ./.github/workflows/_test-auth-refresh-soak.yml

changes:
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
pull-requests: read
outputs:
refresh: ${{ steps.paths.outputs.refresh }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- uses: dorny/paths-filter@0e4a8c6effa4802afeda77dc8d303f8176d7dfad # v3
id: paths
with:
filters: |
refresh:
- 'packages/stack-auth/**'
- 'Cargo.toml'
- 'Cargo.lock'
- 'mise*.toml'
- '.config/nextest.toml'
- '.github/workflows/*auth-refresh*.yml'
- '.github/workflows/release-plz.yml'

long:
needs: changes
if: github.event_name == 'workflow_dispatch' || github.head_ref == 'changeset-release/main' || needs.changes.outputs.refresh == 'true'
uses: ./.github/workflows/_test-auth-refresh-soak.yml
with:
long: true
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -706,6 +706,11 @@ exact pin would stop the suite sharing one `cts-common` with it.
`mutants.yml`, `fuzz.yml`, `miri.yml` and `udeps.yml`.
`scripts/__tests__/crates-ci.test.mjs` fails when a mise task in the root
`mise.toml` or the five crate `tasks.toml` files loses its last CI caller.
- **Auth refresh cancellation soak:** `tests-auth-refresh.yml` runs a short,
seeded, virtual-time soak on every PR and the long run on auth changes and
Version Packages PRs. `release-plz.yml` also requires the long run before
`release-crates` can publish. Run it locally with `mise run
test:soak:stack-auth`; it needs no credentials or live services.

### Fuzzing

Expand Down
1 change: 1 addition & 0 deletions languages/golang/auth/guest/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions languages/golang/auth/guest/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ zeroize = { version = "1", features = ["derive"] }
[dev-dependencies]
serde_json = "1"
tempfile = "3"
# Native stack-auth refreshes run in a Tokio task, including with a mock transport.
tokio = { version = "1.47.1", default-features = false, features = ["macros", "rt"] }
# `Controlled::risky_ref`, to read a codec value back in the tests.
vitaminc-protected = "0.5.0"

Expand Down
7 changes: 7 additions & 0 deletions languages/golang/auth/guest/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ use std::cell::{Cell, RefCell};
use std::collections::HashMap;

use cts_common::Crn;
// Sound on wasm32 only. There stack-auth runs a token refresh inline, so a
// plain executor can drive `get_token` to completion. On a native target
// stack-auth spawns the refresh onto the current Tokio runtime, and
// `block_on` provides none, so the first refresh panics (and inside a
// current-thread runtime it would block the thread the task needs). `token`
// and `refresh` below are reached only through the wasm32 `abi` exports. A
// native build, a test included, must not call them.
use futures::executor::block_on;
use serde::Deserialize;
use stack_auth::{
Expand Down
7 changes: 3 additions & 4 deletions languages/golang/auth/guest/src/headers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,6 @@ pub fn request_headers(headers: &[(String, String)]) -> Vec<u8> {
mod tests {
use std::sync::{Arc, Mutex};

use futures::executor::block_on;
use stack_auth::{
AccessKeyStrategy, AuthStrategy, HttpRequest, HttpResponse, HttpTransport, RequestError,
};
Expand Down Expand Up @@ -90,8 +89,8 @@ mod tests {
/// `user-agent` is Go's default with a bare nginx 403, before CTS sees
/// it. Every request this guest hands the host must name the library
/// and the host, once, and keep the headers stack-auth built.
#[test]
fn every_request_identifies_itself() {
#[tokio::test]
async fn every_request_identifies_itself() {
let capture = Arc::new(Capture::default());
let strategy = AccessKeyStrategy::builder(
"crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY".parse().unwrap(),
Expand All @@ -101,7 +100,7 @@ mod tests {
.transport(Arc::clone(&capture))
.build()
.unwrap();
let _ = block_on((&strategy).get_token());
let _ = (&strategy).get_token().await;

let seen = capture.0.lock().unwrap();
assert_eq!(seen.len(), 1, "one access-key exchange");
Expand Down
Loading
Loading