Skip to content
Closed
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
5 changes: 3 additions & 2 deletions packages/frontpage-oauth/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ type GetClientMetadataOptions = {
appUrl: string;
};

export const AUTH_SCOPES = "atproto transition:generic";
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If everything works correctly in this PR it should just be a matter of deploying the app with new scopes here. This can be done as a followup PR when implementing user email storage.


export function getClientMetadata({
redirectUri,
appUrl,
Expand All @@ -20,8 +22,7 @@ export function getClientMetadata({
subject_type: "public",
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"], // TODO: "code id_token"?
// TODO: Tweak these?
scope: "atproto transition:generic",
scope: AUTH_SCOPES,
client_name: "Frontpage",
token_endpoint_auth_method: "private_key_jwt",
token_endpoint_auth_signing_alg: "ES256",
Expand Down
9 changes: 9 additions & 0 deletions packages/frontpage/app/(app)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,22 @@ import {
DialogTrigger,
} from "@/lib/components/ui/dialog";
import { NewPostForm } from "./post/new/_client";
import { AUTH_SCOPES } from "@repo/frontpage-oauth";
import { redirect } from "next/navigation";

export default async function Layout({
children,
}: {
children: React.ReactNode;
}) {
const session = await getSession();

// If the current session has different scopes than the AUTH_SCOPES, redirect to reauthenticate
// Don't redirect if the request is for the reauthenticate page or oauth callback
if (session && session.user.scope !== AUTH_SCOPES) {
redirect("/reauthenticate");
}

return (
<div className="container mx-auto px-4 md:px-6 pt-4 pb-8 md:py-12 max-w-3xl">
<div className="flex place-content-between items-center mb-8">
Expand Down
9 changes: 9 additions & 0 deletions packages/frontpage/app/(auth)/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Card } from "@/lib/components/ui/card";

export default function Layout({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center justify-center min-h-screen px-4 sm:px-6 lg:px-8">
<Card className="w-full max-w-md space-y-6 p-6">{children}</Card>
</div>
);
}
106 changes: 52 additions & 54 deletions packages/frontpage/app/(auth)/login/_lib/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,61 +26,59 @@ export function LoginForm() {
);

return (
<>
<div className="space-y-3">
<form className="contents" action={pdsAction}>
<Button
className="w-full"
type="submit"
name="pdsUrl"
value={DEFAULT_PDS_URL}
disabled={isPdsPending}
size="lg"
>
Login or signup with {DEFAULT_PDS_URL}
<div className="space-y-3">
<form className="contents" action={pdsAction}>
<Button
className="w-full"
type="submit"
name="pdsUrl"
value={DEFAULT_PDS_URL}
disabled={isPdsPending}
size="lg"
>
Login or signup with {DEFAULT_PDS_URL}
</Button>
</form>

<Dialog open={pdsDialogOpen} onOpenChange={setPdsDialogOpen}>
<DialogTrigger asChild>
<Button className="w-full" variant="outline">
Continue with another PDS
</Button>
</form>

<Dialog open={pdsDialogOpen} onOpenChange={setPdsDialogOpen}>
<DialogTrigger asChild>
<Button className="w-full" variant="outline">
Continue with another PDS
</Button>
</DialogTrigger>
<DialogContent className="top-1/3">
<DialogHeader>
<DialogTitle>Login with another PDS</DialogTitle>
<DialogDescription>
Enter the URL of your PDS to login.
</DialogDescription>
</DialogHeader>

<PdsForm />
</DialogContent>
</Dialog>

<Dialog>
<DialogTrigger asChild>
<Button className="w-full" variant="outline">
Continue with @handle
</Button>
</DialogTrigger>

<DialogContent className="top-1/3">
<DialogHeader>
<DialogTitle>Login with handle</DialogTitle>
<DialogDescription>
Enter your Bluesky/AT Protocol handle to login.
</DialogDescription>
</DialogHeader>

<IdentifierForm />
</DialogContent>
</Dialog>

<LoginError errorState={pdsState?.error} />
</div>
</>
</DialogTrigger>
<DialogContent className="top-1/3">
<DialogHeader>
<DialogTitle>Login with another PDS</DialogTitle>
<DialogDescription>
Enter the URL of your PDS to login.
</DialogDescription>
</DialogHeader>

<PdsForm />
</DialogContent>
</Dialog>

<Dialog>
<DialogTrigger asChild>
<Button className="w-full" variant="outline">
Continue with @handle
</Button>
</DialogTrigger>

<DialogContent className="top-1/3">
<DialogHeader>
<DialogTitle>Login with handle</DialogTitle>
<DialogDescription>
Enter your Bluesky/AT Protocol handle to login.
</DialogDescription>
</DialogHeader>

<IdentifierForm />
</DialogContent>
</Dialog>

<LoginError errorState={pdsState?.error} />
</div>
);
}

Expand Down
39 changes: 18 additions & 21 deletions packages/frontpage/app/(auth)/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { LoginForm } from "./_lib/form";
import { getUser } from "@/lib/data/user";
import { Alert, AlertDescription, AlertTitle } from "@/lib/components/ui/alert";
import { CrossCircledIcon } from "@radix-ui/react-icons";
import { Card } from "@/lib/components/ui/card";

export default async function LoginPage({
searchParams,
Expand All @@ -19,25 +18,23 @@ export default async function LoginPage({
const error = (await searchParams).error;

return (
<div className="flex items-center justify-center min-h-screen px-4 sm:px-6 lg:px-8">
<Card className="w-full max-w-md space-y-6 p-6">
<div className="text-center">
<h2 className="text-3xl font-bold tracking-tight text-gray-900 dark:text-gray-100">
Sign in to Frontpage
</h2>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
Sign in or create an account in the Atmosphere to get started.
</p>
</div>
<LoginForm />
{error ? (
<Alert variant="destructive">
<CrossCircledIcon className="h-4 w-4" />
<AlertTitle>Login error, please try again</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
</Card>
</div>
<>
<div className="text-center">
<h2 className="text-3xl font-bold tracking-tight text-gray-900 dark:text-gray-100">
Sign in to Frontpage
</h2>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
Sign in or create an account in the Atmosphere to get started.
</p>
</div>
<LoginForm />
{error ? (
<Alert variant="destructive">
<CrossCircledIcon className="h-4 w-4" />
<AlertTitle>Login error, please try again</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
</>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"use server";

import { getSession } from "@/lib/auth";
import { signIn } from "@/lib/auth-sign-in";
import { redirect } from "next/navigation";

export async function reauthenticateAction() {
const session = await getSession();
if (!session) {
redirect("/login?error=You've been logged out. Please log in again.");
}
const result = await signIn({
Copy link
Contributor Author

@tom-sherman tom-sherman Jul 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this is successful we should probably delete the old session here. Could just leave it orphaned and let it expire naturally tho.

identifier: session.user.did,
});

if (result && "error" in result) {
return {
error: `An error occurred while re-authenticating (${result.error}), please try again.`,
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"use client";

import { type ReactNode, useActionState } from "react";
import { reauthenticateAction } from "./reauthenticate-action";
import { Button } from "@/lib/components/ui/button";
import { Alert, AlertDescription, AlertTitle } from "@/lib/components/ui/alert";
import { CrossCircledIcon } from "@radix-ui/react-icons";

export function ReauthenticateForm({
// TODO: Use this prop to redirect after re-authentication, requires changes in signIn method
// eslint-disable-next-line @typescript-eslint/no-unused-vars
redirectPath,
avatar,
}: {
redirectPath?: string;
avatar: ReactNode;
}) {
const [state, action, isPending] = useActionState(reauthenticateAction, null);

return (
<div className="space-y-3">
<form action={action} className="flex gap-2 items-center">
<div>{avatar}</div>
<Button type="submit" disabled={isPending} size="lg" className="w-full">
Re-authenticate now
</Button>
</form>
{state?.error ? (
<Alert variant="destructive">
<CrossCircledIcon className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{state?.error}</AlertDescription>
</Alert>
) : null}
</div>
);
}
60 changes: 60 additions & 0 deletions packages/frontpage/app/(auth)/reauthenticate/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { getSession, signOut } from "@/lib/auth";
import { AUTH_SCOPES } from "@repo/frontpage-oauth";
import { redirect } from "next/navigation";
import { ReauthenticateForm } from "./_lib/reauthenticate-form";
import { Button } from "@/lib/components/ui/button";
import { revalidatePath } from "next/cache";
import { UserAvatar } from "@/lib/components/user-avatar";

export default async function LoginPage({
searchParams,
}: {
searchParams: Promise<{ redirect?: string }>;
}) {
const session = await getSession();
if (!session) {
redirect("/login?error=You've been logged out. Please log in again.");
}

const redirectParam = (await searchParams).redirect;

// TODO: Test this doesnt allow you to redirect to an external URL
const redirectPath = redirectParam?.startsWith("/") ? redirectParam : "/";

if (session.user.scope === AUTH_SCOPES) {
console.warn(
"User has AUTH_SCOPES, redirecting to the specified path or defaulting to /",
);
redirect(redirectPath);
}

return (
<>
<div className="text-center">
<h2 className="text-3xl font-bold tracking-tight text-gray-900 dark:text-gray-100">
Re-authenticate to Frontpage
</h2>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
You need to re-authenticate to continue using Frontpage so that we
have the latest permissions to access your data.
</p>
</div>
<div>
<ReauthenticateForm
avatar={<UserAvatar did={session.user.did} size="smedium" />}
/>
<form
action={async () => {
"use server";
await signOut();
revalidatePath("/", "layout");
}}
>
<Button size="lg" variant="secondary" className="w-full mt-4">
Logout
</Button>
</form>
</div>
</>
);
}
1 change: 1 addition & 0 deletions packages/frontpage/drizzle/0007_silent_franklin_storm.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE `oauth_sessions` ADD `scope` text DEFAULT 'atproto transition:generic' NOT NULL;
Loading