11use crate :: config:: GoogleConfig ;
22use crate :: error:: { CalendarchyError , Result } ;
3- use crate :: google:: types:: { DeviceCodeResponse , TokenInfo , TokenResponse } ;
3+ use crate :: google:: types:: { TokenInfo , TokenResponse } ;
44use crate :: logging:: { log_request, log_response} ;
55use chrono:: Utc ;
66use reqwest:: Client ;
7+ use tokio:: io:: { AsyncReadExt , AsyncWriteExt } ;
8+ use tokio:: net:: TcpListener ;
79
8- const DEVICE_CODE_URL : & str = "https://oauth2.googleapis .com/device/code " ;
10+ const AUTH_URL : & str = "https://accounts.google .com/o/oauth2/v2/auth " ;
911const TOKEN_URL : & str = "https://oauth2.googleapis.com/token" ;
10- const CALENDAR_SCOPE : & str = "https://www.googleapis.com/auth/calendar" ;
12+ const CALENDAR_SCOPE : & str = "https://www.googleapis.com/auth/calendar.events" ;
13+ const REDIRECT_URI : & str = "http://127.0.0.1:18457" ;
1114
1215pub struct GoogleAuth {
1316 client : Client ,
1417 config : GoogleConfig ,
1518}
1619
17- #[ derive( Debug ) ]
18- pub enum PollResult {
19- Success ( TokenInfo ) ,
20- Pending ,
21- SlowDown ,
22- Denied ,
23- Expired ,
24- }
25-
2620impl GoogleAuth {
2721 pub fn new ( config : GoogleConfig ) -> Self {
2822 Self {
@@ -31,70 +25,80 @@ impl GoogleAuth {
3125 }
3226 }
3327
34- /// Step 1: Request device code
35- pub async fn request_device_code ( & self ) -> Result < DeviceCodeResponse > {
36- log_request ( "POST" , DEVICE_CODE_URL ) ;
37- let response = self
38- . client
39- . post ( DEVICE_CODE_URL )
40- . form ( & [
41- ( "client_id" , self . config . client_id . as_str ( ) ) ,
42- ( "scope" , CALENDAR_SCOPE ) ,
43- ] )
44- . send ( )
45- . await ?;
46- log_response ( response. status ( ) . as_u16 ( ) , DEVICE_CODE_URL ) ;
28+ /// Get the authorization URL that the user should open in their browser
29+ pub fn auth_url ( & self ) -> String {
30+ format ! (
31+ "{}?client_id={}&redirect_uri={}&response_type=code&scope={}&access_type=offline&prompt=consent" ,
32+ AUTH_URL ,
33+ urlencoding:: encode( & self . config. client_id) ,
34+ urlencoding:: encode( REDIRECT_URI ) ,
35+ urlencoding:: encode( CALENDAR_SCOPE ) ,
36+ )
37+ }
4738
48- if !response. status ( ) . is_success ( ) {
49- let body = response. text ( ) . await . unwrap_or_default ( ) ;
50- return Err ( CalendarchyError :: Auth ( format ! (
51- "Failed to get device code: {}" ,
52- body
53- ) ) ) ;
54- }
39+ /// Start a localhost server, wait for the OAuth callback, and exchange the code for tokens.
40+ /// Returns the tokens on success.
41+ pub async fn authenticate_with_browser ( & self ) -> Result < TokenInfo > {
42+ let listener = TcpListener :: bind ( "127.0.0.1:18457" ) . await
43+ . map_err ( |e| CalendarchyError :: Auth ( format ! ( "Failed to start auth server: {}" , e) ) ) ?;
44+
45+ // Wait for the callback
46+ let ( mut stream, _) = listener. accept ( ) . await
47+ . map_err ( |e| CalendarchyError :: Auth ( format ! ( "Failed to accept connection: {}" , e) ) ) ?;
48+
49+ let mut buf = vec ! [ 0u8 ; 4096 ] ;
50+ let n = stream. read ( & mut buf) . await
51+ . map_err ( |e| CalendarchyError :: Auth ( format ! ( "Failed to read request: {}" , e) ) ) ?;
52+
53+ let request = String :: from_utf8_lossy ( & buf[ ..n] ) ;
5554
56- let device_code: DeviceCodeResponse = response. json ( ) . await ?;
57- Ok ( device_code)
55+ // Extract the authorization code from the request
56+ let code = extract_code ( & request)
57+ . ok_or_else ( || CalendarchyError :: Auth ( "No authorization code in callback" . to_string ( ) ) ) ?;
58+
59+ // Send a response to the browser
60+ let response = "HTTP/1.1 200 OK\r \n Content-Type: text/html\r \n \r \n \
61+ <html><body style=\" font-family:system-ui;display:flex;justify-content:center;align-items:center;height:100vh;margin:0\" >\
62+ <h2>Authenticated! You can close this tab.</h2></body></html>";
63+ let _ = stream. write_all ( response. as_bytes ( ) ) . await ;
64+ let _ = stream. shutdown ( ) . await ;
65+
66+ // Exchange the code for tokens
67+ self . exchange_code ( & code) . await
5868 }
5969
60- /// Step 2: Poll for token (call this repeatedly)
61- pub async fn poll_for_token ( & self , device_code : & str ) -> Result < PollResult > {
70+ /// Exchange an authorization code for tokens
71+ async fn exchange_code ( & self , code : & str ) -> Result < TokenInfo > {
6272 log_request ( "POST" , TOKEN_URL ) ;
6373 let response = self
6474 . client
6575 . post ( TOKEN_URL )
6676 . form ( & [
6777 ( "client_id" , self . config . client_id . as_str ( ) ) ,
6878 ( "client_secret" , self . config . client_secret . as_str ( ) ) ,
69- ( "device_code" , device_code) ,
70- ( "grant_type" , "urn:ietf:params:oauth:grant-type:device_code" ) ,
79+ ( "code" , code) ,
80+ ( "grant_type" , "authorization_code" ) ,
81+ ( "redirect_uri" , REDIRECT_URI ) ,
7182 ] )
7283 . send ( )
7384 . await ?;
7485 log_response ( response. status ( ) . as_u16 ( ) , TOKEN_URL ) ;
7586
76- if response. status ( ) . is_success ( ) {
77- let token_response: TokenResponse = response. json ( ) . await ?;
78- let token_info = TokenInfo {
79- access_token : token_response. access_token ,
80- refresh_token : token_response. refresh_token ,
81- expires_at : Utc :: now ( ) + chrono:: Duration :: seconds ( token_response. expires_in as i64 ) ,
82- token_type : token_response. token_type ,
83- } ;
84- Ok ( PollResult :: Success ( token_info) )
85- } else {
86- let error: serde_json:: Value = response. json ( ) . await ?;
87- match error. get ( "error" ) . and_then ( |e| e. as_str ( ) ) {
88- Some ( "authorization_pending" ) => Ok ( PollResult :: Pending ) ,
89- Some ( "slow_down" ) => Ok ( PollResult :: SlowDown ) ,
90- Some ( "access_denied" ) => Ok ( PollResult :: Denied ) ,
91- Some ( "expired_token" ) => Ok ( PollResult :: Expired ) ,
92- _ => Err ( CalendarchyError :: Auth ( format ! (
93- "Unknown error: {:?}" ,
94- error
95- ) ) ) ,
96- }
87+ if !response. status ( ) . is_success ( ) {
88+ let body = response. text ( ) . await . unwrap_or_default ( ) ;
89+ return Err ( CalendarchyError :: Auth ( format ! (
90+ "Failed to exchange code: {}" ,
91+ body
92+ ) ) ) ;
9793 }
94+
95+ let token_response: TokenResponse = response. json ( ) . await ?;
96+ Ok ( TokenInfo {
97+ access_token : token_response. access_token ,
98+ refresh_token : token_response. refresh_token ,
99+ expires_at : Utc :: now ( ) + chrono:: Duration :: seconds ( token_response. expires_in as i64 ) ,
100+ token_type : token_response. token_type ,
101+ } )
98102 }
99103
100104 /// Refresh an expired token
@@ -130,3 +134,16 @@ impl GoogleAuth {
130134 } )
131135 }
132136}
137+
138+ /// Extract the authorization code from an HTTP request line
139+ fn extract_code ( request : & str ) -> Option < String > {
140+ let first_line = request. lines ( ) . next ( ) ?;
141+ let path = first_line. split_whitespace ( ) . nth ( 1 ) ?;
142+ let query = path. split ( '?' ) . nth ( 1 ) ?;
143+ for param in query. split ( '&' ) {
144+ if let Some ( value) = param. strip_prefix ( "code=" ) {
145+ return Some ( urlencoding:: decode ( value) . ok ( ) ?. to_string ( ) ) ;
146+ }
147+ }
148+ None
149+ }
0 commit comments