Skip to content

Commit e004cf9

Browse files
committed
feat(auth): implement "act" field
1 parent 9caf2a0 commit e004cf9

File tree

3 files changed

+161
-0
lines changed

3 files changed

+161
-0
lines changed

httpapi/auth/README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,16 @@ Authorization: Bearer <access_token>
115115
}
116116
```
117117

118+
如果這是一個代理操作憑證 (impersonation),則會多出一個 `act` 欄位:
119+
120+
```json
121+
{
122+
"act": {
123+
"sub": "2"
124+
}
125+
}
126+
```
127+
118128
判斷管理員的依據,是判斷 `scope` 是否包含 `*`(所有權限)。
119129

120130
如果沒有此 token,回傳 HTTP 200,且 `active``false` 的回應:

httpapi/auth/introspect.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,14 @@ type IntrospectionResponse struct {
2121
Exp int64 `json:"exp,omitempty"` // expiration time (Unix timestamp)
2222
Iat int64 `json:"iat,omitempty"` // issued at (Unix timestamp)
2323
Azp string `json:"azp,omitempty"` // authorized party (machine name)
24+
25+
// the acting party to whom authority has been delegated
26+
Act *IntrospectionAct `json:"act,omitempty"`
27+
}
28+
29+
// IntrospectionAct represents the acting party to whom authority has been delegated
30+
type IntrospectionAct struct {
31+
Sub string `json:"sub"` // subject (user ID)
2432
}
2533

2634
// IntrospectToken implements OAuth 2.0 Token Introspection (RFC 7662)
@@ -105,5 +113,11 @@ func (s *AuthService) IntrospectToken(c *gin.Context) {
105113
Azp: tokenInfo.Machine,
106114
}
107115

116+
if impersonator, ok := tokenInfo.Meta[useraccount.MetaImpersonation]; ok {
117+
response.Act = &IntrospectionAct{
118+
Sub: impersonator,
119+
}
120+
}
121+
108122
c.JSON(http.StatusOK, response)
109123
}

httpapi/auth/introspect_test.go

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -420,4 +420,141 @@ func TestAuthService_IntrospectToken(t *testing.T) {
420420
assert.Equal(t, "server_error", response["error"])
421421
assert.Equal(t, "Failed to introspect the token. Please try again later.", response["error_description"])
422422
})
423+
424+
t.Run("successful token introspection with impersonation (act field)", func(t *testing.T) {
425+
authService, storage, entClient := setupTestAuthServiceWithDatabase(t)
426+
ctx := context.Background()
427+
428+
// Create a group for the user
429+
unverifiedGroup, err := entClient.Group.Query().Where(group.NameEQ("unverified")).Only(ctx)
430+
require.NoError(t, err)
431+
432+
// Create a test user (the impersonated user)
433+
user, err := entClient.User.Create().
434+
SetName("Impersonated User").
435+
SetEmail("[email protected]").
436+
SetGroup(unverifiedGroup).
437+
Save(ctx)
438+
require.NoError(t, err)
439+
440+
// Create an impersonator user
441+
impersonator, err := entClient.User.Create().
442+
SetName("Admin User").
443+
SetEmail("[email protected]").
444+
SetGroup(unverifiedGroup).
445+
Save(ctx)
446+
require.NoError(t, err)
447+
448+
// Create a test token with impersonation metadata
449+
tokenInfo := auth.TokenInfo{
450+
UserID: user.ID,
451+
UserEmail: "[email protected]",
452+
Machine: "test-machine",
453+
Scopes: []string{"read", "write"},
454+
Meta: map[string]string{
455+
"impersonation": strconv.Itoa(impersonator.ID),
456+
},
457+
}
458+
token, err := storage.Create(ctx, tokenInfo)
459+
require.NoError(t, err)
460+
require.NotEmpty(t, token)
461+
462+
// Setup router
463+
router := gin.New()
464+
router.POST("/auth/v2/introspect", authService.IntrospectToken)
465+
466+
// Create introspect request
467+
form := url.Values{}
468+
form.Add("token", token)
469+
form.Add("token_type_hint", "access_token")
470+
471+
req := httptest.NewRequest("POST", "/auth/v2/introspect", strings.NewReader(form.Encode()))
472+
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
473+
rr := httptest.NewRecorder()
474+
475+
// Execute request
476+
router.ServeHTTP(rr, req)
477+
478+
// Verify response
479+
assert.Equal(t, http.StatusOK, rr.Code)
480+
481+
var response IntrospectionResponse
482+
err = json.NewDecoder(rr.Body).Decode(&response)
483+
require.NoError(t, err)
484+
485+
// Verify basic token information
486+
assert.True(t, response.Active)
487+
assert.Equal(t, "[email protected]", response.Username)
488+
assert.Equal(t, "read write", response.Scope)
489+
assert.Equal(t, strconv.Itoa(user.ID), response.Sub)
490+
assert.Equal(t, "test-machine", response.Azp)
491+
assert.Greater(t, response.Exp, int64(0))
492+
assert.Greater(t, response.Iat, int64(0))
493+
494+
// Verify the act field is populated with impersonator information
495+
require.NotNil(t, response.Act, "Act field should be populated when impersonation is present")
496+
assert.Equal(t, strconv.Itoa(impersonator.ID), response.Act.Sub, "Act.Sub should contain the impersonator's user ID")
497+
})
498+
499+
t.Run("successful token introspection without impersonation (no act field)", func(t *testing.T) {
500+
authService, storage, entClient := setupTestAuthServiceWithDatabase(t)
501+
ctx := context.Background()
502+
503+
// Create a group for the user
504+
unverifiedGroup, err := entClient.Group.Query().Where(group.NameEQ("unverified")).Only(ctx)
505+
require.NoError(t, err)
506+
507+
// Create a test user
508+
user, err := entClient.User.Create().
509+
SetName("Regular User").
510+
SetEmail("[email protected]").
511+
SetGroup(unverifiedGroup).
512+
Save(ctx)
513+
require.NoError(t, err)
514+
515+
// Create a test token without impersonation metadata
516+
tokenInfo := auth.TokenInfo{
517+
UserID: user.ID,
518+
UserEmail: "[email protected]",
519+
Machine: "test-machine",
520+
Scopes: []string{"read", "write"},
521+
Meta: map[string]string{}, // No impersonation metadata
522+
}
523+
token, err := storage.Create(ctx, tokenInfo)
524+
require.NoError(t, err)
525+
require.NotEmpty(t, token)
526+
527+
// Setup router
528+
router := gin.New()
529+
router.POST("/auth/v2/introspect", authService.IntrospectToken)
530+
531+
// Create introspect request
532+
form := url.Values{}
533+
form.Add("token", token)
534+
form.Add("token_type_hint", "access_token")
535+
536+
req := httptest.NewRequest("POST", "/auth/v2/introspect", strings.NewReader(form.Encode()))
537+
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
538+
rr := httptest.NewRecorder()
539+
540+
// Execute request
541+
router.ServeHTTP(rr, req)
542+
543+
// Verify response
544+
assert.Equal(t, http.StatusOK, rr.Code)
545+
546+
var response IntrospectionResponse
547+
err = json.NewDecoder(rr.Body).Decode(&response)
548+
require.NoError(t, err)
549+
550+
// Verify basic token information
551+
assert.True(t, response.Active)
552+
assert.Equal(t, "[email protected]", response.Username)
553+
assert.Equal(t, "read write", response.Scope)
554+
assert.Equal(t, strconv.Itoa(user.ID), response.Sub)
555+
assert.Equal(t, "test-machine", response.Azp)
556+
557+
// Verify the act field is NOT populated when there's no impersonation
558+
assert.Nil(t, response.Act, "Act field should be nil when no impersonation is present")
559+
})
423560
}

0 commit comments

Comments
 (0)