Skip to content

feat(nest.js/auth): add refreshToken api#147

Merged
kagol merged 1 commit intoopentiny:devfrom
GaoNeng-wWw:feat/refresh-token
Dec 6, 2025
Merged

feat(nest.js/auth): add refreshToken api#147
kagol merged 1 commit intoopentiny:devfrom
GaoNeng-wWw:feat/refresh-token

Conversation

@GaoNeng-wWw
Copy link
Collaborator

@GaoNeng-wWw GaoNeng-wWw commented Dec 3, 2025

PR

PR Checklist

Please check if your PR fulfills the following requirements:

  • The commit message follows our Commit Message Guidelines
  • Tests for the changes have been added (for bug fixes / features)
  • Docs have been added / updated (for bug fixes / features)

PR Type

What kind of change does this PR introduce?

提供了令牌刷新接口

output.mp4
  • Bugfix
  • Feature
  • Code style update (formatting, local variables)
  • Refactoring (no functional changes, no api changes)
  • Build related changes
  • CI related changes
  • Documentation content changes
  • Other... Please describe:

What is the current behavior?

Issue Number: N/A

What is the new behavior?

可以通过调用

POST /auth/token/refresh
{token: <your-refresh-token>}

来获取一个新的令牌对.

Does this PR introduce a breaking change?

  • Yes
  • No

对于存量应用, 后端无需修改, 前端需要提供令牌刷新及吊销的支持. 可以参考

template/tinyvue/src/store/modules/user/index.ts

Other information

Summary by CodeRabbit

Release Notes

  • New Features
    • Added token refresh capability enabling users to maintain active sessions without re-authenticating
    • Enhanced session management with automatic token persistence and lifecycle handling for a more seamless experience

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link

coderabbitai bot commented Dec 3, 2025

Walkthrough

This pull request introduces a token refresh flow across the backend and frontend. The NestJS backend adds a new POST /auth/token/refresh endpoint that accepts a refresh token, validates and revokes the old token pair, and issues new tokens. The Vue.js frontend adds corresponding API methods and store actions to manage refresh tokens alongside access tokens in session storage.

Changes

Cohort / File(s) Summary
Backend Auth Endpoint & Data Transfer
template/nestJs/src/auth/auth.controller.ts, template/nestJs/src/auth/dto/refresh-token.dto.ts
Added new public POST /token/refresh endpoint in AuthController accepting RefreshToken DTO; removed Permission decorator import. Created RefreshToken DTO with IsNotEmpty validation.
Backend Auth Service
template/nestJs/src/auth/auth.service.ts
Implemented new refreshToken(maybeToken) method that decodes tokens, validates via TokenService, revokes old token pair, and issues new tokens. Updated logout flow to revoke specific tokens by JTI instead of by user ID.
Backend Token Service
template/nestJs/src/auth/token.service.ts
Made revokeExpiredToken() private; added new public getTokenByJti(id, jti, type) method to retrieve tokens from Redis by JTI.
Frontend Auth Utilities
template/tinyvue/src/utils/auth.ts
Added REFRESH_TOKEN_KEY constant, getRefreshToken() and setRefreshToken(token) functions to manage refresh tokens in sessionStorage alongside existing token utilities.
Frontend Store State
template/tinyvue/src/store/modules/user/types.ts
Extended UserState type with new refreshToken and accessToken string properties.
Frontend Store Implementation
template/tinyvue/src/store/modules/user/index.ts
Added state fields for refreshToken and accessToken; implemented new flushToken() action that calls the refresh API and updates both tokens in state and storage; updated login action to extract and persist refresh token from response.
Frontend API Integration
template/tinyvue/src/api/user.ts
Added RefreshToken interface and flushToken(data) function that posts to the token refresh endpoint and returns LoginResponse.

Sequence Diagram

sequenceDiagram
    participant Client as Client
    participant Controller as AuthController
    participant Service as AuthService
    participant TokenSvc as TokenService
    participant Redis as Redis
    
    Client->>Controller: POST /auth/token/refresh<br/>{token: refreshToken}
    Controller->>Service: refreshToken(body.token)
    
    Service->>Service: Decode token payload
    Service->>Service: Validate token<br/>(check no refreshTokenJti)
    
    Service->>TokenSvc: getTokenByJti(id, jti, 'rt')
    TokenSvc->>Redis: Get refresh token<br/>from cache
    Redis-->>TokenSvc: Token record
    TokenSvc-->>Service: Token found
    
    Service->>Service: Extract associated<br/>access token JTI
    Service->>TokenSvc: Revoke access token
    TokenSvc->>Redis: Delete access token
    
    Service->>TokenSvc: Revoke refresh token
    TokenSvc->>Redis: Delete refresh token
    
    Service->>Service: Generate new<br/>token pair
    Service->>TokenSvc: Issue new tokens
    TokenSvc->>Redis: Store new tokens
    
    Service-->>Controller: {accessToken,<br/>refreshToken,<br/>TTLs}
    Controller-->>Client: 200 OK<br/>New token pair
Loading

Estimated Code Review Effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

  • AuthService.refreshToken logic: Token decoding, validation flow with JTI checks, and token revocation cascading require careful verification of security assumptions
  • Token lifecycle management: Ensure old token pairs are fully revoked before new ones are issued; logout flow changes affect existing behavior
  • State synchronization: Frontend store must keep accessToken and refreshToken in sync across state and sessionStorage; verify login and flushToken actions handle all edge cases
  • API contract consistency: Confirm RefreshToken DTO structure and response shape match frontend expectations

Poem

🐰✨ A token refreshed, new guards in place,
Old credentials fade without a trace,
From backend vaults to frontend stores,
Security flows through every door! 🔐

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main feature: adding a refresh token API endpoint to the NestJS authentication module.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions bot added the enhancement New feature or request label Dec 3, 2025
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
template/tinyvue/src/utils/auth.ts (1)

2-25: Refresh token is never cleared by clearToken (logout leaves a valid refresh token behind).

clearToken() (Line 21) still only removes TOKEN_KEY, so any stored refresh token under REFRESH_TOKEN_KEY persists across logout and error paths. Given the new refresh flow, this means a “logged out” client could still silently obtain new access tokens via the saved refresh token.

I’d treat this as an essential fix and have clearToken clear both keys:

-const clearToken = () => {
-  sessionStorage.removeItem(TOKEN_KEY);
-};
+const clearToken = () => {
+  sessionStorage.removeItem(TOKEN_KEY);
+  sessionStorage.removeItem(REFRESH_TOKEN_KEY);
+};

This will also ensure that getRefreshToken() returns null after logout/reset, keeping the store state consistent.

♻️ Duplicate comments (1)
template/tinyvue/src/store/modules/user/index.ts (1)

96-105: Align login/logout error paths with the new refresh token lifecycle.

In login you now set both accessToken and refreshToken in state and storage (Lines 100–105), but in the error path (Lines 135–137) and in logout (Lines 151–160) you only call clearToken(). Combined with the current clearToken implementation, this leaves the refresh token untouched until the utils fix is applied.

Once you update clearToken to remove REFRESH_TOKEN_KEY as well (see template/tinyvue/src/utils/auth.ts comment), these paths will correctly clear both tokens. Please just double‑check that there are no other custom “partial clear” scenarios that relied on clearToken removing only the access token.

Also applies to: 151-160

🧹 Nitpick comments (3)
template/nestJs/src/auth/dto/refresh-token.dto.ts (1)

3-6: Add @IsString() decorator for type validation.

The @IsNotEmpty() decorator only checks that the value is not empty but doesn't validate the type. Adding @IsString() ensures the token is a string and prevents potential type coercion issues.

-import { IsNotEmpty } from "class-validator";
+import { IsNotEmpty, IsString } from "class-validator";

 export class RefreshToken {
+  @IsString()
   @IsNotEmpty()
   token: string;
 }
template/tinyvue/src/api/user.ts (1)

47-53: Consider renaming flushToken to refreshToken for clarity.

The function name flushToken is unconventional for a token refresh operation. "Flush" typically implies clearing or emptying, whereas this function obtains a new token pair. Consider renaming to refreshToken or renewToken for better semantic clarity and alignment with the endpoint name /auth/token/refresh.

-export interface RefreshToken {
+export interface RefreshTokenRequest {
   token: string;
 }

-export function flushToken(data: RefreshToken){
+export function refreshToken(data: RefreshTokenRequest){
   return axios.post<LoginResponse>(`${import.meta.env.VITE_BASE_API}/auth/token/refresh`, data)
 }
template/nestJs/src/auth/auth.controller.ts (1)

1-21: Refresh endpoint wiring looks correct and consistent with DTO/frontend.

The new POST /auth/token/refresh endpoint correctly uses the RefreshToken DTO and delegates to authService.refreshToken(body.token), matching the frontend flushToken contract.

Optionally, you could add an explicit return type for refreshToken() (e.g., Promise<LoginResponse> or similar) to keep controller signatures self‑documenting, in line with other methods.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between bf2c8a0 and f3dcebb.

📒 Files selected for processing (8)
  • template/nestJs/src/auth/auth.controller.ts (1 hunks)
  • template/nestJs/src/auth/auth.service.ts (2 hunks)
  • template/nestJs/src/auth/dto/refresh-token.dto.ts (1 hunks)
  • template/nestJs/src/auth/token.service.ts (2 hunks)
  • template/tinyvue/src/api/user.ts (1 hunks)
  • template/tinyvue/src/store/modules/user/index.ts (4 hunks)
  • template/tinyvue/src/store/modules/user/types.ts (1 hunks)
  • template/tinyvue/src/utils/auth.ts (3 hunks)
🧰 Additional context used
🧬 Code graph analysis (5)
template/tinyvue/src/api/user.ts (3)
template/nestJs/src/auth/dto/refresh-token.dto.ts (1)
  • RefreshToken (3-6)
template/tinyvue/src/store/modules/user/index.ts (1)
  • flushToken (69-81)
template/nestJs/src/auth/dto/login-in.dto.ts (1)
  • LoginResponse (1-6)
template/nestJs/src/auth/dto/refresh-token.dto.ts (1)
template/tinyvue/src/api/user.ts (1)
  • RefreshToken (47-49)
template/nestJs/src/auth/auth.service.ts (3)
template/nestJs/src/auth/entity/token.ts (2)
  • AccessTokenPayload (11-15)
  • RefreshTokenPayload (16-20)
template/nestJs/src/auth/auth.controller.ts (1)
  • refreshToken (17-21)
template/nestJs/libs/utils/pick.ts (1)
  • pick (1-16)
template/nestJs/src/auth/auth.controller.ts (2)
template/nestJs/src/auth/dto/refresh-token.dto.ts (1)
  • RefreshToken (3-6)
template/tinyvue/src/api/user.ts (1)
  • RefreshToken (47-49)
template/tinyvue/src/store/modules/user/index.ts (4)
template/tinyvue/src/utils/auth.ts (4)
  • getRefreshToken (25-25)
  • getToken (25-25)
  • setRefreshToken (25-25)
  • setToken (25-25)
template/nestJs/src/auth/auth.service.ts (2)
  • getToken (28-30)
  • refreshToken (46-76)
template/nestJs/src/auth/auth.controller.ts (1)
  • refreshToken (17-21)
template/tinyvue/src/api/user.ts (1)
  • flushToken (51-53)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: e2e-test
🔇 Additional comments (4)
template/nestJs/src/auth/token.service.ts (2)

185-192: LGTM!

The getTokenByJti method is well-designed with a clear interface. The type parameter effectively reuses the key prefix pattern used elsewhere in the service. Consider adding an explicit return type annotation for documentation purposes.

   async getTokenByJti(
     id: number,
     jti: string,
     type: 'at' | 'rt'
-  ) {
+  ): Promise<string | null> {
     const redis = this.redisService.getRedis();
     return redis.get(`${type}:${id}:${jti}`);
   }

36-36: LGTM!

Making revokeExpiredToken private is appropriate encapsulation since it's only used internally within issueToken.

template/nestJs/src/auth/auth.service.ts (1)

36-44: LGTM with a note on silent success.

The logout flow improvement to look up the specific token before revocation is good. The silent success when token is not found (already expired/revoked) is acceptable behavior for logout operations.

template/tinyvue/src/store/modules/user/types.ts (1)

41-41: Consider making token fields optional if the store is used before authentication.

The refreshToken and accessToken fields are currently required strings. If the store can be initialized before login, this may cause type errors. Verify the store initialization pattern: if tokens are always set before state creation, the current approach is acceptable; otherwise, consider using string | null to allow for pre-authentication states.

Comment on lines +46 to +76
async refreshToken(
maybeToken: string
){
const token = this.jwtService.decode<AccessTokenPayload | RefreshTokenPayload>(maybeToken);
if ('refreshTokenJti' in token) {
throw new HttpException(
this.i18n.translate('exception.common.tokenError'),
HttpStatus.BAD_REQUEST
)
}
const refresTokenObject = token as RefreshTokenPayload;
const {id, jti, accessTokenJti, email} = refresTokenObject;
const refreshToken = await this.tokenService.getTokenByJti(id, jti,'rt');
if (!refreshToken) {
throw new HttpException(
this.i18n.translate('exception.common.tokenExpire'),
HttpStatus.UNAUTHORIZED
)
}
const accessToken = await this.tokenService.getTokenByJti(id, accessTokenJti, 'at');
if (accessToken){
await this.tokenService.revokeToken(accessToken);
}
await this.tokenService.revokeToken(refreshToken);
const tokenPair = await this.tokenService.createToken(id, email);
// 颁发一个新的token
// issueToken 内部会在颁发前踢出最老的会话, 也会删除过期的会话, 这里就不用调用 this.tokenService.revokeExpiredToken 了
await this.tokenService.issueToken(id, tokenPair);
// 返回一个新的TokenPair
return pick(tokenPair, ['accessToken', 'accessTokenTTL', 'refreshToken', 'refreshTokenTTL'])
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Critical: revokeToken called with refresh token will fail.

The revokeToken method in TokenService (line 28) expects an access token because it decodes an AccessTokenPayload and extracts refreshTokenJti. However, on line 69, you're passing a refresh token string, which has accessTokenJti instead of refreshTokenJti. This will cause the revocation to fail or delete incorrect keys.

You need to revoke the refresh token directly using its JTI and ID rather than calling revokeToken:

     const accessToken = await this.tokenService.getTokenByJti(id, accessTokenJti, 'at');
     if (accessToken){
       await this.tokenService.revokeToken(accessToken);
     }
-    await this.tokenService.revokeToken(refreshToken);
+    // Revoke refresh token directly - cannot use revokeToken() as it expects access token format
+    const redis = this.redisService.getRedis();
+    await redis.del(`rt:${id}:${jti}`);
+    await redis.lrem(`user:${id}:rt`, 0, jti);

Alternatively, add a dedicated revokeRefreshToken method to TokenService that handles refresh token revocation properly.

Committable suggestion skipped: line range outside the PR's diff.

async refreshToken(
maybeToken: string
){
const token = this.jwtService.decode<AccessTokenPayload | RefreshTokenPayload>(maybeToken);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Security concern: Token decoded without signature verification.

The decode method only parses the JWT without verifying its signature, unlike logout which uses verify() on line 37. An attacker could craft a JWT with arbitrary claims. While the Redis lookup at line 58 provides some protection (the token string must match), consider using verify() for consistency and defense-in-depth.

-    const token = this.jwtService.decode<AccessTokenPayload | RefreshTokenPayload>(maybeToken);
+    const verifyResult = await this.jwtService.verify<RefreshTokenPayload>(maybeToken);
+    if (!verifyResult.valid) {
+      throw new HttpException(
+        this.i18n.translate('exception.common.tokenError'),
+        HttpStatus.BAD_REQUEST
+      );
+    }
+    const token = verifyResult.payload;
     if ('refreshTokenJti' in token) {

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In template/nestJs/src/auth/auth.service.ts around line 49, the code uses
jwtService.decode(...) which parses the JWT without verifying its signature;
replace decode with jwtService.verify<AccessTokenPayload |
RefreshTokenPayload>(maybeToken) and wrap the call in a try/catch to handle
JsonWebTokenError/TokenExpiredError, returning null or throwing an auth-specific
error on verification failure; keep the subsequent Redis token lookup intact to
ensure the exact token string is still checked, and ensure the verified payload
is properly type-guarded before use.

Comment on lines +43 to 45
refreshToken: getRefreshToken(),
accessToken: getToken()
}),
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Guard against missing/invalid refresh token and improve flushToken action behavior.

flushToken() currently assumes this.refreshToken is always a non‑null string (Line 70) and casts it, then calls the API without any guard or error handling. After your utils change to clear both tokens, this.refreshToken can legitimately be null, and this action would send { token: null } to the backend and give callers no way to await or react to failures.

Consider tightening this action along these lines:

  • If there is no refreshToken in state (or storage), short‑circuit (e.g., throw or trigger a logout) instead of calling the API.
  • Make the action async and return the Promise so callers can await it and handle errors.
  • On refresh failure (e.g., 401/invalid refresh token), clear local tokens and user state to avoid being stuck with unusable credentials.

For example:

-    flushToken(){
-      const refreshToken = this.refreshToken as string;
-      flushToken({token: refreshToken})
-      .then((tokenPair) => {
-        return tokenPair.data
-      })
-      .then((data) => {
-        this.refreshToken = data.refreshToken;
-        this.accessToken = data.accessToken;
-        setRefreshToken(data.refreshToken)
-        setToken(data.accessToken);
-      })
-    },
+    async flushToken() {
+      const refreshToken = this.refreshToken;
+      if (!refreshToken) {
+        // e.g. optionally: await this.logout(); or throw an error
+        throw new Error('No refresh token available');
+      }
+      const { data } = await flushToken({ token: refreshToken });
+      this.refreshToken = data.refreshToken;
+      this.accessToken = data.accessToken;
+      setRefreshToken(data.refreshToken);
+      setToken(data.accessToken);
+    },

You can then add targeted handling (e.g., try/catch + logout) at call sites if needed.

Also applies to: 69-81

@kagol
Copy link
Member

kagol commented Dec 6, 2025

@GaoNeng-wWw 想了解下这个新的 API 有什么作用呢?已经有了 setToken / getToken 了,登录功能也是正常的,增加 setRefreshToken/getRefreshToken 的作用是什么?使用场景是什么呢?没有这个 API 会有什么问题呢?是用来做 token 过期之后自动刷新 token,持续保持登录态对吗?

@GaoNeng-wWw
Copy link
Collaborator Author

@GaoNeng-wWw 想了解下这个新的 API 有什么作用呢?已经有了 setToken / getToken 了,登录功能也是正常的,增加 setRefreshToken/getRefreshToken 的作用是什么?使用场景是什么呢?没有这个 API 会有什么问题呢?是用来做 token 过期之后自动刷新 token,持续保持登录态对吗?

没有这个API其实不会有任何问题,主要是上一个pr写了refreshToken, 不写一个刷新令牌的接口总感觉很奇怪。

并不是保持登录态,而是根据refreshToken来获取一个新的TokenPair。

@kagol
Copy link
Member

kagol commented Dec 6, 2025

@GaoNeng-wWw 想了解下这个新的 API 有什么作用呢?已经有了 setToken / getToken 了,登录功能也是正常的,增加 setRefreshToken/getRefreshToken 的作用是什么?使用场景是什么呢?没有这个 API 会有什么问题呢?是用来做 token 过期之后自动刷新 token,持续保持登录态对吗?

没有这个API其实不会有任何问题,主要是上一个pr写了refreshToken, 不写一个刷新令牌的接口总感觉很奇怪。

并不是保持登录态,而是根据refreshToken来获取一个新的TokenPair。

#139

@kagol kagol merged commit ecb6a31 into opentiny:dev Dec 6, 2025
3 of 4 checks passed
@GaoNeng-wWw GaoNeng-wWw mentioned this pull request Dec 6, 2025
20 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

Comments