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