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:
+
+
+
+ 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" && (
<>