- CompileAPITokenScopes builds the catalog from the built-ins and the activated plugins' declarations; a bad name, a redeclared built-in, a duplicate, an empty label or a missing phrase key fails boot - POST /tokens, user:token:create and MintAPITokenFor check the application's own catalog (422 text unchanged); MintAPIToken keeps its signature with a process-wide fallback for declared scopes - GET /_user/api/v1/tokens/scopes lists the built-ins and the declared scopes with translated labels - built-in scope labels in en and pl; TestMemberTokenScopesSmoke
198 lines
6.2 KiB
Go
198 lines
6.2 KiB
Go
package controllers
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/sm-user-plugin/classes"
|
|
"git.golem15.com/golem15/sm-user-plugin/models"
|
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
|
"git.golem15.com/golem15/summercms/modules/pact"
|
|
"git.golem15.com/golem15/summercms/modules/wire"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// APITokenStore mints one shared personal API token for the authenticated
|
|
// user. The clear-text token appears only in this response.
|
|
func APITokenStore(app *backpack.App) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
db, user, ok := apiTokenDeps(w, r, app)
|
|
if !ok {
|
|
return
|
|
}
|
|
fields, err := readFields(r)
|
|
if err != nil {
|
|
writeOpaque500(w)
|
|
return
|
|
}
|
|
name := strings.TrimSpace(strField(fields, "name"))
|
|
if name == "" || len(name) > 255 {
|
|
writeValidation(w, map[string][]string{"name": {"The name field is required and may not be longer than 255 characters."}})
|
|
return
|
|
}
|
|
catalog := classes.APITokenScopesFor(app)
|
|
scopes, err := apiTokenScopes(catalog, fields["scopes"])
|
|
if err != nil {
|
|
writeValidation(w, map[string][]string{"scopes": {err.Error()}})
|
|
return
|
|
}
|
|
var expiresAt *time.Time
|
|
if raw := strings.TrimSpace(strField(fields, "expires_at")); raw != "" {
|
|
parsed, err := time.Parse(time.RFC3339, raw)
|
|
if err != nil {
|
|
writeValidation(w, map[string][]string{"expires_at": {"The expires_at field must be a valid RFC3339 date."}})
|
|
return
|
|
}
|
|
expiresAt = &parsed
|
|
}
|
|
prefix, err := classes.APITokenPrefix(app)
|
|
if err != nil {
|
|
writeOpaque500(w)
|
|
return
|
|
}
|
|
secret, token, err := classes.MintAPITokenFor(app, prefix, user.ID, name, scopes, expiresAt, nil)
|
|
if err != nil || db.WithContext(r.Context()).Create(token).Error != nil {
|
|
writeOpaque500(w)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, map[string]any{"token": secret, "meta": apiTokenJSON(token)})
|
|
}
|
|
}
|
|
|
|
// APITokenScopes serves GET /_user/api/v1/tokens/scopes behind jwt.auth:
|
|
// the member token scopes the caller may mint, {"data":[{"name","label",
|
|
// "description","built_in"}]}, the built-ins first, then the scopes the
|
|
// application's plugins declared. Phrase-key labels and descriptions are
|
|
// translated for the request locale.
|
|
func APITokenScopes(app *backpack.App) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
user, ok := bouncer.User(r.Context())
|
|
if !ok || user == nil || user.ID == 0 {
|
|
writeUnauthorized(w)
|
|
return
|
|
}
|
|
tr := appTranslator(app)
|
|
text := func(s string) string {
|
|
if tr == nil || !strings.Contains(s, "::") {
|
|
return s
|
|
}
|
|
return tr.Get(r.Context(), s, nil)
|
|
}
|
|
scopes := classes.APITokenScopesFor(app).Scopes()
|
|
data := make([]map[string]any, 0, len(scopes))
|
|
for _, scope := range scopes {
|
|
data = append(data, map[string]any{
|
|
"name": scope.Name, "label": text(scope.Label),
|
|
"description": text(scope.Description), "built_in": scope.BuiltIn,
|
|
})
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"data": wire.Slice(data)})
|
|
}
|
|
}
|
|
|
|
// APITokenIndex lists the caller's live personal tokens without hashes.
|
|
func APITokenIndex(app *backpack.App) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
db, user, ok := apiTokenDeps(w, r, app)
|
|
if !ok {
|
|
return
|
|
}
|
|
var rows []models.APIToken
|
|
if err := db.WithContext(r.Context()).
|
|
Where("user_id = ? AND oauth_client_id IS NULL AND revoked_at IS NULL", user.ID).
|
|
Order("created_at DESC").Find(&rows).Error; err != nil {
|
|
writeOpaque500(w)
|
|
return
|
|
}
|
|
data := make([]map[string]any, 0, len(rows))
|
|
for i := range rows {
|
|
data = append(data, apiTokenJSON(&rows[i]))
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"data": wire.Slice(data)})
|
|
}
|
|
}
|
|
|
|
// APITokenDestroy revokes one live personal token owned by the caller.
|
|
func APITokenDestroy(app *backpack.App) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
db, user, ok := apiTokenDeps(w, r, app)
|
|
if !ok {
|
|
return
|
|
}
|
|
id, err := strconv.ParseUint(r.PathValue("id"), 10, 64)
|
|
if err != nil || id == 0 {
|
|
writeJSON(w, http.StatusNotFound, map[string]any{"error": "Token not found"})
|
|
return
|
|
}
|
|
var token models.APIToken
|
|
err = db.WithContext(r.Context()).Where("user_id = ? AND oauth_client_id IS NULL", user.ID).First(&token, id).Error
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
writeJSON(w, http.StatusNotFound, map[string]any{"error": "Token not found"})
|
|
return
|
|
}
|
|
if err != nil || classes.RevokeAPIToken(db.WithContext(r.Context()), &token) != nil {
|
|
writeOpaque500(w)
|
|
return
|
|
}
|
|
// Only the MCP sessions opened with this token end.
|
|
classes.FireSubjectEvent(r.Context(), app, pact.MCPSubjectChanged{Guard: classes.SubjectGuardUser, UserID: user.ID, Kind: pact.MCPSubjectRevoked, TokenID: token.ID})
|
|
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"revoked": true}})
|
|
}
|
|
}
|
|
|
|
func apiTokenDeps(w http.ResponseWriter, r *http.Request, app *backpack.App) (*gorm.DB, *bouncer.Principal, bool) {
|
|
if app == nil {
|
|
writeOpaque500(w)
|
|
return nil, nil, false
|
|
}
|
|
db, ok := app.Lookup[*gorm.DB]()
|
|
if !ok || db == nil {
|
|
writeOpaque500(w)
|
|
return nil, nil, false
|
|
}
|
|
user, ok := bouncer.User(r.Context())
|
|
if !ok || user == nil || user.ID == 0 {
|
|
writeUnauthorized(w)
|
|
return nil, nil, false
|
|
}
|
|
return db, user, true
|
|
}
|
|
|
|
// apiTokenScopes reads the scopes field: nil, or an array of names the
|
|
// catalog allows.
|
|
func apiTokenScopes(catalog *classes.APITokenScopeCatalog, raw any) ([]string, error) {
|
|
if raw == nil {
|
|
return nil, nil
|
|
}
|
|
values, ok := raw.([]any)
|
|
if !ok {
|
|
return nil, errors.New("The scopes field must be an array.")
|
|
}
|
|
out := make([]string, 0, len(values))
|
|
for _, rawScope := range values {
|
|
scope, ok := rawScope.(string)
|
|
if !ok || !catalog.Allowed(scope) {
|
|
return nil, errors.New("The selected scope is invalid.")
|
|
}
|
|
out = append(out, scope)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func apiTokenJSON(token *models.APIToken) map[string]any {
|
|
name := ""
|
|
if token.Name != nil {
|
|
name = *token.Name
|
|
}
|
|
return map[string]any{
|
|
"id": token.ID, "name": name, "scopes": wire.Slice(token.Scopes.Get()),
|
|
"last_used_at": token.LastUsedAt, "last_used_ip": token.LastUsedIP,
|
|
"expires_at": token.ExpiresAt, "revoked_at": token.RevokedAt,
|
|
"created_at": token.CreatedAt,
|
|
}
|
|
}
|