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
13 changes: 13 additions & 0 deletions clients/python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,19 @@ token = SecretKey(
client = Client("http://localhost:8888", token=token)
```

### Object URLs

`Session.object_url` returns a GET URL for an object. By default the URL carries
no auth, so the recipient needs their own key to generate a token for it. Pass
`token_validity` to embed a read-only token in the URL instead, valid for the
given duration (requires `SecretKey` authentication - see above section).

```python
from datetime import timedelta

url = session.object_url("my-key", token_validity=timedelta(hours=1))
```

### Pre-signed URLs

A **pre-signed URL** is a time-limited URL that authorizes a single request on
Expand Down
24 changes: 22 additions & 2 deletions clients/python/src/objectstore_client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@
from objectstore_client.multipart import MultipartUpload
from objectstore_client.scope import Scope

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


class GetResponse(NamedTuple):
metadata: Metadata
Expand Down Expand Up @@ -437,15 +440,32 @@ def get(

return GetResponse(metadata, stream)

def object_url(self, key: str) -> str:
def object_url(self, key: str, token_validity: timedelta | None = None) -> str:
"""
Generates a GET url to the object with the given `key`.

This can then be used by downstream services to fetch the given object.
NOTE however that the service does not strictly follow HTTP semantics,
in particular in relation to `Accept-Encoding`.

When ``token_validity`` is provided, read-only authorization
information is embedded in the returned URL's query string, valid
for the given duration.

Raises ``ValueError`` if ``token_validity`` is provided but no
``SecretKey`` is configured on this session.
"""
return self._make_url(key, full=True)
url = self._make_url(key, full=True)
if token_validity is None:
return url

token = self.mint_token(
permissions=[Permission.OBJECT_READ],
expiry_seconds=math.ceil(token_validity.total_seconds()),
)
# A JWT's compact serialization is base64url, whose alphabet is URL-safe,
# so it can go in the query string as-is with no further encoding.
return f"{url}?{PARAM_AUTH}={token}"

def presigned_object_url(
self,
Expand Down
44 changes: 44 additions & 0 deletions clients/python/tests/test_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import tempfile
import time
import urllib.error
import urllib.parse
import urllib.request
from collections.abc import Generator
from datetime import timedelta
Expand Down Expand Up @@ -826,6 +827,49 @@ def test_presigned_get_encoding_corner_cases(server_url: str, key: str) -> None:
assert body == payload


def test_object_url_read_only_token_succeeds(server_url: str) -> None:
session = _presign_session(server_url)
session.put(b"read only hello", key="read-only-url")

url = session.object_url("read-only-url", token_validity=timedelta(minutes=5))

# Parse the URL like a real HTTP client would, so this also covers structural
# breakage such as a duplicate `?` or wrong encoding of the query string.
query = urllib.parse.parse_qs(urllib.parse.urlparse(url).query, strict_parsing=True)
assert list(query) == ["os_auth"]
# The token round-trips verbatim through URL parsing (no encoding applied).
(token,) = query["os_auth"]
assert token.count(".") == 2 # a JWT is `header.payload.signature`

status, body = _fetch(url)
assert status == 200
assert body == b"read only hello"


def test_object_url_without_token_is_unauthorized(server_url: str) -> None:
session = _presign_session(server_url)
session.put(b"needs auth", key="needs-auth")

# No token embedded: the server enforces auth, so a bare GET is rejected.
url = session.object_url("needs-auth")
assert urllib.parse.urlparse(url).query == ""

status, _ = _fetch(url)
assert status == 400


def test_object_url_read_only_token_requires_secret_key(server_url: str) -> None:
# A static token string cannot mint a re-scoped read-only token.
token = TestSecretKey.get().token_for_scope(
"test-usecase", Scope(org=42, project=1337)
)
client = Client(server_url, token=token)
session = client.session(Usecase("test-usecase"), org=42, project=1337)

with pytest.raises(ValueError, match="no secret key"):
session.object_url("whatever", token_validity=timedelta(minutes=5))


def test_put_stores_under_literal_key(server_url: str) -> None:
# A literal "%XX" in a key must be stored verbatim, not decoded: `put` sends
# `looks%2520encoded` on the wire, which the server decodes back to the
Expand Down
5 changes: 5 additions & 0 deletions objectstore-server/docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,11 @@ Tokens must include:
- **Permissions**: array of granted operations (`object.read`, `object.write`,
`object.delete`)

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

### Key Management

The [`PublicKeyDirectory`](auth::PublicKeyDirectory) maps key IDs (`kid`) to
Expand Down
51 changes: 48 additions & 3 deletions objectstore-server/src/extractors/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ use std::time::SystemTime;
use axum::extract::{FromRequestParts, OriginalUri, Query};
use axum::http::{Method, header, request::Parts};
use objectstore_types::presign::PARAM_SIG;
use serde::Deserialize;

use crate::auth::{AuthAwareService, AuthContext, AuthError, PresignParams};
use crate::endpoints::common::ApiError;
Expand All @@ -14,17 +15,39 @@ const BEARER_PREFIX: &str = "Bearer ";
/// `Authorization` header so that proxy setups (e.g. Django) can use
/// `Authorization` for their own auth while forwarding an Objectstore token in
/// this header.
const OBJECTSTORE_AUTH_HEADER: &str = "x-os-auth";
const HEADER_AUTH: &str = "x-os-auth";

/// Query parameters carrying authentication, as an alternative to the
/// `x-os-auth`/`Authorization` header. The header takes precedence when both
/// are present.
#[derive(Debug, Deserialize)]
struct AuthParams {
/// A JWT, mirroring the `x-os-auth` header value (without the `Bearer `
/// prefix). Lets callers embed a token directly in a URL.
os_auth: Option<String>,
}

impl AuthAwareService {
fn from_token(parts: &mut Parts, state: &ServiceState) -> Result<AuthContext, AuthError> {
let token = parts
let header_token = parts
.headers
.get(OBJECTSTORE_AUTH_HEADER)
.get(HEADER_AUTH)
.or_else(|| parts.headers.get(header::AUTHORIZATION))
.and_then(|v| v.to_str().ok())
.and_then(strip_bearer);

let query_token = match header_token {
Some(_) => None,
None => {
Query::<AuthParams>::try_from_uri(&parts.uri)
.map_err(|_| AuthError::BadRequest("invalid query string"))?
.0
.os_auth
}
};

let token = header_token.or(query_token.as_deref());

AuthContext::from_encoded_jwt(token, &state.key_directory)
}

Expand Down Expand Up @@ -144,4 +167,26 @@ mod tests {
assert!(!has_signature(None));
assert!(!has_signature(Some("os_kid=relay")));
}

#[test]
fn test_auth_params_from_query() {
fn parse(query: &str) -> Option<String> {
let uri = format!("http://localhost/?{query}").parse().unwrap();
Query::<AuthParams>::try_from_uri(&uri).unwrap().0.os_auth
}

let jwt = "header.payload.signature";

// Present, and correctly URL-decoded (a proxy may percent-encode `.`).
assert_eq!(parse(&format!("os_auth={jwt}")), Some(jwt.to_owned()));
assert_eq!(parse("os_auth=a%2Eb"), Some("a.b".to_owned()));
assert_eq!(
parse(&format!("foo=bar&os_auth={jwt}")),
Some(jwt.to_owned())
);

// Absent: gracefully `None`, not an error.
assert_eq!(parse(""), None);
assert_eq!(parse("foo=bar"), None);
}
}
107 changes: 107 additions & 0 deletions objectstore-server/tests/query_auth.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
//! End-to-end tests for the `os_auth` query parameter authentication path.
Comment thread
lcian marked this conversation as resolved.
//!
//! A JWT can be supplied either via the `x-os-auth`/`Authorization` header or,
//! as-is, via the `os_auth` query parameter. The header takes precedence when
//! both are present.

use anyhow::Result;
use http::header;
use jsonwebtoken::{Algorithm, EncodingKey, Header, encode, get_current_timestamp};
use objectstore_server::config::{AuthZ, Config};
use objectstore_test::server::{TEST_EDDSA_KID, TEST_EDDSA_PRIVKEY, TestServer};

/// Object path used across the tests: usecase `test`, scope `org=1`, key `query-auth-key`.
const OBJECT_PATH: &str = "/v1/objects/test/org=1/query-auth-key";

async fn test_server() -> TestServer {
TestServer::with_config(Config {
auth: AuthZ {
enforce: true,
..Default::default()
},
..Default::default()
})
.await
}

/// Builds a JWT for `test`/`org=1` with the given permissions.
fn jwt(permissions: &[&str]) -> String {
let mut header = Header::new(Algorithm::EdDSA);
header.kid = Some(TEST_EDDSA_KID.into());

let claims = serde_json::json!({
"exp": get_current_timestamp() + 300,
"res": {"os:usecase": "test", "org": "1"},
"permissions": permissions,
});

let key = EncodingKey::from_ed_pem(TEST_EDDSA_PRIVKEY.as_bytes()).unwrap();
encode(&header, &claims, &key).unwrap()
}

/// Seeds the object at [`OBJECT_PATH`] with the given body via an authorized `PUT`.
async fn seed_object(server: &TestServer, body: &'static str) -> Result<()> {
let resp = reqwest::Client::new()
.put(server.url(OBJECT_PATH))
.header(
header::AUTHORIZATION.as_str(),
format!("Bearer {}", jwt(&["object.read", "object.write"])),
)
.body(body)
.send()
.await?;
assert_eq!(resp.status(), reqwest::StatusCode::OK);
Ok(())
}

#[tokio::test]
async fn query_auth_get_succeeds() -> Result<()> {
let server = test_server().await;
seed_object(&server, "hello").await?;

let token = jwt(&["object.read"]);
let url = format!("{}?os_auth={token}", server.url(OBJECT_PATH));
let resp = reqwest::Client::new().get(url).send().await?;

assert_eq!(resp.status(), reqwest::StatusCode::OK);
assert_eq!(resp.text().await?, "hello");
Ok(())
}

#[tokio::test]
async fn query_auth_tampered_token_is_unauthorized() -> Result<()> {
let server = test_server().await;

// Flip the last character of the JWT signature so verification fails.
let mut token = jwt(&["object.read"]);
let last = token.pop().unwrap();
token.push(if last == 'A' { 'B' } else { 'A' });

let url = format!("{}?os_auth={token}", server.url(OBJECT_PATH));
let resp = reqwest::Client::new().get(url).send().await?;

assert_eq!(resp.status(), reqwest::StatusCode::UNAUTHORIZED);
Ok(())
}

#[tokio::test]
async fn header_takes_precedence_over_query() -> Result<()> {
let server = test_server().await;
seed_object(&server, "hello").await?;

// Valid header token, garbage query token: the header must win, so the
// request succeeds despite the unusable query value.
let url = format!("{}?os_auth=not-a-valid-jwt", server.url(OBJECT_PATH));
let resp = reqwest::Client::new()
.get(url)
.header(
header::AUTHORIZATION.as_str(),
format!("Bearer {}", jwt(&["object.read"])),
)
.send()
.await?;

assert_eq!(resp.status(), reqwest::StatusCode::OK);
assert_eq!(resp.text().await?, "hello");
Ok(())
}
Loading