Live video on the AT Protocol

Merge pull request #755 from streamplace/natb/pds-login

feat: add PDS login

authored by

natalie and committed by
GitHub
7b3c04a8 e5835f81

+208 -7
+1 -1
go.mod
··· 58 58 github.com/slok/go-http-metrics v0.13.0 59 59 github.com/starttoaster/prometheus-exporter-scraper v0.0.1 60 60 github.com/streamplace/atproto-oauth-golang v0.0.0-20250619231223-a9c04fb888ac 61 - github.com/streamplace/oatproxy v0.0.0-20251022224044-56ff4e867807 61 + github.com/streamplace/oatproxy v0.0.0-20251207230012-236e9bd6dae6 62 62 github.com/stretchr/testify v1.10.0 63 63 github.com/tdewolff/canvas v0.0.0-20250728095813-50d4cb1eee71 64 64 github.com/whyrusleeping/cbor-gen v0.3.1
+4 -2
go.sum
··· 1307 1307 github.com/streamplace/atproto-oauth-golang v0.0.0-20250619231223-a9c04fb888ac/go.mod h1:9LlKkqciiO5lRfbX0n4Wn5KNY9nvFb4R3by8FdW2TWc= 1308 1308 github.com/streamplace/go-dpop v0.0.0-20250510031900-c897158a8ad4 h1:L1fS4HJSaAyNnkwfuZubgfeZy8rkWmA0cMtH5Z0HqNc= 1309 1309 github.com/streamplace/go-dpop v0.0.0-20250510031900-c897158a8ad4/go.mod h1:bGUXY9Wd4mnd+XUrOYZr358J2f6z9QO/dLhL1SsiD+0= 1310 - github.com/streamplace/oatproxy v0.0.0-20251022224044-56ff4e867807 h1:9GXp2QxTVxsK7QLRnjDV9qxdTziLuTVhGMoVicHQ18U= 1311 - github.com/streamplace/oatproxy v0.0.0-20251022224044-56ff4e867807/go.mod h1:pXi24hA7xBHj8eEywX6wGqJOR9FaEYlGwQ/72rN6okw= 1310 + github.com/streamplace/oatproxy v0.0.0-20251201231246-9e9aa13c659d h1:xVIF467970izRZBXBC+/XWyCB6zBtxwZ1KGytEp1rTc= 1311 + github.com/streamplace/oatproxy v0.0.0-20251201231246-9e9aa13c659d/go.mod h1:pXi24hA7xBHj8eEywX6wGqJOR9FaEYlGwQ/72rN6okw= 1312 + github.com/streamplace/oatproxy v0.0.0-20251207230012-236e9bd6dae6 h1:Y81F18H+qQGWk58Vqangsw75XQ6G1shJOsUEqgKQdYI= 1313 + github.com/streamplace/oatproxy v0.0.0-20251207230012-236e9bd6dae6/go.mod h1:pXi24hA7xBHj8eEywX6wGqJOR9FaEYlGwQ/72rN6okw= 1312 1314 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 1313 1315 github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 1314 1316 github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+78 -1
js/app/features/bluesky/oauthClient.tsx
··· 1 + import type { OAuthAuthorizationServerMetadata } from "@atproto/oauth-client"; 2 + import { 3 + OAuthResolver, 4 + ResolveOAuthOptions, 5 + } from "@atproto/oauth-client/dist/oauth-resolver"; 1 6 import { 2 7 ClientMetadata, 3 8 clientMetadataSchema, ··· 5 10 } from "@streamplace/atproto-oauth-client-react-native"; 6 11 import Constants from "expo-constants"; 7 12 import { Platform } from "react-native"; 13 + 14 + class StreamplaceOAuthResolver extends OAuthResolver { 15 + private currentResourceServer: string | null = null; 16 + 17 + constructor( 18 + private streamplaceUrl: string, 19 + ...args: ConstructorParameters<typeof OAuthResolver> 20 + ) { 21 + super(...args); 22 + } 23 + 24 + async resolveFromService( 25 + input: string, 26 + options?: ResolveOAuthOptions, 27 + ): Promise<{ 28 + metadata: OAuthAuthorizationServerMetadata; 29 + }> { 30 + // Input is the resource server URL (e.g., https://selfhosted.social) 31 + // Store it for use in login_hint 32 + this.currentResourceServer = input; 33 + 34 + // Always fetch metadata from our backend 35 + // The issuer will be our backend, not the resource server 36 + const metadata = await this.getResourceServerMetadata( 37 + this.streamplaceUrl, 38 + options, 39 + ); 40 + 41 + return { metadata }; 42 + } 43 + 44 + getCurrentResourceServer(): string | null { 45 + return this.currentResourceServer; 46 + } 47 + } 8 48 9 49 export type StreamplaceOAuthClient = Omit< 10 50 ReactNativeOAuthClient, ··· 17 57 if (!streamplaceUrl) { 18 58 throw new Error("streamplaceUrl is required"); 19 59 } 60 + 61 + // Will be set after we create the custom resolver 62 + let customResolver: StreamplaceOAuthResolver | null = null; 63 + 20 64 let meta: ClientMetadata; 21 65 if ( 22 66 streamplaceUrl.startsWith("http://localhost") || ··· 75 119 console.error("error parsing client metadata", e, meta); 76 120 throw e; 77 121 } 78 - return new ReactNativeOAuthClient({ 122 + const client = new ReactNativeOAuthClient({ 79 123 fetch: async (input, init) => { 80 124 // Normalize input to a Request object 81 125 let request: Request; ··· 84 128 } else { 85 129 request = input; 86 130 } 131 + 132 + // Add login_hint parameter to PAR requests 133 + if ( 134 + customResolver && 135 + request.url.includes("/oauth/par") && 136 + request.method === "POST" 137 + ) { 138 + const resourceServer = customResolver.getCurrentResourceServer(); 139 + if (resourceServer) { 140 + const clonedRequest = request.clone(); 141 + const body = await clonedRequest.text(); 142 + const params = new URLSearchParams(body); 143 + params.set("login_hint", resourceServer); 144 + request = new Request(request.url, { 145 + method: request.method, 146 + headers: request.headers, 147 + body: params.toString(), 148 + }); 149 + } 150 + } 151 + 87 152 if (streamplaceUrl.startsWith("http://127.0.0.1")) { 88 153 // everything other than PDS resolution gets rewritten to the host 89 154 if ( ··· 148 213 // "client_id" endpoint (except when using a loopback client) 149 214 clientMetadata: meta, 150 215 }); 216 + 217 + // Replace the default OAuth resolver with our custom one 218 + customResolver = new StreamplaceOAuthResolver( 219 + streamplaceUrl, 220 + client.oauthResolver.identityResolver, 221 + client.oauthResolver.protectedResourceMetadataResolver, 222 + client.oauthResolver.authorizationServerMetadataResolver, 223 + ); 224 + // @ts-ignore override readonly property 225 + client.oauthResolver = customResolver; 226 + 227 + return client; 151 228 }
+88
js/app/hooks/useBlueskyNotifications.tsx
··· 1 + import { useToast } from "@streamplace/components"; 2 + import { CircleX } from "lucide-react-native"; 3 + import { useEffect } from "react"; 4 + import { useStore } from "../store"; 5 + 6 + function titleCase(str: string) { 7 + return str 8 + .split(" ") 9 + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) 10 + .join(" "); 11 + } 12 + 13 + /** 14 + * hook to listen for bluesky notifications and display them as toasts 15 + * call this hook once at the app level 16 + */ 17 + export function useBlueskyNotifications() { 18 + let toast = useToast(); 19 + const notification = useStore((state) => state.notification); 20 + const clearNotification = useStore((state) => state.clearNotification); 21 + 22 + useEffect(() => { 23 + if (notification) { 24 + // if it's a missing params notification, we'll want to parse the message properly 25 + // e.g. Missing params, got: https://iori.kiryu.cloud/login?error=authorize_failed&error_description=code%3D400%2C+message%3Dfailed+to+resolve+handle+%27https%3A%2F%2Fselfhosted.social%27%3A+handle+syntax+didn%27t+validate+via+regex%3A+https%3A%2F%2Fselfhosted.social 26 + if (notification.message.startsWith("Missing params, got")) { 27 + const urlPart = notification.message.replace( 28 + "Missing params, got: ", 29 + "", 30 + ); 31 + try { 32 + const url = new URL(urlPart); 33 + const error = url.searchParams.get("error") || "Unknown error"; 34 + const errorDescription = 35 + url.searchParams.get("error_description") || "No description"; 36 + toast.show( 37 + notification.type === "success" 38 + ? "Congrats!" 39 + : "Login issue: " + titleCase(error.replace("_", " ")), 40 + `${decodeURIComponent(errorDescription)}`, 41 + { 42 + duration: 100, 43 + variant: notification.type, 44 + actionLabel: "Copy message", 45 + iconLeft: CircleX, 46 + onAction: () => { 47 + navigator.clipboard.writeText( 48 + `${error}: ${decodeURIComponent(errorDescription)}`, 49 + ); 50 + }, 51 + }, 52 + ); 53 + } catch (e) { 54 + // fallback if URL parsing fails 55 + toast.show( 56 + notification.type === "success" 57 + ? "Congrats!" 58 + : "An issue occured when logging in", 59 + notification.message, 60 + { 61 + variant: notification.type, 62 + actionLabel: "Copy message", 63 + onAction: () => { 64 + navigator.clipboard.writeText(notification.message); 65 + }, 66 + }, 67 + ); 68 + } 69 + } else { 70 + toast.show( 71 + notification.type === "success" 72 + ? "Congrats!" 73 + : "An issue occured when logging in", 74 + notification.message, 75 + { 76 + variant: notification.type, 77 + actionLabel: "Copy message", 78 + onAction: () => { 79 + navigator.clipboard.writeText(notification.message); 80 + }, 81 + }, 82 + ); 83 + } 84 + // clears the notification in the store after showing 85 + clearNotification(); 86 + } 87 + }, [notification, clearNotification]); 88 + }
+2 -1
js/app/src/router.tsx
··· 74 74 import { useUrl } from "@streamplace/components"; 75 75 import { LanguagesCategorySettings } from "components/settings/languages-category-settings"; 76 76 import Constants from "expo-constants"; 77 + import { useBlueskyNotifications } from "hooks/useBlueskyNotifications"; 77 78 import { SystemBars } from "react-native-edge-to-edge"; 78 79 import { 79 80 configureReanimatedLogger, ··· 440 441 }, []); 441 442 442 443 const userIsLive = useLiveUser(); 443 - // Note: Toast functionality removed, would need simple alert replacement 444 + useBlueskyNotifications(); 444 445 445 446 let foregroundColor = theme.theme.colors.text || "#fff"; 446 447
+35 -2
js/app/store/slices/blueskySlice.ts
··· 66 66 }; 67 67 serverSettings: null | PlaceStreamServerSettings.Record; 68 68 returnRoute: null | { name: string; params?: any }; 69 + notification: { 70 + message: string; 71 + type: "error" | "success" | "info"; 72 + } | null; 69 73 // actions 74 + clearNotification: () => void; 70 75 loadOAuthClient: () => Promise<void>; 71 76 oauthError: (error: string, description: string) => void; 72 77 login: ( ··· 205 210 serverSettings: null, 206 211 returnRoute: null, 207 212 showLoginModal: false, 213 + notification: null, 214 + 215 + clearNotification: () => { 216 + set({ notification: null }); 217 + }, 208 218 209 219 setReturnRoute: async (route: { name: string; params?: any } | null) => { 210 220 console.log("setReturnRoute:", route); ··· 286 296 }, 287 297 288 298 oauthError: (error: string, description: string) => { 299 + const message = description || error || "authentication failed"; 289 300 set({ 290 301 loginState: { 291 302 loading: false, 292 - error: description || error, 303 + error: message, 293 304 }, 294 305 authStatus: "loggedOut", 306 + notification: { 307 + message, 308 + type: "error", 309 + }, 295 310 }); 296 311 }, 297 312 ··· 340 355 loading: false, 341 356 error: error?.message ?? null, 342 357 }, 358 + notification: { 359 + message: error?.message || "unknown error", 360 + type: "error", 361 + }, 343 362 }); 344 363 } 345 364 }, ··· 443 462 e = e.cause; 444 463 } 445 464 console.error("oauthCallback error", message); 465 + set({ 466 + authStatus: "loggedOut", 467 + notification: { 468 + message, 469 + type: "error", 470 + }, 471 + }); 446 472 throw e; 447 473 } 448 474 } catch (error) { 449 475 console.error("oauthCallback rejected", error); 450 - set({ authStatus: "loggedOut" }); 476 + const message = error?.message || "authentication failed"; 477 + set({ 478 + authStatus: "loggedOut", 479 + notification: { 480 + message, 481 + type: "error", 482 + }, 483 + }); 451 484 } 452 485 }, 453 486