diff --git a/src/integrations/tanstack-query/api-auth.functions.ts b/src/integrations/tanstack-query/api-auth.functions.ts index 8934925..c9bd64d 100644 --- a/src/integrations/tanstack-query/api-auth.functions.ts +++ b/src/integrations/tanstack-query/api-auth.functions.ts @@ -1,23 +1,42 @@ import type { ActorIdentifier } from "@atcute/lexicons"; +import { isDid } from "@atcute/lexicons/syntax"; import { queryOptions } from "@tanstack/react-query"; import { createServerFn } from "@tanstack/react-start"; import { getRequest } from "@tanstack/react-start/server"; import { atprotoOAuth } from "#/integrations/auth/atproto"; +import { fetchBlueskyPublicProfileFields } from "#/lib/bluesky-public-profile"; import { sanitizeAuthRedirectTarget } from "#/utils/auth-redirect"; import { getSavedHandles } from "#/utils/saved-handles"; import { z } from "zod"; -const authorizeInputSchema = z.object({ - handle: z.string().min(1, "Handle is required"), - redirect: z.string().optional(), -}); +const authorizeInputSchema = z + .object({ + handle: z.string().optional(), + // DID hint carried across the atmosphere (see docs/atmosphere-login.md). + // Used as the OAuth target identifier when no handle is supplied. + referrerDid: z.string().optional(), + redirect: z.string().optional(), + }) + .refine((data) => Boolean(data.handle?.trim() || data.referrerDid?.trim()), { + message: "handle or referrerDid is required", + }); const authorize = createServerFn({ method: "GET" }) .inputValidator(authorizeInputSchema) .handler(async ({ data }) => { const request = getRequest(); - const handle = data.handle.replace(/^@/, "").trim() as ActorIdentifier; + const handle = data.handle?.replace(/^@/, "").trim() || undefined; + const referrerDid = data.referrerDid?.trim(); + + // Prefer an explicit handle; otherwise fall back to the referrer DID hint. + // A DID is a valid ActorIdentifier, so OAuth can start straight from it and + // the callback resolves the handle from the DID's public profile. + const identifier = handle ?? referrerDid; + if (!identifier || (identifier === referrerDid && !isDid(identifier))) { + throw new Error("A valid handle or referrer DID is required."); + } + const redirectTarget = sanitizeAuthRedirectTarget( data.redirect, request.url, @@ -26,17 +45,65 @@ const authorize = createServerFn({ method: "GET" }) const { url } = await atprotoOAuth.authorize({ target: { type: "account", - identifier: handle, + identifier: identifier as ActorIdentifier, }, state: { redirect: redirectTarget, - handle, + // Only carry a handle when we actually have one; the callback fills it + // in from the resolved DID otherwise. + ...(handle ? { handle } : {}), }, }); return { authorizationUrl: url.toString() }; }); +const referrerLoginInputSchema = z.object({ + referrerDid: z.string(), + redirect: z.string().optional(), +}); + +/** + * Resolve a `referrer_did` hint into a one-tap sign-in: the DID's public handle + * + avatar (best effort) and a ready-to-use OAuth authorization URL. Returns + * null when the hint isn't a valid DID so the caller can just ignore it. + */ +const getReferrerLogin = createServerFn({ method: "GET" }) + .inputValidator(referrerLoginInputSchema) + .handler(async ({ data }) => { + const did = data.referrerDid.trim(); + if (!isDid(did)) { + return null; + } + + const request = getRequest(); + const redirectTarget = sanitizeAuthRedirectTarget( + data.redirect, + request.url, + ); + + const profile = await fetchBlueskyPublicProfileFields(did); + const handle = profile?.handle ?? null; + + const { url } = await atprotoOAuth.authorize({ + target: { + type: "account", + identifier: did as ActorIdentifier, + }, + state: { + redirect: redirectTarget, + ...(handle ? { handle } : {}), + }, + }); + + return { + did, + handle, + avatar: profile?.avatarUrl ?? null, + authorizationUrl: url.toString(), + }; + }); + const singupInputSchema = z.object({ redirect: z.string().optional(), }); @@ -79,6 +146,7 @@ const getSavedHandlesQueryOptions = queryOptions({ export const auth = { authorize, + getReferrerLogin, singup, getSavedHandles: getSavedHandlesServer, getSavedHandlesQueryOptions, diff --git a/src/lib/referrer-did-url.test.ts b/src/lib/referrer-did-url.test.ts new file mode 100644 index 0000000..0f733b7 --- /dev/null +++ b/src/lib/referrer-did-url.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; + +import { REFERRER_DID_PARAM, withReferrerDid } from "./referrer-did-url"; + +const DID = "did:plc:abc123"; + +describe("withReferrerDid", () => { + it("appends the did to a bare https URL", () => { + expect(withReferrerDid("https://app.example", DID)).toBe( + `https://app.example/?${REFERRER_DID_PARAM}=did%3Aplc%3Aabc123`, + ); + }); + + it("preserves existing query params and hash", () => { + expect(withReferrerDid("https://app.example/x?a=1#top", DID)).toBe( + `https://app.example/x?a=1&${REFERRER_DID_PARAM}=did%3Aplc%3Aabc123#top`, + ); + }); + + it("overwrites a stale referrer_did", () => { + expect( + withReferrerDid(`https://app.example/?${REFERRER_DID_PARAM}=old`, DID), + ).toBe(`https://app.example/?${REFERRER_DID_PARAM}=did%3Aplc%3Aabc123`); + }); + + it("returns the URL unchanged when there is no did", () => { + const noDid: string | undefined = undefined; + expect(withReferrerDid("https://app.example", null)).toBe( + "https://app.example", + ); + expect(withReferrerDid("https://app.example", noDid)).toBe( + "https://app.example", + ); + }); + + it("leaves non-http(s) and unparseable URLs alone", () => { + expect(withReferrerDid("mailto:hi@example.com", DID)).toBe( + "mailto:hi@example.com", + ); + expect(withReferrerDid("/relative/path", DID)).toBe("/relative/path"); + }); + + it("returns undefined for empty input", () => { + expect(withReferrerDid(null, DID)).toBeUndefined(); + expect(withReferrerDid(undefined, DID)).toBeUndefined(); + expect(withReferrerDid("", DID)).toBeUndefined(); + }); +}); diff --git a/src/lib/referrer-did-url.ts b/src/lib/referrer-did-url.ts new file mode 100644 index 0000000..143764d --- /dev/null +++ b/src/lib/referrer-did-url.ts @@ -0,0 +1,41 @@ +/** + * Query parameter carrying the DID of the logged-in user who is linking out to + * another app in the atmosphere. It is a *hint* only: the destination app should + * use it to resolve DID -> PDS and pre-fill / kick off its own OAuth flow. A DID + * is public and can't be trusted as proof of identity, so the destination must + * still authenticate the user itself. + */ +export const REFERRER_DID_PARAM = "referrer_did"; + +/** + * Append a `referrer_did` query parameter to an outbound app URL so a logged-in + * user can be recognised (and offered a fast sign-in) as they move across the + * atmosphere. + * + * The original URL is returned unchanged when there is no DID, the URL is empty, + * or it isn't an absolute http(s) URL (mailto:, relative paths, etc.). Any hash + * and existing query parameters are preserved; an existing `referrer_did` is + * overwritten so a stale value can't linger. + */ +export function withReferrerDid( + url: string | null | undefined, + did: string | null | undefined, +): string | undefined { + if (!url) return undefined; + if (!did) return url; + + let parsed: URL; + try { + parsed = new URL(url); + } catch { + // Relative or otherwise unparseable URL — leave it untouched. + return url; + } + + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return url; + } + + parsed.searchParams.set(REFERRER_DID_PARAM, did); + return parsed.toString(); +} diff --git a/src/routes/_header-layout.developers.atproto.tsx b/src/routes/_header-layout.developers.atproto.tsx index eb52073..9b6babe 100644 --- a/src/routes/_header-layout.developers.atproto.tsx +++ b/src/routes/_header-layout.developers.atproto.tsx @@ -247,6 +247,51 @@ function DevelopersAtprotoPage() { + + + + Login to the atmosphere + + A convention for letting a user who is logged in on one atmosphere + app get a fast, one-tap sign-in when they follow a link to + another. It is a hint, not + authentication: a DID is public, so passing one only tells the + destination who to offer login to—the destination must still run + its own ATProto{" "} + OAuth. + + + + + referrer_did convention + + When your app links out to another atmosphere app and the current + viewer is logged in, append the viewer's DID as{" "} + referrer_did: + +
+ + https://other-app.example/path?referrer_did=did:plc:abc123 + +
+ + Consuming it: on load, read{" "} + referrer_did from the URL. If it is a + valid DID and the user is not signed in, offer "Continue as + @handle" that starts OAuth with the DID as the login hint + (resolve DID → PDS → authorize). ATStore does this at{" "} + /login?referrer_did=…. + +
+ + Privacy: this discloses the viewer's DID to the + destination. Only send it to atmosphere destinations you intend + to hand off to (not, for example, a privacy-policy or + source-code link). + +
+
+
); diff --git a/src/routes/_header-layout.products.$productId.index.tsx b/src/routes/_header-layout.products.$productId.index.tsx index 8fd938b..52ee504 100644 --- a/src/routes/_header-layout.products.$productId.index.tsx +++ b/src/routes/_header-layout.products.$productId.index.tsx @@ -17,6 +17,7 @@ import { BlueskyIcon } from "#/components/bluesky-icon"; import { FundingPopoverChip } from "#/components/funding-popover-chip"; import { useButtonStyles } from "#/design-system/theme/useButtonStyles"; import { ToggleButton } from "#/design-system/toggle-button"; +import { withReferrerDid } from "#/lib/referrer-did-url"; import { BadgeCheck, BookOpen, @@ -924,7 +925,7 @@ function HeroSection({ ) : null} {primaryLink ? ( { - const handleParam = search.handle; + const handleParam = search.handle?.trim(); + const referrerDid = search.referrer_did?.trim(); - if (!handleParam) { + // Need something to authorize against — a handle the user typed, or a + // referrer DID hint. With neither, send them to the login page to choose. + if (!handleParam && !referrerDid) { throw redirect({ to: "/login", }); @@ -21,6 +26,7 @@ export const Route = createFileRoute("/api/auth/atproto/authorize")({ const result = await auth.authorize({ data: { handle: handleParam, + referrerDid, redirect: search.redirect, }, }); diff --git a/src/routes/login.tsx b/src/routes/login.tsx index 6b6778c..69c34e9 100644 --- a/src/routes/login.tsx +++ b/src/routes/login.tsx @@ -46,6 +46,9 @@ const searchSchema = z.object({ handle: z.string().optional(), avatar: z.string().optional(), error: z.string().optional(), + // DID hint from another atmosphere app (see docs/atmosphere-login.md): when + // present we offer a one-tap "Continue as @handle" for that identity. + referrer_did: z.string().optional(), }); const styles = stylex.create({ @@ -131,21 +134,34 @@ export const Route = createFileRoute("/login")({ middleware: [unauthMiddleware], }, loader: async ({ context, location }) => { + const search = location.search as Record; + const redirectTarget = search["redirect"]; + const referrerDid = search["referrer_did"]?.trim(); + const savedHandles = await context.queryClient.ensureQueryData( auth.getSavedHandlesQueryOptions, ); - return { - savedHandles, - redirects: await Promise.all( + const [redirects, referrerLogin] = await Promise.all([ + Promise.all( savedHandles.map((h) => auth.authorize({ data: { handle: h.handle, - redirect: (location.search as Record)["redirect"], + redirect: redirectTarget, }, }), ), ), + referrerDid + ? auth.getReferrerLogin({ + data: { referrerDid, redirect: redirectTarget }, + }) + : null, + ]); + return { + savedHandles, + redirects, + referrerLogin, }; }, component: AuthPage, @@ -165,8 +181,11 @@ function AuthPage() { avatar: avatarParam, error, } = Route.useSearch(); - const { savedHandles: initialSavedHandles, redirects } = - Route.useLoaderData(); + const { + savedHandles: initialSavedHandles, + redirects, + referrerLogin, + } = Route.useLoaderData(); const navigate = useNavigate(); const [handle, setHandle] = useState(""); @@ -238,6 +257,37 @@ function AuthPage() { ) : null} + {referrerLogin ? ( + + + Continue with your atmosphere identity + + + + + Continue as {referrerLogin.handle ?? referrerLogin.did} + + + + + + ) : null} + {view === "saved-handles" && ( <>