AT-based link agregator. Mirror of https://github.com/likeandscribe/frontpage

Better invalid handle support (#296)

* Refactor auth to to keep sensitive session data private and to remove username from session

* Improve invalid handle rendering

When we attempt to render an invalid handle it should show as "handle.invalid" as per https://atproto.com/specs/handle#invalid-handles

In URLs we fallback to DID if the handle is invalid.

* Set username to empty string for all new rows

* Fix lints

* Display DID instead of "handle.invalid" in moderation UserHandle component (#298)

* Initial plan

* Show DID instead of handle.invalid in moderation user-handle component

Co-authored-by: tom-sherman <9257001+tom-sherman@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tom-sherman <9257001+tom-sherman@users.noreply.github.com>

* Add missing handle fallback

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Remove unused else branch in ternary

https://github.com/frontpagefyi/frontpage/pull/296#discussion_r2604699893

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: tom-sherman <9257001+tom-sherman@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

+299 -250
+5 -2
packages/frontpage/app/(app)/_components/post-card.tsx
··· 103 103 <div className="flex flex-nowrap text-gray-500 dark:text-gray-400 sm:gap-4 justify-between"> 104 104 <div className="flex flex-wrap items-center gap-x-4"> 105 105 <UserHoverCard did={author} asChild> 106 - <Link href={`/profile/${handle}`} className="hover:underline"> 107 - @{handle} 106 + <Link 107 + href={`/profile/${handle ?? author}`} 108 + className="hover:underline" 109 + > 110 + @{handle ?? "handle.invalid"} 108 111 </Link> 109 112 </UserHoverCard> 110 113 {/* <span aria-hidden>•</span> */}
+71 -74
packages/frontpage/app/(app)/layout.tsx
··· 5 5 import { isAdmin } from "@/lib/data/user"; 6 6 import { BellIcon, OpenInNewWindowIcon } from "@radix-ui/react-icons"; 7 7 import { ThemeToggleMenuGroup } from "./_components/theme-toggle"; 8 - import { 9 - getDidFromHandleOrDid, 10 - getVerifiedHandle, 11 - } from "@/lib/data/atproto/identity"; 8 + import { getVerifiedHandle } from "@/lib/data/atproto/identity"; 12 9 13 10 import { 14 11 DropdownMenu, ··· 42 39 43 40 // If the current session has different scopes than the AUTH_SCOPES, redirect to reauthenticate 44 41 // Don't redirect if the request is for the reauthenticate page or oauth callback 45 - if (session && session.user.scope !== AUTH_SCOPES) { 42 + if (session && session.scope !== AUTH_SCOPES) { 46 43 redirect("/reauthenticate"); 47 44 } 48 45 ··· 93 90 94 91 async function LoginOrLogout() { 95 92 const session = await getSession(); 96 - if (session) { 97 - const [did, handle] = await Promise.all([ 98 - getDidFromHandleOrDid(session.user.username), 99 - getVerifiedHandle(session.user.did), 100 - ]); 93 + 94 + if (!session) { 101 95 return ( 102 - <> 103 - <NotificationIndicator> 104 - <Button asChild variant="outline" size="icon"> 105 - <Link href="/notifications" aria-label="Notifications"> 106 - <BellIcon /> 96 + <Button variant="outline" asChild> 97 + <Link href="/login">Login</Link> 98 + </Button> 99 + ); 100 + } 101 + 102 + const handle = await getVerifiedHandle(session.did); 103 + 104 + return ( 105 + <> 106 + <NotificationIndicator> 107 + <Button asChild variant="outline" size="icon"> 108 + <Link href="/notifications" aria-label="Notifications"> 109 + <BellIcon /> 110 + </Link> 111 + </Button> 112 + </NotificationIndicator> 113 + <DropdownMenu> 114 + <DropdownMenuTrigger aria-label="User menu"> 115 + <UserAvatar did={session.did} size="smedium" /> 116 + </DropdownMenuTrigger> 117 + <DropdownMenuContent className="w-56" side="bottom" align="end"> 118 + <DropdownMenuLabel className="truncate"> 119 + {handle ?? "handle.invalid"} 120 + </DropdownMenuLabel> 121 + <DropdownMenuSeparator /> 122 + <DropdownMenuItem asChild> 123 + <Link 124 + href={`/profile/${handle ?? session.did}`} 125 + className="cursor-pointer" 126 + > 127 + Profile 107 128 </Link> 108 - </Button> 109 - </NotificationIndicator> 110 - <DropdownMenu> 111 - <DropdownMenuTrigger aria-label="User menu"> 112 - {did ? ( 113 - <UserAvatar did={did} size="smedium" /> 114 - ) : ( 115 - <span>{handle}</span> 129 + </DropdownMenuItem> 130 + <DropdownMenuItem asChild> 131 + <Link href="/about" className="cursor-pointer"> 132 + About 133 + </Link> 134 + </DropdownMenuItem> 135 + <Suspense fallback={null}> 136 + {isAdmin().then((isAdmin) => 137 + isAdmin ? ( 138 + <DropdownMenuItem asChild> 139 + <Link href="/moderation" className="cursor-pointer"> 140 + Moderation 141 + </Link> 142 + </DropdownMenuItem> 143 + ) : null, 116 144 )} 117 - </DropdownMenuTrigger> 118 - <DropdownMenuContent className="w-56" side="bottom" align="end"> 119 - <DropdownMenuLabel className="truncate">{handle}</DropdownMenuLabel> 120 - <DropdownMenuSeparator /> 121 - <DropdownMenuItem asChild> 122 - <Link href={`/profile/${handle}`} className="cursor-pointer"> 123 - Profile 124 - </Link> 125 - </DropdownMenuItem> 145 + </Suspense> 146 + <ThemeToggleMenuGroup /> 147 + <DropdownMenuSeparator /> 148 + <form 149 + action={async () => { 150 + "use server"; 151 + await signOut(); 152 + revalidatePath("/", "layout"); 153 + }} 154 + > 126 155 <DropdownMenuItem asChild> 127 - <Link href="/about" className="cursor-pointer"> 128 - About 129 - </Link> 156 + <button 157 + type="submit" 158 + className="w-full text-start cursor-pointer" 159 + > 160 + Logout 161 + </button> 130 162 </DropdownMenuItem> 131 - <Suspense fallback={null}> 132 - {isAdmin().then((isAdmin) => 133 - isAdmin ? ( 134 - <DropdownMenuItem asChild> 135 - <Link href="/moderation" className="cursor-pointer"> 136 - Moderation 137 - </Link> 138 - </DropdownMenuItem> 139 - ) : null, 140 - )} 141 - </Suspense> 142 - <ThemeToggleMenuGroup /> 143 - <DropdownMenuSeparator /> 144 - <form 145 - action={async () => { 146 - "use server"; 147 - await signOut(); 148 - revalidatePath("/", "layout"); 149 - }} 150 - > 151 - <DropdownMenuItem asChild> 152 - <button 153 - type="submit" 154 - className="w-full text-start cursor-pointer" 155 - > 156 - Logout 157 - </button> 158 - </DropdownMenuItem> 159 - </form> 160 - </DropdownMenuContent> 161 - </DropdownMenu> 162 - </> 163 - ); 164 - } 165 - 166 - return ( 167 - <Button variant="outline" asChild> 168 - <Link href="/login">Login</Link> 169 - </Button> 163 + </form> 164 + </DropdownMenuContent> 165 + </DropdownMenu> 166 + </> 170 167 ); 171 168 }
+1 -1
packages/frontpage/app/(app)/moderation/_components/report-card.tsx
··· 37 37 const newModEvent: ModerationEventDTO = { 38 38 subjectUri: report.subjectUri, 39 39 subjectDid: report.subjectDid as DID, 40 - createdBy: user.did as DID, 40 + createdBy: user.did, 41 41 createdAt: new Date(), 42 42 labelsAdded: report.reportReason, 43 43 creatorReportReason: report.creatorComment,
+6 -3
packages/frontpage/app/(app)/moderation/_components/user-handle.tsx
··· 3 3 import Link from "next/link"; 4 4 5 5 export async function UserHandle({ userDid }: { userDid: DID }) { 6 - const handle = (await getVerifiedHandle(userDid)) ?? userDid; 6 + const handle = await getVerifiedHandle(userDid); 7 7 8 8 return ( 9 - <Link href={`/profile/${handle}`} className="underline text-blue-500"> 10 - {handle} 9 + <Link 10 + href={`/profile/${handle ?? userDid}`} 11 + className="underline text-blue-500" 12 + > 13 + {handle ?? userDid} 11 14 </Link> 12 15 ); 13 16 }
+2 -2
packages/frontpage/app/(app)/notifications/page.tsx
··· 77 77 return { 78 78 type: "commentReply", 79 79 Icon: ChatBubbleIcon, 80 - title: `@${replierHandle ?? "<invalid handle>"} replied to your comment on "${notification.post.title}"`, 80 + title: `@${replierHandle ?? "handle.invalid"} replied to your comment on "${notification.post.title}"`, 81 81 body: notification.comment.body, 82 82 time: notification.createdAt, 83 83 read: notification.read, ··· 90 90 return { 91 91 type: "postComment", 92 92 Icon: Link1Icon, 93 - title: `@${replierHandle ?? "<invalid handle>"} commented on your post: "${notification.post.title}"`, 93 + title: `@${replierHandle ?? "handle.invalid"} commented on your post: "${notification.post.title}"`, 94 94 body: notification.comment.body, 95 95 time: notification.createdAt, 96 96 read: notification.read,
+1 -1
packages/frontpage/app/(app)/post/[postAuthor]/[postRkey]/[commentAuthor]/[commentRkey]/og-image/route.tsx
··· 46 46 fontFamily: "Source Sans 3", 47 47 }} 48 48 > 49 - @{handle}&apos;s comment: 49 + @{handle ?? "handle.invalid"}&apos;s comment: 50 50 </OgBox> 51 51 <OgBox 52 52 style={{
+4 -3
packages/frontpage/app/(app)/post/[postAuthor]/[postRkey]/[commentAuthor]/[commentRkey]/page.tsx
··· 25 25 const { comment, post } = await getCommentPageData(params); 26 26 27 27 const handle = await getVerifiedHandle(comment.authorDid); 28 + const handleDisplay = handle ?? "handle.invalid"; 28 29 const path = getPagePath(params); 29 30 30 31 return { 31 32 title: 32 33 comment.status === "live" 33 - ? `@${handle}'s comment on "${truncateText(post.title, 15)}"` 34 + ? `@${handleDisplay}'s comment on "${truncateText(post.title, 15)}"` 34 35 : "Deleted comment", 35 36 description: 36 37 comment.status === "live" ? truncateText(comment.body, 47) : null, ··· 40 41 openGraph: 41 42 comment.status === "live" 42 43 ? { 43 - title: `@${handle}'s comment on Frontpage`, 44 + title: `@${handleDisplay}'s comment on Frontpage`, 44 45 description: truncateText(comment.body, 47), 45 46 type: "article", 46 47 publishedTime: comment.createdAt.toISOString(), 47 - authors: [`@${handle}`], 48 + authors: handle ? [`@${handle}`] : undefined, 48 49 url: `https://frontpage.fyi${path}`, 49 50 images: [ 50 51 {
+3 -3
packages/frontpage/app/(app)/post/[postAuthor]/[postRkey]/_lib/comment.tsx
··· 68 68 const hasAuthored = user?.did === comment.authorDid; 69 69 70 70 const childCommentLevel = getChildCommentLevel(level); 71 - const commentHref = `/post/${postAuthorParam}/${postRkey}/${handle}/${comment.rkey}`; 71 + const commentHref = `/post/${postAuthorParam}/${postRkey}/${handle ?? comment.authorDid}/${comment.rkey}`; 72 72 73 73 return ( 74 74 <> ··· 90 90 <div className="flex items-center gap-2"> 91 91 <UserHoverCard asChild did={comment.authorDid}> 92 92 <Link 93 - href={`/profile/${handle}`} 93 + href={`/profile/${handle ?? comment.authorDid}`} 94 94 className="flex items-center gap-2" 95 95 > 96 96 <UserAvatar did={comment.authorDid} /> 97 - <div className="font-medium">@{handle}</div> 97 + <div className="font-medium">@{handle ?? "handle.invalid"}</div> 98 98 </Link> 99 99 </UserHoverCard> 100 100 {comment.status === "pending" ? (
+2 -2
packages/frontpage/app/(app)/post/[postAuthor]/[postRkey]/page.tsx
··· 28 28 }, 29 29 openGraph: { 30 30 title: post.title, 31 - description: `Discuss @${handle}'s post on Frontpage.`, 31 + description: `Discuss @${handle ?? "handle.invalid"}'s post on Frontpage.`, 32 32 type: "article", 33 33 publishedTime: post.createdAt.toISOString(), 34 - authors: [`@${handle}`], 34 + authors: handle ? [`@${handle}`] : undefined, 35 35 url: `https://frontpage.fyi${path}`, 36 36 images: [ 37 37 {
+1 -1
packages/frontpage/app/(app)/post/new/_action.ts
··· 30 30 getVerifiedHandle(user.did), 31 31 ]); 32 32 33 - redirect(`/post/${handle}/${rkey}`); 33 + redirect(`/post/${handle ?? user.did}/${rkey}`); 34 34 } catch (error) { 35 35 if (!(error instanceof DataLayerError)) throw error; 36 36 return { error: "Failed to create post" };
+5 -4
packages/frontpage/app/(app)/profile/[user]/page.tsx
··· 40 40 getVerifiedHandle(did), 41 41 getBlueskyProfile(did), 42 42 ]); 43 - const description = `@${handle}'s profile on Frontpage`; 43 + const handleDisplay = handle ?? "handle.invalid"; 44 + const description = `@${handleDisplay}'s profile on Frontpage`; 44 45 return { 45 - title: `@${handle} on Frontpage`, 46 + title: `@${handleDisplay} on Frontpage`, 46 47 description: description, 47 48 openGraph: { 48 - title: `@${handle}`, 49 + title: `@${handleDisplay}`, 49 50 description: description, 50 51 type: "profile", 51 52 images: profile?.avatar, ··· 84 85 <UserAvatar did={did} size="medium" /> 85 86 <div className="flex flex-wrap items-center gap-2"> 86 87 <h1 className="md:text-2xl font-bold"> 87 - {userHandle ?? "Invalid handle"} 88 + {userHandle ?? "handle.invalid"} 88 89 </h1> 89 90 <EllipsisDropdown aria-label="User actions"> 90 91 <ReportDialogDropdownButton
+1 -1
packages/frontpage/app/(auth)/reauthenticate/_lib/reauthenticate-action.ts
··· 10 10 redirect("/login?error=You've been logged out. Please log in again."); 11 11 } 12 12 const result = await signIn({ 13 - identifier: session.user.did, 13 + identifier: session.did, 14 14 }); 15 15 16 16 if (result && "error" in result) {
+2 -2
packages/frontpage/app/(auth)/reauthenticate/page.tsx
··· 18 18 19 19 const redirectParam = (await searchParams).redirect; 20 20 21 - if (session.user.scope === AUTH_SCOPES) { 21 + if (session.scope === AUTH_SCOPES) { 22 22 // Checking for // and forcing to / to avoid open redirect vulnerabilities 23 23 // This should ensure we only redirect to internal paths and not external sites 24 24 const redirectPath = ··· 44 44 </div> 45 45 <div> 46 46 <ReauthenticateForm 47 - avatar={<UserAvatar did={session.user.did} size="smedium" />} 47 + avatar={<UserAvatar did={session.did} size="smedium" />} 48 48 /> 49 49 <form 50 50 action={async () => {
+1
packages/frontpage/app/api/hover-card-content/route.ts
··· 20 20 21 21 return { 22 22 ...submissions, 23 + did, 23 24 handle, 24 25 }; 25 26 });
+149 -24
packages/frontpage/lib/auth.ts
··· 19 19 type Client as OauthClient, 20 20 isOAuth2Error, 21 21 customFetch as oauth4webapiCustomFetchSymbol, 22 + refreshTokenGrantRequest, 23 + processRefreshTokenResponse, 22 24 } from "oauth4webapi"; 23 25 import { cookies } from "next/headers"; 24 26 import { ··· 29 31 import { db } from "./db"; 30 32 import * as schema from "./schema"; 31 33 import { eq } from "drizzle-orm"; 32 - import { 33 - getDidFromHandleOrDid, 34 - getVerifiedHandle, 35 - } from "./data/atproto/identity"; 34 + import { getDidFromHandleOrDid } from "./data/atproto/identity"; 36 35 import { 37 36 AUTH_SCOPES, 38 37 getClientMetadata as createClientMetadata, ··· 315 314 invariant(tokensResult.data.refresh_token, "Missing refresh token"); 316 315 invariant(tokensResult.data.expires_in, "Missing expires_in"); 317 316 318 - const handle = row.username || (await getVerifiedHandle(subjectDid)); 319 - 320 - invariant(handle, "Failed to get handle"); 321 - 322 317 const expiresAt = new Date( 323 318 Date.now() + tokensResult.data.expires_in * 1000, 324 319 ); 325 320 326 321 const { lastInsertRowid } = await db.insert(schema.OauthSession).values({ 327 322 did: subjectDid, 328 - username: handle, 323 + // At some point we should remove the username field entirely 324 + username: "", 329 325 iss: row.iss, 330 326 accessToken: tokensResult.data.access_token, 331 327 refreshToken: tokensResult.data.refresh_token, ··· 365 361 token_exp: number; 366 362 }; 367 363 368 - export async function setAuthCookie(payload: AuthCookiePayload) { 364 + async function setAuthCookie(payload: AuthCookiePayload) { 369 365 const userToken = await new SignJWT(payload) 370 366 .setProtectedHeader({ alg: USER_SESSION_JWT_ALG }) 371 367 .setIssuedAt() ··· 403 399 }); 404 400 405 401 export async function signOut() { 406 - const session = await getSession(); 402 + const session = await getFullSession(); 407 403 if (!session) { 408 404 console.warn("No session to sign out of"); 409 405 return; 410 406 } 411 407 const authServer = await processDiscoveryResponse( 412 - new URL(session.user.iss), 413 - await oauthDiscoveryRequest(new URL(session.user.iss)), 408 + new URL(session.iss), 409 + await oauthDiscoveryRequest(new URL(session.iss)), 414 410 ); 415 411 416 412 await revocationRequest( 417 413 authServer, 418 414 await getOauthClientOptions(), 419 - session.user.accessToken, 415 + session.accessToken, 420 416 { 421 417 clientPrivateKey: await getClientPrivateKey(), 422 418 }, ··· 424 420 425 421 await db 426 422 .delete(schema.OauthSession) 427 - .where(eq(schema.OauthSession.sessionId, session.user.sessionId)); 423 + .where(eq(schema.OauthSession.sessionId, session.sessionId)); 428 424 429 425 (await cookies()).delete(AUTH_COOKIE_NAME); 430 426 } 431 427 432 - export const getSession = cache(async () => { 428 + /** 429 + * @private This returns the full session row from the database and should not be used outside of this module. This is done to protect sensitive fields like access tokens that should only be handled within this module. 430 + */ 431 + const getFullSession = cache(async () => { 433 432 const token = await getAuthCookie(); 434 433 if (!token) { 435 434 return null; ··· 448 447 return null; 449 448 } 450 449 450 + return session; 451 + }); 452 + 453 + /** 454 + * Session DTO contains only non-sensitive session fields, this is safe to pass to other modules and the client. 455 + */ 456 + type SessionDTO = { 457 + did: DID; 458 + scope: string; 459 + }; 460 + 461 + export async function getSession(): Promise<SessionDTO | null> { 462 + const session = await getFullSession(); 463 + if (!session) { 464 + return null; 465 + } 466 + 451 467 return { 452 - user: session, 468 + did: session.did, 469 + scope: session.scope, 453 470 }; 454 - }); 471 + } 455 472 456 473 export async function importDpopJwks({ 457 474 privateJwk, ··· 489 506 input: RequestInfo | URL, 490 507 init?: RequestInit, 491 508 ) { 492 - const session = await getSession(); 509 + const session = await getFullSession(); 493 510 494 511 if (!session) { 495 512 throw new Error("Not authenticated"); 496 513 } 497 514 498 515 const { privateDpopKey, publicDpopKey } = await importDpopJwks({ 499 - privateJwk: session.user.dpopPrivateJwk, 500 - publicJwk: session.user.dpopPublicJwk, 516 + privateJwk: session.dpopPrivateJwk, 517 + publicJwk: session.dpopPublicJwk, 501 518 }); 502 519 503 520 const makeRequest = (dpopNonce: string) => { 504 521 // It's important to reconstruct the request because we can't send the same body readable stream twice 505 522 const request = new Request(input, init); 506 523 return protectedResourceRequest( 507 - session.user.accessToken, 524 + session.accessToken, 508 525 request.method, 509 526 new URL(request.url), 510 527 request.headers, ··· 531 548 ); 532 549 }; 533 550 534 - let response = await makeRequest(session.user.dpopNonce); 551 + let response = await makeRequest(session.dpopNonce); 535 552 536 553 if (response.status === 401) { 537 554 if ( ··· 561 578 .set({ 562 579 dpopNonce, 563 580 }) 564 - .where(eq(schema.OauthSession.sessionId, session.user.sessionId)); 581 + .where(eq(schema.OauthSession.sessionId, session.sessionId)); 565 582 566 583 return response; 567 584 } 585 + 586 + type RefreshSessionResult = 587 + | { success: true } 588 + | { 589 + success: false; 590 + error: 591 + | "NOT_AUTHENTICATED" 592 + | "REFRESH_FAILED" 593 + | "CONCURRENT_REFRESH_REPLAYED"; 594 + }; 595 + 596 + export async function refreshSession(): Promise<RefreshSessionResult> { 597 + const session = await getFullSession(); 598 + if (!session) { 599 + return { error: "NOT_AUTHENTICATED", success: false }; 600 + } 601 + const authServer = await processDiscoveryResponse( 602 + new URL(session.iss), 603 + await oauthDiscoveryRequest(new URL(session.iss)), 604 + ); 605 + 606 + const client = await getOauthClientOptions(); 607 + 608 + const { privateDpopKey, publicDpopKey } = await importDpopJwks({ 609 + privateJwk: session.dpopPrivateJwk, 610 + publicJwk: session.dpopPublicJwk, 611 + }); 612 + 613 + let response = await refreshTokenGrantRequest( 614 + authServer, 615 + client, 616 + session.refreshToken, 617 + { 618 + clientPrivateKey: await getClientPrivateKey(), 619 + DPoP: { 620 + privateKey: privateDpopKey, 621 + publicKey: publicDpopKey, 622 + nonce: session.dpopNonce, 623 + }, 624 + }, 625 + ); 626 + 627 + let result = await processRefreshTokenResponse(authServer, client, response); 628 + 629 + if ("error" in result && result.error == "use_dpop_nonce") { 630 + const nonce = response.headers.get("DPoP-Nonce"); 631 + if (!nonce) { 632 + throw new Error("Missing DPoP nonce"); 633 + } 634 + response = await refreshTokenGrantRequest( 635 + authServer, 636 + client, 637 + session.refreshToken, 638 + { 639 + clientPrivateKey: await getClientPrivateKey(), 640 + DPoP: { 641 + privateKey: privateDpopKey, 642 + publicKey: publicDpopKey, 643 + nonce: nonce, 644 + }, 645 + }, 646 + ); 647 + 648 + result = await processRefreshTokenResponse(authServer, client, response); 649 + } 650 + 651 + if ( 652 + "error" in result && 653 + result.error === "invalid_grant" && 654 + result.error_description === "refresh token replayed" 655 + ) { 656 + console.warn("Concurrent refresh token replayed"); 657 + return { error: "CONCURRENT_REFRESH_REPLAYED", success: false }; 658 + } 659 + 660 + if ("error" in result) { 661 + return { error: "REFRESH_FAILED", success: false }; 662 + } 663 + 664 + const dpopNonce = response.headers.get("DPoP-Nonce"); 665 + if (!dpopNonce) { 666 + throw new Error("Missing DPoP nonce"); 667 + } 668 + 669 + if (result.expires_in == null) { 670 + throw new Error("Missing expires_in"); 671 + } 672 + 673 + const expiresAt = new Date(Date.now() + result.expires_in * 1000); 674 + 675 + await db 676 + .update(schema.OauthSession) 677 + .set({ 678 + accessToken: result.access_token, 679 + refreshToken: result.refresh_token, 680 + expiresAt, 681 + dpopNonce, 682 + }) 683 + .where(eq(schema.OauthSession.sessionId, session.sessionId)); 684 + 685 + await setAuthCookie({ 686 + jti: String(session.sessionId), 687 + sub: session.did, 688 + token_exp: expiresAt.getTime(), 689 + }); 690 + 691 + return { success: true }; 692 + }
+15 -9
packages/frontpage/lib/components/user-hover-card-client.tsx
··· 17 17 children: ReactNode; 18 18 asChild?: boolean; 19 19 avatar: ReactNode; 20 - initialHandle: string; 20 + handle: string | null; 21 21 reportAction: (formData: FormData) => Promise<void>; 22 22 }; 23 23 ··· 26 26 children, 27 27 asChild, 28 28 avatar, 29 - initialHandle, 29 + handle, 30 30 reportAction, 31 31 }: Props) { 32 32 return ( ··· 41 41 </HoverCardTrigger> 42 42 <HoverCardContent className="w-80"> 43 43 <div className="flex gap-4"> 44 - <Link href={`/profile/${initialHandle}`} className="shrink-0"> 44 + <Link href={`/profile/${handle ?? did}`} className="shrink-0"> 45 45 {avatar} 46 46 </Link> 47 47 <div className="flex flex-col gap-1 basis-full"> 48 - <Suspense fallback={<Fallback handle={initialHandle} />}> 48 + <Suspense fallback={<Fallback handle={handle} did={did} />}> 49 49 <Content did={did} /> 50 50 </Suspense> 51 51 </div> ··· 67 67 68 68 return ( 69 69 <> 70 - <Link href={`/profile/${data.handle}`} className="text-sm font-semibold"> 71 - @{data.handle} 70 + <Link 71 + href={`/profile/${data.handle ?? data.did}`} 72 + className="text-sm font-semibold" 73 + > 74 + @{data.handle ?? "handle.invalid"} 72 75 </Link> 73 76 <p 74 77 className="text-sm flex gap-2 items-center" ··· 86 89 ); 87 90 } 88 91 89 - function Fallback({ handle }: { handle: string }) { 92 + function Fallback({ handle, did }: { handle: string | null; did: DID }) { 90 93 return ( 91 94 <> 92 - <Link href={`/profile/${handle}`} className="text-sm font-semibold"> 93 - @{handle} 95 + <Link 96 + href={`/profile/${handle ?? did}`} 97 + className="text-sm font-semibold" 98 + > 99 + @{handle ?? "handle.invalid"} 94 100 </Link> 95 101 <Skeleton className="h-5 w-12" /> 96 102 <Skeleton className="h-5 w-12" />
+1 -1
packages/frontpage/lib/components/user-hover-card.tsx
··· 22 22 avatar={<UserAvatar did={did} size="medium" />} 23 23 did={did} 24 24 asChild={asChild} 25 - initialHandle={handle ?? ""} 25 + handle={handle} 26 26 reportAction={reportUserAction.bind(null, { did })} 27 27 > 28 28 {children}
+2 -2
packages/frontpage/lib/data/user.ts
··· 17 17 return null; 18 18 } 19 19 20 - const pdsUrl = await getPdsUrl(session.user.did); 20 + const pdsUrl = await getPdsUrl(session.did); 21 21 if (!pdsUrl) { 22 22 throw new Error("No AtprotoPersonalDataServer service found"); 23 23 } 24 24 25 25 return { 26 26 pdsUrl, 27 - did: session.user.did, 27 + did: session.did, 28 28 }; 29 29 }); 30 30
+3
packages/frontpage/lib/schema.ts
··· 256 256 export const OauthSession = sqliteTable("oauth_sessions", { 257 257 sessionId: integer("id").primaryKey(), 258 258 did: did("did").notNull(), 259 + /** 260 + * @deprecated Don't use this, it's just set to "" for all new rows 261 + */ 259 262 username: text("username").notNull(), 260 263 iss: text("iss").notNull(), 261 264 accessToken: text("access_token").notNull(),
+24 -115
packages/frontpage/proxy.ts
··· 1 - import { eq } from "drizzle-orm"; 2 1 import { type NextRequest, NextResponse } from "next/server"; 3 - import { 4 - processDiscoveryResponse, 5 - refreshTokenGrantRequest, 6 - processRefreshTokenResponse, 7 - } from "oauth4webapi"; 8 - import { db } from "./lib/db"; 9 - import * as schema from "./lib/schema"; 10 - import { 11 - getClientPrivateKey, 12 - getOauthClientOptions, 13 - getSession, 14 - importDpopJwks, 15 - signOut, 16 - oauthDiscoveryRequest, 17 - getAuthCookie, 18 - setAuthCookie, 19 - } from "./lib/auth"; 2 + import { signOut, getAuthCookie, refreshSession } from "./lib/auth"; 3 + import { exhaustiveCheck } from "./lib/utils"; 20 4 21 5 export async function proxy(request: NextRequest) { 22 6 const cookieJwt = await getAuthCookie(); ··· 33 17 } 34 18 35 19 if (cookieJwt.payload.token_exp < new Date().getTime() - 500) { 36 - const session = await getSession(); 37 - if (!session) { 20 + const result = await refreshSession(); 21 + 22 + if (result.success) { 38 23 return NextResponse.next(); 39 24 } 40 - const authServer = await processDiscoveryResponse( 41 - new URL(session.user.iss), 42 - await oauthDiscoveryRequest(new URL(session.user.iss)), 43 - ); 44 25 45 - const client = await getOauthClientOptions(); 46 - 47 - const { privateDpopKey, publicDpopKey } = await importDpopJwks({ 48 - privateJwk: session.user.dpopPrivateJwk, 49 - publicJwk: session.user.dpopPublicJwk, 50 - }); 26 + switch (result.error) { 27 + case "NOT_AUTHENTICATED": { 28 + // If there's no session somehow, just continue. This probably shouldn't happen as the cookie exists. 29 + return NextResponse.next(); 30 + } 51 31 52 - let response = await refreshTokenGrantRequest( 53 - authServer, 54 - client, 55 - session.user.refreshToken, 56 - { 57 - clientPrivateKey: await getClientPrivateKey(), 58 - DPoP: { 59 - privateKey: privateDpopKey, 60 - publicKey: publicDpopKey, 61 - nonce: session.user.dpopNonce, 62 - }, 63 - }, 64 - ); 32 + case "CONCURRENT_REFRESH_REPLAYED": { 33 + // Just ignore and continue, another request is already refreshing the token 34 + return NextResponse.next(); 35 + } 65 36 66 - let result = await processRefreshTokenResponse( 67 - authServer, 68 - client, 69 - response, 70 - ); 71 - 72 - if ("error" in result && result.error == "use_dpop_nonce") { 73 - const nonce = response.headers.get("DPoP-Nonce"); 74 - if (!nonce) { 75 - throw new Error("Missing DPoP nonce"); 37 + case "REFRESH_FAILED": { 38 + // Logout and show error 39 + console.error("session corrupt, logging out", result); 40 + await signOut(); 41 + const response = NextResponse.redirect(new URL("/login", request.url), { 42 + status: 307, 43 + headers: NextResponse.next().headers, 44 + }); 45 + return response; 76 46 } 77 - response = await refreshTokenGrantRequest( 78 - authServer, 79 - client, 80 - session.user.refreshToken, 81 - { 82 - clientPrivateKey: await getClientPrivateKey(), 83 - DPoP: { 84 - privateKey: privateDpopKey, 85 - publicKey: publicDpopKey, 86 - nonce: nonce, 87 - }, 88 - }, 89 - ); 90 - 91 - result = await processRefreshTokenResponse(authServer, client, response); 92 47 } 93 48 94 - if ( 95 - "error" in result && 96 - result.error === "invalid_grant" && 97 - result.error_description === "refresh token replayed" 98 - ) { 99 - // Concurrent refresh token replayed, just ignore for now 100 - console.warn("Concurrent refresh token replayed"); 101 - return NextResponse.next(); 102 - } 103 - 104 - if ("error" in result) { 105 - // Logout and show error 106 - console.error("session corrupt, logging out", result); 107 - await signOut(); 108 - const response = NextResponse.redirect(new URL("/login", request.url), { 109 - status: 307, 110 - headers: NextResponse.next().headers, 111 - }); 112 - return response; 113 - } 114 - 115 - const dpopNonce = response.headers.get("DPoP-Nonce"); 116 - if (!dpopNonce) { 117 - throw new Error("Missing DPoP nonce"); 118 - } 119 - 120 - if (result.expires_in == null) { 121 - throw new Error("Missing expires_in"); 122 - } 123 - 124 - const expiresAt = new Date(Date.now() + result.expires_in * 1000); 125 - 126 - await db 127 - .update(schema.OauthSession) 128 - .set({ 129 - accessToken: result.access_token, 130 - refreshToken: result.refresh_token, 131 - expiresAt, 132 - dpopNonce, 133 - }) 134 - .where(eq(schema.OauthSession.sessionId, session.user.sessionId)); 135 - 136 - await setAuthCookie({ 137 - jti: String(session.user.sessionId), 138 - sub: session.user.did, 139 - token_exp: expiresAt.getTime(), 140 - }); 49 + exhaustiveCheck(result.error, "Unhandled refreshSession error"); 141 50 } 142 51 143 52 return NextResponse.next();