Skip to content

Commit 99ffdbe

Browse files
authored
Auth for Jupyter Notebooks (#11855)
### Related Related to https://linear.app/rerun/issue/RR-2861/authentication-in-jupyter-notebooks ### What Implements authentication in Jupyter notebooks. - By running ``rr.login()`` the user can log in. - Uses `re_auth` module, starts a web server in the background to listen to events. - Automatically passes credentials to the wasm viewer.
1 parent b482f67 commit 99ffdbe

File tree

20 files changed

+513
-125
lines changed

20 files changed

+513
-125
lines changed

crates/utils/re_auth/src/cli.rs

Lines changed: 30 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@ use std::time::Duration;
22

33
use indicatif::ProgressBar;
44

5+
use crate::OauthLoginFlow;
56
pub use crate::callback_server::Error;
6-
use crate::callback_server::OauthCallbackServer;
7-
use crate::oauth::api::{AuthenticateWithCode, Pkce, send_async};
8-
use crate::oauth::{self, Credentials};
7+
use crate::oauth;
8+
use crate::oauth::login_flow::OauthLoginFlowState;
99

1010
pub struct LoginOptions {
1111
pub open_browser: bool,
@@ -42,92 +42,47 @@ pub async fn token() -> Result<(), Error> {
4242
/// This first checks if valid credentials already exist locally,
4343
/// and doesn't perform the login flow if so, unless `options.force_login` is set to `true`.
4444
pub async fn login(options: LoginOptions) -> Result<(), Error> {
45-
let mut login_hint = None;
46-
if !options.force_login {
47-
// NOTE: If the loading fails for whatever reason, we debug log the error
48-
// and have the user login again as if nothing happened.
49-
match oauth::load_credentials() {
50-
Ok(Some(credentials)) => {
51-
login_hint = Some(credentials.user().email.clone());
52-
match oauth::refresh_credentials(credentials).await {
53-
Ok(credentials) => {
54-
println!("You're already logged in as: {}", credentials.user().email);
55-
println!("Note: We've refreshed your credentials.");
56-
println!("Note: Run `rerun auth login --force` to login again.");
57-
return Ok(());
58-
}
59-
Err(err) => {
60-
re_log::debug!("refreshing credentials failed: {err}");
61-
// Credentials are bad, login again.
62-
// fallthrough
63-
}
64-
}
65-
}
66-
67-
Ok(None) => {
68-
// No credentials yet, login as usual.
69-
// fallthrough
70-
}
71-
72-
Err(err) => {
73-
re_log::debug!(
74-
"validating credentials failed, logging user in again anyway. reason: {err}"
75-
);
76-
// fallthrough
77-
}
78-
}
79-
}
80-
81-
let p = ProgressBar::new_spinner();
82-
8345
// Login process:
8446

8547
// 1. Start web server listening for token
86-
let pkce = Pkce::new();
87-
let server = OauthCallbackServer::new(&pkce, login_hint.as_deref())?;
88-
p.inc(1);
48+
let login_flow = match OauthLoginFlow::init(options.force_login).await? {
49+
OauthLoginFlowState::AlreadyLoggedIn(credentials) => {
50+
println!("You're already logged in as: {}", credentials.user().email);
51+
println!("Note: We've refreshed your credentials.");
52+
println!("Note: Run `rerun auth login --force` to login again.");
53+
return Ok(());
54+
}
55+
OauthLoginFlowState::LoginFlowStarted(login_flow) => login_flow,
56+
};
8957

90-
// 2. Open authorization URL in browser
91-
let login_url = server.get_login_url();
58+
let progress_bar = ProgressBar::new_spinner();
9259

93-
// Once the user opens the link, they are redirected to the login UI.
94-
// If they were already logged in, it will immediately redirect them
95-
// to the login callback with an authorization code.
96-
// That code is then sent by our callback page back to the web server here.
60+
// 2. Open authorization URL in browser
61+
let login_url = login_flow.get_login_url();
9762
if options.open_browser {
98-
p.println("Opening login page in your browser.");
99-
p.println("Once you've logged in, the process will continue here.");
100-
p.println(format!(
63+
progress_bar.println("Opening login page in your browser.");
64+
progress_bar.println("Once you've logged in, the process will continue here.");
65+
progress_bar.println(format!(
10166
"Alternatively, manually open this url: {login_url}"
10267
));
10368
webbrowser::open(login_url).ok(); // Ok to ignore error here. The user can just open the above url themselves.
10469
} else {
105-
p.println("Open the following page in your browser:");
106-
p.println(login_url);
70+
progress_bar.println("Open the following page in your browser:");
71+
progress_bar.println(login_url);
10772
}
108-
p.inc(1);
109-
110-
// 3. Wait for callback
111-
p.set_message("Waiting for browser…");
112-
let code = loop {
113-
match server.check_for_browser_response()? {
114-
None => {
115-
p.inc(1);
116-
std::thread::sleep(Duration::from_millis(10));
117-
}
118-
Some(response) => break response,
73+
progress_bar.inc(1);
74+
75+
// 3. Wait for login to finish
76+
progress_bar.set_message("Waiting for browser…");
77+
let credentials = loop {
78+
if let Some(code) = login_flow.poll().await? {
79+
break code;
11980
}
81+
progress_bar.inc(1);
82+
std::thread::sleep(Duration::from_millis(10));
12083
};
12184

122-
// 4. Exchange code for credentials
123-
let auth = send_async(AuthenticateWithCode::new(&code, &pkce))
124-
.await
125-
.map_err(|err| Error::Generic(err.into()))?;
126-
127-
// 5. Store credentials
128-
let credentials = Credentials::from_auth_response(auth.into())?.ensure_stored()?;
129-
130-
p.finish_and_clear();
85+
progress_bar.finish_and_clear();
13186

13287
println!(
13388
"Success! You are now logged in as {}",

crates/utils/re_auth/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
1010
#[cfg(not(target_arch = "wasm32"))]
1111
mod error;
12+
1213
#[cfg(not(target_arch = "wasm32"))]
1314
mod provider;
1415

@@ -31,6 +32,8 @@ pub use token::{Jwt, TokenError};
3132

3233
#[cfg(not(target_arch = "wasm32"))]
3334
pub use error::Error;
35+
#[cfg(all(feature = "oauth", not(target_arch = "wasm32")))]
36+
pub use oauth::login_flow::OauthLoginFlow;
3437
#[cfg(not(target_arch = "wasm32"))]
3538
pub use provider::{Claims, RedapProvider, SecretKey, VerificationOptions};
3639
#[cfg(not(target_arch = "wasm32"))]

crates/utils/re_auth/src/oauth.rs

Lines changed: 50 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ use crate::Jwt;
55
pub mod api;
66
mod storage;
77

8+
#[cfg(not(target_arch = "wasm32"))]
9+
pub mod login_flow;
10+
811
/// Tokens with fewer than this number of seconds left before expiration
912
/// are considered expired. This ensures tokens don't become expired
1013
/// during network transit.
@@ -53,6 +56,9 @@ pub enum CredentialsRefreshError {
5356

5457
#[error("failed to deserialize credentials: {0}")]
5558
MalformedToken(#[from] MalformedTokenError),
59+
60+
#[error("no refresh token available")]
61+
NoRefreshToken,
5662
}
5763

5864
/// Refresh credentials if they are expired.
@@ -73,7 +79,11 @@ pub async fn refresh_credentials(
7379
-credentials.access_token().remaining_duration_secs()
7480
);
7581

76-
let response = api::refresh(&credentials.refresh_token).await?;
82+
let Some(refresh_token) = &credentials.refresh_token else {
83+
return Err(CredentialsRefreshError::NoRefreshToken);
84+
};
85+
86+
let response = api::refresh(refresh_token).await?;
7787
let credentials = Credentials::from_auth_response(response)?
7888
.ensure_stored()
7989
.map_err(|err| CredentialsRefreshError::Store(err.0))?;
@@ -181,7 +191,12 @@ pub enum VerifyError {
181191
#[derive(Debug, serde::Serialize, serde::Deserialize)]
182192
pub struct Credentials {
183193
user: User,
184-
refresh_token: RefreshToken,
194+
195+
// Refresh token is optional because it may not be available in some cases,
196+
// like the Jupyter notebook Wasm viewer. In that case, the SDK handles
197+
// token refreshes.
198+
refresh_token: Option<RefreshToken>,
199+
185200
access_token: AccessToken,
186201
}
187202

@@ -209,14 +224,42 @@ impl Credentials {
209224
pub fn from_auth_response(
210225
res: api::RefreshResponse,
211226
) -> Result<InMemoryCredentials, MalformedTokenError> {
212-
let access_token = AccessToken::unverified(Jwt(res.access_token))?;
227+
let access_token = AccessToken::try_from_unverified_jwt(Jwt(res.access_token))?;
213228
Ok(InMemoryCredentials(Self {
214229
user: res.user,
215-
refresh_token: RefreshToken(res.refresh_token),
230+
refresh_token: Some(RefreshToken(res.refresh_token)),
216231
access_token,
217232
}))
218233
}
219234

235+
/// Creates credentials from raw token strings.
236+
///
237+
/// Warning: it does not check the signature of the access token.
238+
pub fn try_new(
239+
access_token: String,
240+
refresh_token: Option<String>,
241+
email: String,
242+
) -> Result<InMemoryCredentials, MalformedTokenError> {
243+
// TODO(aedm): check signature of the JWT token
244+
let claims = Jwt(access_token.clone()).unverified_claims()?;
245+
246+
let user = User {
247+
id: claims.sub,
248+
email,
249+
};
250+
let access_token = AccessToken {
251+
token: access_token,
252+
expires_at: claims.exp,
253+
};
254+
let refresh_token = refresh_token.map(RefreshToken);
255+
256+
Ok(InMemoryCredentials(Self {
257+
user,
258+
access_token,
259+
refresh_token,
260+
}))
261+
}
262+
220263
pub fn access_token(&self) -> &AccessToken {
221264
&self.access_token
222265
}
@@ -266,25 +309,11 @@ impl AccessToken {
266309
/// Construct an [`AccessToken`] without verifying it.
267310
///
268311
/// The token should come from a trusted source, like the Rerun auth API.
269-
pub(crate) fn unverified(jwt: Jwt) -> Result<Self, MalformedTokenError> {
270-
use base64::prelude::*;
271-
272-
let (_header, rest) = jwt
273-
.as_str()
274-
.split_once('.')
275-
.ok_or(MalformedTokenError::MissingHeaderPayloadSeparator)?;
276-
let (payload, _signature) = rest
277-
.split_once('.')
278-
.ok_or(MalformedTokenError::MissingPayloadSignatureSeparator)?;
279-
let payload = BASE64_URL_SAFE_NO_PAD
280-
.decode(payload)
281-
.map_err(MalformedTokenError::Base64)?;
282-
let payload: RerunCloudClaims =
283-
serde_json::from_slice(&payload).map_err(MalformedTokenError::Serde)?;
284-
312+
pub(crate) fn try_from_unverified_jwt(jwt: Jwt) -> Result<Self, MalformedTokenError> {
313+
let claims = jwt.unverified_claims()?;
285314
Ok(Self {
286315
token: jwt.0,
287-
expires_at: payload.exp,
316+
expires_at: claims.exp,
288317
})
289318
}
290319
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
use crate::callback_server::Error;
2+
use crate::callback_server::OauthCallbackServer;
3+
use crate::oauth;
4+
use crate::oauth::Credentials;
5+
use crate::oauth::api::AuthenticateWithCode;
6+
use crate::oauth::api::Pkce;
7+
use crate::oauth::api::send_async;
8+
9+
pub enum OauthLoginFlowState {
10+
AlreadyLoggedIn(Credentials),
11+
LoginFlowStarted(OauthLoginFlow),
12+
}
13+
14+
pub struct OauthLoginFlow {
15+
pub server: OauthCallbackServer,
16+
pub login_hint: Option<String>,
17+
pkce: Pkce,
18+
}
19+
20+
impl OauthLoginFlow {
21+
/// Login to Rerun using Authorization Code flow.
22+
///
23+
/// This first checks if valid credentials already exist locally,
24+
/// and doesn't perform the login flow if so, unless `force_login` is set to `true`.
25+
pub async fn init(force_login: bool) -> Result<OauthLoginFlowState, Error> {
26+
let mut login_hint = None;
27+
if !force_login {
28+
// NOTE: If the loading fails for whatever reason, we debug log the error
29+
// and have the user login again as if nothing happened.
30+
match oauth::load_credentials() {
31+
Ok(Some(credentials)) => {
32+
login_hint = Some(credentials.user().email.clone());
33+
match oauth::refresh_credentials(credentials).await {
34+
Ok(credentials) => {
35+
return Ok(OauthLoginFlowState::AlreadyLoggedIn(credentials));
36+
}
37+
Err(err) => {
38+
// Credentials are bad, login again.
39+
re_log::debug!("refreshing credentials failed: {err}");
40+
}
41+
}
42+
}
43+
44+
Ok(None) => {
45+
// No credentials yet, login as usual.
46+
}
47+
48+
Err(err) => {
49+
re_log::debug!(
50+
"validating credentials failed, logging user in again anyway. reason: {err}"
51+
);
52+
}
53+
}
54+
}
55+
56+
// Start web server that listens for the authorization code received from the auth server.
57+
let pkce = Pkce::new();
58+
let server = OauthCallbackServer::new(&pkce, login_hint.as_deref())?;
59+
60+
Ok(OauthLoginFlowState::LoginFlowStarted(Self {
61+
server,
62+
pkce,
63+
login_hint,
64+
}))
65+
}
66+
67+
pub fn get_login_url(&self) -> &str {
68+
self.server.get_login_url()
69+
}
70+
71+
/// Polls the web server for the authorization code received from the auth server.
72+
///
73+
/// This will not block, and will return `None` if no authorization code has been received yet.
74+
pub async fn poll(&self) -> Result<Option<Credentials>, Error> {
75+
// Once the user opens the link, they are redirected to the login UI.
76+
// If they were already logged in, it will immediately redirect them
77+
// to the login callback with an authorization code.
78+
// That code is then sent by our callback page back to the web server here.
79+
let Some(code) = self.server.check_for_browser_response()? else {
80+
return Ok(None);
81+
};
82+
83+
// Exchange code for credentials.
84+
let auth = send_async(AuthenticateWithCode::new(&code, &self.pkce))
85+
.await
86+
.map_err(|err| Error::Generic(err.into()))?;
87+
88+
// Store and return credentials
89+
let credentials = Credentials::from_auth_response(auth.into())?.ensure_stored()?;
90+
Ok(Some(credentials))
91+
}
92+
}

crates/utils/re_auth/src/token.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1+
use base64::Engine as _;
2+
use base64::prelude::BASE64_URL_SAFE_NO_PAD;
13
use jsonwebtoken::decode_header;
24

5+
use crate::oauth::{MalformedTokenError, RerunCloudClaims};
6+
37
#[derive(Debug, thiserror::Error)]
48
pub enum TokenError {
59
#[error("token does not seem to be a valid JWT token: {0}")]
@@ -15,6 +19,22 @@ impl Jwt {
1519
pub fn as_str(&self) -> &str {
1620
&self.0
1721
}
22+
23+
pub fn unverified_claims(&self) -> Result<RerunCloudClaims, MalformedTokenError> {
24+
let (_header, rest) = self
25+
.as_str()
26+
.split_once('.')
27+
.ok_or(MalformedTokenError::MissingHeaderPayloadSeparator)?;
28+
let (payload, _signature) = rest
29+
.split_once('.')
30+
.ok_or(MalformedTokenError::MissingPayloadSignatureSeparator)?;
31+
let payload = BASE64_URL_SAFE_NO_PAD
32+
.decode(payload)
33+
.map_err(MalformedTokenError::Base64)?;
34+
let claims: RerunCloudClaims =
35+
serde_json::from_slice(&payload).map_err(MalformedTokenError::Serde)?;
36+
Ok(claims)
37+
}
1838
}
1939

2040
impl TryFrom<String> for Jwt {

0 commit comments

Comments
 (0)