Skip to content
Merged
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.toml
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ tracing = { version = "0.1.40", default-features = false, features = ["std"] }

[dev-dependencies]
axum = { version = "0.8.4", default-features = false, features = ["tokio", "http1", "json"] }
tokio = { version = "1.38", features = ["macros", "rt-multi-thread", "net"] }
tokio = { version = "1.38", features = ["macros", "rt-multi-thread", "net", "test-util"] }
# The fake auth API in the tests signs RS256 with a PEM test key.
jsonwebtoken = { version = "11.1.0", default-features = false, features = ["aws_lc_rs", "use_pem"] }
tower = { version = "0.5", default-features = false, features = ["util"] }
Expand Down
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,27 @@ carry any value; only `bearer` selects bearer transport. `POST /refresh`, `DELET
header, but they still get the cross-site check. Routes behind `require_auth` get the cross-site
check only.

### Refresh

In cookie transport the auth routes refresh an expired access cookie from the refresh cookie on
their own, and a browser can also call `POST /auth/refresh`. Parallel requests presenting one
refresh token share a single rotation, and a request presenting the same refresh cookie within 5
seconds of a successful rotation gets that rotated session, so several tabs with one expired
session all succeed. A bearer client presenting a spent refresh token always reaches the auth API.

Only a successful rotation is shared for those 5 seconds, and only a refresh the auth API refuses
ends a browser session. Both the silent refresh and `POST /auth/refresh` clear the session cookies
only when the auth API answers the refresh with 401 (the silent refresh answers 401
`Refresh failed`, `POST /auth/refresh` answers 401 `invalid_refresh_token`) or when the refresh
cookie cannot be read. Any other failure keeps the cookies, so the next request tries again:

| The refresh | The answer |
| --- | --- |
| does not reach the auth API | 502 `upstream_unavailable` |
| gets another non-2xx answer (a 503 during maintenance, a 429) | that status, body and `Retry-After` |
| gets a 2xx other than 200, a 200 with no session, or a session that fails verification | 502 `upstream_unavailable` |
| does not settle within 30 seconds, or its task panics | 502 `upstream_unavailable` |

### Errors and logout

An error from the auth API goes back with its status, its JSON body and its `Retry-After` header,
Expand Down
58 changes: 47 additions & 11 deletions src/refresh.rs
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,22 @@ impl Refresher {

pub(crate) enum SilentRefreshError {
NoRefreshCookie,
Failed(String),
/// The auth API refused the refresh token with 401, or the refresh cookie
/// cannot be read. The session is over, so its cookies are cleared.
Rejected(String),
/// The refresh failed without saying the session is over: the auth API was
/// unreachable or answered another error, or the rotation did not settle on a
/// session the adapter can vouch for. The answer to send, with the cookies
/// kept so the next request tries again.
Transient(Box<Reply>),
}

fn unavailable(err: &str) -> SilentRefreshError {
tracing::debug!("[seamless-auth] Refresh failed: {err}");
SilentRefreshError::Transient(Box::new(Reply::error(
StatusCode::BAD_GATEWAY,
"upstream_unavailable",
)))
}

impl Inner {
Expand All @@ -200,7 +215,9 @@ impl Inner {
.map(|p| str_claim(p, "refreshToken"))
.unwrap_or("");
if refresh_token.is_empty() {
return Err(SilentRefreshError::Failed("invalid refresh cookie".into()));
return Err(SilentRefreshError::Rejected(
"invalid refresh cookie".into(),
));
}
let subject = payload.as_ref().map(|p| str_claim(p, "sub")).unwrap_or("");
self.refresh_session(
Expand All @@ -226,9 +243,10 @@ impl Inner {
let mut extra = Claims::new();
extra.insert("refreshToken".into(), Value::from(refresh_token));
let Some(service) = self.tokens.sign(subject, extra) else {
return Err(SilentRefreshError::Failed(
"could not sign the service token".into(),
));
return Err(SilentRefreshError::Transient(Box::new(Reply::error(
StatusCode::INTERNAL_SERVER_ERROR,
"internal_error",
))));
};

let inner = self.clone();
Expand All @@ -253,21 +271,38 @@ impl Inner {

let session = match outcome {
RefreshOutcome::Session(session) => session,
RefreshOutcome::Rejected { status, .. } => {
return Err(SilentRefreshError::Failed(format!(
RefreshOutcome::Rejected { status, .. } if status == StatusCode::UNAUTHORIZED => {
return Err(SilentRefreshError::Rejected("refresh answered 401".into()));
}
RefreshOutcome::Rejected { status, .. } if status.is_success() => {
return Err(unavailable(&format!(
"refresh answered {}",
status.as_u16()
)));
}
RefreshOutcome::Failed(err) => return Err(SilentRefreshError::Failed(err)),
RefreshOutcome::Rejected {
status,
body,
is_json,
retry_after,
} => {
tracing::debug!(
"[seamless-auth] Refresh failed: refresh answered {}",
status.as_u16()
);
return Err(SilentRefreshError::Transient(Box::new(
failure(status, body, is_json).with_retry_after(retry_after),
)));
}
RefreshOutcome::Failed(err) => return Err(unavailable(&err)),
};

let session_id = self
.verify_session(&session, "access")
.await
.map_err(SilentRefreshError::Failed)?;
.map_err(|err| unavailable(&err))?;
let cookies = session_cookies(&self.config, &session, &session_id, true)
.map_err(SilentRefreshError::Failed)?;
.map_err(|err| unavailable(&err))?;
Ok((session, cookies))
}

Expand Down Expand Up @@ -356,13 +391,14 @@ impl Inner {
Err(SilentRefreshError::NoRefreshCookie) => {
Reply::error(StatusCode::UNAUTHORIZED, "refresh token required")
}
Err(SilentRefreshError::Failed(err)) => {
Err(SilentRefreshError::Rejected(err)) => {
tracing::debug!("[seamless-auth] Refresh failed: {err}");
Reply::error(StatusCode::UNAUTHORIZED, "invalid_refresh_token").with_clear(vec![
self.config.access_cookie_name.clone(),
self.config.refresh_cookie_name.clone(),
])
}
Err(SilentRefreshError::Transient(reply)) => *reply,
}
}

Expand Down
11 changes: 9 additions & 2 deletions src/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,8 @@ impl Inner {
});
}
Err(SilentRefreshError::NoRefreshCookie) => {}
Err(SilentRefreshError::Failed(err)) => {
Err(SilentRefreshError::Transient(reply)) => return Err(reply),
Err(SilentRefreshError::Rejected(err)) => {
tracing::debug!("[seamless-auth] Silent refresh failed: {err}");
return Err(Box::new(
Reply::error(StatusCode::UNAUTHORIZED, "Refresh failed").with_clear(vec![
Expand Down Expand Up @@ -460,9 +461,15 @@ impl Inner {
reply.set.extend(cookies);
}
Err(SilentRefreshError::NoRefreshCookie) => {}
Err(SilentRefreshError::Failed(err)) => {
Err(SilentRefreshError::Rejected(err)) => {
tracing::debug!("[seamless-auth] Refresh after recovery enrollment failed: {err}");
}
Err(SilentRefreshError::Transient(reply)) => {
tracing::debug!(
"[seamless-auth] Refresh after recovery enrollment failed: {}",
reply.status.as_u16()
);
}
}
}
}
Expand Down
Loading
Loading