Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1007,6 +1007,32 @@ OPTIONAL: `PayloadFunc`

This function is called after having successfully authenticated (logged in). It should take whatever was returned from `Authenticator` and convert it into `MapClaims` (i.e. map[string]any). A typical use case of this function is for when `Authenticator` returns a struct which holds the user identifiers, and that struct needs to be converted into a map. `MapClaims` should include one element that is [`IdentityKey` (default is "identity"): some_user_identity]. The elements of `MapClaims` returned in `PayloadFunc` will be embedded within the jwt token (as token claims). When users pass in their token on subsequent requests, you can get these claims back by using `ExtractClaims`.

**Standard JWT Claims (RFC 7519):** You can set standard JWT claims in `PayloadFunc` for better interoperability:

- `sub` (Subject) - The user identifier (e.g., user ID)
- `iss` (Issuer) - The issuer of the token (e.g., your app name)
- `aud` (Audience) - The intended audience (e.g., your API)
- `nbf` (Not Before) - Token is not valid before this time
- `iat` (Issued At) - When the token was issued
- `jti` (JWT ID) - Unique identifier for the token

**Note:** The `exp` (Expiration) and `orig_iat` claims are managed by the framework and cannot be overwritten.

```go
PayloadFunc: func(data any) jwt.MapClaims {
if user, ok := data.(*User); ok {
return jwt.MapClaims{
"sub": user.ID, // Standard: Subject (user ID)
"iss": "my-app", // Standard: Issuer
"aud": "my-api", // Standard: Audience
"identity": user.UserName, // Custom claim
"role": user.Role, // Custom claim
}
}
return jwt.MapClaims{}
}
```

OPTIONAL: `LoginResponse`

After having successfully authenticated with `Authenticator`, created the jwt token using the identifiers from map returned from `PayloadFunc`, and set it as a cookie if `SendCookie` is enabled, this function is called. It receives the complete token information (including access token, refresh token, expiry, etc.) as a structured `core.Token` object. This function is used to handle any post-login logic and return the token response to the user.
Expand Down
27 changes: 26 additions & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -1004,9 +1004,34 @@ CookieSameSite: http.SameSiteDefaultMode, // SameSiteDefaultMode, SameSiteLaxM
- **必须:** `Authenticator`
验证 Gin context 内的用户凭证。验证成功后返回要嵌入 JWT Token 的用户数据(如账号、角色等)。失败则调用 `Unauthorized`。

- **可选:** `PayloadFunc`
- **可选:** `PayloadFunc`
将认证通过的用户数据转为 `MapClaims`(map[string]any),必须包含 `IdentityKey`(默认 `"identity"`)。

**标准 JWT Claims(RFC 7519):** 您可以在 `PayloadFunc` 中设置标准 JWT claims 以提高互操作性:
- `sub`(Subject)- 用户标识符(例如用户 ID)
- `iss`(Issuer)- Token 签发者(例如您的应用程序名称)
- `aud`(Audience)- 预期的接收方(例如您的 API)
- `nbf`(Not Before)- Token 在此时间之前无效
- `iat`(Issued At)- Token 签发时间
- `jti`(JWT ID)- Token 的唯一标识符

**注意:** `exp`(过期时间)和 `orig_iat` claims 由框架管理,无法覆盖。

```go
PayloadFunc: func(data any) jwt.MapClaims {
if user, ok := data.(*User); ok {
return jwt.MapClaims{
"sub": user.ID, // 标准:Subject(用户 ID)
"iss": "my-app", // 标准:Issuer
"aud": "my-api", // 标准:Audience
"identity": user.UserName, // 自定义 claim
"role": user.Role, // 自定义 claim
}
}
return jwt.MapClaims{}
}
```

- **可选:** `LoginResponse`
在成功验证后处理登录后逻辑。此函数接收完整的 token 信息(包括访问 token、刷新 token、过期时间等)作为结构化的 `core.Token` 对象,用于处理登录后逻辑并返回 token 响应给用户。

Expand Down
27 changes: 26 additions & 1 deletion README.zh-TW.md
Original file line number Diff line number Diff line change
Expand Up @@ -1004,9 +1004,34 @@ CookieSameSite: http.SameSiteDefaultMode, // SameSiteDefaultMode, SameSiteLaxM
- **必須:** `Authenticator`
驗證 Gin context 內的使用者憑證。驗證成功後回傳要嵌入 JWT Token 的使用者資料(如帳號、角色等)。失敗則呼叫 `Unauthorized`。

- **可選:** `PayloadFunc`
- **可選:** `PayloadFunc`
將驗證通過的使用者資料轉為 `MapClaims`(map[string]any),必須包含 `IdentityKey`(預設為 `"identity"`)。

**標準 JWT Claims(RFC 7519):** 您可以在 `PayloadFunc` 中設定標準 JWT claims 以提高互通性:
- `sub`(Subject)- 使用者識別碼(例如使用者 ID)
- `iss`(Issuer)- Token 簽發者(例如您的應用程式名稱)
- `aud`(Audience)- 預期的接收方(例如您的 API)
- `nbf`(Not Before)- Token 在此時間之前無效
- `iat`(Issued At)- Token 簽發時間
- `jti`(JWT ID)- Token 的唯一識別碼

**注意:** `exp`(過期時間)和 `orig_iat` claims 由框架管理,無法覆寫。

```go
PayloadFunc: func(data any) jwt.MapClaims {
if user, ok := data.(*User); ok {
return jwt.MapClaims{
"sub": user.ID, // 標準:Subject(使用者 ID)
"iss": "my-app", // 標準:Issuer
"aud": "my-api", // 標準:Audience
"identity": user.UserName, // 自訂 claim
"role": user.Role, // 自訂 claim
}
}
return jwt.MapClaims{}
}
```

- **可選:** `LoginResponse`
在成功驗證後處理登入後邏輯。此函式接收完整的 token 資訊(包括存取 token、刷新 token、到期時間等)作為結構化的 `core.Token` 物件,用於處理登入後邏輯並回傳 token 回應給用戶。

Expand Down
15 changes: 9 additions & 6 deletions auth_jwt.go
Original file line number Diff line number Diff line change
Expand Up @@ -777,16 +777,19 @@ func (mw *GinJWTMiddleware) generateAccessToken(data any) (string, time.Time, er
return "", time.Time{}, ErrFailedTokenCreation
}

// 2. Define reserved claims to prevent PayloadFunc from overwriting system fields
reservedClaims := map[string]bool{
"exp": true, "iat": true, "nbf": true, "iss": true,
"aud": true, "sub": true, "jti": true, "orig_iat": true,
// 2. Define framework-controlled claims that PayloadFunc cannot overwrite
// Only claims that the framework calculates/manages internally are reserved.
// Standard JWT claims (sub, iss, aud, nbf, iat, jti) are allowed to be set by users
// via PayloadFunc to comply with RFC 7519 best practices.
frameworkClaims := map[string]bool{
"exp": true, // Framework calculates expiration time
"orig_iat": true, // Framework uses this for refresh mechanism
}

// 3. Safely add custom payload, avoiding system field overwrites
// 3. Safely add custom payload, avoiding framework-controlled field overwrites
if mw.PayloadFunc != nil {
for key, value := range mw.PayloadFunc(data) {
if !reservedClaims[key] {
if !frameworkClaims[key] {
claims[key] = value
}
}
Expand Down
258 changes: 258 additions & 0 deletions auth_jwt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1850,3 +1850,261 @@ func TestWWWAuthenticateHeaderWithDifferentRealms(t *testing.T) {
})
}
}

func TestStandardJWTClaimsInPayloadFunc(t *testing.T) {
// Test that standard JWT claims (sub, iss, aud, nbf, iat, jti) can be set via PayloadFunc
// This ensures RFC 7519 compliance by allowing users to set standard claims
authMiddleware, err := New(&GinJWTMiddleware{
Realm: "test zone",
Key: key,
Timeout: time.Hour,
MaxRefresh: time.Hour * 24,
Authenticator: func(c *gin.Context) (any, error) {
return "user123", nil
},
PayloadFunc: func(data any) jwt.MapClaims {
userID := data.(string)
now := time.Now()
return jwt.MapClaims{
// Standard JWT claims (RFC 7519)
"sub": userID, // Subject - the user ID
"iss": "my-app", // Issuer
"aud": "my-api", // Audience
"nbf": now.Unix(), // Not Before
"iat": now.Unix(), // Issued At
"jti": "unique-token-id-12345", // JWT ID
"exp": now.Add(time.Hour * 2).Unix(), // This should be overwritten by framework

// Custom claims
"identity": userID,
"role": "admin",
}
},
})

assert.NoError(t, err)

// Generate a token
ctx := context.Background()
tokenPair, err := authMiddleware.TokenGenerator(ctx, "user123")
assert.NoError(t, err)
assert.NotNil(t, tokenPair)

// Parse the token and verify claims
token, err := authMiddleware.ParseTokenString(tokenPair.AccessToken)
assert.NoError(t, err)
assert.True(t, token.Valid)

claims, ok := token.Claims.(jwt.MapClaims)
assert.True(t, ok)

// Verify standard claims are present and have correct values
assert.Equal(t, "user123", claims["sub"], "sub claim should be set from PayloadFunc")
assert.Equal(t, "my-app", claims["iss"], "iss claim should be set from PayloadFunc")
assert.Equal(t, "my-api", claims["aud"], "aud claim should be set from PayloadFunc")
assert.NotNil(t, claims["nbf"], "nbf claim should be set from PayloadFunc")
assert.NotNil(t, claims["iat"], "iat claim should be set from PayloadFunc")
assert.Equal(
t,
"unique-token-id-12345",
claims["jti"],
"jti claim should be set from PayloadFunc",
)

// Verify custom claims are present
assert.Equal(t, "user123", claims["identity"])
assert.Equal(t, "admin", claims["role"])
Comment on lines +1913 to +1916
Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

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

The test comment on line 1914 states that exp should be overwritten by the framework, but the assertion only checks that exp is not nil. To properly verify that the framework overwrites the PayloadFunc value (line 1876 sets now.Add(time.Hour * 2).Unix()), the test should assert that exp matches the framework-calculated value (now + Timeout of 1 hour), not the PayloadFunc value (now + 2 hours). Consider adding an assertion like:

expValue, ok := claims["exp"].(float64)
assert.True(t, ok)
// Verify exp is approximately now + 1 hour (Timeout), not now + 2 hours from PayloadFunc
assert.InDelta(t, time.Now().Add(time.Hour).Unix(), int64(expValue), 2.0)

Copilot uses AI. Check for mistakes.

// Verify framework-controlled claims are NOT overwritten by PayloadFunc
// exp should be set by the framework based on Timeout, not the value from PayloadFunc
assert.NotNil(t, claims["exp"])
assert.NotNil(t, claims["orig_iat"])
}

func TestFrameworkClaimsCannotBeOverwritten(t *testing.T) {
// Test that framework-controlled claims (exp, orig_iat) cannot be overwritten by PayloadFunc
fixedTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)

authMiddleware, err := New(&GinJWTMiddleware{
Realm: "test zone",
Key: key,
Timeout: time.Hour,
MaxRefresh: time.Hour * 24,
TimeFunc: func() time.Time { return fixedTime },
Authenticator: func(c *gin.Context) (any, error) {
return "user123", nil
},
PayloadFunc: func(data any) jwt.MapClaims {
// Try to overwrite framework claims
return jwt.MapClaims{
"exp": int64(9999999999), // Should be ignored
"orig_iat": int64(1111111111), // Should be ignored
"identity": data.(string),
}
},
})

assert.NoError(t, err)

// Generate a token
ctx := context.Background()
tokenPair, err := authMiddleware.TokenGenerator(ctx, "user123")
assert.NoError(t, err)

// Parse the token and verify claims
token, err := authMiddleware.ParseTokenString(tokenPair.AccessToken)
assert.NoError(t, err)

claims, ok := token.Claims.(jwt.MapClaims)
assert.True(t, ok)

// Verify exp is set by framework (fixedTime + Timeout), not the PayloadFunc value
expValue, ok := claims["exp"].(float64)
assert.True(t, ok)
expectedExp := fixedTime.Add(time.Hour).Unix()
assert.Equal(
t,
expectedExp,
int64(expValue),
"exp should be calculated by framework, not from PayloadFunc",
)

// Verify orig_iat is set by framework (fixedTime), not the PayloadFunc value
origIatValue, ok := claims["orig_iat"].(float64)
assert.True(t, ok)
assert.Equal(
t,
fixedTime.Unix(),
int64(origIatValue),
"orig_iat should be set by framework, not from PayloadFunc",
)

// Verify custom claims are still present
assert.Equal(t, "user123", claims["identity"])
}

func TestAllStandardClaimsCanBeSet(t *testing.T) {
// Comprehensive test for all standard JWT claims from IANA registry
testCases := []struct {
name string
claimKey string
claimValue any
}{
{"sub (Subject)", "sub", "user-12345"},
{"iss (Issuer)", "iss", "https://auth.example.com"},
{"aud (Audience)", "aud", "https://api.example.com"},
{"nbf (Not Before)", "nbf", time.Now().Unix()},
{"iat (Issued At)", "iat", time.Now().Unix()},
{"jti (JWT ID)", "jti", "550e8400-e29b-41d4-a716-446655440000"},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
authMiddleware, err := New(&GinJWTMiddleware{
Realm: "test zone",
Key: key,
Timeout: time.Hour,
Authenticator: func(c *gin.Context) (any, error) {
return "user", nil
},
PayloadFunc: func(data any) jwt.MapClaims {
return jwt.MapClaims{
tc.claimKey: tc.claimValue,
"identity": data,
}
},
})
assert.NoError(t, err)

ctx := context.Background()
tokenPair, err := authMiddleware.TokenGenerator(ctx, "user")
assert.NoError(t, err)

token, err := authMiddleware.ParseTokenString(tokenPair.AccessToken)
assert.NoError(t, err)

claims, ok := token.Claims.(jwt.MapClaims)
assert.True(t, ok)

// For numeric claims, compare as float64 (JWT library stores numbers as float64)
switch expected := tc.claimValue.(type) {
case int64:
actual, ok := claims[tc.claimKey].(float64)
assert.True(t, ok, "claim %s should be a number", tc.claimKey)
assert.Equal(t, float64(expected), actual, "claim %s should match", tc.claimKey)
default:
assert.Equal(t, tc.claimValue, claims[tc.claimKey], "claim %s should be set correctly", tc.claimKey)
}
})
}
}

func TestSubClaimAsUserIdentifier(t *testing.T) {
// Test using 'sub' claim as the primary user identifier (common use case per RFC 7519)
authMiddleware, err := New(&GinJWTMiddleware{
Realm: "test zone",
Key: key,
Timeout: time.Hour,
IdentityKey: "sub", // Use standard 'sub' claim as identity
Authenticator: func(c *gin.Context) (any, error) {
var loginVals Login
if err := c.ShouldBind(&loginVals); err != nil {
return "", ErrMissingLoginValues
}
return loginVals.Username, nil
},
PayloadFunc: func(data any) jwt.MapClaims {
userID := data.(string)
return jwt.MapClaims{
"sub": userID, // Standard claim for subject/user ID
"name": "Test User",
"email": "[email protected]",
}
},
IdentityHandler: func(c *gin.Context) any {
claims := ExtractClaims(c)
return claims["sub"]
},
Authorizer: func(c *gin.Context, data any) bool {
userID, ok := data.(string)
if !ok {
return false
}
return userID == testAdmin
},
})
assert.NoError(t, err)

handler := ginHandler(authMiddleware)
r := gofight.New()

// Login and get token
var accessToken string
r.POST("/login").
SetJSON(gofight.D{
"username": testAdmin,
"password": testAdmin,
}).
Run(handler, func(r gofight.HTTPResponse, rq gofight.HTTPRequest) {
assert.Equal(t, http.StatusOK, r.Code)
accessToken = gjson.Get(r.Body.String(), "access_token").String()
assert.NotEmpty(t, accessToken)
})

// Use token to access protected resource
r.GET("/auth/hello").
SetHeader(gofight.H{
"Authorization": "Bearer " + accessToken,
}).
Run(handler, func(r gofight.HTTPResponse, rq gofight.HTTPRequest) {
assert.Equal(t, http.StatusOK, r.Code)
})

// Verify the token contains the 'sub' claim
token, err := authMiddleware.ParseTokenString(accessToken)
assert.NoError(t, err)
claims := ExtractClaimsFromToken(token)
assert.Equal(t, testAdmin, claims["sub"])
assert.Equal(t, "Test User", claims["name"])
assert.Equal(t, "[email protected]", claims["email"])
}
Loading