Skip to content

Commit 7a0b9e2

Browse files
authored
Merge pull request #153 from twitch-rs/dcf
implement DCF
2 parents f7ffe1b + 26bef09 commit 7a0b9e2

10 files changed

+388
-38
lines changed

CHANGELOG.md

+6
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,16 @@
1010

1111
- MSRV bumped to `1.71.1`
1212
- Fixed a typo in `ModeratorManageGuestStart` (now: `ModeratorManageGuestStar`)
13+
- `RefreshToken::refresh_token` takes an optional secret to allow public clients to refresh tokens.
14+
15+
### Added
16+
17+
- Added support for Device Code Flow with `DeviceUserTokenBuilder`
1318

1419
### Fixed
1520

1621
- `AppAccessToken` and `UserToken` now return the correct duration in `expires_in` after refreshing.
22+
- It's now possible to refresh a token without a client secret if the token supports it (e.g public client type).
1723

1824
## [v0.14.0] - 2024-09-23
1925

Cargo.toml

+5
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,11 @@ name = "mock_user"
8787
path = "examples/mock_user.rs"
8888
required-features = ["reqwest", "mock_api"]
8989

90+
[[example]]
91+
name = "device_code_flow"
92+
path = "examples/device_code_flow.rs"
93+
required-features = ["reqwest", "client"]
94+
9095
[package.metadata.docs.rs]
9196
features = ["all", "mock_api"]
9297
rustc-args = ["--cfg", "nightly"]

examples/auth_flow.rs

+3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
//! This is an example of the Authorization code grant flow using `twitch_oauth2`
22
//!
33
//! See https://dev.twitch.tv/docs/authentication/getting-tokens-oauth/#authorization-code-grant-flow
4+
//!
5+
//! See also the `device_code_flow` example for possibly easier integration.
6+
47
use anyhow::Context;
58
use twitch_oauth2::tokens::UserTokenBuilder;
69

examples/device_code_flow.rs

+46
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
//! Example of how to create a user token using device code flow.
2+
//! The device code flow can be used on confidential and public clients.
3+
use twitch_oauth2::{DeviceUserTokenBuilder, TwitchToken, UserToken};
4+
5+
#[tokio::main]
6+
async fn main() -> anyhow::Result<()> {
7+
let _ = dotenv::dotenv(); // Eat error
8+
let mut args = std::env::args().skip(1);
9+
10+
// Setup the http client to use with the library.
11+
let reqwest = reqwest::Client::builder()
12+
.redirect(reqwest::redirect::Policy::none())
13+
.build()?;
14+
15+
// Grab the client id, convert to a `ClientId` with the `new` method.
16+
let client_id = get_env_or_arg("TWITCH_CLIENT_ID", &mut args)
17+
.map(twitch_oauth2::ClientId::new)
18+
.expect("Please set env: TWITCH_CLIENT_ID or pass client id as an argument");
19+
20+
// Create the builder!
21+
let mut builder = DeviceUserTokenBuilder::new(client_id, Default::default());
22+
23+
// Start the device code flow. This will return a code that the user must enter on Twitch
24+
let code = builder.start(&reqwest).await?;
25+
26+
println!("Please go to {0}", code.verification_uri);
27+
println!(
28+
"Waiting for user to authorize, time left: {0}",
29+
code.expires_in
30+
);
31+
32+
// Finish the auth with wait_for_code, this will return a token if the user has authorized the app
33+
let mut token = builder.wait_for_code(&reqwest, tokio::time::sleep).await?;
34+
35+
println!("token: {:?}\nTrying to refresh the token", token);
36+
// we can also refresh this token, even without a client secret
37+
// if the application was created as a public client type in the twitch dashboard this will work,
38+
// if the application is a confidential client type, this refresh will fail because it needs the client secret.
39+
token.refresh_token(&reqwest).await?;
40+
println!("refreshed token: {:?}", token);
41+
Ok(())
42+
}
43+
44+
fn get_env_or_arg(env: &str, args: &mut impl Iterator<Item = String>) -> Option<String> {
45+
std::env::var(env).ok().or_else(|| args.next())
46+
}

src/id.rs

+26-14
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,18 @@ impl TwitchTokenResponse {
3333
) -> Result<TwitchTokenResponse, RequestParseError> {
3434
crate::parse_response(response)
3535
}
36+
37+
/// Get the access token from this response
38+
pub fn access_token(&self) -> &crate::AccessTokenRef { &self.access_token }
39+
40+
/// Get the expires in from this response
41+
pub fn expires_in(&self) -> Option<Duration> { self.expires_in.map(Duration::from_secs) }
42+
43+
/// Get the refresh token from this response
44+
pub fn refresh_token(&self) -> Option<&crate::RefreshTokenRef> { self.refresh_token.as_deref() }
45+
46+
/// Get the scopes from this response
47+
pub fn scopes(&self) -> Option<&[crate::Scope]> { self.scopes.as_deref() }
3648
}
3749

3850
/// Twitch's representation of the oauth flow for errors
@@ -60,6 +72,20 @@ impl std::fmt::Display for TwitchTokenErrorResponse {
6072
)
6173
}
6274
}
75+
/// Response from the device code flow
76+
#[derive(Clone, Debug, Deserialize, Serialize)]
77+
pub struct DeviceCodeResponse {
78+
/// The identifier for a given user.
79+
pub device_code: String,
80+
/// Time until the code is no longer valid
81+
pub expires_in: u64,
82+
/// Time until another valid code can be requested
83+
pub interval: u64,
84+
/// The code that the user will use to authenticate
85+
pub user_code: String,
86+
/// The address you will send users to, to authenticate
87+
pub verification_uri: String,
88+
}
6389

6490
#[doc(hidden)]
6591
pub mod status_code {
@@ -105,17 +131,3 @@ pub mod scope {
105131
}
106132
}
107133
}
108-
109-
impl TwitchTokenResponse {
110-
/// Get the access token from this response
111-
pub fn access_token(&self) -> &crate::AccessTokenRef { &self.access_token }
112-
113-
/// Get the expires in from this response
114-
pub fn expires_in(&self) -> Option<Duration> { self.expires_in.map(Duration::from_secs) }
115-
116-
/// Get the refresh token from this response
117-
pub fn refresh_token(&self) -> Option<&crate::RefreshTokenRef> { self.refresh_token.as_deref() }
118-
119-
/// Get the scopes from this response
120-
pub fn scopes(&self) -> Option<&[crate::Scope]> { self.scopes.as_deref() }
121-
}

src/lib.rs

+19-5
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
//! to create user tokens in this library.
4040
//!
4141
//! Things like [`UserTokenBuilder`] can be used to create a token from scratch, via the [OAuth authorization code flow](https://dev.twitch.tv/docs/authentication/getting-tokens-oauth/#authorization-code-grant-flow)
42+
//! You can also use the newer [OAuth device code flow](https://dev.twitch.tv/docs/authentication/getting-tokens-oauth/#device-code-grant-flow) with [`DeviceUserTokenBuilder`].
4243
//!
4344
//! ## App access token
4445
//!
@@ -70,8 +71,8 @@ use tokens::errors::{RefreshTokenError, RevokeTokenError, ValidationError};
7071
pub use scopes::{Scope, Validator};
7172
#[doc(inline)]
7273
pub use tokens::{
73-
AppAccessToken, ImplicitUserTokenBuilder, TwitchToken, UserToken, UserTokenBuilder,
74-
ValidatedToken,
74+
AppAccessToken, DeviceUserTokenBuilder, ImplicitUserTokenBuilder, TwitchToken, UserToken,
75+
UserTokenBuilder, ValidatedToken,
7576
};
7677

7778
pub use url;
@@ -125,6 +126,17 @@ pub static AUTH_URL: once_cell::sync::Lazy<url::Url> = mock_env_url!("TWITCH_OAU
125126
pub static TOKEN_URL: once_cell::sync::Lazy<url::Url> = mock_env_url!("TWITCH_OAUTH2_TOKEN_URL", {
126127
TWITCH_OAUTH2_URL.to_string() + "token"
127128
},);
129+
/// Device URL (`https://id.twitch.tv/oauth2/device`) for `id.twitch.tv`
130+
///
131+
/// Can be overridden when feature `mock_api` is enabled with environment variable `TWITCH_OAUTH2_URL` to set the root path, or with `TWITCH_OAUTH2_DEVICE_URL` to override the base (`https://id.twitch.tv/oauth2/`) url.
132+
///
133+
/// # Examples
134+
///
135+
/// Set the environment variable `TWITCH_OAUTH2_URL` to `http://localhost:8080/auth/` to use [`twitch-cli` mock](https://github.com/twitchdev/twitch-cli/blob/main/docs/mock-api.md) endpoints.
136+
pub static DEVICE_URL: once_cell::sync::Lazy<url::Url> =
137+
mock_env_url!("TWITCH_OAUTH2_DEVICE_URL", {
138+
TWITCH_OAUTH2_URL.to_string() + "device"
139+
},);
128140
/// Validation URL (`https://id.twitch.tv/oauth2/validate`) for `id.twitch.tv`
129141
///
130142
/// Can be overridden when feature `mock_api` is enabled with environment variable `TWITCH_OAUTH2_URL` to set the root path, or with `TWITCH_OAUTH2_VALIDATE_URL` to override the base (`https://id.twitch.tv/oauth2/`) url.
@@ -241,14 +253,16 @@ impl RefreshTokenRef {
241253
pub fn refresh_token_request(
242254
&self,
243255
client_id: &ClientId,
244-
client_secret: &ClientSecret,
256+
client_secret: Option<&ClientSecret>,
245257
) -> http::Request<Vec<u8>> {
246258
use http::{HeaderMap, Method};
247259
use std::collections::HashMap;
248260

249261
let mut params = HashMap::new();
250262
params.insert("client_id", client_id.as_str());
251-
params.insert("client_secret", client_secret.secret());
263+
if let Some(client_secret) = client_secret {
264+
params.insert("client_secret", client_secret.secret());
265+
}
252266
params.insert("grant_type", "refresh_token");
253267
params.insert("refresh_token", self.secret());
254268

@@ -269,7 +283,7 @@ impl RefreshTokenRef {
269283
&self,
270284
http_client: &'a C,
271285
client_id: &ClientId,
272-
client_secret: &ClientSecret,
286+
client_secret: Option<&ClientSecret>,
273287
) -> Result<
274288
(AccessToken, std::time::Duration, Option<RefreshToken>),
275289
RefreshTokenError<<C as Client>::Error>,

src/tokens.rs

+3-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ mod user_token;
66

77
pub use app_access_token::AppAccessToken;
88
use twitch_types::{UserId, UserIdRef, UserName, UserNameRef};
9-
pub use user_token::{ImplicitUserTokenBuilder, UserToken, UserTokenBuilder};
9+
pub use user_token::{
10+
DeviceUserTokenBuilder, ImplicitUserTokenBuilder, UserToken, UserTokenBuilder,
11+
};
1012

1113
#[cfg(feature = "client")]
1214
use crate::client::Client;

src/tokens/app_access_token.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ impl TwitchToken for AppAccessToken {
6969
let (access_token, expires_in, refresh_token) =
7070
if let Some(token) = self.refresh_token.take() {
7171
token
72-
.refresh_token(http_client, &self.client_id, &self.client_secret)
72+
.refresh_token(http_client, &self.client_id, Some(&self.client_secret))
7373
.await?
7474
} else {
7575
return Err(RefreshTokenError::NoRefreshToken);

src/tokens/errors.rs

+33
Original file line numberDiff line numberDiff line change
@@ -103,3 +103,36 @@ pub enum ImplicitUserTokenExchangeError<RE: std::error::Error + Send + Sync + 's
103103
/// could not get validation for token
104104
ValidationError(#[from] ValidationError<RE>),
105105
}
106+
/// Errors for [`DeviceUserTokenBuilder`][crate::tokens::DeviceUserTokenBuilder]
107+
#[derive(thiserror::Error, Debug, displaydoc::Display)]
108+
#[non_exhaustive]
109+
#[cfg(feature = "client")]
110+
pub enum DeviceUserTokenExchangeError<RE: std::error::Error + Send + Sync + 'static> {
111+
/// request for exchange token failed
112+
DeviceExchangeRequestError(#[source] RE),
113+
/// could not parse response when getting exchange token
114+
DeviceExchangeParseError(#[source] crate::RequestParseError),
115+
/// request for user token failed
116+
TokenRequestError(#[source] RE),
117+
/// could not parse response when getting user token
118+
TokenParseError(#[source] crate::RequestParseError),
119+
/// could not get validation for token
120+
ValidationError(#[from] ValidationError<RE>),
121+
/// no device code found, exchange not started
122+
NoDeviceCode,
123+
/// the device code has expired
124+
Expired,
125+
}
126+
127+
#[cfg(feature = "client")]
128+
impl<RE: std::error::Error + Send + Sync + 'static> DeviceUserTokenExchangeError<RE> {
129+
/// Check if the error is due to the authorization request being pending
130+
pub fn is_pending(&self) -> bool {
131+
matches!(self, DeviceUserTokenExchangeError::TokenParseError(
132+
crate::RequestParseError::TwitchError(crate::id::TwitchTokenErrorResponse {
133+
message,
134+
..
135+
}),
136+
) if message == "authorization_pending")
137+
}
138+
}

0 commit comments

Comments
 (0)