mod authenticator;
mod device;
use std::fmt;
use std::future::Future;
use std::pin::Pin;
use std::sync::Mutex;
use std::time::{Duration, SystemTime};
use serde::{Deserialize, Serialize};
pub use authenticator::{AuthStatus, Authenticator};
pub use device::{DeviceCode, DeviceFlow, Login};
pub const AUTH_BASE: &str = "https://auth.tidal.com/v1/oauth2";
pub const API_BASE: &str = "https://api.tidal.com/v1";
pub const CLIENT_ID: &str = "fX2JxdmntZWK0ixT";
pub const CLIENT_SECRET: &str = "1Nn9AfDAjxrgJFJbKNWLeAyKGVGmINuXPPLHVXAvxAg=";
pub const PKCE_CLIENT_ID: &str = "6BDSRdpK9hqEBTgU";
pub const PKCE_CLIENT_SECRET: &str = "xeuPmY7nbpZ9IIbLAcQ93shka1VNheUAqN6IcszjTG8=";
pub const SCOPE: &str = "r_usr w_usr w_sub";
pub const DEVICE_CODE_GRANT: &str = "urn:ietf:params:oauth:grant-type:device_code";
pub const REFRESH_MARGIN: Duration = Duration::from_secs(60);
pub const SLOW_DOWN_STEP: Duration = Duration::from_secs(5);
pub const LOST_RECHECK: Duration = Duration::from_secs(5);
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Session {
pub access_token: String,
pub refresh_token: String,
pub expires_at: SystemTime,
pub user_id: u64,
pub country_code: String,
}
impl fmt::Debug for Session {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Session")
.field("access_token", &"<redacted>")
.field("refresh_token", &"<redacted>")
.field("expires_at", &self.expires_at)
.field("user_id", &self.user_id)
.field("country_code", &self.country_code)
.finish()
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum StoreError {
#[error("session store unavailable: {0}")]
Unavailable(String),
#[error("wrong passphrase for the session file")]
WrongPassphrase,
#[error("session store error: {0}")]
Other(String),
}
pub trait SessionStore: Send + Sync {
fn load(&self) -> Result<Option<Session>, StoreError>;
fn save(&self, session: &Session) -> Result<(), StoreError>;
fn delete(&self) -> Result<(), StoreError>;
}
#[derive(Debug, Default)]
pub struct MemoryStore {
session: Mutex<Option<Session>>,
}
impl MemoryStore {
pub fn with_session(session: Session) -> Self {
Self {
session: Mutex::new(Some(session)),
}
}
}
impl SessionStore for MemoryStore {
fn load(&self) -> Result<Option<Session>, StoreError> {
Ok(self.session.lock().expect("poisoned").clone())
}
fn save(&self, session: &Session) -> Result<(), StoreError> {
*self.session.lock().expect("poisoned") = Some(session.clone());
Ok(())
}
fn delete(&self) -> Result<(), StoreError> {
*self.session.lock().expect("poisoned") = None;
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum AuthError {
#[error("network error talking to Tidal: {0}")]
Transport(String),
#[error("unexpected HTTP status {status}{}", code.as_deref().map(|c| format!(" ({c})")).unwrap_or_default())]
Http { status: u16, code: Option<String> },
#[error("malformed {0} response from Tidal")]
Decode(&'static str),
#[error("Login code expired, run \"tidal-player login\" again")]
CodeExpired,
#[error("Login was denied")]
Denied,
#[error("Tidal's login response has no {0}")]
SessionInfo(&'static str),
#[error("Session expired: run \"tidal-player login\"")]
LoginRequired,
#[error("Tidal rejected the access token (HTTP 401) after a refresh")]
Unauthorized,
#[error(transparent)]
Store(#[from] StoreError),
}
impl AuthError {
pub fn is_transient(&self) -> bool {
match self {
Self::Transport(_) => true,
Self::Http { status, .. } => *status == 429 || *status >= 500,
_ => false,
}
}
pub(crate) fn transport(error: &reqwest::Error) -> Self {
Self::Transport(error.to_string())
}
}
pub trait Clock: Send + Sync {
fn now(&self) -> SystemTime;
}
pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
pub trait Sleeper: Send + Sync {
fn sleep(&self, duration: Duration) -> BoxFuture<'_, ()>;
}
#[derive(Debug, Clone, Copy, Default)]
pub struct SystemClock;
impl Clock for SystemClock {
fn now(&self) -> SystemTime {
SystemTime::now()
}
}
impl Sleeper for SystemClock {
fn sleep(&self, duration: Duration) -> BoxFuture<'_, ()> {
Box::pin(tokio::time::sleep(duration))
}
}
#[derive(Debug)]
pub struct ManualClock {
now: Mutex<SystemTime>,
sleeps: Mutex<Vec<Duration>>,
}
impl ManualClock {
pub fn new(start: SystemTime) -> Self {
Self {
now: Mutex::new(start),
sleeps: Mutex::new(Vec::new()),
}
}
pub fn advance(&self, by: Duration) {
*self.now.lock().expect("poisoned") += by;
}
pub fn sleeps(&self) -> Vec<Duration> {
self.sleeps.lock().expect("poisoned").clone()
}
}
impl Clock for ManualClock {
fn now(&self) -> SystemTime {
*self.now.lock().expect("poisoned")
}
}
impl Sleeper for ManualClock {
fn sleep(&self, duration: Duration) -> BoxFuture<'_, ()> {
self.sleeps.lock().expect("poisoned").push(duration);
self.advance(duration);
Box::pin(std::future::ready(()))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthConfig {
pub auth_base: String,
pub api_base: String,
pub client_id: String,
pub client_secret: String,
pub pkce_client_id: String,
pub pkce_client_secret: String,
}
impl AuthConfig {
pub fn production() -> Self {
Self::with_bases(AUTH_BASE, API_BASE)
}
pub fn with_bases(auth_base: impl Into<String>, api_base: impl Into<String>) -> Self {
Self {
auth_base: auth_base.into(),
api_base: api_base.into(),
client_id: CLIENT_ID.into(),
client_secret: CLIENT_SECRET.into(),
pkce_client_id: PKCE_CLIENT_ID.into(),
pkce_client_secret: PKCE_CLIENT_SECRET.into(),
}
}
pub(crate) fn auth_url(&self, endpoint: &str) -> String {
join(&self.auth_base, endpoint)
}
pub(crate) fn api_url(&self, path: &str) -> String {
join(&self.api_base, path)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RefreshClient {
Pkce,
DeviceFlow,
}
pub const LOSSY_WARNING: &str = "Could not switch this session to the PKCE client: \
CD-quality tracks will stream as AAC instead of FLAC";
pub(crate) async fn post_refresh(
http: &reqwest::Client,
config: &AuthConfig,
client: RefreshClient,
refresh_token: &str,
) -> Result<(u16, String), AuthError> {
let (id, secret) = match client {
RefreshClient::Pkce => (&config.pkce_client_id, &config.pkce_client_secret),
RefreshClient::DeviceFlow => (&config.client_id, &config.client_secret),
};
let response = http
.post(config.auth_url("token"))
.header("content-type", "application/x-www-form-urlencoded")
.body(form_body(&[
("client_id", id),
("client_secret", secret),
("grant_type", "refresh_token"),
("refresh_token", refresh_token),
("scope", SCOPE),
]))
.send()
.await
.map_err(|e| AuthError::transport(&e))?;
let status = response.status().as_u16();
let body = response
.text()
.await
.map_err(|e| AuthError::transport(&e))?;
Ok((status, body))
}
pub(crate) fn rejects_client(status: u16, body: &str) -> bool {
matches!(
(status, error_code(body)),
(400 | 401, Some("invalid_client" | "unauthorized_client"))
)
}
fn join(base: &str, path: &str) -> String {
format!(
"{}/{}",
base.trim_end_matches('/'),
path.trim_start_matches('/')
)
}
pub(crate) fn form_body(pairs: &[(&str, &str)]) -> String {
let mut url = reqwest::Url::parse("http://form.invalid/").expect("static URL");
url.query_pairs_mut().extend_pairs(pairs);
url.query().unwrap_or_default().to_owned()
}
#[derive(Clone, PartialEq, Eq, Deserialize)]
pub struct TokenGrant {
access_token: String,
#[serde(default)]
refresh_token: Option<String>,
expires_in: u64,
#[serde(default)]
user_id: Option<u64>,
#[serde(default)]
user: Option<GrantUser>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
struct GrantUser {
#[serde(rename = "countryCode", default)]
country_code: Option<String>,
}
impl fmt::Debug for TokenGrant {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("TokenGrant")
.field("access_token", &"<redacted>")
.field(
"refresh_token",
&self.refresh_token.as_ref().map(|_| "<redacted>"),
)
.field("expires_in", &self.expires_in)
.field("user_id", &self.user_id)
.field("user", &self.user)
.finish()
}
}
impl TokenGrant {
pub fn from_json(body: &str) -> Result<Self, AuthError> {
serde_json::from_str(body).map_err(|_| AuthError::Decode("token"))
}
fn expires_at(&self, now: SystemTime) -> SystemTime {
now + Duration::from_secs(self.expires_in)
}
fn account(&self) -> Result<(u64, String), AuthError> {
let user_id = self.user_id.ok_or(AuthError::SessionInfo("user ID"))?;
let country = self
.country_code()
.ok_or(AuthError::SessionInfo("country code"))?;
Ok((user_id, country))
}
fn country_code(&self) -> Option<String> {
self.user
.as_ref()?
.country_code
.clone()
.filter(|country| !country.is_empty())
}
}
pub fn session_from_login(grant: &TokenGrant, now: SystemTime) -> Result<Session, AuthError> {
let (user_id, country_code) = grant.account()?;
Ok(Session {
access_token: grant.access_token.clone(),
refresh_token: grant.refresh_token.clone().unwrap_or_default(),
expires_at: grant.expires_at(now),
user_id,
country_code,
})
}
pub fn session_from_refresh(previous: &Session, grant: &TokenGrant, now: SystemTime) -> Session {
let (user_id, country_code) = match grant.account() {
Ok(account) => account,
Err(error) => {
tracing::warn!(%error, "refresh response lacks account data; keeping the previous values");
(previous.user_id, previous.country_code.clone())
}
};
Session {
access_token: grant.access_token.clone(),
refresh_token: grant
.refresh_token
.clone()
.unwrap_or_else(|| previous.refresh_token.clone()),
expires_at: grant.expires_at(now),
user_id,
country_code,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PollOutcome {
Pending { interval: Duration },
Granted(TokenGrant),
}
const KNOWN_ERROR_CODES: [&str; 10] = [
"authorization_pending",
"slow_down",
"expired_token",
"access_denied",
"invalid_grant",
"invalid_client",
"invalid_request",
"invalid_scope",
"unauthorized_client",
"unsupported_grant_type",
];
pub(crate) fn error_code(body: &str) -> Option<&'static str> {
#[derive(Deserialize)]
struct ErrorBody {
error: Option<String>,
}
let error = serde_json::from_str::<ErrorBody>(body).ok()?.error?;
KNOWN_ERROR_CODES.into_iter().find(|known| *known == error)
}
pub fn classify_poll(
status: u16,
body: &str,
interval: Duration,
) -> Result<PollOutcome, AuthError> {
if (200..300).contains(&status) {
return TokenGrant::from_json(body).map(PollOutcome::Granted);
}
if status == 429 || status >= 500 {
return Ok(PollOutcome::Pending { interval });
}
match error_code(body) {
Some("authorization_pending") => Ok(PollOutcome::Pending { interval }),
Some("slow_down") => Ok(PollOutcome::Pending {
interval: interval + SLOW_DOWN_STEP,
}),
Some("expired_token") => Err(AuthError::CodeExpired),
Some("access_denied") => Err(AuthError::Denied),
code => Err(AuthError::Http {
status,
code: code.map(Into::into),
}),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RefreshFailure {
SessionLost,
Transient,
}
pub fn classify_refresh_failure(status: u16, body: &str) -> RefreshFailure {
match (status, error_code(body)) {
(400 | 401, Some("invalid_grant" | "invalid_client" | "unauthorized_client")) => {
RefreshFailure::SessionLost
}
(401, _) if body.trim().is_empty() => RefreshFailure::SessionLost,
_ => RefreshFailure::Transient,
}
}
pub fn needs_refresh(session: &Session, now: SystemTime) -> bool {
match session.expires_at.duration_since(now) {
Ok(left) => left < REFRESH_MARGIN,
Err(_) => true,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use wiremock::matchers::{body_string_contains, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
const T0: Duration = Duration::from_secs(1_800_000_000);
fn t0() -> SystemTime {
SystemTime::UNIX_EPOCH + T0
}
fn fixture(name: &str) -> &'static str {
match name {
"token_pending" => include_str!("../tests/fixtures/auth/token_pending.json"),
"token_slow_down" => include_str!("../tests/fixtures/auth/token_slow_down.json"),
"token_expired" => include_str!("../tests/fixtures/auth/token_expired.json"),
"token_denied" => include_str!("../tests/fixtures/auth/token_denied.json"),
"token_granted" => include_str!("../tests/fixtures/auth/token_granted.json"),
"token_granted_no_user" => {
include_str!("../tests/fixtures/auth/token_granted_no_user.json")
}
"token_granted_empty_country" => {
include_str!("../tests/fixtures/auth/token_granted_empty_country.json")
}
"token_granted_no_user_id" => {
include_str!("../tests/fixtures/auth/token_granted_no_user_id.json")
}
"token_refreshed" => include_str!("../tests/fixtures/auth/token_refreshed.json"),
"token_refreshed_rotated" => {
include_str!("../tests/fixtures/auth/token_refreshed_rotated.json")
}
"token_refreshed_country_changed" => {
include_str!("../tests/fixtures/auth/token_refreshed_country_changed.json")
}
"token_refreshed_no_user" => {
include_str!("../tests/fixtures/auth/token_refreshed_no_user.json")
}
"refresh_invalid_grant" => {
include_str!("../tests/fixtures/auth/refresh_invalid_grant.json")
}
"refresh_invalid_client" => {
include_str!("../tests/fixtures/auth/refresh_invalid_client.json")
}
"refresh_unauthorized_client" => {
include_str!("../tests/fixtures/auth/refresh_unauthorized_client.json")
}
"echoing_error" => include_str!("../tests/fixtures/auth/echoing_error.json"),
"echoing_malformed_grant" => {
include_str!("../tests/fixtures/auth/echoing_malformed_grant.json")
}
other => panic!("unknown fixture {other}"),
}
}
fn session(access: &str, refresh: &str, expires_at: SystemTime) -> Session {
Session {
access_token: access.into(),
refresh_token: refresh.into(),
expires_at,
user_id: 1,
country_code: "FI".into(),
}
}
#[test]
fn ac2_poll_outcome() {
let two = Duration::from_secs(2);
let granted = TokenGrant::from_json(fixture("token_granted")).unwrap();
let cases: [(&str, u16, &str, Result<PollOutcome, AuthError>); 8] = [
(
"pending keeps the interval",
400,
"token_pending",
Ok(PollOutcome::Pending { interval: two }),
),
(
"slow_down adds 5 s",
400,
"token_slow_down",
Ok(PollOutcome::Pending {
interval: Duration::from_secs(7),
}),
),
(
"expired_token",
400,
"token_expired",
Err(AuthError::CodeExpired),
),
("access_denied", 400, "token_denied", Err(AuthError::Denied)),
(
"granted",
200,
"token_granted",
Ok(PollOutcome::Granted(granted)),
),
(
"5xx is not yet",
503,
"token_pending",
Ok(PollOutcome::Pending { interval: two }),
),
(
"other OAuth error",
401,
"refresh_invalid_client",
Err(AuthError::Http {
status: 401,
code: Some("invalid_client".into()),
}),
),
(
"malformed 200",
200,
"echoing_malformed_grant",
Err(AuthError::Decode("token")),
),
];
for (name, status, body, expected) in cases {
assert_eq!(
classify_poll(status, fixture(body), two),
expected,
"{name}"
);
}
}
#[tokio::test]
async fn ac3_session_from_token_response() {
type Account = Result<(u64, &'static str), AuthError>;
let cases: [(&str, Account); 4] = [
("token_granted", Ok((1, "FI"))),
(
"token_granted_no_user",
Err(AuthError::SessionInfo("country code")),
),
(
"token_granted_empty_country",
Err(AuthError::SessionInfo("country code")),
),
(
"token_granted_no_user_id",
Err(AuthError::SessionInfo("user ID")),
),
];
for (body, expected) in cases {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/oauth2/token"))
.and(body_string_contains("device_code=FAKE-DEVICE-CODE"))
.respond_with(
ResponseTemplate::new(200).set_body_raw(fixture(body), "application/json"),
)
.expect(1)
.mount(&server)
.await;
Mock::given(path("/v1/sessions"))
.respond_with(ResponseTemplate::new(500))
.expect(0)
.mount(&server)
.await;
let clock = Arc::new(ManualClock::new(t0()));
let config = AuthConfig::with_bases(
format!("{}/oauth2", server.uri()),
format!("{}/v1", server.uri()),
);
let flow = DeviceFlow::with_time(config, clock.clone(), clock).unwrap();
let code = DeviceCode {
device_code: "FAKE-DEVICE-CODE".into(),
user_code: "ABCDE".into(),
verification_uri: "link.tidal.com".into(),
verification_uri_complete: None,
expires_in: Duration::from_secs(300),
interval: Duration::from_secs(2),
};
let store = MemoryStore::default();
let result = flow
.complete_login(&code, &store)
.await
.map(|login| login.session);
match expected {
Ok((user_id, country)) => {
let session = result.unwrap_or_else(|e| panic!("{body}: {e}"));
assert_eq!(
(session.user_id, session.country_code.as_str()),
(user_id, country),
"{body}"
);
assert_eq!(session.expires_at, t0() + Duration::from_secs(14_400));
assert_eq!(store.load(), Ok(Some(session)), "{body}: stored");
}
Err(error) => {
assert_eq!(result, Err(error), "{body}");
assert_eq!(store.load(), Ok(None), "{body}: nothing stored");
}
}
server.verify().await;
}
let previous = session("FAKE-ACCESS", "FAKE-REFRESH", t0());
let refresh_cases = [
("token_refreshed_country_changed", 1, "SE"),
("token_refreshed_no_user", 1, "FI"),
("token_refreshed", 1, "FI"),
];
for (body, user_id, country) in refresh_cases {
let grant = TokenGrant::from_json(fixture(body)).unwrap();
let refreshed = session_from_refresh(&previous, &grant, t0());
assert_eq!(
(
refreshed.access_token.as_str(),
refreshed.user_id,
refreshed.country_code.as_str()
),
("FAKE-ACCESS-2", user_id, country),
"{body}"
);
}
}
#[test]
fn ac4_needs_refresh() {
let cases = [
("far future", Duration::from_secs(14_400), 0, false),
("exactly 60 s", Duration::from_secs(60), 0, false),
("59 s", Duration::from_secs(59), 0, true),
("expires now", Duration::ZERO, 0, true),
("expired", Duration::ZERO, 10, true),
];
for (name, expires_in, elapsed, expected) in cases {
let s = session("FAKE-ACCESS", "FAKE-REFRESH", t0() + expires_in);
let now = t0() + Duration::from_secs(elapsed);
assert_eq!(needs_refresh(&s, now), expected, "{name}");
}
}
#[test]
fn ac6_refresh_token_kept_or_replaced() {
let previous = session("FAKE-ACCESS", "FAKE-REFRESH", t0());
let cases = [
("token_refreshed", "FAKE-REFRESH"),
("token_refreshed_rotated", "FAKE-REFRESH-2"),
];
for (body, refresh) in cases {
let grant = TokenGrant::from_json(fixture(body)).unwrap();
let refreshed = session_from_refresh(&previous, &grant, t0());
assert_eq!(refreshed.refresh_token, refresh, "{body}");
assert_eq!(refreshed.access_token, "FAKE-ACCESS-2", "{body}");
assert_eq!(refreshed.expires_at, t0() + Duration::from_secs(14_400));
}
}
#[test]
fn ac7_classify_refresh_failure() {
use RefreshFailure::{SessionLost, Transient};
let cases = [
(400, "refresh_invalid_grant", SessionLost),
(401, "refresh_invalid_grant", SessionLost),
(400, "refresh_invalid_client", SessionLost),
(401, "refresh_invalid_client", SessionLost),
(400, "refresh_unauthorized_client", SessionLost),
(401, "refresh_unauthorized_client", SessionLost),
(401, "", SessionLost),
(500, "", Transient),
(502, "refresh_invalid_grant", Transient),
(503, "", Transient),
(429, "", Transient),
(400, "token_pending", Transient),
(403, "", Transient),
];
for (status, body, expected) in cases {
let body = if body.is_empty() { "" } else { fixture(body) };
assert_eq!(
classify_refresh_failure(status, body),
expected,
"{status} {body}"
);
}
}
#[test]
fn ac19_warning_names_no_cause() {
assert!(LOSSY_WARNING.contains("AAC"), "{LOSSY_WARNING}");
for cause in ["did not accept", "rejected", "refused"] {
assert!(!LOSSY_WARNING.contains(cause), "{LOSSY_WARNING}");
}
}
#[test]
fn ac15_no_tokens_in_debug_or_errors() {
let s = session("FAKE-ACCESS", "FAKE-REFRESH", t0());
let debug = format!("{s:?}");
assert!(!debug.contains("FAKE-ACCESS"), "{debug}");
assert!(!debug.contains("FAKE-REFRESH"), "{debug}");
assert!(debug.contains("<redacted>"), "{debug}");
let grant = TokenGrant::from_json(fixture("token_granted")).unwrap();
let debug = format!("{grant:?} {:?}", PollOutcome::Granted(grant.clone()));
assert!(!debug.contains("FAKE-ACCESS"), "{debug}");
assert!(!debug.contains("FAKE-REFRESH"), "{debug}");
let two = Duration::from_secs(2);
let echoed = [
classify_poll(400, fixture("echoing_error"), two).unwrap_err(),
classify_poll(200, fixture("echoing_malformed_grant"), two).unwrap_err(),
TokenGrant::from_json(fixture("echoing_malformed_grant")).unwrap_err(),
];
let mut errors = vec![
AuthError::Transport("connection refused".into()),
AuthError::Http {
status: 500,
code: None,
},
AuthError::Decode("token"),
AuthError::CodeExpired,
AuthError::Denied,
AuthError::SessionInfo("country code"),
AuthError::LoginRequired,
AuthError::Unauthorized,
AuthError::Store(StoreError::WrongPassphrase),
];
errors.extend(echoed);
for error in &errors {
match error {
AuthError::Transport(_)
| AuthError::Http { .. }
| AuthError::Decode(_)
| AuthError::CodeExpired
| AuthError::Denied
| AuthError::SessionInfo(_)
| AuthError::LoginRequired
| AuthError::Unauthorized
| AuthError::Store(_) => {}
}
for text in [error.to_string(), format!("{error:?}")] {
assert!(!text.contains("FAKE-ACCESS"), "{text}");
assert!(!text.contains("FAKE-REFRESH"), "{text}");
}
}
}
#[test]
fn memory_store_round_trip() {
let session = session(
"FAKE-ACCESS",
"FAKE-REFRESH",
SystemTime::UNIX_EPOCH + Duration::from_secs(14_400),
);
let store = MemoryStore::default();
assert_eq!(store.load(), Ok(None));
store.save(&session).unwrap();
assert_eq!(store.load(), Ok(Some(session)));
store.delete().unwrap();
store.delete().unwrap();
assert_eq!(store.load(), Ok(None));
}
#[test]
fn form_body_encodes() {
assert_eq!(
form_body(&[("scope", SCOPE), ("grant_type", DEVICE_CODE_GRANT)]),
"scope=r_usr+w_usr+w_sub&grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code"
);
}
}