Skip to content

Commit

Permalink
SDK: Correctly encode auth header, and ser/de Credentials (#68)
Browse files Browse the repository at this point in the history
* SDK: Correctly encode auth header, and ser/de `Credentials`

Prior to this commit, the Rust SDK incorrectly encoded a `Token` passed to `connect`,
making it impossible to re-connect an existing user.
(Shows what I get for never testing that.)
With this commit, auth tokens are correctly base64-encoded
in an `Authorization: Basic` header with the username `token`,
which allows re-connecting as an existing user.

Also, it was difficult to re-use `Credentials` from the Rust SDK, as:
- they were opaque types with non-exported members
- they did not implement any serialization / deserialization
This commit makes `token.string` and `identity.bytes` public fields,
and implements SATN `Serialize` and `Deserialize`
for `Identity`, `Token` and `Credentials`,
allowing clients to save their credentials for re-use, e.g. to a file.

* SDK: Handful of small changes

- `subscribe_owned` accepts `Vec<String>`; behaves like `subscribe`.
  - While developing `letrs`, a demo game, we needed to generate query strings at runtime,
    and passing them to `subscribe` would have involved an unergonomic dance
    of taking references which would then be converted to owned containers
    by the SDK.
    `subscribe_owned` eliminates that, allowing client authors
    to construct a set of queries at runtime and pass it directly to the SDK,
    without intervening referencies and copies.
- Docstrings for `subscribe` and `subscribe_owned`.
- Methods on `Identity` and `Token` for converting to/from strings and byte-vectors.
- Removed long-forgotten `println` within `connect`
  which wrote the computed URI to stdout.
  • Loading branch information
gefjon authored Jul 13, 2023
1 parent bae9c4d commit b543036
Show file tree
Hide file tree
Showing 8 changed files with 148 additions and 14 deletions.
1 change: 1 addition & 0 deletions Cargo.lock

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

1 change: 1 addition & 0 deletions crates/sdk/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,4 @@ log = "0.4"
futures-channel = "0.3"
anymap = "0.12"
im = "15.1"
base64 = "0.21"
8 changes: 5 additions & 3 deletions crates/sdk/src/background_connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -303,11 +303,13 @@ impl BackgroundDbConnection {
}

pub fn subscribe(&self, queries: &[&str]) {
self.subscribe_owned(queries.iter().map(|&s| s.into()).collect());
}

pub fn subscribe_owned(&self, queries: Vec<String>) {
if let Err(e) = self.send_chan.unbounded_send(client_api_messages::Message {
r#type: Some(client_api_messages::message::Type::Subscribe(
client_api_messages::Subscribe {
query_strings: queries.iter().map(|&s| s.into()).collect(),
},
client_api_messages::Subscribe { query_strings: queries },
)),
}) {
// TODO: decide how to handle this error. Panic? Log? Return result? The only
Expand Down
15 changes: 15 additions & 0 deletions crates/sdk/src/callbacks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -852,4 +852,19 @@ impl CredentialStore {
self.credentials = Some(creds);
}
}

/// Return the current connection's `Identity`, if one is stored.
pub(crate) fn identity(&self) -> Option<Identity> {
self.credentials.as_ref().map(|creds| creds.identity.clone())
}

/// Return the current connection's private `Token`, if one is stored.
pub(crate) fn token(&self) -> Option<Token> {
self.credentials.as_ref().map(|creds| creds.token.clone())
}

/// Return the current connection's `Credentials`, if they are stored.
pub(crate) fn credentials(&self) -> Option<Credentials> {
self.credentials.clone()
}
}
18 changes: 15 additions & 3 deletions crates/sdk/src/client_cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,11 @@ impl<T: TableType> TableCache<T> {
let client_api_messages::TableRowOperation { op, row_pk, row } = row_op;
match bsatn::from_slice(&row) {
Err(e) => {
log::error!("Error while deserializing row from TableRowOperation: {:?}", e);
log::error!(
"Error while deserializing row from TableRowOperation: {:?}. Row is {:?}",
e,
row
);
}
Ok(value) => {
if op_is_delete(op) {
Expand Down Expand Up @@ -227,7 +231,11 @@ impl<T: TableType> TableCache<T> {
match diff.remove(&row_pk) {
None => match bsatn::from_slice(&row) {
Err(e) => {
log::error!("Error while deserializing row from `TableRowOperation`: {:?}", e);
log::error!(
"Error while deserializing row from `TableRowOperation`: {:?}. Row is {:?}",
e,
row
);
}
Ok(row) => {
log::info!("Initializing table {:?}: got new row {:?}", T::TABLE_NAME, row);
Expand Down Expand Up @@ -333,7 +341,11 @@ impl<T: TableWithPrimaryKey> TableCache<T> {
) -> Option<DiffEntry<T>> {
match bsatn::from_slice(&row) {
Err(e) => {
log::error!("Error while deserializing row from `TableRowOperation`: {:?}", e);
log::error!(
"Error while deserializing row from `TableRowOperation`: {:?}. Row is {:?}",
e,
row
);
None
}
Ok(row) => {
Expand Down
85 changes: 81 additions & 4 deletions crates/sdk/src/identity.rs
Original file line number Diff line number Diff line change
@@ -1,22 +1,68 @@
use crate::callbacks::CallbackId;
use crate::global_connection::try_with_credential_store;
use anyhow::Result;
use anyhow::{anyhow, Result};
use spacetimedb_lib::de::Deserialize;
use spacetimedb_lib::ser::Serialize;
// TODO: impl ser/de for `Identity`, `Token`, `Credentials` so that clients can stash them
// to disk and use them to re-connect.

#[derive(Clone, Debug, PartialEq, Eq)]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
/// A unique public identifier for a client connected to a database.
pub struct Identity {
pub(crate) bytes: Vec<u8>,
}

#[derive(Clone, Debug, PartialEq, Eq)]
impl Identity {
/// Get a reference to the bytes of this identity.
///
/// This may be useful for saving the bytes to disk in order to reconnect
/// with the same identity, though client authors are encouraged
/// to use the BSATN `Serialize` and `Deserialize` traits
/// rather than saving bytes directly.
///
/// Due to a current limitation in Spacetime's handling of tables which store identities,
/// filter methods for fields defined by the module to have type `Identity`
/// accept bytes, rather than an `Identity` structure.
/// As such, it is necessary to do e.g.
/// `MyTable::filter_by_identity(some_identity.bytes().to_owned())`.
pub fn bytes(&self) -> &[u8] {
&self.bytes
}

/// Construct an `Identity` containing the `bytes`.
///
/// This method does not verify that `bytes` represents a valid identity.
pub fn from_bytes(bytes: Vec<u8>) -> Self {
Identity { bytes }
}
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
/// A private access token for a client connected to a database.
pub struct Token {
pub(crate) string: String,
}

#[derive(Clone, Debug, PartialEq, Eq)]
impl Token {
/// Get a reference to the string representation of this token.
///
/// This may be useful for saving the string to disk in order to reconnect
/// with the same token, though client authors are encouraged
/// to use the BSATN `Serialize` and `Deserialize` traits
/// rather than saving the token string directly.
pub fn string(&self) -> &str {
&self.string
}

/// Construct a token from its string representation.
///
/// This method does not verify that `string` represents a valid token.
pub fn from_string(string: String) -> Self {
Token { string }
}
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
/// Credentials, including a private access token, sufficient to authenticate a client
/// connected to a database.
pub struct Credentials {
Expand Down Expand Up @@ -90,3 +136,34 @@ pub fn once_on_connect(callback: impl FnOnce(&Credentials) + Send + 'static) ->
pub fn remove_on_connect(id: ConnectCallbackId) -> Result<()> {
try_with_credential_store(|cred_store| cred_store.unregister_on_connect(id.id))
}

/// Read the current connection's public `Identity`.
///
/// Returns an error if:
/// - `connect` has not yet been called.
/// - We connected anonymously, and we have not yet received our credentials.
pub fn identity() -> Result<Identity> {
try_with_credential_store(|cred_store| cred_store.identity().ok_or(anyhow!("Identity not yet received")))
.and_then(|inner| inner)
}

/// Read the current connection's private `Token`.
///
/// Returns an error if:
/// - `connect` has not yet been called.
/// - We connected anonymously, and we have not yet received our credentials.
pub fn token() -> Result<Token> {
try_with_credential_store(|cred_store| cred_store.token().ok_or(anyhow!("Token not yet received")))
.and_then(|inner| inner)
}

/// Read the current connection's `Credentials`,
/// including a public `Identity` and a private `Token`.
///
/// Returns an error if:
/// - `connect` has not yet been called.
/// - We connected anonymously, and we have not yet received our credentials.
pub fn credentials() -> Result<Credentials> {
try_with_credential_store(|cred_store| cred_store.credentials().ok_or(anyhow!("Credentials not yet received")))
.and_then(|inner| inner)
}
24 changes: 24 additions & 0 deletions crates/sdk/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,30 @@ pub use http;
#[doc(hidden)]
pub use spacetimedb_sats as sats;

/// Subscribe to a set of queries,
/// to be notified when rows which match those queries are altered.
///
/// The `queries` should be a slice of strings representing SQL queries.
///
/// A new call to `subscribe` (or [`subscribe_owned`]) will remove all previous subscriptions
/// and replace them with the new `queries`.
/// If any rows matched the previous subscribed queries but do not match the new queries,
/// those rows will be removed from the client cache,
/// and `TableType::on_delete` callbacks will be invoked for them.
pub fn subscribe(queries: &[&str]) -> anyhow::Result<()> {
try_with_connection(|conn| conn.subscribe(queries))
}

/// Subscribe to a set of queries,
/// to be notified when rows which match those queries are altered.
///
/// The `queries` should be a `Vec` of `String`s representing SQL queries.
///
/// A new call to `subscribe_owned` (or [`subscribe`]) will remove all previous subscriptions
/// and replace them with the new `queries`.
/// If any rows matched the previous subscribed queries but do not match the new queries,
/// those rows will be removed from the client cache,
/// and `TableType::on_delete` callbacks will be invoked for them.
pub fn subscribe_owned(queries: Vec<String>) -> anyhow::Result<()> {
try_with_connection(|conn| conn.subscribe_owned(queries))
}
10 changes: 6 additions & 4 deletions crates/sdk/src/websocket.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,6 @@ where
<Host as TryInto<Uri>>::Error: std::error::Error + Send + Sync + 'static,
{
let uri = make_uri(host, db_name)?;
println!("Uri: {:?}", uri);
let mut req = IntoClientRequest::into_client_request(uri)?;
request_insert_protocol_header(&mut req);
request_insert_auth_header(&mut req, credentials);
Expand Down Expand Up @@ -94,12 +93,15 @@ const AUTH_HEADER_KEY: &str = "Authorization";
fn request_insert_auth_header(req: &mut http::Request<()>, credentials: Option<&Credentials>) {
// TODO: figure out how the token is supposed to be encoded in the request
if let Some(Credentials { token, .. }) = credentials {
use base64::Engine;

let auth_bytes = format!("token:{}", token.string);
let encoded = base64::prelude::BASE64_STANDARD.encode(auth_bytes);
let auth_header_val = format!("Basic {}", encoded);
request_add_header(
req,
AUTH_HEADER_KEY,
token
.string
.clone()
auth_header_val
.try_into()
.expect("Failed to convert token to http HeaderValue"),
)
Expand Down

0 comments on commit b543036

Please sign in to comment.