use std::env;
use std::fmt::{Debug, Formatter};
use std::fs::File;
use std::io::{ErrorKind, Write};
use std::path::PathBuf;
use std::sync::{Arc, LazyLock};
use std::time::{Duration, SystemTime};
use directories::ProjectDirs;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use tokio::sync::Mutex;
use tokio::time::sleep;
use tracing::info;
use url::Url;
use super::default_auth_url;
use crate::http::shared_client;
use crate::{
CredentialResult, OAuth2Grant, OAuth2TokenRequest, OAuth2TokenResponse, Token, oauth_clients,
};
#[cfg(feature = "browser-auth")]
mod browser;
pub static DEFAULT_DEVICE_AUTH: LazyLock<Arc<DeviceCodeAuth>> = LazyLock::new(|| {
Arc::new(
DeviceAuthBuilder::default()
.build()
.expect("Failed to build default DeviceAuth"),
)
});
/// Device code authentication provider.
pub struct DeviceAuthn {
device_code_auth: Arc<DeviceCodeAuth>,
}
impl Debug for DeviceAuthn {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DeviceAuthProvider").finish()
}
}
impl Default for DeviceAuthn {
fn default() -> Self {
DeviceAuthn::new(DEFAULT_DEVICE_AUTH.clone())
}
}
impl DeviceAuthn {
pub fn new(device_auth: Arc<DeviceCodeAuth>) -> Self {
DeviceAuthn {
device_code_auth: device_auth,
}
}
}
impl DeviceAuthn {
/// A usable token, or `None` when this provider has no credentials to offer.
pub async fn token(&self) -> CredentialResult<Option<Token>> {
let tokens = self.device_code_auth.authenticate(false, None).await?;
tracing::trace!("DeviceAuthProvider has a token");
Ok(Some(tokens.access_token.with_resource(tokens.resource)))
}
}
impl_token_source!(DeviceAuthn);
#[derive(Debug, Deserialize, Clone)]
pub struct AccessTokenClaims {
pub org_id: Option<String>,
pub exp: Option<u64>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct TokensModel {
pub access_token: Token,
pub refresh_token: Token,
/// The API URL the access token is for, as its token response named it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resource: Option<String>,
}
impl TokensModel {
pub fn org_id(&self) -> Option<String> {
self.unverified_access_token()
.and_then(|claims| claims.org_id)
}
pub fn unverified_access_token(&self) -> Option<AccessTokenClaims> {
self.access_token.unverified_claims().ok()
}
}
/// Persistence for one device-authorization session, selected by its host.
///
/// Operations must finish before returning: a cancelled authorization must not leave a
/// background write that can restore credentials after logout.
pub trait DeviceTokenStore: Send + Sync {
/// Read saved tokens, returning `None` only when no session is stored.
fn load(&self) -> CredentialResult<Option<TokensModel>>;
/// Replace the saved session, including any rotated refresh token.
fn save(&self, tokens: &TokensModel) -> CredentialResult<()>;
/// Delete the saved session. An absent session is already deleted.
fn delete(&self) -> CredentialResult<()>;
}
/// Persists a device-authorization session as an unencrypted JSON file.
pub struct FileTokenStore {
path: PathBuf,
}
impl FileTokenStore {
/// Use the given file, preserving the device-authorization JSON format.
pub fn new(path: PathBuf) -> Self {
Self { path }
}
}
impl DeviceTokenStore for FileTokenStore {
fn load(&self) -> CredentialResult<Option<TokensModel>> {
let file = match File::open(&self.path) {
Ok(file) => file,
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(error.into()),
};
Ok(serde_json::from_reader::<_, DeserializedAuthModel>(file)?.tokens)
}
fn save(&self, tokens: &TokensModel) -> CredentialResult<()> {
let parent = self
.path
.parent()
.expect("auth file is always in a directory");
std::fs::create_dir_all(parent)?;
let mut file = File::create(&self.path)?;
writeln!(
file,
"{}",
serde_json::to_string(&SerializableAuthModel::from(Some(tokens)))?
)?;
Ok(())
}
fn delete(&self) -> CredentialResult<()> {
match std::fs::remove_file(&self.path) {
Ok(()) => Ok(()),
Err(error) if error.kind() == ErrorKind::NotFound => Ok(()),
Err(error) => Err(error.into()),
}
}
}
#[derive(Debug, Clone)]
struct AuthModel {
tokens: Arc<Mutex<Option<TokensModel>>>,
}
#[derive(Deserialize)]
struct DeserializedAuthModel {
tokens: Option<TokensModel>,
}
#[derive(Serialize)]
struct SerializableAuthModel<'a> {
tokens: Option<&'a TokensModel>,
}
impl<'a> From<Option<&'a TokensModel>> for SerializableAuthModel<'a> {
fn from(value: Option<&'a TokensModel>) -> Self {
Self { tokens: value }
}
}
/// RFC 8628 §3.1 device authorization request.
#[derive(Debug, Serialize)]
struct DeviceCodeRequest<'a> {
client_id: &'a str,
}
/// RFC 8628 §3.2 device authorization response.
#[derive(Debug, Deserialize)]
struct DeviceCodeResponse {
device_code: String,
user_code: String,
verification_uri: String,
verification_uri_complete: Option<String>,
expires_in: u64,
interval: Option<u64>,
}
/// RFC 7009 §2.1 revocation request.
#[derive(Debug, Serialize)]
struct RevocationRequest<'a> {
client_id: &'a str,
token: &'a str,
token_type_hint: &'a str,
}
/// RFC 6749 §5.2 error response.
#[derive(Debug, Deserialize)]
struct TokenErrorResponse {
error: String,
}
/// RFC 8628 §3.5 default polling interval.
const DEFAULT_POLLING_INTERVAL: u64 = 5;
pub struct DeviceAuthBuilder {
auth_file: Option<PathBuf>,
auth_url: Option<Url>,
client_id: String,
dev_mode: bool,
}
fn is_dev_mode() -> bool {
env::var("SPIRAL__DEV")
.ok()
.map(|x| !x.is_empty())
.unwrap_or(false)
}
fn default_auth_file_name(dev_mode: bool) -> &'static str {
if dev_mode {
"auth.dev.json"
} else {
"auth.json"
}
}
impl Default for DeviceAuthBuilder {
fn default() -> Self {
Self::new(oauth_clients::SPIRAL_SDK.to_owned())
}
}
impl DeviceAuthBuilder {
/// Sign in as the registered Spiral OAuth application `client_id`.
pub fn new(client_id: String) -> Self {
Self {
auth_file: None,
auth_url: None,
client_id,
dev_mode: is_dev_mode(),
}
}
pub fn auth_file(mut self, auth_file: PathBuf) -> Self {
self.auth_file = Some(auth_file);
self
}
/// The authorization server that signs the user in.
pub fn auth_url(mut self, auth_url: Url) -> Self {
self.auth_url = Some(auth_url);
self
}
pub fn build(self) -> CredentialResult<DeviceCodeAuth> {
let auth_file = match self.auth_file {
Some(auth_file) => auth_file,
None => {
let mut auth_file = ProjectDirs::from("", "pyspiral", "")
.ok_or_else(|| {
invalid!("Could not determine config directory. Is HOME unset?")
})?
.config_dir()
.to_path_buf();
auth_file.push(default_auth_file_name(self.dev_mode));
auth_file
}
};
let auth_url = self.auth_url.unwrap_or_else(default_auth_url);
DeviceCodeAuth::try_new(auth_file, auth_url, self.client_id)
}
}
/// Interactive authentication in which a user identifies a device by a randomly generated code.
pub struct DeviceCodeAuth {
store: Arc<dyn DeviceTokenStore>,
auth_url: Url,
client_id: String,
http: Client,
auth: AuthModel,
}
impl DeviceCodeAuth {
pub fn try_new(auth_file: PathBuf, auth_url: Url, client_id: String) -> CredentialResult<Self> {
let store = Arc::new(FileTokenStore::new(auth_file));
let tokens = store.load()?;
Ok(Self::with_token_store(store, tokens, auth_url, client_id))
}
/// Create a signed-out provider without loading previously persisted credentials.
pub fn new_empty(auth_file: PathBuf, auth_url: Url, client_id: String) -> Self {
Self::with_token_store(
Arc::new(FileTokenStore::new(auth_file)),
None,
auth_url,
client_id,
)
}
/// Use host-owned persistence and an already-loaded session, or `None` for a signed-out host.
pub fn with_token_store(
store: Arc<dyn DeviceTokenStore>,
tokens: Option<TokensModel>,
auth_url: Url,
client_id: String,
) -> Self {
Self {
store,
auth_url,
client_id,
http: shared_client(),
auth: AuthModel {
tokens: Arc::new(Mutex::new(tokens)),
},
}
}
/// The authorization server that signs the user in.
pub fn auth_url(&self) -> &Url {
&self.auth_url
}
fn endpoint(&self, path: &str) -> CredentialResult<Url> {
self.auth_url
.join(path)
.map_err(|error| invalid!("invalid authorization server URL: {error}"))
}
/// Return cached tokens if they are not expired and belong to the desired organization.
async fn tokens_if_valid(&self, desired_org_id: Option<&String>) -> Option<TokensModel> {
tracing::trace!(org_id = ?desired_org_id, "DeviceAuth::tokens_if_valid");
let tokens = self.auth.tokens.lock().await;
let tokens = tokens.as_ref()?;
let not_expired = !tokens.access_token.is_expired().ok()?;
let has_desired_org = match desired_org_id {
None => {
// If no organization is specified, we assume the token is valid for any
// organization.
true
}
Some(org_id) => {
// If an organization is specified, we check if the token's org_id matches.
tokens.unverified_access_token()?.org_id.as_ref() == Some(org_id)
}
};
(has_desired_org && not_expired).then_some(tokens.clone())
}
/// Return valid tokens, possibly via an interactive flow.
pub async fn authenticate(
&self,
force: bool,
organization_id: Option<&String>,
) -> CredentialResult<TokensModel> {
if force {
return self.device_code(organization_id).await;
}
if let Some(tokens) = self.tokens_if_valid(organization_id).await {
return Ok(tokens);
}
tracing::debug!(
"No valid tokens found, attempting to refresh or authenticate interactively."
);
match self.refresh_tokens(organization_id).await {
Ok(Some(tokens)) => {
return Ok(tokens);
}
Ok(None) => {}
Err(e) => {
tracing::debug!(
error = &e as &dyn std::error::Error,
"Failed to refresh tokens"
);
}
}
self.device_code(organization_id).await
}
/// Revoke the session at Spiral, then forget the cached tokens.
pub async fn logout(&self) {
if let Err(_unchecked) = self.try_logout().await {
info!("Could not remove saved authentication tokens.");
}
}
/// Revoke the session and clear cached tokens, reporting failures to delete saved credentials.
pub async fn try_logout(&self) -> CredentialResult<()> {
let access_token = self
.auth
.tokens
.lock()
.await
.as_ref()
.map(|tokens| tokens.access_token.clone());
if let Some(access_token) = access_token
&& let Err(error) = self.revoke(&access_token).await
{
tracing::debug!(%error, "Failed to revoke the Spiral session");
}
self.remove_tokens().await
}
async fn revoke(&self, access_token: &Token) -> CredentialResult<()> {
self.http
.post(self.endpoint("/oauth/revoke")?)
.form(&RevocationRequest {
client_id: &self.client_id,
token: access_token.expose_secret(),
token_type_hint: "access_token",
})
.timeout(Duration::from_secs(10))
.send()
.await?
.error_for_status()?;
Ok(())
}
/// An interactive flow in which a user authenticates this device with an identity provider.
async fn device_code(&self, organization_id: Option<&String>) -> CredentialResult<TokensModel> {
let mut locked_tokens = self.auth.tokens.lock().await;
let tokens = self.authorize_device(organization_id).await?;
self.write_tokens(&tokens)?;
*locked_tokens = Some(tokens.clone());
Ok(tokens)
}
/// Run browser device authorization without changing cached or persisted tokens.
///
/// Hosts can cancel this future and validate the result before calling [`Self::store_tokens`].
pub async fn authorize_device(
&self,
organization_id: Option<&String>,
) -> CredentialResult<TokensModel> {
tracing::debug!("DeviceAuth::authorize_device");
let res = self
.http
.post(self.endpoint("/oauth/device_authorization")?)
.form(&DeviceCodeRequest {
client_id: &self.client_id,
})
.send()
.await?;
let response = if res.status().is_success() {
res.json::<DeviceCodeResponse>().await?
} else {
bail!(
"Failed to get device code: {} {} {}",
res.url().to_owned(),
res.status(),
res.text().await?,
);
};
let device_code = response.device_code;
let expires_at = response.expires_in
+ SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.expect("unix epoch is well before now")
.as_secs();
let mut interval = response.interval.unwrap_or(DEFAULT_POLLING_INTERVAL);
let verification_uri = response
.verification_uri_complete
.unwrap_or(response.verification_uri);
eprintln!(
"Please login here: {verification_uri}\nYour code is {}.",
response.user_code
);
// We explicitly do not care if the web browser fails to open.
let _unused = webbrowser::open(&verification_uri);
let tokens = loop {
if SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.expect("unix epoch is well before now")
.as_secs()
> expires_at
{
bail!("Timeout waiting for token");
}
sleep(Duration::from_secs(interval)).await;
let res = self
.http
.post(self.endpoint("/oauth/token")?)
.form(&OAuth2TokenRequest::public(
&self.client_id,
OAuth2Grant::DeviceCode {
device_code: device_code.clone(),
},
))
.send()
.await?;
if res.status().is_success() {
break tokens_from_response(res.json::<OAuth2TokenResponse>().await?, None)?;
}
match res.json::<TokenErrorResponse>().await {
Ok(err) if err.error == "access_denied" => {
bail!("Authorization request was denied by the user");
}
Ok(err) if err.error == "expired_token" => {
bail!("Device code has expired. Please try again.");
}
Ok(err) if err.error == "slow_down" => {
interval = interval.saturating_add(5);
}
// authorization_pending or unknown error, keep polling
_ => {}
}
};
// Device authorization cannot preselect an organization; move the new session there.
match organization_id {
Some(organization_id)
if tokens.org_id().as_deref() != Some(organization_id.as_str()) =>
{
self.exchange_refresh_token(&tokens.refresh_token, Some(organization_id))
.await
}
_ => Ok(tokens),
}
}
/// Persist validated tokens and replace the cached session.
pub async fn store_tokens(&self, tokens: &TokensModel) -> CredentialResult<()> {
let mut locked_tokens = self.auth.tokens.lock().await;
self.write_tokens(tokens)?;
*locked_tokens = Some(tokens.clone());
Ok(())
}
/// Refresh tokens without interactive authorization, returning `None` if no session exists.
/// Without an explicit organization, preserve the current access token's organization.
///
/// Refresh exchanges are serialized to preserve rotating refresh tokens. Successful exchanges
/// are retained even when the returned organization does not match the request; that mismatch
/// returns an error to the caller without discarding the rotated refresh token.
pub async fn refresh_tokens(
&self,
organization_id: Option<&String>,
) -> CredentialResult<Option<TokensModel>> {
tracing::debug!("Refreshing token {}", self.client_id);
let mut locked_tokens = self.auth.tokens.lock().await;
let Some(tokens) = locked_tokens.as_ref() else {
return Ok(None);
};
let current_organization_id = tokens
.unverified_access_token()
.and_then(|claims| claims.org_id);
let organization_id = organization_id.or(current_organization_id.as_ref());
let new_tokens = self
.exchange_refresh_token(&tokens.refresh_token, organization_id)
.await?;
self.accept_refresh(&mut locked_tokens, new_tokens, organization_id)
.map(Some)
}
async fn exchange_refresh_token(
&self,
refresh_token: &Token,
organization_id: Option<&String>,
) -> CredentialResult<TokensModel> {
let res = self
.http
.post(self.endpoint("/oauth/token")?)
.form(&OAuth2TokenRequest::public(
&self.client_id,
OAuth2Grant::RefreshToken {
refresh_token: refresh_token.expose_secret().to_owned(),
organization_id: organization_id.cloned(),
},
))
.timeout(Duration::from_secs(30))
.send()
.await?;
if !res.status().is_success() {
bail!(
"Failed to refresh token: {} {} {}",
res.url().to_owned(),
res.status(),
res.text().await?
);
}
tokens_from_response(
res.json::<OAuth2TokenResponse>().await?,
Some(refresh_token),
)
}
fn accept_refresh(
&self,
cached: &mut Option<TokensModel>,
tokens: TokensModel,
organization_id: Option<&String>,
) -> CredentialResult<TokensModel> {
// The refresh token has already rotated, even if this access token is unusable.
*cached = Some(tokens.clone());
self.write_tokens(&tokens)?;
if let Some(organization_id) = organization_id
&& tokens
.unverified_access_token()
.and_then(|claims| claims.org_id)
.as_ref()
!= Some(organization_id)
{
bail!("Refreshed token does not belong to the requested organization");
}
Ok(tokens)
}
fn write_tokens(&self, tokens: &TokensModel) -> CredentialResult<()> {
self.store.save(tokens)
}
async fn remove_tokens(&self) -> CredentialResult<()> {
let deleted = self.store.delete();
let mut tokens = self.auth.tokens.lock().await;
*tokens = None;
deleted
}
}
/// Spiral rotates refresh tokens; keep the presented one only if none was returned.
fn tokens_from_response(
response: OAuth2TokenResponse,
presented_refresh_token: Option<&Token>,
) -> CredentialResult<TokensModel> {
let refresh_token = match (response.refresh_token, presented_refresh_token) {
(Some(refresh_token), _) => Token::from(refresh_token),
(None, Some(presented)) => presented.clone(),
(None, None) => bail!("Spiral did not issue a refresh token for this client"),
};
Ok(TokensModel {
access_token: response.access_token,
refresh_token,
resource: response.resource,
})
}
#[cfg(test)]
mod tests {
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use std::{env, fs};
use tokio::time::timeout;
use url::Url;
use wiremock::matchers::{body_string_contains, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
use super::{DeviceCodeAuth, TokensModel, default_auth_file_name};
use crate::Token;
use crate::oauth_clients::SPIRAL_SDK;
const ORG_A_TOKEN: &str =
"eyJhbGciOiJSUzI1NiJ9.eyJvcmdfaWQiOiJvcmdfYSIsImV4cCI6NDEwMjQ0NDgwMH0.signature";
const ORG_B_TOKEN: &str =
"eyJhbGciOiJSUzI1NiJ9.eyJvcmdfaWQiOiJvcmdfYiIsImV4cCI6NDEwMjQ0NDgwMH0.signature";
fn test_auth_url() -> Url {
Url::parse("http://127.0.0.1:9").unwrap()
}
#[tokio::test]
async fn mismatched_refresh_retains_rotated_credentials() {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let path = env::temp_dir().join(format!("spiral-rotated-auth-{nonce}.json"));
let auth =
DeviceCodeAuth::new_empty(path.clone(), test_auth_url(), "test_client".to_owned());
let tokens = TokensModel {
access_token: Token::from(
"eyJhbGciOiJSUzI1NiJ9.eyJvcmdfaWQiOiJvcmdfYiIsImV4cCI6NDEwMjQ0NDgwMH0.signature",
),
refresh_token: Token::from("rotated-refresh"),
resource: None,
};
auth.store_tokens(&TokensModel {
access_token: Token::from("old-access"),
refresh_token: Token::from("old-refresh"),
resource: None,
})
.await
.unwrap();
let mut cached = auth.auth.tokens.lock().await;
let result = auth.accept_refresh(&mut cached, tokens, Some(&"org_a".to_owned()));
assert!(result.is_err(), "mismatched access token is rejected");
drop(cached);
let reloaded =
DeviceCodeAuth::try_new(path.clone(), test_auth_url(), "test_client".to_owned())
.unwrap();
for provider in [&auth, &reloaded] {
let tokens = provider
.tokens_if_valid(Some(&"org_b".to_owned()))
.await
.unwrap();
assert_eq!(tokens.refresh_token.expose_secret(), "rotated-refresh");
}
fs::remove_file(path).unwrap();
}
#[tokio::test]
async fn refresh_without_credentials_does_not_start_device_authorization() {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock")
.as_nanos();
let path = env::temp_dir().join(format!("spiral-device-auth-{nonce}.json"));
let auth = DeviceCodeAuth::try_new(path.clone(), test_auth_url(), "test_client".to_owned())
.expect("empty auth");
let org = "org_test".to_owned();
let result = timeout(Duration::from_secs(1), auth.refresh_tokens(Some(&org)))
.await
.expect("no interactive wait");
assert!(result.expect("empty session").is_none());
assert!(!path.exists());
}
/// Refreshing, including an organization switch, goes to Spiral as the SDK client.
#[tokio::test]
async fn refresh_preserves_the_selected_organization_after_switch_and_reload() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/oauth/token"))
.and(body_string_contains(format!("client_id={SPIRAL_SDK}")))
.and(body_string_contains("grant_type=refresh_token"))
.and(body_string_contains("refresh_token=refresh-a"))
.and(body_string_contains("organization_id=org_b"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"access_token": ORG_B_TOKEN,
"token_type": "Bearer",
"expires_in": 300,
"refresh_token": "refresh-b",
})))
.expect(1)
.mount(&server)
.await;
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let token_file = env::temp_dir().join(format!("spiral-refresh-auth-{nonce}.json"));
let auth = DeviceCodeAuth::new_empty(
token_file.clone(),
server.uri().parse().unwrap(),
SPIRAL_SDK.to_owned(),
);
auth.store_tokens(&TokensModel {
access_token: Token::from(ORG_A_TOKEN),
refresh_token: Token::from("refresh-a"),
resource: None,
})
.await
.unwrap();
let tokens = auth
.refresh_tokens(Some(&"org_b".to_owned()))
.await
.unwrap()
.unwrap();
assert_eq!(tokens.org_id(), Some(String::from("org_b")));
assert_eq!(tokens.refresh_token.expose_secret(), "refresh-b");
Mock::given(method("POST"))
.and(path("/oauth/token"))
.and(body_string_contains("refresh_token=refresh-b"))
.and(body_string_contains("organization_id=org_b"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"access_token": ORG_B_TOKEN,
"token_type": "Bearer",
"expires_in": 300,
"refresh_token": "refresh-c",
})))
.expect(1)
.mount(&server)
.await;
let reloaded = DeviceCodeAuth::try_new(
token_file.clone(),
server.uri().parse().unwrap(),
SPIRAL_SDK.to_owned(),
)
.unwrap();
let tokens = reloaded.refresh_tokens(None).await.unwrap().unwrap();
assert_eq!(tokens.org_id(), Some(String::from("org_b")));
assert_eq!(tokens.refresh_token.expose_secret(), "refresh-c");
fs::remove_file(token_file).unwrap();
}
#[test]
fn device_auth_uses_separate_dev_auth_file() {
assert_eq!(default_auth_file_name(false), "auth.json");
assert_eq!(default_auth_file_name(true), "auth.dev.json");
}
#[test]
fn test_tokens_model() {
let tokens = TokensModel {
access_token: Token::from("eyJhbGciOiJSUzI1NiIsImtpZCI6InNzb19vaWRjX2tleV9wYWlyXzAxSjFDUlM5S1JBREc0QTEwTlk1R1dBMkZOIn0.eyJpc3MiOiJodHRwczovL3dvcmtvcy5zcGlyYWxkYi5jb20iLCJzdWIiOiJ1c2VyXzAxSkZBMjIyRk1YUUJONVkwQThSWkVRVk40Iiwic2lkIjoic2Vzc2lvbl8wMUpYV0ZZRDg4SlBaM1ZCVjFDUThGNVBYSyIsImp0aSI6IjAxSlhXUkZNRkRFSzQwS1JLSDYyUkgyS1ZEIiwib3JnX2lkIjoib3JnXzAxSkFYNzQwQzZGMDg3RTU0VEtXOTU1VjdOIiwicm9sZSI6Im93bmVyIiwicGVybWlzc2lvbnMiOltdLCJleHAiOjE3NTAwOTA5MzMsImlhdCI6MTc1MDA5MDYzM30.dxp2LiyiFxMdpBrqOz07eFI5G-FNztnXQ27HcUmS4--2_IQUgwA4EFIeojYbBZVxJA1u-QKN0ZhQTs1AAQkn_xDQmpc1QyEDybEJZb1_mpKYrfJCMsaqaUQO2-R99NDpQwKEWhypMi8LBzJ6U5vppzxL0WsqnwCMmg5-1lgt4RoyiVf68k9MEVmT-CuU1_JJBN_AReEftpb7vqUoQX8NbMw4jxnnSiTSunJGJI3EniWqpRdIdEUBsQ68pxL84ffwGIFFK8LQ2qZzveGwIopjd7tXjoWf-2KZI-uxILdvWAwgpP7DvSBQzgbdVU_YbjKyU4gJCQ5Jd2Ijt-5k6UcZBw".to_string()),
refresh_token: Token::from("".to_string()),
resource: None,
};
let org_id = tokens.org_id().expect("Token should have org_id");
assert_eq!(org_id, String::from("org_01JAX740C6F087E54TKW955V7N"));
let unverified_access_token = tokens.unverified_access_token().expect("Must have claims");
assert!(
unverified_access_token.exp.is_some(),
"Token should have expiration time"
);
}
}