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

Requesting offline_access scope with Azure OAuth and Astro causes cookies to be deleted #766

Closed
2 tasks done
glib-0 opened this issue Apr 9, 2024 · 1 comment
Closed
2 tasks done
Labels
bug Something isn't working

Comments

@glib-0
Copy link

glib-0 commented Apr 9, 2024

Bug report

  • I confirm this is a bug with Supabase, not with my own application.
  • I confirm I have searched the Docs, GitHub Discussions, and Discord.

Describe the bug

  • Using Azure as an OAuth provider for a single tenant application but I get the same problem on my multi-tenant & public minimal reproduction.

  • Have configured my app registration for the offline_access scope

  • Sign in and cookie storage works perfectly fine when I don't request the offline_access scope. The access and refresh tokens are stored in my browser as 2 chunks.

  • I want to query the MS Graph API, so I want the provider refresh token.

  • The cookie chunks are set like normal, I can see them actually set when I throttle the network or log them, they just get deleted after navigation, redirect or refresh. The Set-Cookie headers appear as follows upon navigation, redirect or refresh:
    image

  • Without offline_access, the Set-Cookie headers re-set the access and refresh tokens (actual token redacted, but it's there):

image

Cookie options for the above are: Max-Age=31536000000; Path=/; SameSite=Lax

  • When I request offline_access, the provider_token and provider_refresh_token are present in the initial callback Set-Cookie header where the sb-<project_id>-auth-token-code-verifier cookie is deleted. This cookie is broken up into 3 chunks, rather than 2. When redirected from callback to the next page, the request headers contain the 3 chunks and the response header contains the sanitised version without the provider/provider refresh tokens in 2 chunks.
    Upon navigation, redirect or refresh, the next request header contains the 2 sanitised chunks, and also the original 3rd chunk - this leads to a malformed cookie when the chunks are combined - I guess this is why the Set-Cookie response header deletes them.

To Reproduce

Create an empty Astro ^4.5 project with Supabase SSR 0.1.0, the Astro Vercel adapter, Tailwind and micromatch

pages/index.astro:

<html lang="en">
  <head>
    <meta charset="utf-8" />
    <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
    <meta name="viewport" content="width=device-width" />
    <meta name="generator" content={Astro.generator} />
    <title>Astro</title>
  </head>
  <body>
    <main class="w-full">
      <form
        class="mx-auto w-1/2 flex flex-col items-center"
        action="/api/auth/signin"
        method="post"
      >
        <h1 class="p-8">
          <span class="text-zinc-300">Sign in with Azure</span>
        </h1>
        <button class="bg-black text-white text-lg font-bold p-2">Login</button>
      </form>
      <form
        class="mx-auto w-1/2 flex flex-col items-center"
        action="/api/auth/signin?scope=offline_access"
        method="post"
      >
        <h1 class="p-8">
          <span class="text-zinc-300">Sign in + Offline Access scope</span>
        </h1>
        <button class="bg-black text-white text-lg font-bold p-2">Login w/ Offline Access Scope</button>
      </form>
    </main>
  </body>
</html>

pages/nextpage.astro: <--- here you can see that the cookies are being set, but they've disappeared from Application > Cookies

<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width" />
    <meta name="generator" content={Astro.generator} />
    <title>Document</title>
    <script is:inline>
      const listHTML = document.cookie
        .split(";")
        .map(
          (c) => `<li class=" h-20 overflow-clip ">
            <span class="text-red-500 font-bold text-sm break-all w-full">${c.split("=")[0].trim()}:</span>
            <span class="text-black text-sm break-all w-full p-4">${c.split("=")[1]}</span>
          </li>`
        )
        .join("");
      window.onload = () => {
        const listElement = document.getElementById("list");
        listElement.innerHTML = listHTML;
      };
    </script>
  </head>
  <body>
    <main class="flex flex-col items-center mx-auto p-2 w-1/2">
      <div class="text-center font-bold font-mono text-xl">
        Cookies present onload
        <ul id="list"></ul>
      </div>
      <a
        href="/otherpage"
        class="text-red-500 text-2xl m-2 p-1 font-bold font-mono outline outline-red-500 rounded"
        >Go to next next page</a
      >
      <form action="/api/auth/signout">
        <h1>
          <button
            type="submit"
            class="bg-blue-400 dark:bg-blue-900 dark:text-gray-200 w-fit p-2 h-fit shadow-lg rounded-lg border-2 border-blue-800 my-2"
            >Sign out</button
          >
        </h1>
      </form>
    </main>
  </body>
</html>

pages/otherpage.astro

empty page to demonstrate cookies being deleted, could also just refresh the page - but observe the cookies in the console

helper.ts:

import { createServerClient, type CookieOptions } from "@supabase/ssr";
import type { APIContext } from "astro";

export const supabaseClient = (context: APIContext) =>
  createServerClient(
    import.meta.env.PUBLIC_SUPABASE_URL,
    import.meta.env.PUBLIC_SUPABASE_ANON_KEY,
    {
      cookies: {
        get(key: string) {
          return context.cookies.get(key)?.value;
        },
        set(key: string, value: string, options: CookieOptions) {
          context.cookies.set(key, value, options);
        },
        remove(key: string, options) {
          context.cookies.delete(key, options);
        },
      },
    }
  );

middleware/index.ts:

import { defineMiddleware } from "astro/middleware";
import micromatch from "micromatch";
import type { APIContext } from "astro";
import { createServerClient, type CookieOptions } from "@supabase/ssr";
const protectedRoutes = ["/nextpage(|/*)"];
const redirectRoutes = ["/"];

export const onRequest = defineMiddleware(async (context: APIContext, next) => {
  const supabase = supabaseClient(context);
  const { data, error: userError } = await supabase.auth.getUser();

  const {
    data: { session },
  } = await supabase.auth.getSession();
  const accessToken = session?.access_token;
  const refreshToken = session?.refresh_token;
  const providerToken = session?.provider_token;
  const providerRefreshToken = session?.provider_refresh_token;
  if (session && providerToken) {
    context.cookies.set("provider_token", providerToken, { path: "/" });
  }
  if (session && providerRefreshToken) {
    context.cookies.set("provider_refresh_token", providerRefreshToken, {
      path: "/",
    });
  }
  if (micromatch.isMatch(context.url.pathname, protectedRoutes)) {
    if (userError?.status === 401) {
      return context.redirect("/");
    }
    if (!accessToken || !refreshToken) {
      return context.redirect("/");
    }

    const { data, error: sessionError } = await supabase.auth.setSession({
      refresh_token: refreshToken,
      access_token: accessToken,
    });
    if (sessionError) {
      await supabase.auth.signOut();
      return context.redirect("/");
    }
  }

  if (micromatch.isMatch(context.url.pathname, redirectRoutes)) {
    if (accessToken && refreshToken) {
      return context.redirect("/nextpage");
    }
  }
  const response = await next();
  return response;
});

pages/api/auth/signin.ts:

import type { APIContext, APIRoute } from "astro";
import type { Provider } from "@supabase/supabase-js";
import { supabaseClient } from "../../../helper";
export const POST: APIRoute = async (context: APIContext) => {
  const additionalScope = context.url.searchParams.get("scope");
  const supabase = supabaseClient(context);
  const { data, error } = await supabase.auth.signInWithOAuth({
    provider: "azure" as Provider,
    options: {
      scopes: `email profile ${additionalScope ?? ""}`.trimEnd(),
      redirectTo: import.meta.env.DEV
        ? "http://localhost:4321/api/auth/callback"
        : `${import.meta.env.PUBLIC_VERCEL_URL}/api/auth/callback`,
    },
  });

  if (error) {
    return new Response(error.message, { status: 500 });
  }
  return context.redirect(data.url);

pages/api/auth/callback.ts:

import { type APIContext, type APIRoute } from "astro";
import { createServerClient, type CookieOptions } from "@supabase/ssr";
import { supabaseClient } from "../../../helper";

export const GET: APIRoute = async (context: APIContext) => {
  const requestUrl = new URL(context.request.url);
  const code = requestUrl.searchParams.get("code");
  if (code) {
    const supabase = supabaseClient(context);

    const { error } = await supabase.auth.exchangeCodeForSession(code);

    if (!error) {
      return context.redirect("/nextpage");
    }
  }

  return context.redirect("/"); 
};

signout.ts:

import { createServerClient } from "@supabase/ssr";
import type { APIContext, APIRoute } from "astro";
import { supabaseClient } from "../../../helper";

export const GET: APIRoute = async (context: APIContext) => {
  const supabase = supabaseClient(context);
  await supabase.auth.signOut();
  context.cookies.delete("provider_token", { path: "/" });
  context.cookies.delete("provider_refresh_token", { path: "/" });
  return context.redirect("/");
};

.env:

PUBLIC_SUPABASE_URL=<url>
PUBLIC_SUPABASE_ANON_KEY=<apikey>
PUBLIC_VERCEL_URL=<vercelurl>

Expected behavior

The cookies should be retained in both instances (requesting offline_access and not).

Screenshots

N/A

System information

  • OS: Windows 11
  • Browser (if applies) All
  • Version of supabase-js: 2.42.0
  • Version of Node.js: 20.11.1

Additional context

See my minimal example

@glib-0 glib-0 added the bug Something isn't working label Apr 9, 2024
@glib-0
Copy link
Author

glib-0 commented Apr 26, 2024

Going ahead and closing this issue as it seems to have been fixed by #760 based on some brief testing...just waiting on release 0.4.0. Thanks y'all

@glib-0 glib-0 closed this as completed Apr 26, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working
Projects
None yet
Development

No branches or pull requests

1 participant