Allow env to specify apikeys

This commit is contained in:
Zoe Roux 2025-04-21 23:49:03 +02:00
parent a72ecdb21b
commit e8acb31834
No known key found for this signature in database
4 changed files with 106 additions and 41 deletions

View File

@ -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. # 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 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 # Database things
POSTGRES_USER=kyoo POSTGRES_USER=kyoo
POSTGRES_PASSWORD=password POSTGRES_PASSWORD=password

View File

@ -5,7 +5,9 @@ import (
"crypto/rand" "crypto/rand"
"encoding/base64" "encoding/base64"
"fmt" "fmt"
"maps"
"net/http" "net/http"
"strings"
"time" "time"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
@ -17,6 +19,7 @@ import (
) )
type ApiKey struct { type ApiKey struct {
Id uuid.UUID `json:"id" example:"e05089d6-9179-4b5b-a63e-94dd5fc2a397"`
Name string `json:"name" example:"myapp"` Name string `json:"name" example:"myapp"`
CreatedAt time.Time `json:"createAt" example:"2025-03-29T18:20:05.267Z"` CreatedAt time.Time `json:"createAt" example:"2025-03-29T18:20:05.267Z"`
LastUsed time.Time `json:"lastUsed" 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 { func MapDbKey(key *dbc.Apikey) ApiKeyWToken {
return ApiKeyWToken{ return ApiKeyWToken{
ApiKey: ApiKey{ ApiKey: ApiKey{
Id: key.Id,
Name: key.Name, Name: key.Name,
Claims: key.Claims, Claims: key.Claims,
CreatedAt: key.CreatedAt, CreatedAt: key.CreatedAt,
@ -66,6 +70,10 @@ func (h *Handler) CreateApiKey(c echo.Context) error {
return err 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) id := make([]byte, 64)
_, err = rand.Read(id) _, err = rand.Read(id)
if err != nil { if err != nil {
@ -127,8 +135,57 @@ func (h *Handler) ListApiKey(c echo.Context) error {
for _, key := range dbkeys { for _, key := range dbkeys {
ret = append(ret, MapDbKey(&key).ApiKey) ret = append(ret, MapDbKey(&key).ApiKey)
} }
for _, key := range h.config.EnvApiKeys {
ret = append(ret, key.ApiKey)
}
return c.JSON(200, Page[ApiKey]{ return c.JSON(200, Page[ApiKey]{
Items: ret, Items: ret,
This: c.Request().URL.String(), 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
}

View File

@ -1,11 +1,13 @@
package main package main
import ( import (
"context"
"crypto/rand" "crypto/rand"
"crypto/rsa" "crypto/rsa"
"crypto/x509" "crypto/x509"
"encoding/json" "encoding/json"
"encoding/pem" "encoding/pem"
"fmt"
"maps" "maps"
"os" "os"
"strings" "strings"
@ -25,6 +27,7 @@ type Configuration struct {
GuestClaims jwt.MapClaims GuestClaims jwt.MapClaims
ProtectedClaims []string ProtectedClaims []string
ExpirationDelay time.Duration ExpirationDelay time.Duration
EnvApiKeys map[string]ApiKeyWToken
} }
var DefaultConfig = Configuration{ var DefaultConfig = Configuration{
@ -32,6 +35,7 @@ var DefaultConfig = Configuration{
FirstUserClaims: make(jwt.MapClaims), FirstUserClaims: make(jwt.MapClaims),
ProtectedClaims: []string{"permissions"}, ProtectedClaims: []string{"permissions"},
ExpirationDelay: 30 * 24 * time.Hour, ExpirationDelay: 30 * 24 * time.Hour,
EnvApiKeys: make(map[string]ApiKeyWToken),
} }
func LoadConfiguration(db *dbc.Queries) (*Configuration, error) { func LoadConfiguration(db *dbc.Queries) (*Configuration, error) {
@ -98,5 +102,44 @@ func LoadConfiguration(db *dbc.Queries) (*Configuration, error) {
ret.JwtPublicKey = &ret.JwtPrivateKey.PublicKey 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 return &ret, nil
} }

View File

@ -9,10 +9,8 @@ import (
"time" "time"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
"github.com/jackc/pgx/v5"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/lestrrat-go/jwx/jwk" "github.com/lestrrat-go/jwx/jwk"
"github.com/zoriya/kyoo/keibi/dbc"
) )
type Jwt struct { type Jwt struct {
@ -121,45 +119,6 @@ func (h *Handler) createJwt(token string) (string, error) {
return t, nil 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 // only used for the swagger doc
type JwkSet struct { type JwkSet struct {
Keys []struct { Keys []struct {