Our Personal Data Server from scratch! tranquil.farm
oauth atproto pds rust postgresql objectstorage fun

fix "localhost client" support and use in frontend when in dev mode

authored by nel.pet and committed by tangled.org 1280f804 014d4b57

+41 -35
+18 -21
frontend/src/lib/oauth.ts
··· 1 const OAUTH_STATE_KEY = 'tranquil_pds_oauth_state' 2 const OAUTH_VERIFIER_KEY = 'tranquil_pds_oauth_verifier' 3 4 interface OAuthState { 5 state: string ··· 65 66 saveOAuthState({ state, codeVerifier }) 67 68 - const clientId = `${window.location.origin}/oauth/client-metadata.json` 69 - const redirectUri = `${window.location.origin}/` 70 - 71 const parResponse = await fetch('/oauth/par', { 72 method: 'POST', 73 headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 74 body: new URLSearchParams({ 75 - client_id: clientId, 76 - redirect_uri: redirectUri, 77 response_type: 'code', 78 - scope: [ 79 - 'atproto', 80 - 'repo:*?action=create', 81 - 'repo:*?action=update', 82 - 'repo:*?action=delete', 83 - 'blob:*/*', 84 - ].join(' '), 85 state: state, 86 code_challenge: codeChallenge, 87 code_challenge_method: 'S256', ··· 96 const { request_uri } = await parResponse.json() 97 98 const authorizeUrl = new URL('/oauth/authorize', window.location.origin) 99 - authorizeUrl.searchParams.set('client_id', clientId) 100 authorizeUrl.searchParams.set('request_uri', request_uri) 101 102 window.location.href = authorizeUrl.toString() ··· 122 throw new Error('OAuth state mismatch. Please try logging in again.') 123 } 124 125 - const clientId = `${window.location.origin}/oauth/client-metadata.json` 126 - const redirectUri = `${window.location.origin}/` 127 - 128 const tokenResponse = await fetch('/oauth/token', { 129 method: 'POST', 130 headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 131 body: new URLSearchParams({ 132 grant_type: 'authorization_code', 133 - client_id: clientId, 134 code: code, 135 - redirect_uri: redirectUri, 136 code_verifier: savedState.codeVerifier, 137 }), 138 }) ··· 148 } 149 150 export async function refreshOAuthToken(refreshToken: string): Promise<OAuthTokens> { 151 - const clientId = `${window.location.origin}/oauth/client-metadata.json` 152 - 153 const tokenResponse = await fetch('/oauth/token', { 154 method: 'POST', 155 headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 156 body: new URLSearchParams({ 157 grant_type: 'refresh_token', 158 - client_id: clientId, 159 refresh_token: refreshToken, 160 }), 161 })
··· 1 const OAUTH_STATE_KEY = 'tranquil_pds_oauth_state' 2 const OAUTH_VERIFIER_KEY = 'tranquil_pds_oauth_verifier' 3 + const SCOPES = [ 4 + 'atproto', 5 + 'repo:*?action=create', 6 + 'repo:*?action=update', 7 + 'repo:*?action=delete', 8 + 'blob:*/*', 9 + ].join(' ') 10 + const CLIENT_ID = !(import.meta.env.DEV) 11 + ? `${window.location.origin}/oauth/client-metadata.json` 12 + : `http://localhost/oauth/client-metadata.json?scope=${SCOPES}` 13 + const REDIRECT_URI = `${window.location.origin}/` 14 15 interface OAuthState { 16 state: string ··· 76 77 saveOAuthState({ state, codeVerifier }) 78 79 const parResponse = await fetch('/oauth/par', { 80 method: 'POST', 81 headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 82 body: new URLSearchParams({ 83 + client_id: CLIENT_ID, 84 + redirect_uri: REDIRECT_URI, 85 response_type: 'code', 86 + scope: SCOPES, 87 state: state, 88 code_challenge: codeChallenge, 89 code_challenge_method: 'S256', ··· 98 const { request_uri } = await parResponse.json() 99 100 const authorizeUrl = new URL('/oauth/authorize', window.location.origin) 101 + authorizeUrl.searchParams.set('client_id', CLIENT_ID) 102 authorizeUrl.searchParams.set('request_uri', request_uri) 103 104 window.location.href = authorizeUrl.toString() ··· 124 throw new Error('OAuth state mismatch. Please try logging in again.') 125 } 126 127 const tokenResponse = await fetch('/oauth/token', { 128 method: 'POST', 129 headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 130 body: new URLSearchParams({ 131 grant_type: 'authorization_code', 132 + client_id: CLIENT_ID, 133 code: code, 134 + redirect_uri: REDIRECT_URI, 135 code_verifier: savedState.codeVerifier, 136 }), 137 }) ··· 147 } 148 149 export async function refreshOAuthToken(refreshToken: string): Promise<OAuthTokens> { 150 const tokenResponse = await fetch('/oauth/token', { 151 method: 'POST', 152 headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 153 body: new URLSearchParams({ 154 grant_type: 'refresh_token', 155 + client_id: CLIENT_ID, 156 refresh_token: refreshToken, 157 }), 158 })
+23 -14
src/oauth/client.rs
··· 89 fn is_loopback_client(client_id: &str) -> bool { 90 if let Ok(url) = reqwest::Url::parse(client_id) { 91 url.scheme() == "http" 92 - && matches!(url.host_str(), Some("localhost") | Some("127.0.0.1")) 93 - && url.query().is_some() 94 } else { 95 false 96 } ··· 98 99 fn build_loopback_metadata(client_id: &str) -> Result<ClientMetadata, OAuthError> { 100 let url = reqwest::Url::parse(client_id) 101 - .map_err(|_| OAuthError::InvalidClient("Invalid loopback client_id URL".to_string()))?; 102 - let mut redirect_uris = Vec::new(); 103 for (key, value) in url.query_pairs() { 104 if key == "redirect_uri" { 105 redirect_uris.push(value.to_string()); 106 } 107 } 108 if redirect_uris.is_empty() { 109 - redirect_uris.push("http://127.0.0.1/callback".to_string()); 110 - redirect_uris.push("http://localhost/callback".to_string()); 111 } 112 - let scope = Some("atproto transition:generic transition:chat.bsky".to_string()); 113 Ok(ClientMetadata { 114 - client_id: client_id.to_string(), 115 - client_name: Some("Loopback Client".to_string()), 116 client_uri: None, 117 logo_uri: None, 118 redirect_uris, 119 grant_types: vec![ 120 - "authorization_code".to_string(), 121 - "refresh_token".to_string(), 122 ], 123 - response_types: vec!["code".to_string()], 124 scope, 125 - token_endpoint_auth_method: Some("none".to_string()), 126 dpop_bound_access_tokens: Some(false), 127 jwks: None, 128 jwks_uri: None, 129 - application_type: Some("native".to_string()), 130 }) 131 } 132
··· 89 fn is_loopback_client(client_id: &str) -> bool { 90 if let Ok(url) = reqwest::Url::parse(client_id) { 91 url.scheme() == "http" 92 + && url.host_str() == Some("localhost") 93 + && url.port().is_none() 94 + && url.path().is_empty() 95 } else { 96 false 97 } ··· 99 100 fn build_loopback_metadata(client_id: &str) -> Result<ClientMetadata, OAuthError> { 101 let url = reqwest::Url::parse(client_id) 102 + .map_err(|_| OAuthError::InvalidClient("Invalid loopback client_id URL".into()))?; 103 + let mut redirect_uris = Vec::<String>::new(); 104 + let mut scope: Option<String> = None; 105 for (key, value) in url.query_pairs() { 106 if key == "redirect_uri" { 107 redirect_uris.push(value.to_string()); 108 + break; 109 + } 110 + if key == "scope" { 111 + scope = Some(value.into()); 112 + break; 113 } 114 } 115 if redirect_uris.is_empty() { 116 + redirect_uris.push("http://127.0.0.1/".into()); 117 + redirect_uris.push("http://[::1]/".into()); 118 + } 119 + if scope.is_none() { 120 + scope = Some("atproto".into()); 121 } 122 Ok(ClientMetadata { 123 + client_id: client_id.into(), 124 + client_name: Some("Loopback Client".into()), 125 client_uri: None, 126 logo_uri: None, 127 redirect_uris, 128 grant_types: vec![ 129 + "authorization_code".into(), 130 + "refresh_token".into(), 131 ], 132 + response_types: vec!["code".into()], 133 scope, 134 + token_endpoint_auth_method: Some("none".into()), 135 dpop_bound_access_tokens: Some(false), 136 jwks: None, 137 jwks_uri: None, 138 + application_type: Some("native".into()), 139 }) 140 } 141