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

Explain how to use your own JWT token in next-auth Credentials provider (Not created by next-auth) #11295

Closed
sourcehawk opened this issue Jun 30, 2024 · 11 comments
Labels
docs Relates to documentation triage Unseen or unconfirmed by a maintainer yet. Provide extra information in the meantime.

Comments

@sourcehawk
Copy link

sourcehawk commented Jun 30, 2024

What is the improvement or update you wish to see?

I cannot find a single clue in the docs as to how I can use my own JWT token from a backend in next-auth using the Credentials provider. When using a credentials provider, why would I want NextAuth to generate some random JWT for me? I am supplying a username and password so that I can get a JWT token from my own backend, which I would expect anyone using this provider to be doing... The logic behind this is unclear to me

Is there any context that might help us understand?

  1. In the authorize method of the provider, I send a request to my backend which responds with returning an access and refresh JWT
  2. How can I make NextAuth use my access token instead of creating it's own?

Does the docs page already exist? Please link to it.

https://next-auth.js.org/configuration/providers/credentials

@sourcehawk sourcehawk added docs Relates to documentation triage Unseen or unconfirmed by a maintainer yet. Provide extra information in the meantime. labels Jun 30, 2024
@balazsorban44
Copy link
Member

Auth.js creates an encrypted JWT to securely store information in a cookie, but you can consider it as an implementation detail.

If you don't want to store the access and refresh token in a cookie, you can save it in a database in the authorize callback instead, and only return a session id from the callback, which then you can use to reference your session from the DB.

@sourcehawk
Copy link
Author

sourcehawk commented Jul 4, 2024

I am unsure you really answered my question here. Are you saying that the only use for next-auth with a custom backend is to store the information in a cookie?

I was under the logical impression that I could use next-auth to handle the tokens from my backend. By handling it, I mean auto refreshing the access token received from the backend using the refresh token and loggin out when the token expires. Is that not the case?

@sourcehawk
Copy link
Author

sourcehawk commented Jul 5, 2024

Okay so I figured out a way to do this, although I am not sure it is the intended way of doing things with a custom backend. I am using app router in case anyone else stumbles upon my issue.

All in all I would say that this was just painfully confusing to get right. Not only because of lacking documentation but because of the naming conventions of the variables next-auth uses with what is passed down to the callbacks. For example the JWT and token reference next-auth uses makes you think that you can use your own backend tokens and let next-auth handle it, when in reality the only thing that next auth actually does is allow you to store your own tokens/information in what just so happens to be also called a token, yet gives you no clear direction as to how to actually handle your own tokens for signing in and out of sessions. I spent quite a substantial amount of time trying to overwrite the token that next-auth returns when in reality I should have left it alone and simply added my own token to that 'token'. Apart from that, I only stumbled upon the next-auth.d.ts through some random youtube video and the middleware configuration was made clearer to me by a chatgpt promt that the actual documentation.

Firstly, I created a next-auth.d.ts file at the root of my project where the types declared for next-auth and my custom backend responses are defined.

import type { User, UserObject } from "next-auth";

declare module "next-auth" {
  /**
   * What user information we expect to be able to extract
   * from our backend response
   */
  export interface UserObject {
    id: number;
    email: string;
    name: string;
  }

  /**
   * The contents of our refresh call to the backend is a new access token
   */
  export interface BackendAccessJWT {
    access: string;
  }

  /**
   * The initial backend authentication response contains both an `access` token and a `refresh` token.\
   * The refresh token is a long-lived token that is used to obtain a new access token\
   * when the current access token expires
   */
  export interface BackendJWT extends BackendAccessJWT {
    refresh: string;
  }

  /**
   * The decoded contents of a JWT token returned from the backend (both access and refresh tokens).\
   * It contains both the user information and other token metadata.\
   * `iat` is the time the token was issued, `exp` is the time the token expires, `jti` is the token id.
   */
  export interface DecodedJWT extends UserObject {
    token_type: "refresh" | "access";
    exp: number;
    iat: number;
    jti: string;
  }

  /**
   * Information extracted from our decoded backend tokens so that we don't need to decode them again.\
   * `valid_until` is the time the access token becomes invalid\
   * `refresh_until` is the time the refresh token becomes invalid
   */
  export interface AuthValidity {
    valid_until: number;
    refresh_until: number;
  }

  /**
   * The returned data from the authorize method
   * This is data we extract from our own backend JWT tokens.
   */
  export interface User {
    tokens: BackendJWT;
    user: UserObject;
    validity: AuthValidity;
  }

  /**
   * Returned by `useSession`, `getSession`, returned by the `session` callback and also the shape
   * received as a prop on the SessionProvider React Context
   */
  export interface Session {
    user: UserObject;
    validity: AuthValidity;
    error: "RefreshTokenExpired" | "RefreshAccessTokenError";
  }
}

declare module "next-auth/jwt" {
  /**
   * Returned by the `jwt` callback and `getToken`, when using JWT sessions
   */
  export interface JWT {
    data: User;
    error: "RefreshTokenExpired" | "RefreshAccessTokenError";
  }
}

Then for testing purposes (I have yet to implement the backend), I created some dummy functions called login and refresh which return the response I expect from my backend. I put this inside a file at src/actions/user-auth.ts. These functions return valid tokens, an access token and a refresh token. Not to be confused with the token that next-auth uses to store whatever information you put into it. I installed both jsonwebtokens, uuid and @types/uuid for this.

import type { UserObject, BackendJWT, BackendAccessJWT } from "next-auth";
import { v4 as uuidv4 } from "uuid";

var jwt = require("jsonwebtoken");

/**
 * Log in a user by sending a POST request to the backend using the supplied credentials.
 *
 * TODO: Implement the actual login functionality by sending a POST request to the backend
 *
 * @param email The email of the user
 * @param password The password of the user
 * @returns A BackendJWT response from the backend.
 */
export async function login(
  email: string,
  password: string
): Promise<Response> {
  console.debug("Logging in");

  if (!email) {
    throw new Error("Email is required");
  }
  if (!password) {
    throw new Error("Password is required");
  }

  // Dummy data to simulate a successful login
  const mock_data: BackendJWT = {
    access: create_access_token(),
    refresh: create_refresh_token()
  };

  return new Response(JSON.stringify(mock_data), {
    status: 200,
    statusText: "OK",
    headers: {
      "Content-type": "application/json"
    }
  });
}

/**
 * Refresh the access token by sending a POST request to the backend using the supplied refresh token.
 *
 * TODO: Implement the actual refresh functionality by sending a POST request to the backend
 *
 * @param token The current refresh token
 * @returns A BackendAccessJWT response from the backend.
 */
export async function refresh(token: string): Promise<Response> {
  console.debug("Refreshing token");

  if (!token) {
    throw new Error("Token is required");
  }
  // Verify that the token is valid and not expired
  try {
    jwt.verify(token, secret_signing_salt);
  } catch (err) {
    throw new Error("Refresh token expired");
  }
  const new_access_token: BackendAccessJWT = {
    access: create_access_token()
  };
  return new Response(JSON.stringify(new_access_token), {
    status: 200,
    statusText: "OK",
    headers: {
      "Content-type": "application/json"
    }
  });
}

// Dummy secret salt for signing tokens
const secret_signing_salt = "super-secret-salt";

// Dummy function to create an access token
const create_access_token = (): string => {
  const user: UserObject = {
    id: 1,
    email: "[email protected]",
    name: "Mr User"
  };
  // `iat` and `exp` are generated by the jwt library
  return jwt.sign(
    {
      ...user,
      jti: uuidv4()
    },
    secret_signing_salt,
    {
      algorithm: "HS384",
      expiresIn: "5s" // Refresh token every 5 seconds for testing purposes
    }
  );
};
// Dummy function to create a refresh token
const create_refresh_token = (): string => {
  const user: UserObject = {
    id: 1,
    email: "[email protected]",
    name: "Mr User"
  };
  // `iat` and `exp` are generated by the jwt library
  return jwt.sign(
    {
      ...user,
      jti: uuidv4()
    },
    secret_signing_salt,
    {
      algorithm: "HS384",
      expiresIn: "5m" // Expire refresh token every 5 minutes for testing purposes
    }
  );
};

With that out of the way, I set up the src/app/api/auth/[...nextauth]/route.tsx file, implementing both refresh functionality and the authorization method using my own tokens. I installed jwt-decode to decode the JWT tokens from the backend.

import NextAuth from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import { login, refresh } from "@/actions/user-auth";
import type {
  AuthOptions,
  User,
  UserObject,
  AuthValidity,
  BackendAccessJWT,
  BackendJWT,
  DecodedJWT
} from "next-auth";
import type { JWT } from "next-auth/jwt";
import { jwtDecode } from "jwt-decode";

async function refreshAccessToken(nextAuthJWT: JWT): Promise<JWT> {
  try {
    // Get a new access token from backend using the refresh token
    const res = await refresh(nextAuthJWT.data.tokens.refresh);
    const accessToken: BackendAccessJWT = await res.json();

    if (!res.ok) throw accessToken;
    const { exp }: DecodedJWT = jwtDecode(accessToken.access);

    // Update the token and validity in the next-auth object
    nextAuthJWT.data.validity.valid_until = exp;
    nextAuthJWT.data.tokens.access = accessToken.access;

    return nextAuthJWT;
  } catch (error) {
    console.debug(error);
    return {
      ...nextAuthJWT,
      error: "RefreshAccessTokenError"
    };
  }
}

export const authOptions: AuthOptions = {
  secret: process.env.NEXTAUTH_SECRET,
  session: { strategy: "jwt" },
  providers: [
    CredentialsProvider({
      name: "Login",
      credentials: {
        email: {
          label: "Email",
          type: "email",
          placeholder: "[email protected]"
        },
        password: { label: "Password", type: "password" }
      },
      async authorize(credentials) {
        try {
          const res = await login(
            credentials?.email || "",
            credentials?.password || ""
          );
          const tokens: BackendJWT = await res.json();
          if (!res.ok) throw tokens;

          const access: DecodedJWT = jwtDecode(tokens.access);
          const refresh: DecodedJWT = jwtDecode(tokens.refresh);
          // Extract the user from the access token
          const user: UserObject = {
            name: access.name,
            email: access.email,
            id: access.id
          };
          // Extract the auth validity from the tokens
          const validity: AuthValidity = {
            valid_until: access.exp,
            refresh_until: refresh.exp
          };
          // Return the object that next-auth calls 'User' (which we've defined in next-auth.d.ts)
          return {
            id: refresh.jti, // User object is forced to have a string id so use refresh token id
            tokens: tokens,
            user: user,
            validity: validity
          } as User;
        } catch (error) {
          console.error(error);
          return null;
        }
      }
    })
  ],
  callbacks: {
    async redirect({ url, baseUrl }) {
      return url.startsWith(baseUrl)
        ? Promise.resolve(url)
        : Promise.resolve(baseUrl);
    },
    async jwt({ token, user, account }) {
      // Initial signin contains a 'User' object from authorize method
      if (user && account) {
        console.debug("Initial signin");
        return { ...token, data: user };
      }

      // The current access token is still valid
      if (Date.now() < token.data.validity.valid_until * 1000) {
        console.debug("Access token is still valid");
        return token;
      }

      // The current access token has expired, but the refresh token is still valid
      if (Date.now() < token.data.validity.refresh_until * 1000) {
        console.debug("Access token is being refreshed");
        return await refreshAccessToken(token);
      }

      // The current access token and refresh token have both expired
      // This should not really happen unless you get really unlucky with
      // the timing of the token expiration because the middleware should
      // have caught this case before the callback is called
      console.debug("Both tokens have expired");
      return { ...token, error: "RefreshTokenExpired" } as JWT;
    },
    async session({ session, token, user }) {
      session.user = token.data.user;
      session.validity = token.data.validity;
      session.error = token.error;
      return session;
    }
  }
};

const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };

And then to handle automatic redirects to the login page when the refresh token is no longer valid, I created some custom logic in the src/middleware.tsx file like so

import { withAuth } from "next-auth/middleware";
import { getToken } from "next-auth/jwt";
import { NextResponse } from "next/server";

export default withAuth(
  async function middleware(req) {
    const token = await getToken({ req, secret: process.env.NEXTAUTH_SECRET });
    const baseUrl = req.nextUrl.origin;

    // Check if the user is authenticated
    if (token && Date.now() >= token.data.validity.refresh_until * 1000) {
      // Redirect to the login page
      const response = NextResponse.redirect(`${baseUrl}/api/auth/signin`);
      // Clear the session cookies
      response.cookies.set("next-auth.session-token", "", { maxAge: 0 });
      response.cookies.set("next-auth.csrf-token", "", { maxAge: 0 });

      return response;
    }

    // If authenticated, continue with the request
    return NextResponse.next();
  },
  {
    callbacks: {
      authorized: ({ token }) => {
        // You can add custom logic here, for example, check roles
        return !!token; // if token exists, the user is authenticated
      }
    }
  }
);

// Authenticate all routes except for /api, /_next/static, /_next/image, and .png files
export const config = {
  matcher: ["/((?!api|_next/static|_next/image|.*\\.png$).*)"]
};

@1saifj
Copy link

1saifj commented Jul 15, 2024

@sourcehawk, your last comment was incredibly helpful. Thank you for sharing such detailed information. This looks good to me. With this info, you could consider extending the documentation to explain the usage of next-auth in custom backends.

@wizzyto12
Copy link

Amazing share. Thanks @sourcehawk !

I have ported this to authjs (v5), if someone needs it let me know to prepare it for sharing here.

@1saifj
Copy link

1saifj commented Sep 28, 2024

Amazing share. Thanks @sourcehawk !

I have ported this to authjs (v5), if someone needs it let me know to prepare it for sharing here.

YES I NEED IT

@j-mcfarlane
Copy link

Amazing share. Thanks @sourcehawk !

I have ported this to authjs (v5), if someone needs it let me know to prepare it for sharing here.

+1 would love to see it

@sam-ayo
Copy link

sam-ayo commented Oct 12, 2024

I need it as well! :)

@dualwieldingrogue
Copy link

so how do you do log out with the code?

Amazing share. Thanks @sourcehawk !

I have ported this to authjs (v5), if someone needs it let me know to prepare it for sharing here.

May I have the code please? Thank you.

@sourcehawk
Copy link
Author

Here's the demo project with login/logout navbar: https://github.com/sourcehawk/next-auth-custom-backend
Here's a full writeup: https://medium.com/p/12c8f54ed4ce

@dualwieldingrogue
Copy link

dualwieldingrogue commented Nov 12, 2024

@sourcehawk thank you so much for this. Is it possible to do this in page api? I'd like this to be callable by another app for login/logout if possible.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
docs Relates to documentation triage Unseen or unconfirmed by a maintainer yet. Provide extra information in the meantime.
Projects
None yet
Development

No branches or pull requests

7 participants