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

Implement Refresh Token #317

Merged
merged 17 commits into from
Sep 11, 2024
Merged
Show file tree
Hide file tree
Changes from 6 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
5 changes: 4 additions & 1 deletion backend/.env.development
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ GITHUB_CALLBACK_URL=http://localhost:3000/auth/login/github

# JWT_AUTH_SECRET: Secret key for JWT authentication.
# This key is used to sign and verify JWT tokens.
xet-a marked this conversation as resolved.
Show resolved Hide resolved
JWT_AUTH_SECRET=you_should_change_this_secret_key_in_production
JWT_ACCESS_TOKEN_SECRET=you_should_change_this_secret_key_in_production
JWT_ACCESS_TOKEN_EXPIRATION_TIME=86400
JWT_REFRESH_TOKEN_SECRET=you_should_change_this_secret_key_in_production
xet-a marked this conversation as resolved.
Show resolved Hide resolved
JWT_REFRESH_TOKEN_EXPIRATION_TIME=604800
devleejb marked this conversation as resolved.
Show resolved Hide resolved

# FRONTEND_BASE_URL: Base URL of the frontend application.
# This URL is used for redirecting after authentication, etc.
Expand Down
45 changes: 29 additions & 16 deletions backend/src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,28 @@
import { Controller, Get, HttpRedirectResponse, Redirect, Req, UseGuards } from "@nestjs/common";
import {
Body,
Controller,
Get,
HttpRedirectResponse,
Post,
Redirect,
Req,
UseGuards,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { AuthGuard } from "@nestjs/passport";
import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
import { Public } from "src/utils/decorators/auth.decorator";
import { AuthService } from "./auth.service";
import { LoginRequest } from "./types/login-request.type";
import { JwtService } from "@nestjs/jwt";
import { LoginResponse } from "./types/login-response.type";
import { UsersService } from "src/users/users.service";
import { Public } from "src/utils/decorators/auth.decorator";
import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
import { ConfigService } from "@nestjs/config";
import { RefreshTokenRequest } from "./types/refresh-token-request.type";

@ApiTags("Auth")
@Controller("auth")
export class AuthController {
constructor(
private configService: ConfigService,
private jwtService: JwtService,
private usersService: UsersService
private readonly authService: AuthService,
private configService: ConfigService
) {}

@Public()
Expand All @@ -28,16 +36,21 @@ export class AuthController {
})
@ApiResponse({ type: LoginResponse })
async login(@Req() req: LoginRequest): Promise<HttpRedirectResponse> {
const user = await this.usersService.findOrCreate(
req.user.socialProvider,
req.user.socialUid
);

const accessToken = this.jwtService.sign({ sub: user.id, nickname: user.nickname });
const { accessToken, refreshToken } = await this.authService.loginWithGithub(req);

return {
url: `${this.configService.get("FRONTEND_BASE_URL")}/auth/callback?token=${accessToken}`,
url: `${this.configService.get("FRONTEND_BASE_URL")}/auth/callback?accessToken=${accessToken}&refreshToken=${refreshToken}`,
statusCode: 302,
};
}

@Public()
@Post("refresh")
@UseGuards(AuthGuard("refresh"))
@ApiOperation({ summary: "Refresh Access Token" })
@ApiResponse({ type: LoginResponse })
xet-a marked this conversation as resolved.
Show resolved Hide resolved
async refresh(@Body() body: RefreshTokenRequest): Promise<{ accessToken: string }> {
xet-a marked this conversation as resolved.
Show resolved Hide resolved
const accessToken = await this.authService.getNewAccessToken(body.refreshToken);
return { accessToken };
}
}
15 changes: 9 additions & 6 deletions backend/src/auth/auth.module.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { Module } from "@nestjs/common";
import { AuthService } from "./auth.service";
import { ConfigService } from "@nestjs/config";
import { JwtModule } from "@nestjs/jwt";
import { UsersModule } from "src/users/users.module";
import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service";
import { GithubStrategy } from "./github.strategy";
import { ConfigService } from "@nestjs/config";
import { JwtModule } from "@nestjs/jwt";
import { JwtRefreshStrategy } from "./jwt-refresh.strategy";
import { JwtStrategy } from "./jwt.strategy";

@Module({
Expand All @@ -14,14 +15,16 @@ import { JwtStrategy } from "./jwt.strategy";
useFactory: async (configService: ConfigService) => {
return {
global: true,
signOptions: { expiresIn: "24h" },
secret: configService.get<string>("JWT_AUTH_SECRET"),
signOptions: {
expiresIn: `${configService.get("JWT_ACCESS_TOKEN_EXPIRATION_TIME")}s`,
},
secret: configService.get<string>("JWT_ACCESS_TOKEN_SECRET"),
};
},
inject: [ConfigService],
}),
],
providers: [AuthService, GithubStrategy, JwtStrategy],
providers: [AuthService, GithubStrategy, JwtStrategy, JwtRefreshStrategy],
controllers: [AuthController],
})
export class AuthModule {}
48 changes: 47 additions & 1 deletion backend/src/auth/auth.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,64 @@
import { ConfigModule } from "@nestjs/config";
import { JwtService } from "@nestjs/jwt";
import { Test, TestingModule } from "@nestjs/testing";
import { UsersService } from "../users/users.service";
import { AuthService } from "./auth.service";

describe("AuthService", () => {
let service: AuthService;
let jwtService: JwtService;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [AuthService],
imports: [ConfigModule.forRoot()],
providers: [
AuthService,
{
provide: UsersService,
useValue: {
findOrCreate: jest
.fn()
.mockResolvedValue({ id: "123", nickname: "testuser" }),
},
},
{
provide: JwtService,
useValue: {
sign: jest.fn().mockReturnValue("signedToken"),
verify: jest.fn().mockReturnValue({ sub: "123", nickname: "testuser" }),
},
},
],
}).compile();

service = module.get<AuthService>(AuthService);
jwtService = module.get<JwtService>(JwtService);
});

it("should be defined", () => {
expect(service).toBeDefined();
});

describe("getNewAccessToken", () => {
it("should generate a new access token using refresh token", async () => {
const newToken = await service.getNewAccessToken("refreshToken");

expect(newToken).toBe("signedToken");
expect(jwtService.verify).toHaveBeenCalledWith("refreshToken");
expect(jwtService.sign).toHaveBeenCalledWith(
{ sub: "123", nickname: "testuser" },
expect.any(Object)
);
});

it("should throw an error if refresh token is invalid", async () => {
jwtService.verify = jest.fn().mockImplementation(() => {
throw new Error("Invalid token");
});

await expect(service.getNewAccessToken("invalidToken")).rejects.toThrow(
"Invalid token"
);
});
});
});
40 changes: 39 additions & 1 deletion backend/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,45 @@
import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { JwtService } from "@nestjs/jwt";
import { UsersService } from "src/users/users.service";
import { LoginRequest } from "./types/login-request.type";
import { LoginResponse } from "./types/login-response.type";

@Injectable()
export class AuthService {
constructor(private usersService: UsersService) {}
constructor(
private readonly usersService: UsersService,
private readonly jwtService: JwtService,
private readonly configService: ConfigService
) {}

async loginWithGithub(req: LoginRequest): Promise<LoginResponse> {
xet-a marked this conversation as resolved.
Show resolved Hide resolved
const user = await this.usersService.findOrCreate(
req.user.socialProvider,
req.user.socialUid
);

const accessToken = this.jwtService.sign(
{ sub: user.id, nickname: user.nickname },
{ expiresIn: `${this.configService.get("JWT_ACCESS_TOKEN_EXPIRATION_TIME")}s` }
);

const refreshToken = this.jwtService.sign(
{ sub: user.id },
{ expiresIn: `${this.configService.get("JWT_REFRESH_TOKEN_EXPIRATION_TIME")}s` }
);
xet-a marked this conversation as resolved.
Show resolved Hide resolved

return { accessToken, refreshToken };
}

async getNewAccessToken(refreshToken: string) {
const payload = this.jwtService.verify(refreshToken);
xet-a marked this conversation as resolved.
Show resolved Hide resolved

const newAccessToken = this.jwtService.sign(
{ sub: payload.sub, nickname: payload.nickname },
{ expiresIn: `${this.configService.get("JWT_ACCESS_TOKEN_EXPIRATION_TIME")}s` }
);

return newAccessToken;
}
}
26 changes: 26 additions & 0 deletions backend/src/auth/jwt-refresh.strategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { PassportStrategy } from "@nestjs/passport";
import { Strategy as PassportJwtStrategy } from "passport-jwt";
import { JwtPayload } from "src/utils/types/jwt.type";
import { AuthorizedUser } from "src/utils/types/req.type";

@Injectable()
export class JwtRefreshStrategy extends PassportStrategy(PassportJwtStrategy, "refresh") {
constructor(configService: ConfigService) {
super({
jwtFromRequest: (req) => {
if (req && req.body.refreshToken) {
return req.body.refreshToken;
}
return null;
},
ignoreExpiration: false,
secretOrKey: configService.get<string>("JWT_REFRESH_TOKEN_SECRET"),
});
}

async validate(payload: JwtPayload): Promise<AuthorizedUser> {
return { id: payload.sub, nickname: payload.nickname };
}
}
8 changes: 4 additions & 4 deletions backend/src/auth/jwt.strategy.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
import { ExtractJwt, Strategy as PassportJwtStrategy } from "passport-jwt";
import { ConfigService } from "@nestjs/config";
import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { PassportStrategy } from "@nestjs/passport";
import { ExtractJwt, Strategy as PassportJwtStrategy } from "passport-jwt";
import { JwtPayload } from "src/utils/types/jwt.type";
import { AuthorizedUser } from "src/utils/types/req.type";

@Injectable()
export class JwtStrategy extends PassportStrategy(PassportJwtStrategy) {
export class JwtStrategy extends PassportStrategy(PassportJwtStrategy, "jwt") {
constructor(configService: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.get<string>("JWT_AUTH_SECRET"),
secretOrKey: configService.get<string>("JWT_ACCESS_TOKEN_SECRET"),
});
}

Expand Down
1 change: 1 addition & 0 deletions backend/src/auth/types/login-response.type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ import { ApiProperty } from "@nestjs/swagger";
export class LoginResponse {
@ApiProperty({ type: String, description: "Access token for CodePair" })
accessToken: string;
refreshToken: string;
xet-a marked this conversation as resolved.
Show resolved Hide resolved
}
6 changes: 6 additions & 0 deletions backend/src/auth/types/refresh-token-request.type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { IsString } from "class-validator";

export class RefreshTokenRequest {
xet-a marked this conversation as resolved.
Show resolved Hide resolved
@IsString()
refreshToken: string;
xet-a marked this conversation as resolved.
Show resolved Hide resolved
}
devleejb marked this conversation as resolved.
Show resolved Hide resolved
65 changes: 55 additions & 10 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@ import "@fontsource/roboto/300.css";
import "@fontsource/roboto/400.css";
import "@fontsource/roboto/500.css";
import "@fontsource/roboto/700.css";
import "./App.css";
import { Box, CssBaseline, ThemeProvider, createTheme, useMediaQuery } from "@mui/material";
import { useSelector } from "react-redux";
import * as Sentry from "@sentry/react";
import { QueryCache, QueryClient, QueryClientProvider } from "@tanstack/react-query";
import axios from "axios";
import { useEffect, useMemo } from "react";
import { useDispatch, useSelector } from "react-redux";
import {
RouterProvider,
createBrowserRouter,
Expand All @@ -13,15 +16,15 @@ import {
useLocation,
useNavigationType,
} from "react-router-dom";
import { useEffect, useMemo } from "react";
import { selectConfig } from "./store/configSlice";
import axios from "axios";
import { routes } from "./routes";
import { QueryCache, QueryClient, QueryClientProvider } from "@tanstack/react-query";
import AuthProvider from "./providers/AuthProvider";
import { useErrorHandler } from "./hooks/useErrorHandler";
import * as Sentry from "@sentry/react";
import "./App.css";
import { useGetSettingsQuery } from "./hooks/api/settings";
import { useErrorHandler } from "./hooks/useErrorHandler";
import AuthProvider from "./providers/AuthProvider";
import { routes } from "./routes";
import { setAccessToken, setRefreshToken } from "./store/authSlice";
import { selectConfig } from "./store/configSlice";
import { store } from "./store/store";
import { setUserData } from "./store/userSlice";
import { isAxios404Error, isAxios500Error } from "./utils/axios.default";

if (import.meta.env.PROD) {
Expand Down Expand Up @@ -58,6 +61,7 @@ function SettingLoader() {

function App() {
const config = useSelector(selectConfig);
const dispatch = useDispatch();
const prefersDarkMode = useMediaQuery("(prefers-color-scheme: dark)");
const theme = useMemo(() => {
const defaultMode = prefersDarkMode ? "dark" : "light";
Expand Down Expand Up @@ -95,6 +99,47 @@ function App() {
});
}, [handleError]);

useEffect(() => {
const handleRefreshTokenExpiration = () => {
dispatch(setAccessToken(null));
dispatch(setRefreshToken(null));
dispatch(setUserData(null));
// axios.defaults.headers.common["Authorization"] = "";
};

const interceptor = axios.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response?.status === 401) {
if (error.config.url === "/auth/refresh") {
handleRefreshTokenExpiration();
xet-a marked this conversation as resolved.
Show resolved Hide resolved
return Promise.reject(error);
} else if (!error.config._retry) {
error.config._retry = true;
const refreshToken = store.getState().auth.refreshToken;
try {
const response = await axios.post("/auth/refresh", { refreshToken });
const newAccessToken = response.data.accessToken;
dispatch(setAccessToken(newAccessToken));
axios.defaults.headers.common["Authorization"] =
`Bearer ${newAccessToken}`;
error.config.headers["Authorization"] = `Bearer ${newAccessToken}`;
return axios(error.config);
} catch (refreshError) {
handleRefreshTokenExpiration();
return Promise.reject(refreshError);
}
}
}
return Promise.reject(error);
}
);

return () => {
axios.interceptors.response.eject(interceptor);
};
}, [dispatch]);

return (
<QueryClientProvider client={queryClient}>
<AuthProvider>
Expand Down
Loading