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
50 changes: 47 additions & 3 deletions httpapi/auth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,54 @@ Authorization: Bearer <access_token>

如果沒有 Auth Token 或者是 token 無效,則依然回傳 HTTP 200。請引導使用者重新登入。

## 取得 Token 資訊

您可以使用 `POST /api/auth/v2/introspect` 取得 token 的資訊。

需要帶入以 `application/x-www-form-urlencoded` 編碼的請求體:

- `token`:要 revoke 的 token
- `token_type_hint`:必須是 `access_token`

如果有 token,回傳 HTTP 200,且 `active` 為 `true` 的回應:

```json
{
"active": true,
"username": "pan93412@gmail.com",
"scope": "*", // scope, separated by space
"sub": "1", // subject of token, a.k.a. user id
"exp": 1757873526, // expired at
"iat": 1757844711, // issued at
"azp": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36" // the machine that is authorized to use this token
}
```

判斷管理員的依據,是判斷 `scope` 是否包含 `*`(所有權限)。

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

```json
{
"active": false
}
```

如果發生系統錯誤,則回傳 HTTP 500 錯誤並帶上錯誤資訊:

```json
{
"error": "server_error",
"error_description": "Failed to introspect the token. Please try again later."
}
```

## 參考來源

為了保證登入時的資訊安全,這裡參考了兩份 RFC 進行 API 的設計:
為了保證登入時的資訊安全和規範性,這裡參考了這些資料進行 API 的設計:

- [RFC 6749 – The OAuth 2.0 Authorization Framework](https://datatracker.ietf.org/doc/html/rfc6749#autoid-35)
- [RFC 7636 – Proof Key for Code Exchange by OAuth Public Clients](https://datatracker.ietf.org/doc/html/rfc7636#section-4.1)
- [RFC 6749 – The OAuth 2.0 Authorization Framework](https://datatracker.ietf.org/doc/html/rfc6749)
- [RFC 7636 – Proof Key for Code Exchange by OAuth Public Clients](https://datatracker.ietf.org/doc/html/rfc7636)
- [RFC 7009 – OAuth 2.0 Token Revocation](https://datatracker.ietf.org/doc/html/rfc7009)
- [RFC 7662 – OAuth 2.0 Token Introspection](https://datatracker.ietf.org/doc/html/rfc7662)
- [IANA – JSON Web Token Claims](https://www.iana.org/assignments/jwt/jwt.xhtml)
109 changes: 109 additions & 0 deletions httpapi/auth/introspect.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package authservice

import (
"errors"
"net/http"
"strconv"
"strings"
"time"

"github.com/database-playground/backend-v2/internal/auth"
"github.com/database-playground/backend-v2/internal/useraccount"
"github.com/gin-gonic/gin"
)

// IntrospectionResponse represents the OAuth 2.0 token introspection response (RFC 7662)
type IntrospectionResponse struct {
Active bool `json:"active"`
Username string `json:"username,omitempty"` // user email
Scope string `json:"scope,omitempty"` // space-separated scopes
Sub string `json:"sub,omitempty"` // subject (user ID)
Exp int64 `json:"exp,omitempty"` // expiration time (Unix timestamp)
Iat int64 `json:"iat,omitempty"` // issued at (Unix timestamp)
Azp string `json:"azp,omitempty"` // authorized party (machine name)
}

// IntrospectToken implements OAuth 2.0 Token Introspection (RFC 7662)
// POST /api/auth/v2/introspect
func (s *AuthService) IntrospectToken(c *gin.Context) {
// Parse form data
token := c.PostForm("token")
tokenTypeHint := c.PostForm("token_type_hint")

// Validate required parameters
if token == "" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "invalid_request",
"error_description": "Missing required parameter: token",
})
return
}

// Validate token_type_hint if provided
if tokenTypeHint != "" && tokenTypeHint != "access_token" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "unsupported_token_type",
"error_description": "Only access_token is supported for token_type_hint",
})
return
}

// Try to peek the token (doesn't extend expiration)
tokenInfo, err := s.storage.Peek(c.Request.Context(), token)
if err != nil {
if errors.Is(err, auth.ErrNotFound) {
// Token not found or expired - return inactive token response
c.JSON(http.StatusOK, IntrospectionResponse{
Active: false,
})
return
}

// Internal server error
c.JSON(http.StatusInternalServerError, gin.H{
"error": "server_error",
"error_description": "Failed to introspect the token. Please try again later.",
})
return
}

// Get user information
useraccountCtx := useraccount.NewContext(s.entClient, s.storage)
entUser, err := useraccountCtx.GetUser(c.Request.Context(), tokenInfo.UserID)
if err != nil {
if errors.Is(err, useraccount.ErrUserNotFound) {
// User not found - token is technically invalid
c.JSON(http.StatusOK, IntrospectionResponse{
Active: false,
})
return
}

// Internal server error
c.JSON(http.StatusInternalServerError, gin.H{
"error": "server_error",
"error_description": err.Error(),
})
return
}

// Calculate token expiration and issue time
// Note: This is an approximation since we don't store these explicitly
// We assume token is valid for DefaultTokenExpire seconds from now
now := time.Now()
exp := now.Add(time.Duration(auth.DefaultTokenExpire) * time.Second).Unix()
iat := now.Unix() // Approximation - we don't have the actual issue time

// Build successful introspection response
response := IntrospectionResponse{
Active: true,
Username: entUser.Email,
Scope: strings.Join(tokenInfo.Scopes, " "),
Sub: strconv.Itoa(tokenInfo.UserID),
Exp: exp,
Iat: iat,
Azp: tokenInfo.Machine,
}

c.JSON(http.StatusOK, response)
}
Loading
Loading