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

Fix/refresh token app router #254

Merged
merged 36 commits into from
Dec 20, 2024
Merged
Show file tree
Hide file tree
Changes from 34 commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
19217fd
fix: WIP
DanielRivers Dec 13, 2024
5b64eb8
test commit
Yoshify Dec 16, 2024
17e25ff
refactor: move validateToken into jwt/validation.ts to colocate with …
Yoshify Dec 16, 2024
0248395
refactor: move cookie consts out of sessionManager into constants.ts
Yoshify Dec 16, 2024
a47061c
feat(auto-refresh): move refresh logic out of getAccessToken and getI…
Yoshify Dec 16, 2024
d28a034
feat(auto-refresh): util function for generating splitCookie objects
Yoshify Dec 16, 2024
921caa3
chore: update types on GLOBAL_COOKIE_OPTIONS
Yoshify Dec 16, 2024
48d3bfc
docs: commentary
Yoshify Dec 16, 2024
a4f2d05
refactor: move splitString to it's own util file
Yoshify Dec 16, 2024
01a5bee
chore: format
Yoshify Dec 16, 2024
6891b8b
chore: format
Yoshify Dec 16, 2024
f8d516d
feat(auto-refresh): rework middleware to be the source-of-truth for r…
Yoshify Dec 16, 2024
76e57be
chore: fix commentary wording
Yoshify Dec 16, 2024
8a5a13c
feat(auto-refresh): add a token expiry check to isAuthenticatedFactory
Yoshify Dec 16, 2024
a6b3be2
chore: tidy console logs
Yoshify Dec 16, 2024
c2d48ab
fix: await getIdToken before passing it to jwtDecoder. Fixes #252
Yoshify Dec 16, 2024
bc42c10
feat(auto-refresh): optimize refresh flow, re-use previous refresh re…
Yoshify Dec 16, 2024
317e477
chore: wrap console.logs in isDebugMode check, remove unnecessary log…
Yoshify Dec 16, 2024
f5b4ca3
chore: naming nitpicks
Yoshify Dec 17, 2024
1c28f9a
fix: update kindeAccessToken and kindeIdToken when refreshed
Yoshify Dec 17, 2024
7b229f5
fix: decodedToken can be null, check it's not null before checking ex…
Yoshify Dec 17, 2024
370a9fd
refactor: break expired token redirection for RSCs into own utility f…
Yoshify Dec 18, 2024
e8ced3b
refactor: use new redirectOnExpiredToken function
Yoshify Dec 18, 2024
2f5eb9c
refactor: make debug logs warn instead of error
Yoshify Dec 18, 2024
9a80b3c
chore: commentary changes
Yoshify Dec 18, 2024
7ce9b83
chore: more debug logs
Yoshify Dec 18, 2024
508a8de
feat(auto-refresh): copy cookies to the request object as well
Yoshify Dec 18, 2024
199ead9
feat(auto-refresh): export the standard middleware regex pattern
Yoshify Dec 18, 2024
d29222c
chore: formatting
Yoshify Dec 18, 2024
7e8816f
fix: wrap token decoding in middleware in try/catch blocks
Yoshify Dec 18, 2024
95bacf0
chore: debug logs
Yoshify Dec 18, 2024
a7ee5bb
feat(auto-refresh): refresh on public paths
Yoshify Dec 18, 2024
dd12000
chore: fix exports
Yoshify Dec 18, 2024
45d7211
feat(auto-refresh): override callback result headers/cookies if the r…
Yoshify Dec 18, 2024
f662f4a
Merge branch 'main' into fix/refresh-token-app-router
Yoshify Dec 18, 2024
cd91971
fix: refresh both tokens at the same time
Yoshify Dec 19, 2024
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
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,28 @@ All notable changes to this project will be documented in this file. Dates are d

Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).

#### [v2.5.0-6](https://github.com/kinde-oss/kinde-auth-nextjs/compare/v2.4.6...v2.5.0-6)

- chore: pretter run [`#250`](https://github.com/kinde-oss/kinde-auth-nextjs/pull/250)
- test: migrate to vitest [`#249`](https://github.com/kinde-oss/kinde-auth-nextjs/pull/249)
- fix: API handler warning [`#248`](https://github.com/kinde-oss/kinde-auth-nextjs/pull/248)
- Fix/refresh token [`#247`](https://github.com/kinde-oss/kinde-auth-nextjs/pull/247)
- Fix: Token validation strength [`#243`](https://github.com/kinde-oss/kinde-auth-nextjs/pull/243)
- [Snyk] Upgrade @babel/preset-env from 7.25.9 to 7.26.0 [`#245`](https://github.com/kinde-oss/kinde-auth-nextjs/pull/245)
- Peter/split cookie [`#229`](https://github.com/kinde-oss/kinde-auth-nextjs/pull/229)
- [Snyk] Upgrade @babel/preset-env from 7.25.8 to 7.25.9 [`#241`](https://github.com/kinde-oss/kinde-auth-nextjs/pull/241)
- [Snyk] Upgrade @babel/preset-env from 7.25.7 to 7.25.8 [`#235`](https://github.com/kinde-oss/kinde-auth-nextjs/pull/235)
- chore: PR updated [`afd7428`](https://github.com/kinde-oss/kinde-auth-nextjs/commit/afd7428afc04983b04a6953075ce35e3986bb83d)
- chore: update lock [`ed40c31`](https://github.com/kinde-oss/kinde-auth-nextjs/commit/ed40c31ffd5b5fb2e09f859fbede2fdef5ff050a)
- fix: upgrade @babel/preset-env from 7.25.8 to 7.25.9 [`d067227`](https://github.com/kinde-oss/kinde-auth-nextjs/commit/d067227e7fce621186c734e2183bce60c610a551)

#### [v2.4.6](https://github.com/kinde-oss/kinde-auth-nextjs/compare/v2.4.5...v2.4.6)

> 7 November 2024

- fix: remove noisy error log [`#239`](https://github.com/kinde-oss/kinde-auth-nextjs/pull/239)
- [Snyk] Upgrade cookie from 1.0.0 to 1.0.1 [`#236`](https://github.com/kinde-oss/kinde-auth-nextjs/pull/236)
- chore: release v2.4.6 [`1ea683f`](https://github.com/kinde-oss/kinde-auth-nextjs/commit/1ea683fb869368e08445f8afe506a81993b2010f)
- fix: upgrade cookie from 1.0.0 to 1.0.1 [`b387e60`](https://github.com/kinde-oss/kinde-auth-nextjs/commit/b387e6061473ae1e8c6d13561b72f07648ef4776)

#### [v2.4.5](https://github.com/kinde-oss/kinde-auth-nextjs/compare/v2.4.4...v2.4.5)
Expand Down
13 changes: 7 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@kinde-oss/kinde-auth-nextjs",
"version": "2.4.6",
"version": "2.5.0-6",
"description": "Kinde Auth SDK for NextJS",
"main": "dist/cjs/index.js",
"module": "dist/index.js",
Expand Down
2 changes: 1 addition & 1 deletion playground/next-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
/// <reference types="next/image-types/global" />

// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
6 changes: 6 additions & 0 deletions playground/src/app/AuthProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"use client";
import { KindeProvider } from "@kinde-oss/kinde-auth-nextjs";

export const AuthProvider = ({ children }) => {
return <KindeProvider>{children}</KindeProvider>;
};
8 changes: 4 additions & 4 deletions playground/src/app/api/protected/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ export async function GET() {
return new Response("Unauthorized", { status: 401 });
}

const user = await getUser();
const data = { message: "Hello User", id: user?.given_name };

return NextResponse.json({ data });
// const user = await getUser();
// const data = { message: "Hello User", id: user?.given_name };
console.log('I AM BEING CALLED');
return NextResponse.json({ });
Yoshify marked this conversation as resolved.
Show resolved Hide resolved
}
3 changes: 2 additions & 1 deletion playground/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ export default async function RootLayout({
children: React.ReactNode;
}) {
const { isAuthenticated, getUser } = getKindeServerSession();
const user = await getUser();
const user = {} // await getUser();
// const isAuthenticated = () => true;
Yoshify marked this conversation as resolved.
Show resolved Hide resolved
return (
<html lang="en">
<body>
Expand Down
20 changes: 16 additions & 4 deletions playground/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": false,
Expand All @@ -16,8 +20,16 @@
{
"name": "next"
}
]
],
"target": "ES2017"
},
"include": ["next-env.d.ts", ".next/types/**/*.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
"include": [
"next-env.d.ts",
".next/types/**/*.ts",
"**/*.ts",
"**/*.tsx"
],
"exclude": [
"node_modules"
]
}
211 changes: 189 additions & 22 deletions src/authMiddleware/authMiddleware.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import { NextResponse } from "next/server";
import { config } from "../config/index";
import { type KindeAccessToken, KindeIdToken } from "../../types";
import { KindeAccessToken, KindeIdToken } from "../../types";
import { jwtDecoder } from "@kinde/jwt-decoder";
import { validateToken } from "../utils/validateToken";
import { isTokenExpired } from "../utils/jwt/validation";
import { getAccessToken } from "../utils/getAccessToken";
import { kindeClient } from "../session/kindeServerClient";
import { sessionManager } from "../session/sessionManager";
import { getSplitCookies } from "../utils/cookies/getSplitSerializedCookies";
import { getIdToken } from "../utils/getIdToken";
import { OAuth2CodeExchangeResponse } from "@kinde-oss/kinde-typescript-sdk";
import { copyCookiesToRequest } from "../utils/copyCookiesToRequest";

const handleMiddleware = async (req, options, onSuccess) => {
const { pathname } = req.nextUrl;
Expand All @@ -21,36 +28,162 @@ const handleMiddleware = async (req, options, onSuccess) => {
? `${loginPage}?post_login_redirect_url=${pathname}`
: loginPage;

if (loginPage == pathname || publicPaths.some((p) => pathname.startsWith(p)))
return;
const isPublicPath = loginPage == pathname || publicPaths.some((p) => pathname.startsWith(p));
const resp = NextResponse.next();

const { value: kindeToken } = req.cookies.get("access_token");
// getAccessToken will validate the token
let kindeAccessToken = await getAccessToken(req);

if (!kindeToken) {
const response = NextResponse.redirect(
// if no access token, redirect to login
if (!kindeAccessToken && !isPublicPath) {
if(config.isDebugMode) {
console.log('authMiddleware: no id token, redirecting to login')
}
return NextResponse.redirect(
new URL(loginRedirectUrl, options?.redirectURLBase || config.redirectURL),
);
return response;
}

const accessTokenValue = jwtDecoder<KindeAccessToken>(
req.cookies.get("access_token")?.value,
);
const idTokenValue = jwtDecoder<KindeIdToken>(
req.cookies.get("id_token")?.value,
);
const session = await sessionManager(req)
let refreshResponse: OAuth2CodeExchangeResponse | null = null

// if accessToken is expired, refresh it
if(isTokenExpired(kindeAccessToken)) {
if(config.isDebugMode) {
console.log('authMiddleware: access token expired, refreshing')
}

try {
refreshResponse = await kindeClient.refreshTokens(session);
kindeAccessToken = refreshResponse.access_token

// if we want layouts/pages to get immediate access to the new token,
// we need to set the cookie on the response here
const splitCookies = getSplitCookies("access_token", refreshResponse.access_token)
splitCookies.forEach((cookie) => {
resp.cookies.set(cookie.name, cookie.value, cookie.options);
})

// copy the cookies from the response to the request
// in Next versions prior to 14.2.8, the cookies function
// reads the Set-Cookie header from the *request* object, not the *response* object
// in order to get the new cookies to the request, we need to copy them over
copyCookiesToRequest(req, resp)

if(config.isDebugMode) {
console.log('authMiddleware: access token refreshed')
}
} catch(error) {
// token is expired and refresh failed, redirect to login
if(config.isDebugMode) {
console.error('authMiddleware: access token refresh failed, redirecting to login')
}

if(!isPublicPath) {
return NextResponse.redirect(
new URL(loginRedirectUrl, options?.redirectURLBase || config.redirectURL),
);
}
}
}

// getIdToken will validate the token
let kindeIdToken = await getIdToken(req);

// check token is valid
const isTokenValid = await validateToken({
token: kindeToken,
});
// if no id token, redirect to login
if(!kindeIdToken && !isPublicPath) {
if(config.isDebugMode) {
console.log('authMiddleware: no id token, redirecting to login')
}
return NextResponse.redirect(
new URL(loginRedirectUrl, options?.redirectURLBase || config.redirectURL),
);
}

// if idToken is expired, refresh it
if(isTokenExpired(kindeIdToken)) {
if(config.isDebugMode) {
console.log('authMiddleware: id token expired, refreshing')
}

try {
// if we have a refresh response from an access token refresh, we'll use the id_token from that
if(!refreshResponse) {
refreshResponse = await kindeClient.refreshTokens(session);
}

kindeIdToken = refreshResponse.id_token

// as above, if we want layouts/pages to get immediate access to the new token,
// we need to set the cookie on the response here
const splitCookies = getSplitCookies("id_token", refreshResponse.id_token)
splitCookies.forEach((cookie) => {
resp.cookies.set(cookie.name, cookie.value, cookie.options);
})

copyCookiesToRequest(req, resp)

if(config.isDebugMode) {
console.log('authMiddleware: id token refreshed')
}
} catch(error) {
// token is expired and refresh failed, redirect to login
if(config.isDebugMode) {
console.error('authMiddleware: id token refresh failed, redirecting to login')
}

if(!isPublicPath) {
return NextResponse.redirect(
new URL(loginRedirectUrl, options?.redirectURLBase || config.redirectURL),
);
}
}
}

// we don't bail out earlier than here because we want to refresh the tokens
// if they are expired, even if the path is public
if(isPublicPath) {
return resp;
}

let accessTokenValue: KindeAccessToken | null = null
let idTokenValue: KindeIdToken | null = null

try {
accessTokenValue = jwtDecoder<KindeAccessToken>(
kindeAccessToken,
);
} catch(error) {
if(config.isDebugMode) {
console.error('authMiddleware: access token decode failed, redirecting to login')
}
return NextResponse.redirect(
new URL(loginRedirectUrl, options?.redirectURLBase || config.redirectURL),
);
}

try {
idTokenValue = jwtDecoder<KindeIdToken>(
kindeIdToken,
);
} catch(error) {
if(config.isDebugMode) {
console.error('authMiddleware: id token decode failed, redirecting to login')
}
return NextResponse.redirect(
new URL(loginRedirectUrl, options?.redirectURLBase || config.redirectURL),
);
}

const customValidationValid = options?.isAuthorized
? options.isAuthorized({ req, token: accessTokenValue })
: true;

if (isTokenValid && customValidationValid && onSuccess) {
return await onSuccess({
if (customValidationValid && onSuccess) {
if(config.isDebugMode) {
console.log('authMiddleware: invoking onSuccess callback')
}
const callbackResult = await onSuccess({
token: accessTokenValue,
user: {
family_name: idTokenValue.family_name,
Expand All @@ -60,10 +193,44 @@ const handleMiddleware = async (req, options, onSuccess) => {
picture: idTokenValue.picture,
},
});

// If a user returned a response from their onSuccess callback, copy our refreshed tokens to it
if (callbackResult instanceof NextResponse) {
if(config.isDebugMode) {
console.log('authMiddleware: onSuccess callback returned a response, copying our cookies to it')
}
// Copy our cookies to their response
resp.cookies.getAll().forEach(cookie => {
callbackResult.cookies.set(cookie.name, cookie.value, {
...cookie
});
});

// Copy any headers we set (if any) to their response
resp.headers.forEach((value, key) => {
callbackResult.headers.set(key, value);
});

return callbackResult;
}

// If they didn't return a response, return our response with the refreshed tokens
if(config.isDebugMode) {
console.log('authMiddleware: onSuccess callback did not return a response, returning our response')
}

return resp;
}

if (customValidationValid) {
if(config.isDebugMode) {
console.log('authMiddleware: customValidationValid is true, returning response')
}
return resp;
}

if (isTokenValid && customValidationValid) {
return NextResponse.next();
if(config.isDebugMode) {
console.log('authMiddleware: default behaviour, redirecting to login')
}

return NextResponse.redirect(
Expand Down
Loading
Loading