Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 75 additions & 7 deletions src/integrations/tanstack-query/api-auth.functions.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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(),
});
Expand Down Expand Up @@ -79,6 +146,7 @@ const getSavedHandlesQueryOptions = queryOptions({

export const auth = {
authorize,
getReferrerLogin,
singup,
getSavedHandles: getSavedHandlesServer,
getSavedHandlesQueryOptions,
Expand Down
48 changes: 48 additions & 0 deletions src/lib/referrer-did-url.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
41 changes: 41 additions & 0 deletions src/lib/referrer-did-url.ts
Original file line number Diff line number Diff line change
@@ -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();
}
45 changes: 45 additions & 0 deletions src/routes/_header-layout.developers.atproto.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,51 @@ function DevelopersAtprotoPage() {
</Text>
</Blockquote>
</Flex>

<Flex direction="column" gap="6xl">
<Flex direction="column" gap="4xl">
<Heading2>Login to the atmosphere</Heading2>
<Body variant="secondary">
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 <Text weight="medium">hint</Text>, 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{" "}
<Link href="https://atproto.com/specs/oauth">OAuth</Link>.
</Body>
</Flex>

<Flex direction="column" gap="4xl">
<Heading3>referrer_did convention</Heading3>
<Body variant="secondary">
When your app links out to another atmosphere app and the current
viewer is logged in, append the viewer&apos;s DID as{" "}
<InlineCode>referrer_did</InlineCode>:
</Body>
<Blockquote>
<span {...stylex.props(styles.monoTight)}>
https://other-app.example/path?referrer_did=did:plc:abc123
</span>
</Blockquote>
<Body variant="secondary">
<Text weight="medium">Consuming it:</Text> on load, read{" "}
<InlineCode>referrer_did</InlineCode> from the URL. If it is a
valid DID and the user is not signed in, offer &quot;Continue as
@handle&quot; that starts OAuth with the DID as the login hint
(resolve DID → PDS → authorize). ATStore does this at{" "}
<InlineCode>/login?referrer_did=…</InlineCode>.
</Body>
<Blockquote>
<Text leading="base">
Privacy: this discloses the viewer&apos;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).
</Text>
</Blockquote>
</Flex>
</Flex>
</Flex>
</Page.Root>
);
Expand Down
3 changes: 2 additions & 1 deletion src/routes/_header-layout.products.$productId.index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -924,7 +925,7 @@ function HeroSection({
) : null}
{primaryLink ? (
<ButtonLink
to={primaryLink}
to={withReferrerDid(primaryLink, session?.user?.did)}
size="lg"
target="_blank"
rel="noopener noreferrer"
Expand Down
10 changes: 8 additions & 2 deletions src/routes/api.auth.atproto.authorize.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,19 @@ import { z } from "zod";
const searchSchema = z.object({
redirect: z.string().optional(),
handle: z.string().optional(),
// DID hint from another atmosphere app (see docs/atmosphere-login.md).
referrer_did: z.string().optional(),
});

export const Route = createFileRoute("/api/auth/atproto/authorize")({
validateSearch: searchSchema,
beforeLoad: async ({ search }) => {
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",
});
Expand All @@ -21,6 +26,7 @@ export const Route = createFileRoute("/api/auth/atproto/authorize")({
const result = await auth.authorize({
data: {
handle: handleParam,
referrerDid,
redirect: search.redirect,
},
});
Expand Down
Loading
Loading