Skip to content

Commit c7f0ee7

Browse files
committed
auth: Implement WorkOS device authorization flow with polling
Add complete OAuth 2.0 Device Authorization Flow (RFC 8628) runtime
1 parent 8afd3fa commit c7f0ee7

4 files changed

Lines changed: 248 additions & 4 deletions

File tree

cli/src/services/auth.rs

Lines changed: 244 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
use std::fmt;
2+
use std::time::Duration;
23

34
use serde::{Deserialize, Serialize};
45

6+
use crate::services::token_storage::{save_tokens, StoredTokens, TokenStorageError};
7+
58
pub const DEVICE_CODE_GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:device_code";
69
pub const REFRESH_TOKEN_GRANT_TYPE: &str = "refresh_token";
10+
pub const WORKOS_DEFAULT_BASE_URL: &str = "https://api.workos.com";
11+
pub const DEFAULT_DEVICE_POLL_INTERVAL_SECONDS: u64 = 5;
712

813
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
914
pub struct DeviceAuthorizationRequest {
@@ -50,13 +55,27 @@ pub struct OAuthErrorResponse {
5055
pub error_uri: Option<String>,
5156
}
5257

58+
#[derive(Clone, Debug, Eq, PartialEq)]
59+
pub struct DeviceAuthFlowResult {
60+
pub authorization: DeviceAuthorizationResponse,
61+
pub stored_tokens: StoredTokens,
62+
}
63+
64+
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
65+
enum PollDecision {
66+
Continue,
67+
SlowDown,
68+
Stop,
69+
}
70+
5371
#[derive(Debug)]
5472
pub enum AuthError {
5573
MissingClientId,
5674
InvalidResponse(String),
5775
Unauthorized(String),
5876
RequestFailed(reqwest::Error),
5977
Io(std::io::Error),
78+
Storage(TokenStorageError),
6079
}
6180

6281
impl fmt::Display for AuthError {
@@ -78,6 +97,7 @@ impl fmt::Display for AuthError {
7897
write!(f, "WorkOS authentication request failed: {error}")
7998
}
8099
Self::Io(error) => write!(f, "Authentication storage operation failed: {error}"),
100+
Self::Storage(error) => write!(f, "Authentication storage operation failed: {error}"),
81101
}
82102
}
83103
}
@@ -96,10 +116,185 @@ impl From<std::io::Error> for AuthError {
96116
}
97117
}
98118

119+
impl From<TokenStorageError> for AuthError {
120+
fn from(value: TokenStorageError) -> Self {
121+
Self::Storage(value)
122+
}
123+
}
124+
125+
pub async fn start_device_auth_flow(
126+
client: &reqwest::Client,
127+
api_base_url: &str,
128+
client_id: &str,
129+
) -> Result<DeviceAuthFlowResult, AuthError> {
130+
if client_id.trim().is_empty() {
131+
return Err(AuthError::MissingClientId);
132+
}
133+
134+
let authorization = request_device_authorization(client, api_base_url, client_id).await?;
135+
let token = poll_for_device_token(client, api_base_url, client_id, &authorization).await?;
136+
let stored_tokens = save_tokens(&token)?;
137+
138+
Ok(DeviceAuthFlowResult {
139+
authorization,
140+
stored_tokens,
141+
})
142+
}
143+
144+
async fn request_device_authorization(
145+
client: &reqwest::Client,
146+
api_base_url: &str,
147+
client_id: &str,
148+
) -> Result<DeviceAuthorizationResponse, AuthError> {
149+
let endpoint = format!(
150+
"{}/oauth/device/authorize",
151+
api_base_url.trim_end_matches('/')
152+
);
153+
let request = DeviceAuthorizationRequest {
154+
client_id: client_id.to_string(),
155+
};
156+
157+
let response = client.post(endpoint).json(&request).send().await?;
158+
159+
if response.status().is_success() {
160+
let parsed = response
161+
.json::<DeviceAuthorizationResponse>()
162+
.await
163+
.map_err(AuthError::RequestFailed)?;
164+
if parsed.device_code.trim().is_empty()
165+
|| parsed.user_code.trim().is_empty()
166+
|| parsed.verification_uri.trim().is_empty()
167+
{
168+
return Err(AuthError::InvalidResponse(
169+
"device authorization response is missing required fields".to_string(),
170+
));
171+
}
172+
return Ok(parsed);
173+
}
174+
175+
let oauth_error = parse_oauth_error_response(response).await?;
176+
Err(map_oauth_terminal_error(
177+
&oauth_error.error,
178+
oauth_error.error_description.as_deref(),
179+
))
180+
}
181+
182+
async fn poll_for_device_token(
183+
client: &reqwest::Client,
184+
api_base_url: &str,
185+
client_id: &str,
186+
authorization: &DeviceAuthorizationResponse,
187+
) -> Result<TokenResponse, AuthError> {
188+
let endpoint = format!("{}/oauth/device/token", api_base_url.trim_end_matches('/'));
189+
let request = DeviceTokenPollRequest {
190+
grant_type: DEVICE_CODE_GRANT_TYPE.to_string(),
191+
device_code: authorization.device_code.clone(),
192+
client_id: client_id.to_string(),
193+
};
194+
195+
let mut poll_interval_seconds = authorization
196+
.interval
197+
.unwrap_or(DEFAULT_DEVICE_POLL_INTERVAL_SECONDS)
198+
.max(1);
199+
let max_polls = authorization
200+
.expires_in
201+
.saturating_div(poll_interval_seconds)
202+
.max(1)
203+
+ 1;
204+
let mut attempts = 0_u64;
205+
206+
loop {
207+
attempts = attempts.saturating_add(1);
208+
if attempts > max_polls {
209+
return Err(AuthError::Unauthorized(
210+
"WorkOS device authorization expired before approval completed. Try: run 'sce login' again and complete verification before the code expires.".to_string(),
211+
));
212+
}
213+
214+
let response = client.post(&endpoint).json(&request).send().await?;
215+
if response.status().is_success() {
216+
let token = response
217+
.json::<TokenResponse>()
218+
.await
219+
.map_err(AuthError::RequestFailed)?;
220+
return Ok(token);
221+
}
222+
223+
let oauth_error = parse_oauth_error_response(response).await?;
224+
match poll_decision_for_error_code(&oauth_error.error) {
225+
PollDecision::Continue => {
226+
tokio::time::sleep(Duration::from_secs(poll_interval_seconds)).await;
227+
}
228+
PollDecision::SlowDown => {
229+
poll_interval_seconds = poll_interval_seconds.saturating_add(5);
230+
tokio::time::sleep(Duration::from_secs(poll_interval_seconds)).await;
231+
}
232+
PollDecision::Stop => {
233+
return Err(map_oauth_terminal_error(
234+
&oauth_error.error,
235+
oauth_error.error_description.as_deref(),
236+
));
237+
}
238+
}
239+
}
240+
}
241+
242+
fn poll_decision_for_error_code(code: &str) -> PollDecision {
243+
match code {
244+
"authorization_pending" => PollDecision::Continue,
245+
"slow_down" => PollDecision::SlowDown,
246+
_ => PollDecision::Stop,
247+
}
248+
}
249+
250+
async fn parse_oauth_error_response(
251+
response: reqwest::Response,
252+
) -> Result<OAuthErrorResponse, AuthError> {
253+
response
254+
.json::<OAuthErrorResponse>()
255+
.await
256+
.map_err(|error| {
257+
AuthError::InvalidResponse(format!("unable to parse OAuth error payload: {error}"))
258+
})
259+
}
260+
261+
fn map_oauth_terminal_error(code: &str, description: Option<&str>) -> AuthError {
262+
let detail = description
263+
.map(str::trim)
264+
.filter(|value| !value.is_empty())
265+
.map(|value| format!(" ({value})"))
266+
.unwrap_or_default();
267+
268+
match code {
269+
"access_denied" => AuthError::Unauthorized(format!(
270+
"WorkOS login was declined by the user{detail}. Try: rerun 'sce login' and approve the request in the browser."
271+
)),
272+
"expired_token" => AuthError::Unauthorized(format!(
273+
"WorkOS device code expired{detail}. Try: rerun 'sce login' to request a fresh device code."
274+
)),
275+
"invalid_request" => AuthError::Unauthorized(format!(
276+
"WorkOS rejected the device auth request as invalid{detail}. Try: verify CLI auth parameters and rerun 'sce login'."
277+
)),
278+
"invalid_client" => AuthError::Unauthorized(format!(
279+
"WorkOS rejected the client configuration{detail}. Try: verify WORKOS_CLIENT_ID (or config value) and rerun 'sce login'."
280+
)),
281+
"invalid_grant" => AuthError::Unauthorized(format!(
282+
"WorkOS reported an invalid or already-used device code{detail}. Try: rerun 'sce login' to restart the device flow."
283+
)),
284+
"unsupported_grant_type" => AuthError::Unauthorized(format!(
285+
"WorkOS rejected the OAuth grant type{detail}. Try: update the CLI and rerun 'sce login'."
286+
)),
287+
other => AuthError::Unauthorized(format!(
288+
"WorkOS returned OAuth error '{other}'{detail}. Try: rerun 'sce login'; if the issue persists, check WorkOS auth configuration."
289+
)),
290+
}
291+
}
292+
99293
#[cfg(test)]
100294
mod tests {
101295
use super::{
102-
DeviceAuthorizationResponse, DeviceTokenPollRequest, OAuthErrorResponse, TokenResponse,
296+
map_oauth_terminal_error, poll_decision_for_error_code, DeviceAuthorizationResponse,
297+
DeviceTokenPollRequest, OAuthErrorResponse, PollDecision, TokenResponse,
103298
DEVICE_CODE_GRANT_TYPE,
104299
};
105300

@@ -173,4 +368,52 @@ mod tests {
173368
);
174369
assert_eq!(parsed.error_uri, None);
175370
}
371+
372+
#[test]
373+
fn oauth_error_mapping_for_all_required_terminal_codes_has_try_guidance() {
374+
let codes = [
375+
"access_denied",
376+
"expired_token",
377+
"invalid_request",
378+
"invalid_client",
379+
"invalid_grant",
380+
"unsupported_grant_type",
381+
];
382+
383+
for code in codes {
384+
let message = map_oauth_terminal_error(code, Some("detail")).to_string();
385+
assert!(message.contains("Try:"), "missing Try guidance for {code}");
386+
}
387+
}
388+
389+
#[test]
390+
fn oauth_error_mapping_includes_original_code_for_unknown_errors() {
391+
let message = map_oauth_terminal_error("unexpected_error", None).to_string();
392+
assert!(message.contains("unexpected_error"));
393+
assert!(message.contains("Try:"));
394+
}
395+
396+
#[test]
397+
fn poll_decision_uses_fixed_interval_and_slow_down_increment_path() {
398+
assert_eq!(
399+
poll_decision_for_error_code("authorization_pending"),
400+
PollDecision::Continue
401+
);
402+
assert_eq!(
403+
poll_decision_for_error_code("slow_down"),
404+
PollDecision::SlowDown
405+
);
406+
}
407+
408+
#[test]
409+
fn poll_decision_stops_for_terminal_oauth_errors() {
410+
assert_eq!(
411+
poll_decision_for_error_code("access_denied"),
412+
PollDecision::Stop
413+
);
414+
assert_eq!(
415+
poll_decision_for_error_code("invalid_client"),
416+
PollDecision::Stop
417+
);
418+
}
176419
}

context/cli/placeholder-foundation.md

Lines changed: 2 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

context/context-map.md

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

context/plans/workos-cli-auth.md

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)