From e8acb31834f3bbf42cabf7af2a06fadd949ed99f Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Mon, 21 Apr 2025 23:49:03 +0200 Subject: [PATCH] Allow env to specify apikeys --- auth/.env.example | 6 +++++ auth/apikey.go | 57 +++++++++++++++++++++++++++++++++++++++++++++++ auth/config.go | 43 +++++++++++++++++++++++++++++++++++ auth/jwt.go | 41 ---------------------------------- 4 files changed, 106 insertions(+), 41 deletions(-) diff --git a/auth/.env.example b/auth/.env.example index c1ea11eb..9e3f410f 100644 --- a/auth/.env.example +++ b/auth/.env.example @@ -22,6 +22,12 @@ PROTECTED_CLAIMS="permissions" # The url you can use to reach your kyoo instance. This is used during oidc to redirect users to your instance. PUBLIC_URL=http://localhost:8901 +# You can create apikeys at runtime via POST /apikey but you can also have some defined in the env. +# Replace $YOURNAME with the name of the key you want (only alpha are valid) +# The value will be the apikey (max 128 bytes) +# KEIBI_APIKEY_$YOURNAME=oaeushtaoesunthoaensuth +# KEIBI_APIKEY_$YOURNAME_CLAIMS='{"permissions": ["users.read"]}' + # Database things POSTGRES_USER=kyoo POSTGRES_PASSWORD=password diff --git a/auth/apikey.go b/auth/apikey.go index 81a0695f..aee4919d 100644 --- a/auth/apikey.go +++ b/auth/apikey.go @@ -5,7 +5,9 @@ import ( "crypto/rand" "encoding/base64" "fmt" + "maps" "net/http" + "strings" "time" "github.com/golang-jwt/jwt/v5" @@ -17,6 +19,7 @@ import ( ) type ApiKey struct { + Id uuid.UUID `json:"id" example:"e05089d6-9179-4b5b-a63e-94dd5fc2a397"` Name string `json:"name" example:"myapp"` CreatedAt time.Time `json:"createAt" example:"2025-03-29T18:20:05.267Z"` LastUsed time.Time `json:"lastUsed" example:"2025-03-29T18:20:05.267Z"` @@ -36,6 +39,7 @@ type ApiKeyDto struct { func MapDbKey(key *dbc.Apikey) ApiKeyWToken { return ApiKeyWToken{ ApiKey: ApiKey{ + Id: key.Id, Name: key.Name, Claims: key.Claims, CreatedAt: key.CreatedAt, @@ -66,6 +70,10 @@ func (h *Handler) CreateApiKey(c echo.Context) error { return err } + if _, conflict := h.config.EnvApiKeys[req.Name]; conflict { + return echo.NewHTTPError(409, "An env apikey is already defined with the same name") + } + id := make([]byte, 64) _, err = rand.Read(id) if err != nil { @@ -127,8 +135,57 @@ func (h *Handler) ListApiKey(c echo.Context) error { for _, key := range dbkeys { ret = append(ret, MapDbKey(&key).ApiKey) } + + for _, key := range h.config.EnvApiKeys { + ret = append(ret, key.ApiKey) + } + return c.JSON(200, Page[ApiKey]{ Items: ret, This: c.Request().URL.String(), }) } + +func (h *Handler) createApiJwt(apikey string) (string, error) { + info := strings.Split(apikey, "-") + if len(info) != 2 { + return "", echo.NewHTTPError(http.StatusForbidden, "Invalid api key format") + } + + key, fromEnv := h.config.EnvApiKeys[info[0]] + if !fromEnv { + dbKey, err := h.db.GetApiKey(context.Background(), dbc.GetApiKeyParams{ + Name: info[0], + Token: info[1], + }) + if err == pgx.ErrNoRows { + return "", echo.NewHTTPError(http.StatusForbidden, "Invalid api key") + } else if err != nil { + return "", err + } + + go func() { + h.db.TouchApiKey(context.Background(), dbKey.Pk) + }() + + key = MapDbKey(&dbKey) + } + + claims := maps.Clone(key.Claims) + claims["username"] = key.Name + claims["sub"] = key.Id + claims["sid"] = key.Id + claims["iss"] = h.config.PublicUrl + claims["iat"] = &jwt.NumericDate{ + Time: time.Now().UTC(), + } + claims["exp"] = &jwt.NumericDate{ + Time: time.Now().UTC().Add(time.Hour), + } + jwt := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + t, err := jwt.SignedString(h.config.JwtPrivateKey) + if err != nil { + return "", err + } + return t, nil +} diff --git a/auth/config.go b/auth/config.go index b503ca30..402b7002 100644 --- a/auth/config.go +++ b/auth/config.go @@ -1,11 +1,13 @@ package main import ( + "context" "crypto/rand" "crypto/rsa" "crypto/x509" "encoding/json" "encoding/pem" + "fmt" "maps" "os" "strings" @@ -25,6 +27,7 @@ type Configuration struct { GuestClaims jwt.MapClaims ProtectedClaims []string ExpirationDelay time.Duration + EnvApiKeys map[string]ApiKeyWToken } var DefaultConfig = Configuration{ @@ -32,6 +35,7 @@ var DefaultConfig = Configuration{ FirstUserClaims: make(jwt.MapClaims), ProtectedClaims: []string{"permissions"}, ExpirationDelay: 30 * 24 * time.Hour, + EnvApiKeys: make(map[string]ApiKeyWToken), } func LoadConfiguration(db *dbc.Queries) (*Configuration, error) { @@ -98,5 +102,44 @@ func LoadConfiguration(db *dbc.Queries) (*Configuration, error) { ret.JwtPublicKey = &ret.JwtPrivateKey.PublicKey } + for _, env := range os.Environ() { + if !strings.HasPrefix(env, "KEIBI_APIKEY_") || strings.HasSuffix(env, "_CLAIMS") { + continue + } + + v := strings.Split(env, "=") + name := strings.TrimPrefix(v[0], "KEIBI_APIKEY_") + cstr := os.Getenv(fmt.Sprintf("KEIBI_APIKEY_%s_CLAIMS", name)) + + var claims jwt.MapClaims + if cstr != "" { + err := json.Unmarshal([]byte(cstr), &claims) + if err != nil { + return nil, err + } + } + + ret.EnvApiKeys[name] = ApiKeyWToken{ + ApiKey: ApiKey{ + Name: name, + Claims: claims, + }, + Token: v[1], + } + + } + apikeys, err := db.ListApiKeys(context.Background()) + if err != nil { + return nil, err + } + for _, key := range apikeys { + if _, defined := ret.EnvApiKeys[key.Name]; defined { + return nil, fmt.Errorf( + "an api key with the name %s is already defined in database. Can't specify a new one via env var", + key.Name, + ) + } + } + return &ret, nil } diff --git a/auth/jwt.go b/auth/jwt.go index 9efa4f83..ffc6dce6 100644 --- a/auth/jwt.go +++ b/auth/jwt.go @@ -9,10 +9,8 @@ import ( "time" "github.com/golang-jwt/jwt/v5" - "github.com/jackc/pgx/v5" "github.com/labstack/echo/v4" "github.com/lestrrat-go/jwx/jwk" - "github.com/zoriya/kyoo/keibi/dbc" ) type Jwt struct { @@ -121,45 +119,6 @@ func (h *Handler) createJwt(token string) (string, error) { return t, nil } -func (h *Handler) createApiJwt(apikey string) (string, error) { - info := strings.Split(apikey, "-") - if len(info) != 2 { - return "", echo.NewHTTPError(http.StatusForbidden, "Invalid api key format") - } - - key, err := h.db.GetApiKey(context.Background(), dbc.GetApiKeyParams{ - Name: info[0], - Token: info[1], - }) - if err == pgx.ErrNoRows { - return "", echo.NewHTTPError(http.StatusForbidden, "Invalid api key") - } else if err != nil { - return "", err - } - - go func() { - h.db.TouchApiKey(context.Background(), key.Pk) - }() - - claims := maps.Clone(key.Claims) - claims["username"] = key.Name - claims["sub"] = key.Id - claims["sid"] = key.Id - claims["iss"] = h.config.PublicUrl - claims["iat"] = &jwt.NumericDate{ - Time: time.Now().UTC(), - } - claims["exp"] = &jwt.NumericDate{ - Time: time.Now().UTC().Add(time.Hour), - } - jwt := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) - t, err := jwt.SignedString(h.config.JwtPrivateKey) - if err != nil { - return "", err - } - return t, nil -} - // only used for the swagger doc type JwkSet struct { Keys []struct {