+18
-21
frontend/src/lib/oauth.ts
+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
+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