Skip to content

Commit b74a53a

Browse files
lcianclaude
andauthored
feat: Read JWT from query params (#556)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 358d747 commit b74a53a

6 files changed

Lines changed: 239 additions & 5 deletions

File tree

clients/python/README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,19 @@ token = SecretKey(
184184
client = Client("http://localhost:8888", token=token)
185185
```
186186

187+
### Object URLs
188+
189+
`Session.object_url` returns a GET URL for an object. By default the URL carries
190+
no auth, so the recipient needs their own key to generate a token for it. Pass
191+
`token_validity` to embed a read-only token in the URL instead, valid for the
192+
given duration (requires `SecretKey` authentication - see above section).
193+
194+
```python
195+
from datetime import timedelta
196+
197+
url = session.object_url("my-key", token_validity=timedelta(hours=1))
198+
```
199+
187200
### Pre-signed URLs
188201

189202
> **Experimental:** pre-signed URLs are an experimental feature and this API may

clients/python/src/objectstore_client/client.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@
3434
from objectstore_client.multipart import MultipartUpload
3535
from objectstore_client.scope import Scope
3636

37+
# Query parameter carrying a JWT, mirroring the `x-os-auth` header.
38+
PARAM_AUTH = "os_auth"
39+
3740

3841
class GetResponse(NamedTuple):
3942
metadata: Metadata
@@ -437,15 +440,32 @@ def get(
437440

438441
return GetResponse(metadata, stream)
439442

440-
def object_url(self, key: str) -> str:
443+
def object_url(self, key: str, token_validity: timedelta | None = None) -> str:
441444
"""
442445
Generates a GET url to the object with the given `key`.
443446
444447
This can then be used by downstream services to fetch the given object.
445448
NOTE however that the service does not strictly follow HTTP semantics,
446449
in particular in relation to `Accept-Encoding`.
450+
451+
When ``token_validity`` is provided, read-only authorization
452+
information is embedded in the returned URL's query string, valid
453+
for the given duration.
454+
455+
Raises ``ValueError`` if ``token_validity`` is provided but no
456+
``SecretKey`` is configured on this session.
447457
"""
448-
return self._make_url(key, full=True)
458+
url = self._make_url(key, full=True)
459+
if token_validity is None:
460+
return url
461+
462+
token = self.mint_token(
463+
permissions=[Permission.OBJECT_READ],
464+
expiry_seconds=math.ceil(token_validity.total_seconds()),
465+
)
466+
# A JWT's compact serialization is base64url, whose alphabet is URL-safe,
467+
# so it can go in the query string as-is with no further encoding.
468+
return f"{url}?{PARAM_AUTH}={token}"
449469

450470
def presigned_object_url(
451471
self,

clients/python/tests/test_e2e.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import tempfile
77
import time
88
import urllib.error
9+
import urllib.parse
910
import urllib.request
1011
from collections.abc import Generator
1112
from datetime import timedelta
@@ -826,6 +827,49 @@ def test_presigned_get_encoding_corner_cases(server_url: str, key: str) -> None:
826827
assert body == payload
827828

828829

830+
def test_object_url_read_only_token_succeeds(server_url: str) -> None:
831+
session = _presign_session(server_url)
832+
session.put(b"read only hello", key="read-only-url")
833+
834+
url = session.object_url("read-only-url", token_validity=timedelta(minutes=5))
835+
836+
# Parse the URL like a real HTTP client would, so this also covers structural
837+
# breakage such as a duplicate `?` or wrong encoding of the query string.
838+
query = urllib.parse.parse_qs(urllib.parse.urlparse(url).query, strict_parsing=True)
839+
assert list(query) == ["os_auth"]
840+
# The token round-trips verbatim through URL parsing (no encoding applied).
841+
(token,) = query["os_auth"]
842+
assert token.count(".") == 2 # a JWT is `header.payload.signature`
843+
844+
status, body = _fetch(url)
845+
assert status == 200
846+
assert body == b"read only hello"
847+
848+
849+
def test_object_url_without_token_is_unauthorized(server_url: str) -> None:
850+
session = _presign_session(server_url)
851+
session.put(b"needs auth", key="needs-auth")
852+
853+
# No token embedded: the server enforces auth, so a bare GET is rejected.
854+
url = session.object_url("needs-auth")
855+
assert urllib.parse.urlparse(url).query == ""
856+
857+
status, _ = _fetch(url)
858+
assert status == 400
859+
860+
861+
def test_object_url_read_only_token_requires_secret_key(server_url: str) -> None:
862+
# A static token string cannot mint a re-scoped read-only token.
863+
token = TestSecretKey.get().token_for_scope(
864+
"test-usecase", Scope(org=42, project=1337)
865+
)
866+
client = Client(server_url, token=token)
867+
session = client.session(Usecase("test-usecase"), org=42, project=1337)
868+
869+
with pytest.raises(ValueError, match="no secret key"):
870+
session.object_url("whatever", token_validity=timedelta(minutes=5))
871+
872+
829873
def test_put_stores_under_literal_key(server_url: str) -> None:
830874
# A literal "%XX" in a key must be stored verbatim, not decoded: `put` sends
831875
# `looks%2520encoded` on the wire, which the server decodes back to the

objectstore-server/docs/architecture.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,11 @@ Tokens must include:
9393
- **Permissions**: array of granted operations (`object.read`, `object.write`,
9494
`object.delete`)
9595

96+
The token is supplied in the `x-os-auth` header (falling back to the standard
97+
`Authorization` header), optionally prefixed with `Bearer `. It may also be
98+
supplied as an `os_auth` query parameter, which lets callers embed a token
99+
directly in a URL. The header takes precedence when both are present.
100+
96101
### Key Management
97102

98103
The [`PublicKeyDirectory`](auth::PublicKeyDirectory) maps key IDs (`kid`) to

objectstore-server/src/extractors/service.rs

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ use std::time::SystemTime;
33
use axum::extract::{FromRequestParts, OriginalUri, Query};
44
use axum::http::{Method, header, request::Parts};
55
use objectstore_types::presign::PARAM_SIG;
6+
use serde::Deserialize;
67

78
use crate::auth::{AuthAwareService, AuthContext, AuthError, PresignParams};
89
use crate::endpoints::common::ApiError;
@@ -14,17 +15,39 @@ const BEARER_PREFIX: &str = "Bearer ";
1415
/// `Authorization` header so that proxy setups (e.g. Django) can use
1516
/// `Authorization` for their own auth while forwarding an Objectstore token in
1617
/// this header.
17-
const OBJECTSTORE_AUTH_HEADER: &str = "x-os-auth";
18+
const HEADER_AUTH: &str = "x-os-auth";
19+
20+
/// Query parameters carrying authentication, as an alternative to the
21+
/// `x-os-auth`/`Authorization` header. The header takes precedence when both
22+
/// are present.
23+
#[derive(Debug, Deserialize)]
24+
struct AuthParams {
25+
/// A JWT, mirroring the `x-os-auth` header value (without the `Bearer `
26+
/// prefix). Lets callers embed a token directly in a URL.
27+
os_auth: Option<String>,
28+
}
1829

1930
impl AuthAwareService {
2031
fn from_token(parts: &mut Parts, state: &ServiceState) -> Result<AuthContext, AuthError> {
21-
let token = parts
32+
let header_token = parts
2233
.headers
23-
.get(OBJECTSTORE_AUTH_HEADER)
34+
.get(HEADER_AUTH)
2435
.or_else(|| parts.headers.get(header::AUTHORIZATION))
2536
.and_then(|v| v.to_str().ok())
2637
.and_then(strip_bearer);
2738

39+
let query_token = match header_token {
40+
Some(_) => None,
41+
None => {
42+
Query::<AuthParams>::try_from_uri(&parts.uri)
43+
.map_err(|_| AuthError::BadRequest("invalid query string"))?
44+
.0
45+
.os_auth
46+
}
47+
};
48+
49+
let token = header_token.or(query_token.as_deref());
50+
2851
AuthContext::from_encoded_jwt(token, &state.key_directory)
2952
}
3053

@@ -144,4 +167,26 @@ mod tests {
144167
assert!(!has_signature(None));
145168
assert!(!has_signature(Some("os_kid=relay")));
146169
}
170+
171+
#[test]
172+
fn test_auth_params_from_query() {
173+
fn parse(query: &str) -> Option<String> {
174+
let uri = format!("http://localhost/?{query}").parse().unwrap();
175+
Query::<AuthParams>::try_from_uri(&uri).unwrap().0.os_auth
176+
}
177+
178+
let jwt = "header.payload.signature";
179+
180+
// Present, and correctly URL-decoded (a proxy may percent-encode `.`).
181+
assert_eq!(parse(&format!("os_auth={jwt}")), Some(jwt.to_owned()));
182+
assert_eq!(parse("os_auth=a%2Eb"), Some("a.b".to_owned()));
183+
assert_eq!(
184+
parse(&format!("foo=bar&os_auth={jwt}")),
185+
Some(jwt.to_owned())
186+
);
187+
188+
// Absent: gracefully `None`, not an error.
189+
assert_eq!(parse(""), None);
190+
assert_eq!(parse("foo=bar"), None);
191+
}
147192
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
//! End-to-end tests for the `os_auth` query parameter authentication path.
2+
//!
3+
//! A JWT can be supplied either via the `x-os-auth`/`Authorization` header or,
4+
//! as-is, via the `os_auth` query parameter. The header takes precedence when
5+
//! both are present.
6+
7+
use anyhow::Result;
8+
use http::header;
9+
use jsonwebtoken::{Algorithm, EncodingKey, Header, encode, get_current_timestamp};
10+
use objectstore_server::config::{AuthZ, Config};
11+
use objectstore_test::server::{TEST_EDDSA_KID, TEST_EDDSA_PRIVKEY, TestServer};
12+
13+
/// Object path used across the tests: usecase `test`, scope `org=1`, key `query-auth-key`.
14+
const OBJECT_PATH: &str = "/v1/objects/test/org=1/query-auth-key";
15+
16+
async fn test_server() -> TestServer {
17+
TestServer::with_config(Config {
18+
auth: AuthZ {
19+
enforce: true,
20+
..Default::default()
21+
},
22+
..Default::default()
23+
})
24+
.await
25+
}
26+
27+
/// Builds a JWT for `test`/`org=1` with the given permissions.
28+
fn jwt(permissions: &[&str]) -> String {
29+
let mut header = Header::new(Algorithm::EdDSA);
30+
header.kid = Some(TEST_EDDSA_KID.into());
31+
32+
let claims = serde_json::json!({
33+
"exp": get_current_timestamp() + 300,
34+
"res": {"os:usecase": "test", "org": "1"},
35+
"permissions": permissions,
36+
});
37+
38+
let key = EncodingKey::from_ed_pem(TEST_EDDSA_PRIVKEY.as_bytes()).unwrap();
39+
encode(&header, &claims, &key).unwrap()
40+
}
41+
42+
/// Seeds the object at [`OBJECT_PATH`] with the given body via an authorized `PUT`.
43+
async fn seed_object(server: &TestServer, body: &'static str) -> Result<()> {
44+
let resp = reqwest::Client::new()
45+
.put(server.url(OBJECT_PATH))
46+
.header(
47+
header::AUTHORIZATION.as_str(),
48+
format!("Bearer {}", jwt(&["object.read", "object.write"])),
49+
)
50+
.body(body)
51+
.send()
52+
.await?;
53+
assert_eq!(resp.status(), reqwest::StatusCode::OK);
54+
Ok(())
55+
}
56+
57+
#[tokio::test]
58+
async fn query_auth_get_succeeds() -> Result<()> {
59+
let server = test_server().await;
60+
seed_object(&server, "hello").await?;
61+
62+
let token = jwt(&["object.read"]);
63+
let url = format!("{}?os_auth={token}", server.url(OBJECT_PATH));
64+
let resp = reqwest::Client::new().get(url).send().await?;
65+
66+
assert_eq!(resp.status(), reqwest::StatusCode::OK);
67+
assert_eq!(resp.text().await?, "hello");
68+
Ok(())
69+
}
70+
71+
#[tokio::test]
72+
async fn query_auth_tampered_token_is_unauthorized() -> Result<()> {
73+
let server = test_server().await;
74+
75+
// Flip the last character of the JWT signature so verification fails.
76+
let mut token = jwt(&["object.read"]);
77+
let last = token.pop().unwrap();
78+
token.push(if last == 'A' { 'B' } else { 'A' });
79+
80+
let url = format!("{}?os_auth={token}", server.url(OBJECT_PATH));
81+
let resp = reqwest::Client::new().get(url).send().await?;
82+
83+
assert_eq!(resp.status(), reqwest::StatusCode::UNAUTHORIZED);
84+
Ok(())
85+
}
86+
87+
#[tokio::test]
88+
async fn header_takes_precedence_over_query() -> Result<()> {
89+
let server = test_server().await;
90+
seed_object(&server, "hello").await?;
91+
92+
// Valid header token, garbage query token: the header must win, so the
93+
// request succeeds despite the unusable query value.
94+
let url = format!("{}?os_auth=not-a-valid-jwt", server.url(OBJECT_PATH));
95+
let resp = reqwest::Client::new()
96+
.get(url)
97+
.header(
98+
header::AUTHORIZATION.as_str(),
99+
format!("Bearer {}", jwt(&["object.read"])),
100+
)
101+
.send()
102+
.await?;
103+
104+
assert_eq!(resp.status(), reqwest::StatusCode::OK);
105+
assert_eq!(resp.text().await?, "hello");
106+
Ok(())
107+
}

0 commit comments

Comments
 (0)