Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Reload refresh token if required #286

Merged
merged 2 commits into from
Jan 2, 2025
Merged
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
1 change: 1 addition & 0 deletions apps/web/app/(app)/PermissionsCheck.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export function PermissionsCheck() {

checkPermissionsAction().then((result) => {
if (!result?.hasAllPermissions) router.replace("/permissions/error");
if (!result?.hasRefreshToken) router.replace("/permissions/consent");
});
}, [router]);

Expand Down
38 changes: 38 additions & 0 deletions apps/web/app/(app)/permissions/consent/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"use client";

import Image from "next/image";
import { Button } from "@/components/ui/button";
import { logOut } from "@/utils/user";
import { PageHeading, TypographyP } from "@/components/Typography";

export default function PermissionsConsentPage() {
return (
<div className="flex flex-col items-center justify-center sm:p-20 md:p-32">
<PageHeading className="text-center">
We are missing consent 😔
</PageHeading>

<TypographyP className="mx-auto mt-4 max-w-prose text-center">
You must sign in and give access to all permissions for Inbox Zero to
work.
</TypographyP>

<Button
className="mt-4"
onClick={() => logOut("/login?error=RequiresReconsent")}
>
Sign in again
</Button>

<div className="mt-8">
<Image
src="/images/falling.svg"
alt=""
width={400}
height={400}
unoptimized
/>
</div>
</div>
);
}
7 changes: 5 additions & 2 deletions apps/web/app/(app)/permissions/error/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,18 @@ export default function PermissionsErrorPage() {
return (
<div className="flex flex-col items-center justify-center sm:p-20 md:p-32">
<PageHeading className="text-center">
You are missing permissions 😔
We are missing permissions 😔
</PageHeading>

<TypographyP className="mx-auto mt-4 max-w-prose text-center">
You must sign in and give access to all permissions for Inbox Zero to
work.
</TypographyP>

<Button className="mt-4" onClick={() => logOut("/login")}>
<Button
className="mt-4"
onClick={() => logOut("/login?error=RequiresReconsent")}
>
Sign in again
</Button>

Expand Down
19 changes: 12 additions & 7 deletions apps/web/app/(landing)/login/LoginForm.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
"use client";

import { useState } from "react";
import { signIn } from "next-auth/react";
import Image from "next/image";
import { useSearchParams } from "next/navigation";
import { Button } from "@/components/Button";
import { Modal, useModal } from "@/components/Modal";
import { SectionDescription } from "@/components/Typography";
import { useState } from "react";

export function LoginForm() {
const searchParams = useSearchParams();
Expand Down Expand Up @@ -53,12 +53,17 @@ export function LoginForm() {
loading={loading}
onClick={() => {
setLoading(true);
signIn("google", {
consent: error === "RefreshAccessTokenError",
...(next && next.length > 0
? { callbackUrl: next }
: { callbackUrl: "/welcome" }),
});
signIn(
"google",
{
...(next && next.length > 0
? { callbackUrl: next }
: { callbackUrl: "/welcome" }),
},
error === "RequiresReconsent"
? { consent: true }
: undefined,
);
}}
>
I agree
Expand Down
2 changes: 1 addition & 1 deletion apps/web/app/(landing)/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export default async function AuthenticationPage({
</Suspense>
</div>

{searchParams?.error && (
{searchParams?.error && searchParams?.error !== "RequiresReconsent" && (
<>
<AlertBasic
variant="destructive"
Expand Down
20 changes: 18 additions & 2 deletions apps/web/app/api/auth/[...nextauth]/auth.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,23 @@
import NextAuth from "next-auth";
import { authOptions } from "@/utils/auth";
import { getAuthOptions } from "@/utils/auth";
import { createScopedLogger } from "@/utils/logger";

const logger = createScopedLogger("Auth API");

const defaultAuthOptions = getAuthOptions();

export const {
handlers: { GET, POST },
auth,
} = NextAuth(authOptions);
} = NextAuth((req) => {
if (req?.url) {
const url = new URL(req?.url);
const consent = url.searchParams.get("consent");
if (consent) {
logger.info("Consent requested");
return getAuthOptions({ consent: true });
}
}

return defaultAuthOptions;
});
6 changes: 5 additions & 1 deletion apps/web/components/TokenCheck.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,12 @@ export function TokenCheck() {

useEffect(() => {
if (session?.error === "RefreshAccessTokenError") {
console.log("Token check error");
router.replace("/login?error=RefreshAccessTokenError");
return;
}
if (session?.error === "RequiresReconsent") {
router.replace("/login?error=RequiresReconsent");
return;
}
}, [session, router]);

Expand Down
17 changes: 16 additions & 1 deletion apps/web/utils/actions/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,22 @@ export const checkPermissionsAction = withActionInstrumentation(
token.token,
);
if (error) return { error };
return { hasAllPermissions };

if (!hasAllPermissions) return { hasAllPermissions: false };

// Check for refresh token
const user = await prisma.account.findFirst({
where: {
userId: session.user.id,
provider: "google",
},
select: { refresh_token: true },
});

if (!user?.refresh_token)
return { hasRefreshToken: false, hasAllPermissions };

return { hasRefreshToken: true, hasAllPermissions };
} catch (error) {
return { error: "Failed to check permissions" };
}
Expand Down
24 changes: 17 additions & 7 deletions apps/web/utils/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@ export const SCOPES = [
: []),
];

const getAuthOptions: (options?: { consent: boolean }) => NextAuthConfig = (
options,
) => ({
export const getAuthOptions: (options?: {
consent: boolean;
}) => NextAuthConfig = (options) => ({
// debug: true,
providers: [
GoogleProvider({
Expand All @@ -45,7 +45,7 @@ const getAuthOptions: (options?: { consent: boolean }) => NextAuthConfig = (
},
}),
],
adapter: PrismaAdapter(prisma) as any, // TODO
adapter: PrismaAdapter(prisma),
session: { strategy: "jwt" },
// based on: https://authjs.dev/guides/basics/refresh-token-rotation
// and: https://github.com/nextauthjs/next-auth-refresh-token-example/blob/main/pages/api/auth/%5B...nextauth%5D.js
Expand Down Expand Up @@ -158,8 +158,6 @@ const getAuthOptions: (options?: { consent: boolean }) => NextAuthConfig = (
},
});

export const authOptions = getAuthOptions();

/**
* Takes a token, and returns a new token with updated
* `access_token` and `expires_at`. If an error occurs,
Expand Down Expand Up @@ -262,11 +260,23 @@ export async function saveRefreshToken(
},
account: Pick<Account, "refresh_token" | "providerAccountId">,
) {
const refreshToken = tokens.refresh_token ?? account.refresh_token;

if (!refreshToken) {
logger.error("Attempted to save null refresh token", {
providerAccountId: account.providerAccountId,
});
captureException("Cannot save null refresh token", {
extra: { providerAccountId: account.providerAccountId },
});
return;
}

return await prisma.account.update({
data: {
access_token: tokens.access_token,
expires_at: tokens.expires_at,
refresh_token: tokens.refresh_token ?? account.refresh_token,
refresh_token: refreshToken,
},
where: {
provider_providerAccountId: {
Expand Down
Loading