General linting, document react-native-streamplace-oauth-problem

This commit is contained in:
lewis
2026-01-05 23:08:25 +02:00
parent df30e0253f
commit 7faec624d1
20 changed files with 214 additions and 178 deletions
+23
View File
@@ -0,0 +1,23 @@
# Known Issues
## stream.place iOS app OAuth flow fails
OAuth flow with stream.place's iOS app (using expo-web-browser's ASWebAuthenticationSession) does not complete. After user approves consent, the redirect from our PDS to stream.place's callback URL is not followed by ASWebAuthenticationSession.
What does work with stream.place: everything else :P
- Desktop browsers
- ios safari (regular browser)
- ASWebAuthenticationSession using the reference pds
What fails:
- ASWebAuthenticationSession with this pds
Attempted fixes (all failed):
- HTTP 302/303/307 redirects
- JavaScript navigation
- Meta refresh
- Form auto-submit
- Removing CORS headers
- HTTP/1.1 instead of HTTP/2
- Minimal response headers
@@ -111,7 +111,7 @@
</div>
<div class="field">
<label>{$_('migration.inbound.chooseHandle.authMethod')}</label>
<span class="field-label">{$_('migration.inbound.chooseHandle.authMethod')}</span>
<div class="auth-method-options">
<label class="auth-option" class:selected={authMethod === 'password'}>
<input
-18
View File
@@ -674,24 +674,6 @@
padding: var(--space-7);
}
.message {
padding: var(--space-3);
border-radius: var(--radius-md);
margin-bottom: var(--space-4);
}
.message.error {
background: var(--error-bg);
border: 1px solid var(--error-border);
color: var(--error-text);
}
.message.success {
background: var(--success-bg);
border: 1px solid var(--success-border);
color: var(--success-text);
}
.config-form {
max-width: 500px;
}
-9
View File
@@ -234,15 +234,6 @@
margin-bottom: var(--space-7);
}
.error {
padding: var(--space-3);
background: var(--error-bg);
border: 1px solid var(--error-border);
border-radius: var(--radius-md);
color: var(--error-text);
margin-bottom: var(--space-4);
}
.created-password {
display: flex;
flex-direction: column;
-6
View File
@@ -412,12 +412,6 @@
margin: var(--space-2) 0 0 0;
}
.loading {
text-align: center;
color: var(--text-secondary);
padding: var(--space-7);
}
.split-layout {
display: grid;
grid-template-columns: 1fr;
-19
View File
@@ -453,31 +453,12 @@
margin: var(--space-2) 0 0 0;
}
.loading,
.empty {
text-align: center;
color: var(--text-secondary);
padding: var(--space-4);
}
.message {
padding: var(--space-3);
border-radius: var(--radius-md);
margin-bottom: var(--space-4);
}
.message.error {
background: var(--error-bg);
border: 1px solid var(--error-border);
color: var(--error-text);
}
.message.success {
background: var(--success-bg);
border: 1px solid var(--success-border);
color: var(--success-text);
}
.constraint-notice {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
@@ -216,22 +216,12 @@
margin: var(--space-2) 0 0 0;
}
.loading,
.empty {
text-align: center;
color: var(--text-secondary);
padding: var(--space-7);
}
.message.error {
padding: var(--space-3);
background: var(--error-bg);
border: 1px solid var(--error-border);
border-radius: var(--radius-md);
color: var(--error-text);
margin-bottom: var(--space-4);
}
.audit-list {
display: flex;
flex-direction: column;
@@ -439,12 +439,6 @@
margin-top: var(--space-6);
}
.loading {
text-align: center;
padding: var(--space-9);
color: var(--text-secondary);
}
@media (max-width: 600px) {
.field-row {
flex-direction: column;
-9
View File
@@ -192,15 +192,6 @@
margin-bottom: var(--space-7);
}
.error {
padding: var(--space-3);
background: var(--error-bg);
border: 1px solid var(--error-border);
border-radius: var(--radius-md);
color: var(--error-text);
margin-bottom: var(--space-4);
}
.created-code {
padding: var(--space-6);
background: var(--success-bg);
+10 -8
View File
@@ -74,17 +74,18 @@
if (!hasOAuthCallback) {
if (hasPendingMigration()) {
resumeInfo = getResumeInfo()
if (resumeInfo) {
if (resumeInfo.step === 'success') {
const info = getResumeInfo()
if (info) {
if (info.step === 'success') {
clearMigrationState()
resumeInfo = null
} else {
resumeInfo = info
const stored = loadMigrationState()
if (stored && stored.direction === 'inbound') {
direction = 'inbound'
inboundFlow = createInboundMigrationFlow()
inboundFlow.resumeFromState(stored)
const flow = createInboundMigrationFlow()
flow.resumeFromState(stored)
inboundFlow = flow
}
}
}
@@ -94,8 +95,9 @@
clearOfflineState()
} else {
direction = 'offline-inbound'
offlineFlow = createOfflineInboundMigrationFlow()
offlineFlow.tryResume()
const flow = createOfflineInboundMigrationFlow()
flow.tryResume()
offlineFlow = flow
}
}
}
+2 -2
View File
@@ -93,8 +93,8 @@
body: JSON.stringify({
request_uri: consentData.request_uri,
approved_scopes: approvedScopes,
remember: rememberChoice
})
remember: rememberChoice,
}),
})
if (!response.ok) {
-6
View File
@@ -516,12 +516,6 @@
color: var(--error-text);
}
.section-hint {
font-size: var(--text-sm);
color: var(--text-secondary);
margin: 0 0 var(--space-5) 0;
}
.radio-group {
display: flex;
flex-direction: column;
-6
View File
@@ -599,12 +599,6 @@
color: var(--success-text);
}
.loading-text {
text-align: center;
color: var(--text-secondary);
padding: var(--space-7);
}
.toolbar {
display: flex;
gap: var(--space-2);
-6
View File
@@ -795,12 +795,6 @@
margin: var(--space-2) 0 0 0;
}
.loading {
text-align: center;
color: var(--text-secondary);
padding: var(--space-7);
}
section {
padding: var(--space-6);
background: var(--bg-secondary);
+1 -2
View File
@@ -960,8 +960,7 @@
font-size: var(--text-xs);
}
.empty,
.loading {
.empty {
color: var(--text-secondary);
font-size: var(--text-sm);
margin-bottom: var(--space-4);
@@ -244,12 +244,6 @@
font-size: var(--text-sm);
}
.loading {
text-align: center;
padding: var(--space-7);
color: var(--text-secondary);
}
.empty-state {
text-align: center;
padding: var(--space-8) var(--space-4);
+8
View File
@@ -3,6 +3,14 @@
margin: 0 auto;
}
.field-label {
display: block;
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: var(--text-primary);
margin-bottom: var(--space-2);
}
.step-indicator {
display: flex;
align-items: center;
+4
View File
@@ -550,6 +550,10 @@ pub fn app(state: AppState) -> Router {
.route("/authorize/deny", post(oauth::endpoints::authorize_deny))
.route("/authorize/consent", get(oauth::endpoints::consent_get))
.route("/authorize/consent", post(oauth::endpoints::consent_post))
.route(
"/authorize/redirect",
get(oauth::endpoints::authorize_redirect),
)
.route("/delegation/auth", post(oauth::endpoints::delegation_auth))
.route(
"/delegation/totp",
+130 -63
View File
@@ -22,7 +22,18 @@ use urlencoding::encode as url_encode;
const DEVICE_COOKIE_NAME: &str = "oauth_device_id";
fn redirect_see_other(uri: &str) -> Response {
(StatusCode::SEE_OTHER, [(LOCATION, uri.to_string())]).into_response()
(
StatusCode::SEE_OTHER,
[
(LOCATION, uri.to_string()),
(axum::http::header::CACHE_CONTROL, "no-store".to_string()),
(
SET_COOKIE,
"bfCacheBypass=foo; max-age=1; SameSite=Lax".to_string(),
),
],
)
.into_response()
}
fn redirect_to_frontend_error(error: &str, description: &str) -> Response {
@@ -783,13 +794,13 @@ pub async fn authorize_post(
{
return show_login_error("An error occurred. Please try again.", json_response);
}
let redirect_url = build_success_redirect(
&request_data.parameters.redirect_uri,
&code.0,
request_data.parameters.state.as_deref(),
request_data.parameters.response_mode.as_deref(),
);
if json_response {
let redirect_url = build_intermediate_redirect_url(
&request_data.parameters.redirect_uri,
&code.0,
request_data.parameters.state.as_deref(),
request_data.parameters.response_mode.as_deref(),
);
if let Some(cookie) = new_cookie {
(
StatusCode::OK,
@@ -800,14 +811,22 @@ pub async fn authorize_post(
} else {
Json(serde_json::json!({"redirect_uri": redirect_url})).into_response()
}
} else if let Some(cookie) = new_cookie {
(
StatusCode::SEE_OTHER,
[(SET_COOKIE, cookie), (LOCATION, redirect_url)],
)
.into_response()
} else {
redirect_see_other(&redirect_url)
let redirect_url = build_success_redirect(
&request_data.parameters.redirect_uri,
&code.0,
request_data.parameters.state.as_deref(),
request_data.parameters.response_mode.as_deref(),
);
if let Some(cookie) = new_cookie {
(
StatusCode::SEE_OTHER,
[(SET_COOKIE, cookie), (LOCATION, redirect_url)],
)
.into_response()
} else {
redirect_see_other(&redirect_url)
}
}
}
@@ -984,7 +1003,7 @@ pub async fn authorize_select(
"An error occurred. Please try again.",
);
}
let redirect_url = build_success_redirect(
let redirect_url = build_intermediate_redirect_url(
&request_data.parameters.redirect_uri,
&code.0,
request_data.parameters.state.as_deref(),
@@ -1012,18 +1031,70 @@ fn build_success_redirect(
'?'
};
redirect_url.push(separator);
redirect_url.push_str(&format!("code={}", url_encode(code)));
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
redirect_url.push_str(&format!(
"iss={}",
url_encode(&format!("https://{}", pds_hostname))
));
if let Some(req_state) = state {
redirect_url.push_str(&format!("&state={}", url_encode(req_state)));
}
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
redirect_url.push_str(&format!(
"&iss={}",
url_encode(&format!("https://{}", pds_hostname))
));
redirect_url.push_str(&format!("&code={}", url_encode(code)));
redirect_url
}
fn build_intermediate_redirect_url(
redirect_uri: &str,
code: &str,
state: Option<&str>,
response_mode: Option<&str>,
) -> String {
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let mut url = format!(
"https://{}/oauth/authorize/redirect?redirect_uri={}&code={}",
pds_hostname,
url_encode(redirect_uri),
url_encode(code)
);
if let Some(s) = state {
url.push_str(&format!("&state={}", url_encode(s)));
}
if let Some(rm) = response_mode {
url.push_str(&format!("&response_mode={}", url_encode(rm)));
}
url
}
#[derive(Debug, Deserialize)]
pub struct AuthorizeRedirectParams {
redirect_uri: String,
code: String,
state: Option<String>,
response_mode: Option<String>,
}
pub async fn authorize_redirect(Query(params): Query<AuthorizeRedirectParams>) -> Response {
let final_url = build_success_redirect(
&params.redirect_uri,
&params.code,
params.state.as_deref(),
params.response_mode.as_deref(),
);
tracing::info!(
final_url = %final_url,
client_redirect = %params.redirect_uri,
"authorize_redirect performing 303 redirect"
);
(
StatusCode::SEE_OTHER,
[
(axum::http::header::LOCATION, final_url),
(axum::http::header::CACHE_CONTROL, "no-store".to_string()),
],
)
.into_response()
}
#[derive(Debug, Serialize)]
pub struct AuthorizeDenyResponse {
pub error: String,
@@ -1367,11 +1438,16 @@ pub async fn consent_post(
}
};
if let Some(err_response) = validate_auth_flow_state(&flow_state, true) {
if flow_state.is_expired() {
let _ = db::delete_authorization_request(&state.db, &form.request_uri).await;
}
return err_response;
if flow_state.is_expired() {
let _ = db::delete_authorization_request(&state.db, &form.request_uri).await;
return json_error(
StatusCode::BAD_REQUEST,
"invalid_request",
"Authorization request has expired",
);
}
if flow_state.is_pending() {
return json_error(StatusCode::FORBIDDEN, "access_denied", "Not authenticated");
}
let did = flow_state.did().unwrap().to_string();
@@ -1420,14 +1496,11 @@ pub async fn consent_post(
&& !has_granular_scopes
&& !form.approved_scopes.contains(&"atproto".to_string())
{
return (
return json_error(
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_request",
"error_description": "The atproto scope was requested and must be approved"
})),
)
.into_response();
"invalid_request",
"The atproto scope was requested and must be approved",
);
}
let final_approved: Vec<String> = if user_denied_some_granular {
form.approved_scopes
@@ -1439,14 +1512,11 @@ pub async fn consent_post(
form.approved_scopes.clone()
};
if final_approved.is_empty() {
return (
return json_error(
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_request",
"error_description": "At least one scope must be approved"
})),
)
.into_response();
"invalid_request",
"At least one scope must be approved",
);
}
let approved_scope_str = final_approved.join(" ");
let has_valid_scope = final_approved.iter().all(|s| {
@@ -1462,14 +1532,11 @@ pub async fn consent_post(
|| s.starts_with("include:")
});
if !has_valid_scope {
return (
return json_error(
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_request",
"error_description": "Invalid scope format"
})),
)
.into_response();
"invalid_request",
"Invalid scope format",
);
}
if form.remember {
let preferences: Vec<db::ScopePreference> = requested_scopes
@@ -1503,25 +1570,25 @@ pub async fn consent_post(
.await
.is_err()
{
return (
return json_error(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "server_error",
"error_description": "Failed to complete authorization"
})),
)
.into_response();
"server_error",
"Failed to complete authorization",
);
}
let redirect_url = build_success_redirect(
&request_data.parameters.redirect_uri,
let redirect_uri = &request_data.parameters.redirect_uri;
let intermediate_url = build_intermediate_redirect_url(
redirect_uri,
&code.0,
request_data.parameters.state.as_deref(),
request_data.parameters.response_mode.as_deref(),
);
Json(serde_json::json!({
"redirect_uri": redirect_url
}))
.into_response()
tracing::info!(
intermediate_url = %intermediate_url,
client_redirect = %redirect_uri,
"consent_post returning JSON with intermediate URL (for 303 redirect)"
);
Json(serde_json::json!({ "redirect_uri": intermediate_url })).into_response()
}
pub async fn authorize_2fa_post(
@@ -1630,7 +1697,7 @@ pub async fn authorize_2fa_post(
"An error occurred. Please try again.",
);
}
let redirect_url = build_success_redirect(
let redirect_url = build_intermediate_redirect_url(
&request_data.parameters.redirect_uri,
&code.0,
request_data.parameters.state.as_deref(),
@@ -1725,7 +1792,7 @@ pub async fn authorize_2fa_post(
"An error occurred. Please try again.",
);
}
let redirect_url = build_success_redirect(
let redirect_url = build_intermediate_redirect_url(
&request_data.parameters.redirect_uri,
&code.0,
request_data.parameters.state.as_deref(),
@@ -2367,7 +2434,7 @@ pub async fn passkey_finish(
.into_response();
}
let redirect_url = build_success_redirect(
let redirect_url = build_intermediate_redirect_url(
&request_data.parameters.redirect_uri,
&code.0,
request_data.parameters.state.as_deref(),
+35 -1
View File
@@ -154,7 +154,16 @@ pub fn pds_public_url() -> String {
}
pub fn build_full_url(path: &str) -> String {
format!("{}{}", pds_public_url(), path)
let normalized_path = if !path.starts_with("/xrpc/")
&& (path.starts_with("/com.atproto.")
|| path.starts_with("/app.bsky.")
|| path.starts_with("/_"))
{
format!("/xrpc{}", path)
} else {
path.to_string()
};
format!("{}{}", pds_public_url(), normalized_path)
}
pub fn json_to_ipld(value: &JsonValue) -> Ipld {
@@ -355,4 +364,29 @@ mod tests {
}
panic!("Failed to find CID link in parsed CBOR");
}
#[test]
fn test_build_full_url_adds_xrpc_prefix_for_atproto_paths() {
unsafe { std::env::set_var("PDS_HOSTNAME", "example.com") };
assert_eq!(
build_full_url("/com.atproto.server.getSession"),
"https://example.com/xrpc/com.atproto.server.getSession"
);
assert_eq!(
build_full_url("/app.bsky.feed.getTimeline"),
"https://example.com/xrpc/app.bsky.feed.getTimeline"
);
assert_eq!(
build_full_url("/_health"),
"https://example.com/xrpc/_health"
);
assert_eq!(
build_full_url("/xrpc/com.atproto.server.getSession"),
"https://example.com/xrpc/com.atproto.server.getSession"
);
assert_eq!(
build_full_url("/oauth/token"),
"https://example.com/oauth/token"
);
}
}