From 822a7029efdff3b697ce96c1cc776fa7047c200c Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sat, 12 Apr 2025 16:12:24 +0200 Subject: [PATCH 1/7] wip: Add api keys apis --- auth/README.md | 4 +- auth/apikey.go | 44 +++++++++++++++++++++ auth/sql/migrations/000003_apikeys.down.sql | 5 +++ auth/sql/migrations/000003_apikeys.up.sql | 14 +++++++ 4 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 auth/apikey.go create mode 100644 auth/sql/migrations/000003_apikeys.down.sql create mode 100644 auth/sql/migrations/000003_apikeys.up.sql diff --git a/auth/README.md b/auth/README.md index 12391e4d..0b446249 100644 --- a/auth/README.md +++ b/auth/README.md @@ -61,11 +61,11 @@ GET `/users/$id/sessions` can be used by admins to list others session ``` Get `/apikeys` -Post `/apikeys` {...nlaims} Create a new api keys with given claims +Post `/apikeys` {...claims} Create a new api keys with given claims ``` An api key can be used like an opaque token, calling /jwt with it will return a valid jwt with the claims you specified during the post request to create it. -Creating an apikeys requires the `apikey.create` permission, reading them requires the `apikey.read` permission. +Creating an apikeys requires the `apikey.write` permission, reading them requires the `apikey.read` permission. ### OIDC diff --git a/auth/apikey.go b/auth/apikey.go new file mode 100644 index 00000000..c73375e4 --- /dev/null +++ b/auth/apikey.go @@ -0,0 +1,44 @@ +package main + +import ( + "net/http" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/labstack/echo/v4" +) + +type ApiKey struct { + Name string `json:"name" example:"my-app"` + Token string `json:"token" example:"lyHzTYm9yi+pkEv3m2tamAeeK7Dj7N3QRP7xv7dPU5q9MAe8tU4ySwYczE0RaMr4fijsA=="` + CreatedAt time.Time `json:"createAt" example:"2025-03-29T18:20:05.267Z"` + LastUsed time.Time `json:"lastUsed" example:"2025-03-29T18:20:05.267Z"` + Claims jwt.MapClaims `json:"claims" example:"isAdmin: true"` +} + +type ApiKeyDto struct { + Name string `json:"name" example:"my-app" validate:"alpha"` + Claims jwt.MapClaims `json:"claims" example:"isAdmin: true"` +} + +// @Summary Create API key +// @Description Create a new API key +// @Tags apikeys +// @Accept json +// @Produce json +// @Security Jwt[apikeys.write] +// @Param key body ApiKeyDto false "Api key info" +// @Success 201 {object} ApiKey +// @Failure 409 {object} KError "Duplicated api key" +// @Failure 422 {object} KError "Invalid create body" +// @Router /users [get] +func (h *Handler) CreateApiKey(c echo.Context) error { + var req ApiKeyDto + err := c.Bind(&req) + if err != nil { + return echo.NewHTTPError(http.StatusUnprocessableEntity, err.Error()) + } + if err = c.Validate(&req); err != nil { + return err + } +} diff --git a/auth/sql/migrations/000003_apikeys.down.sql b/auth/sql/migrations/000003_apikeys.down.sql new file mode 100644 index 00000000..3bdbcde3 --- /dev/null +++ b/auth/sql/migrations/000003_apikeys.down.sql @@ -0,0 +1,5 @@ +begin; + +drop table apikeys; + +commit; diff --git a/auth/sql/migrations/000003_apikeys.up.sql b/auth/sql/migrations/000003_apikeys.up.sql new file mode 100644 index 00000000..cb333485 --- /dev/null +++ b/auth/sql/migrations/000003_apikeys.up.sql @@ -0,0 +1,14 @@ +begin; + +create table apikeys( + pk serial primary key, + id uuid not null default gen_random_uuid(), + name varchar(256) not null unique, + token varchar(128) not null unique, + claims jsonb not null, + + created_at timestamptz not null default now()::timestamptz, + last_used timestamptz not null default now()::temistamptz +); + +commit; From 85186a74c86fd35a1efbffea8af4733624da11bb Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sun, 20 Apr 2025 19:09:13 +0200 Subject: [PATCH 2/7] Add apikeys routes --- auth/apikey.go | 98 +++++++++++++++++++- auth/dbc/apikeys.sql.go | 103 ++++++++++++++++++++++ auth/dbc/models.go | 11 +++ auth/main.go | 4 + auth/sql/migrations/000003_apikeys.up.sql | 1 + auth/sql/queries/apikeys.sql | 20 +++++ auth/sqlc.yaml | 5 ++ auth/users.go | 4 +- scanner/scanner/scanner.py | 1 - 9 files changed, 240 insertions(+), 7 deletions(-) create mode 100644 auth/dbc/apikeys.sql.go create mode 100644 auth/sql/queries/apikeys.sql diff --git a/auth/apikey.go b/auth/apikey.go index c73375e4..b4e0a44d 100644 --- a/auth/apikey.go +++ b/auth/apikey.go @@ -1,26 +1,50 @@ package main import ( + "context" + "crypto/rand" + "encoding/base64" + "fmt" "net/http" "time" "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" + "github.com/jackc/pgerrcode" + "github.com/jackc/pgx/v5" "github.com/labstack/echo/v4" + "github.com/zoriya/kyoo/keibi/dbc" ) type ApiKey struct { - Name string `json:"name" example:"my-app"` - Token string `json:"token" example:"lyHzTYm9yi+pkEv3m2tamAeeK7Dj7N3QRP7xv7dPU5q9MAe8tU4ySwYczE0RaMr4fijsA=="` + 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"` Claims jwt.MapClaims `json:"claims" example:"isAdmin: true"` } +type ApiKeyWToken struct { + ApiKey + Token string `json:"token" example:"myapp-lyHzTYm9yi+pkEv3m2tamAeeK7Dj7N3QRP7xv7dPU5q9MAe8tU4ySwYczE0RaMr4fijsA=="` +} + type ApiKeyDto struct { Name string `json:"name" example:"my-app" validate:"alpha"` Claims jwt.MapClaims `json:"claims" example:"isAdmin: true"` } +func MapDbKey(key *dbc.Apikey) ApiKeyWToken { + return ApiKeyWToken{ + ApiKey: ApiKey{ + Name: key.Name, + Claims: key.Claims, + CreatedAt: key.CreatedAt, + LastUsed: key.LastUsed, + }, + Token: key.Token, + } +} + // @Summary Create API key // @Description Create a new API key // @Tags apikeys @@ -28,10 +52,10 @@ type ApiKeyDto struct { // @Produce json // @Security Jwt[apikeys.write] // @Param key body ApiKeyDto false "Api key info" -// @Success 201 {object} ApiKey +// @Success 201 {object} ApiKeyWToken // @Failure 409 {object} KError "Duplicated api key" // @Failure 422 {object} KError "Invalid create body" -// @Router /users [get] +// @Router /keys [post] func (h *Handler) CreateApiKey(c echo.Context) error { var req ApiKeyDto err := c.Bind(&req) @@ -41,4 +65,70 @@ func (h *Handler) CreateApiKey(c echo.Context) error { if err = c.Validate(&req); err != nil { return err } + + id := make([]byte, 64) + _, err = rand.Read(id) + if err != nil { + return err + } + + dbkey, err := h.db.CreateApiKey(context.Background(), dbc.CreateApiKeyParams{ + Name: req.Name, + Token: fmt.Sprintf("%s-%s", req.Name, base64.RawURLEncoding.EncodeToString(id)), + Claims: req.Claims, + }) + if ErrIs(err, pgerrcode.UniqueViolation) { + return echo.NewHTTPError(409, "An apikey with the same name already exists.") + } else if err != nil { + return err + } + return c.JSON(201, MapDbKey(&dbkey)) +} + +// @Summary Delete API key +// @Description Delete an existing API key +// @Tags apikeys +// @Accept json +// @Produce json +// @Security Jwt[apikeys.write] +// @Success 200 {object} ApiKey +// @Failure 404 {object} KError "Invalid id" +// @Failure 422 {object} KError "Invalid id format" +// @Router /keys [delete] +func (h *Handler) DeleteApiKey(c echo.Context) error { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + return echo.NewHTTPError(422, "Invalid id given: not an uuid") + } + + dbkey, err := h.db.DeleteApiKey(context.Background(), id) + if err == pgx.ErrNoRows { + return echo.NewHTTPError(404, "No apikey found") + } else if err != nil { + return err + } + return c.JSON(200, MapDbKey(&dbkey).ApiKey) +} + +// @Summary List API keys +// @Description List all api keys +// @Tags apikeys +// @Accept json +// @Produce json +// @Security Jwt[apikeys.read] +// @Success 200 {object} Page[ApiKey] +// @Router /keys [get] +func (h *Handler) ListApiKey(c echo.Context) error { + dbkeys, err := h.db.ListApiKeys(context.Background()) + if err != nil { + return err + } + var ret []ApiKey + for _, key := range dbkeys { + ret = append(ret, MapDbKey(&key).ApiKey) + } + return c.JSON(200, Page[ApiKey]{ + Items: ret, + This: c.Request().URL.String(), + }) } diff --git a/auth/dbc/apikeys.sql.go b/auth/dbc/apikeys.sql.go new file mode 100644 index 00000000..26b63e04 --- /dev/null +++ b/auth/dbc/apikeys.sql.go @@ -0,0 +1,103 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.28.0 +// source: apikeys.sql + +package dbc + +import ( + "context" + + jwt "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" +) + +const createApiKey = `-- name: CreateApiKey :one +insert into apikeys(name, token, claims) + values ($1, $2, $3) +returning + pk, id, name, token, claims, created_by, created_at, last_used +` + +type CreateApiKeyParams struct { + Name string `json:"name"` + Token string `json:"token"` + Claims jwt.MapClaims `json:"claims"` +} + +func (q *Queries) CreateApiKey(ctx context.Context, arg CreateApiKeyParams) (Apikey, error) { + row := q.db.QueryRow(ctx, createApiKey, arg.Name, arg.Token, arg.Claims) + var i Apikey + err := row.Scan( + &i.Pk, + &i.Id, + &i.Name, + &i.Token, + &i.Claims, + &i.CreatedBy, + &i.CreatedAt, + &i.LastUsed, + ) + return i, err +} + +const deleteApiKey = `-- name: DeleteApiKey :one +delete from apikeys +where id = $1 +returning + pk, id, name, token, claims, created_by, created_at, last_used +` + +func (q *Queries) DeleteApiKey(ctx context.Context, id uuid.UUID) (Apikey, error) { + row := q.db.QueryRow(ctx, deleteApiKey, id) + var i Apikey + err := row.Scan( + &i.Pk, + &i.Id, + &i.Name, + &i.Token, + &i.Claims, + &i.CreatedBy, + &i.CreatedAt, + &i.LastUsed, + ) + return i, err +} + +const listApiKeys = `-- name: ListApiKeys :many +select + pk, id, name, token, claims, created_by, created_at, last_used +from + apikeys +order by + last_used +` + +func (q *Queries) ListApiKeys(ctx context.Context) ([]Apikey, error) { + rows, err := q.db.Query(ctx, listApiKeys) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Apikey + for rows.Next() { + var i Apikey + if err := rows.Scan( + &i.Pk, + &i.Id, + &i.Name, + &i.Token, + &i.Claims, + &i.CreatedBy, + &i.CreatedAt, + &i.LastUsed, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/auth/dbc/models.go b/auth/dbc/models.go index 2310f424..b64b96aa 100644 --- a/auth/dbc/models.go +++ b/auth/dbc/models.go @@ -11,6 +11,17 @@ import ( "github.com/google/uuid" ) +type Apikey struct { + Pk int32 `json:"pk"` + Id uuid.UUID `json:"id"` + Name string `json:"name"` + Token string `json:"token"` + Claims jwt.MapClaims `json:"claims"` + CreatedBy int32 `json:"createdBy"` + CreatedAt time.Time `json:"createdAt"` + LastUsed time.Time `json:"lastUsed"` +} + type OidcHandle struct { UserPk int32 `json:"userPk"` Provider string `json:"provider"` diff --git a/auth/main.go b/auth/main.go index ab76c2a0..20dbea8c 100644 --- a/auth/main.go +++ b/auth/main.go @@ -224,6 +224,10 @@ func main() { r.DELETE("/sessions", h.Logout) r.DELETE("/sessions/:id", h.Logout) + r.GET("/keys", h.ListApiKey) + r.POST("/keys", h.CreateApiKey) + r.DELETE("/keys", h.DeleteApiKey) + g.GET("/jwt", h.CreateJwt) e.GET("/.well-known/jwks.json", h.GetJwks) e.GET("/.well-known/openid-configuration", h.GetOidcConfig) diff --git a/auth/sql/migrations/000003_apikeys.up.sql b/auth/sql/migrations/000003_apikeys.up.sql index cb333485..8d1f0a17 100644 --- a/auth/sql/migrations/000003_apikeys.up.sql +++ b/auth/sql/migrations/000003_apikeys.up.sql @@ -7,6 +7,7 @@ create table apikeys( token varchar(128) not null unique, claims jsonb not null, + created_by integer not null references users(pk) on delete cascade, created_at timestamptz not null default now()::timestamptz, last_used timestamptz not null default now()::temistamptz ); diff --git a/auth/sql/queries/apikeys.sql b/auth/sql/queries/apikeys.sql new file mode 100644 index 00000000..fb02171e --- /dev/null +++ b/auth/sql/queries/apikeys.sql @@ -0,0 +1,20 @@ +-- name: ListApiKeys :many +select + * +from + apikeys +order by + last_used; + +-- name: CreateApiKey :one +insert into apikeys(name, token, claims) + values ($1, $2, $3) +returning + *; + +-- name: DeleteApiKey :one +delete from apikeys +where id = $1 +returning + *; + diff --git a/auth/sqlc.yaml b/auth/sqlc.yaml index 2c48d06e..638f61b5 100644 --- a/auth/sqlc.yaml +++ b/auth/sqlc.yaml @@ -35,5 +35,10 @@ sql: import: "github.com/golang-jwt/jwt/v5" package: "jwt" type: "MapClaims" + - column: "apikeys.claims" + go_type: + import: "github.com/golang-jwt/jwt/v5" + package: "jwt" + type: "MapClaims" diff --git a/auth/users.go b/auth/users.go index 7aef41e4..24406478 100644 --- a/auth/users.go +++ b/auth/users.go @@ -250,8 +250,8 @@ func (h *Handler) Register(c echo.Context) error { // @Security Jwt[users.delete] // @Param id path string false "User id of the user to delete" Format(uuid) // @Success 200 {object} User -// @Failure 404 {object} KError "Invalid id format" // @Failure 404 {object} KError "Invalid user id" +// @Failure 422 {object} KError "Invalid id format" // @Router /users/{id} [delete] func (h *Handler) DeleteUser(c echo.Context) error { err := CheckPermissions(c, []string{"users.delete"}) @@ -261,7 +261,7 @@ func (h *Handler) DeleteUser(c echo.Context) error { uid, err := uuid.Parse(c.Param("id")) if err != nil { - return echo.NewHTTPError(400, "Invalid id given: not an uuid") + return echo.NewHTTPError(422, "Invalid id given: not an uuid") } ret, err := h.db.DeleteUser(context.Background(), uid) diff --git a/scanner/scanner/scanner.py b/scanner/scanner/scanner.py index a5209d93..9fceac58 100644 --- a/scanner/scanner/scanner.py +++ b/scanner/scanner/scanner.py @@ -11,7 +11,6 @@ logger = getLogger(__name__) def get_ignore_pattern(): - """Compile ignore pattern from environment variable.""" try: pattern = os.environ.get("LIBRARY_IGNORE_PATTERN") return re.compile(pattern) if pattern else None From a72ecdb21bf1bc8c810a97fa6b43e72a6c8c8f2b Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Mon, 21 Apr 2025 14:54:07 +0200 Subject: [PATCH 3/7] Add apikey support to /jwt --- auth/apikey.go | 4 +-- auth/dbc/apikeys.sql.go | 45 +++++++++++++++++++++++++++ auth/dbc/sessions.sql.go | 9 ++++-- auth/dbc/users.sql.go | 6 ++-- auth/jwt.go | 58 +++++++++++++++++++++++++++++++++-- auth/main.go | 36 ++++++++++++++-------- auth/sql/queries/apikeys.sql | 17 ++++++++++ auth/sql/queries/sessions.sql | 3 +- auth/sql/queries/users.sql | 2 +- 9 files changed, 154 insertions(+), 26 deletions(-) diff --git a/auth/apikey.go b/auth/apikey.go index b4e0a44d..81a0695f 100644 --- a/auth/apikey.go +++ b/auth/apikey.go @@ -41,7 +41,7 @@ func MapDbKey(key *dbc.Apikey) ApiKeyWToken { CreatedAt: key.CreatedAt, LastUsed: key.LastUsed, }, - Token: key.Token, + Token: fmt.Sprintf("%s-%s", key.Name, key.Token), } } @@ -74,7 +74,7 @@ func (h *Handler) CreateApiKey(c echo.Context) error { dbkey, err := h.db.CreateApiKey(context.Background(), dbc.CreateApiKeyParams{ Name: req.Name, - Token: fmt.Sprintf("%s-%s", req.Name, base64.RawURLEncoding.EncodeToString(id)), + Token: base64.RawURLEncoding.EncodeToString(id), Claims: req.Claims, }) if ErrIs(err, pgerrcode.UniqueViolation) { diff --git a/auth/dbc/apikeys.sql.go b/auth/dbc/apikeys.sql.go index 26b63e04..230a5367 100644 --- a/auth/dbc/apikeys.sql.go +++ b/auth/dbc/apikeys.sql.go @@ -64,6 +64,37 @@ func (q *Queries) DeleteApiKey(ctx context.Context, id uuid.UUID) (Apikey, error return i, err } +const getApiKey = `-- name: GetApiKey :one +select + pk, id, name, token, claims, created_by, created_at, last_used +from + apikeys +where + name = $1 + and token = $2 +` + +type GetApiKeyParams struct { + Name string `json:"name"` + Token string `json:"token"` +} + +func (q *Queries) GetApiKey(ctx context.Context, arg GetApiKeyParams) (Apikey, error) { + row := q.db.QueryRow(ctx, getApiKey, arg.Name, arg.Token) + var i Apikey + err := row.Scan( + &i.Pk, + &i.Id, + &i.Name, + &i.Token, + &i.Claims, + &i.CreatedBy, + &i.CreatedAt, + &i.LastUsed, + ) + return i, err +} + const listApiKeys = `-- name: ListApiKeys :many select pk, id, name, token, claims, created_by, created_at, last_used @@ -101,3 +132,17 @@ func (q *Queries) ListApiKeys(ctx context.Context) ([]Apikey, error) { } return items, nil } + +const touchApiKey = `-- name: TouchApiKey :exec +update + apikeys +set + last_used = now()::timestamptz +where + pk = $1 +` + +func (q *Queries) TouchApiKey(ctx context.Context, pk int32) error { + _, err := q.db.Exec(ctx, touchApiKey, pk) + return err +} diff --git a/auth/dbc/sessions.sql.go b/auth/dbc/sessions.sql.go index 212ab91f..bcb81869 100644 --- a/auth/dbc/sessions.sql.go +++ b/auth/dbc/sessions.sql.go @@ -88,6 +88,7 @@ func (q *Queries) DeleteSession(ctx context.Context, arg DeleteSessionParams) (S const getUserFromToken = `-- name: GetUserFromToken :one select + s.pk, s.id, s.last_used, u.pk, u.id, u.username, u.email, u.password, u.claims, u.created_date, u.last_seen @@ -100,6 +101,7 @@ limit 1 ` type GetUserFromTokenRow struct { + Pk int32 `json:"pk"` Id uuid.UUID `json:"id"` LastUsed time.Time `json:"lastUsed"` User User `json:"user"` @@ -109,6 +111,7 @@ func (q *Queries) GetUserFromToken(ctx context.Context, token string) (GetUserFr row := q.db.QueryRow(ctx, getUserFromToken, token) var i GetUserFromTokenRow err := row.Scan( + &i.Pk, &i.Id, &i.LastUsed, &i.User.Pk, @@ -169,10 +172,10 @@ update set last_used = now()::timestamptz where - id = $1 + pk = $1 ` -func (q *Queries) TouchSession(ctx context.Context, id uuid.UUID) error { - _, err := q.db.Exec(ctx, touchSession, id) +func (q *Queries) TouchSession(ctx context.Context, pk int32) error { + _, err := q.db.Exec(ctx, touchSession, pk) return err } diff --git a/auth/dbc/users.sql.go b/auth/dbc/users.sql.go index ecc89fbb..02964f00 100644 --- a/auth/dbc/users.sql.go +++ b/auth/dbc/users.sql.go @@ -261,11 +261,11 @@ update set last_used = now()::timestamptz where - id = $1 + pk = $1 ` -func (q *Queries) TouchUser(ctx context.Context, id uuid.UUID) error { - _, err := q.db.Exec(ctx, touchUser, id) +func (q *Queries) TouchUser(ctx context.Context, pk int32) error { + _, err := q.db.Exec(ctx, touchUser, pk) return err } diff --git a/auth/jwt.go b/auth/jwt.go index c854da19..9efa4f83 100644 --- a/auth/jwt.go +++ b/auth/jwt.go @@ -9,8 +9,10 @@ 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 { @@ -19,7 +21,7 @@ type Jwt struct { } // @Summary Get JWT -// @Description Convert a session token to a short lived JWT. +// @Description Convert a session token or an API key to a short lived JWT. // @Tags jwt // @Produce json // @Security Token @@ -28,6 +30,17 @@ type Jwt struct { // @Header 200 {string} Authorization "Jwt (same value as the returned token)" // @Router /jwt [get] func (h *Handler) CreateJwt(c echo.Context) error { + apikey := c.Request().Header.Get("X-Api-Key") + if apikey != "" { + token, err := h.createApiJwt(apikey) + if err != nil { + return err + } + return c.JSON(http.StatusOK, Jwt{ + Token: &token, + }) + } + auth := c.Request().Header.Get("Authorization") var jwt *string @@ -85,8 +98,8 @@ func (h *Handler) createJwt(token string) (string, error) { } go func() { - h.db.TouchSession(context.Background(), session.Id) - h.db.TouchUser(context.Background(), session.User.Id) + h.db.TouchSession(context.Background(), session.Pk) + h.db.TouchUser(context.Background(), session.User.Pk) }() claims := maps.Clone(session.User.Claims) @@ -108,6 +121,45 @@ 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 { diff --git a/auth/main.go b/auth/main.go index 20dbea8c..a48aebd8 100644 --- a/auth/main.go +++ b/auth/main.go @@ -131,24 +131,34 @@ type Handler struct { func (h *Handler) TokenToJwt(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { - auth := c.Request().Header.Get("Authorization") var jwt *string - if auth == "" || !strings.HasPrefix(auth, "Bearer ") { - jwt = h.createGuestJwt() - } else { - token := auth[len("Bearer "):] - // this is only used to check if it is a session token or a jwt - _, err := base64.RawURLEncoding.DecodeString(token) - if err != nil { - return next(c) - } - - tkn, err := h.createJwt(token) + apikey := c.Request().Header.Get("X-Api-Key") + if apikey != "" { + token, err := h.createApiJwt(apikey) if err != nil { return err } - jwt = &tkn + jwt = &token + } else { + auth := c.Request().Header.Get("Authorization") + + if auth == "" || !strings.HasPrefix(auth, "Bearer ") { + jwt = h.createGuestJwt() + } else { + token := auth[len("Bearer "):] + // this is only used to check if it is a session token or a jwt + _, err := base64.RawURLEncoding.DecodeString(token) + if err != nil { + return next(c) + } + + tkn, err := h.createJwt(token) + if err != nil { + return err + } + jwt = &tkn + } } if jwt != nil { diff --git a/auth/sql/queries/apikeys.sql b/auth/sql/queries/apikeys.sql index fb02171e..257dfe2f 100644 --- a/auth/sql/queries/apikeys.sql +++ b/auth/sql/queries/apikeys.sql @@ -1,3 +1,20 @@ +-- name: GetApiKey :one +select + * +from + apikeys +where + name = $1 + and token = $2; + +-- name: TouchApiKey :exec +update + apikeys +set + last_used = now()::timestamptz +where + pk = $1; + -- name: ListApiKeys :many select * diff --git a/auth/sql/queries/sessions.sql b/auth/sql/queries/sessions.sql index b665848f..a2a06727 100644 --- a/auth/sql/queries/sessions.sql +++ b/auth/sql/queries/sessions.sql @@ -1,5 +1,6 @@ -- name: GetUserFromToken :one select + s.pk, s.id, s.last_used, sqlc.embed(u) @@ -16,7 +17,7 @@ update set last_used = now()::timestamptz where - id = $1; + pk = $1; -- name: GetUserSessions :many select diff --git a/auth/sql/queries/users.sql b/auth/sql/queries/users.sql index 8bc00feb..b80181c2 100644 --- a/auth/sql/queries/users.sql +++ b/auth/sql/queries/users.sql @@ -49,7 +49,7 @@ update set last_used = now()::timestamptz where - id = $1; + pk = $1; -- name: CreateUser :one insert into users(username, email, password, claims) From e8acb31834f3bbf42cabf7af2a06fadd949ed99f Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Mon, 21 Apr 2025 23:49:03 +0200 Subject: [PATCH 4/7] 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 { From dcbbb6352a4ff2fdf8613e09bf529738c352eced Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Wed, 23 Apr 2025 19:38:05 +0200 Subject: [PATCH 5/7] Add hurl tests for apikeys --- .env.example | 2 +- .github/workflows/auth-hurl.yml | 4 ++ auth/apikey.go | 19 +++++++- auth/config.go | 12 ++++- auth/main.go | 4 +- auth/sql/migrations/000003_apikeys.up.sql | 2 +- auth/tests/apikey.hurl | 58 +++++++++++++++++++++++ auth/utils.go | 9 ++-- 8 files changed, 99 insertions(+), 11 deletions(-) create mode 100644 auth/tests/apikey.hurl diff --git a/.env.example b/.env.example index 8735891b..a8829d6c 100644 --- a/.env.example +++ b/.env.example @@ -99,6 +99,6 @@ RABBITMQ_DEFAULT_PASS=aohohunuhouhuhhoahothonseuhaoensuthoaentsuhha # v5 stuff, does absolutely nothing on master (aka: you can delete this) EXTRA_CLAIMS='{"permissions": ["core.read"], "verified": false}' -FIRST_USER_CLAIMS='{"permissions": ["users.read", "users.write", "users.delete", "core.read"], "verified": true}' +FIRST_USER_CLAIMS='{"permissions": ["users.read", "users.write", "apikeys.read", "apikeys.write", "users.delete", "core.read", "core.write"], "verified": true}' GUEST_CLAIMS='{"permissions": ["core.read"]}' PROTECTED_CLAIMS="permissions,verified" diff --git a/.github/workflows/auth-hurl.yml b/.github/workflows/auth-hurl.yml index 061e3743..26675789 100644 --- a/.github/workflows/auth-hurl.yml +++ b/.github/workflows/auth-hurl.yml @@ -53,8 +53,12 @@ jobs: env: POSTGRES_SERVER: localhost FIRST_USER_CLAIMS: '{"permissions": ["users.read"]}' + KEIBI_APIKEY_HURL: 1234apikey + KEIBI_APIKEY_HURL_CLAIMS: '{"permissions": ["apikeys.write", "apikeys.read"]}' + - name: Show logs + if: failure() working-directory: ./auth run: cat logs diff --git a/auth/apikey.go b/auth/apikey.go index aee4919d..c258b9f1 100644 --- a/auth/apikey.go +++ b/auth/apikey.go @@ -32,7 +32,7 @@ type ApiKeyWToken struct { } type ApiKeyDto struct { - Name string `json:"name" example:"my-app" validate:"alpha"` + Name string `json:"name" example:"myapp" validate:"alpha"` Claims jwt.MapClaims `json:"claims" example:"isAdmin: true"` } @@ -61,8 +61,13 @@ func MapDbKey(key *dbc.Apikey) ApiKeyWToken { // @Failure 422 {object} KError "Invalid create body" // @Router /keys [post] func (h *Handler) CreateApiKey(c echo.Context) error { + err := CheckPermissions(c, []string{"apikeys.write"}) + if err != nil { + return err + } + var req ApiKeyDto - err := c.Bind(&req) + err = c.Bind(&req) if err != nil { return echo.NewHTTPError(http.StatusUnprocessableEntity, err.Error()) } @@ -104,6 +109,11 @@ func (h *Handler) CreateApiKey(c echo.Context) error { // @Failure 422 {object} KError "Invalid id format" // @Router /keys [delete] func (h *Handler) DeleteApiKey(c echo.Context) error { + err := CheckPermissions(c, []string{"apikeys.write"}) + if err != nil { + return err + } + id, err := uuid.Parse(c.Param("id")) if err != nil { return echo.NewHTTPError(422, "Invalid id given: not an uuid") @@ -127,6 +137,11 @@ func (h *Handler) DeleteApiKey(c echo.Context) error { // @Success 200 {object} Page[ApiKey] // @Router /keys [get] func (h *Handler) ListApiKey(c echo.Context) error { + err := CheckPermissions(c, []string{"apikeys.read"}) + if err != nil { + return err + } + dbkeys, err := h.db.ListApiKeys(context.Background()) if err != nil { return err diff --git a/auth/config.go b/auth/config.go index 402b7002..86ad7190 100644 --- a/auth/config.go +++ b/auth/config.go @@ -14,6 +14,7 @@ import ( "time" "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" "github.com/zoriya/kyoo/keibi/dbc" ) @@ -103,11 +104,14 @@ func LoadConfiguration(db *dbc.Queries) (*Configuration, error) { } for _, env := range os.Environ() { - if !strings.HasPrefix(env, "KEIBI_APIKEY_") || strings.HasSuffix(env, "_CLAIMS") { + if !strings.HasPrefix(env, "KEIBI_APIKEY_"){ + continue + } + v := strings.Split(env, "=") + if strings.HasSuffix(v[0], "_CLAIMS") { continue } - v := strings.Split(env, "=") name := strings.TrimPrefix(v[0], "KEIBI_APIKEY_") cstr := os.Getenv(fmt.Sprintf("KEIBI_APIKEY_%s_CLAIMS", name)) @@ -117,10 +121,14 @@ func LoadConfiguration(db *dbc.Queries) (*Configuration, error) { if err != nil { return nil, err } + } else { + return nil, fmt.Errorf("missing claims env var KEIBI_APIKEY_%s_CLAIMS", name) } + name = strings.ToLower(name) ret.EnvApiKeys[name] = ApiKeyWToken{ ApiKey: ApiKey{ + Id: uuid.New(), Name: name, Claims: claims, }, diff --git a/auth/main.go b/auth/main.go index a48aebd8..9c40cf09 100644 --- a/auth/main.go +++ b/auth/main.go @@ -162,7 +162,7 @@ func (h *Handler) TokenToJwt(next echo.HandlerFunc) echo.HandlerFunc { } if jwt != nil { - c.Request().Header.Set("Authorization", *jwt) + c.Request().Header.Set("Authorization", fmt.Sprintf("Bearer %s", *jwt)) } return next(c) } @@ -236,7 +236,7 @@ func main() { r.GET("/keys", h.ListApiKey) r.POST("/keys", h.CreateApiKey) - r.DELETE("/keys", h.DeleteApiKey) + r.DELETE("/keys/:id", h.DeleteApiKey) g.GET("/jwt", h.CreateJwt) e.GET("/.well-known/jwks.json", h.GetJwks) diff --git a/auth/sql/migrations/000003_apikeys.up.sql b/auth/sql/migrations/000003_apikeys.up.sql index 8d1f0a17..40e5d3d8 100644 --- a/auth/sql/migrations/000003_apikeys.up.sql +++ b/auth/sql/migrations/000003_apikeys.up.sql @@ -9,7 +9,7 @@ create table apikeys( created_by integer not null references users(pk) on delete cascade, created_at timestamptz not null default now()::timestamptz, - last_used timestamptz not null default now()::temistamptz + last_used timestamptz not null default now()::timestamptz ); commit; diff --git a/auth/tests/apikey.hurl b/auth/tests/apikey.hurl new file mode 100644 index 00000000..20d281d6 --- /dev/null +++ b/auth/tests/apikey.hurl @@ -0,0 +1,58 @@ +# perm check +POST {{host}}/keys +{ + "name": "dryflower", + "claims": { + "isAdmin": true, + "permssions": ["core.read"] + } +} +HTTP 401 + +POST {{host}}/keys +# this is created from the gh workflow file's env var +X-API-KEY: hurl-1234apikey +{ + "name": "dryflower", + "claims": { + "isAdmin": true, + "permssions": ["core.read"] + } +} +HTTP 201 +[Captures] +token: jsonpath "$.token" + +GET {{host}}/jwt +Authorization: Bearer {{token}} +HTTP 200 +[Captures] +id: jsonpath "$.id" +jwt: jsonpath "$.token" + +# Duplicates email +POST {{host}}/keys +X-API-KEY: hurl-1234apikey +{ + "name": "dryflower", + "claims": { + "isAdmin": true, + "permssions": ["core.read"] + } +} +HTTP 409 + +# List +GET {{host}}/keys +Authorization: Bearer {{token}} +HTTP 200 +[Asserts] +jsonpath "$.items[0].id" == {{id}} +jsonpath "$.items[0].name" == "dryflower" +jsonpath "$.items[0].claims.permissions" contains "core.read" + + + +DELETE {{host}}/keys/{{id}} +Authorization: Bearer {{jwt}} +HTTP 200 diff --git a/auth/utils.go b/auth/utils.go index e607cbee..942614b2 100644 --- a/auth/utils.go +++ b/auth/utils.go @@ -56,7 +56,12 @@ func GetCurrentSessionId(c echo.Context) (uuid.UUID, error) { func CheckPermissions(c echo.Context, perms []string) error { token, ok := c.Get("user").(*jwt.Token) - if !ok { + if !ok{ + return echo.NewHTTPError(401, "Not logged in") + } + sub, err := token.Claims.GetSubject() + // ignore guests + if err != nil || sub == "00000000-0000-0000-0000-000000000000" { return echo.NewHTTPError(401, "Not logged in") } claims, ok := token.Claims.(jwt.MapClaims) @@ -68,8 +73,6 @@ func CheckPermissions(c echo.Context, perms []string) error { if !ok { return echo.NewHTTPError(403, fmt.Sprintf("Missing permissions: %s.", ", ")) } - fmt.Printf("%v\n", permissions_claims) - fmt.Printf("%t\n", permissions_claims) permissions_int, ok := permissions_claims.([]any) if !ok { return echo.NewHTTPError(403, "Invalid permission claim.") From 667249bc815240dea31731e419937ac56dab6b38 Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Wed, 23 Apr 2025 22:45:39 +0200 Subject: [PATCH 6/7] Add CreatedBy value in apikeys --- auth/apikey.go | 12 ++++++++++++ auth/dbc/apikeys.sql.go | 18 ++++++++++++------ auth/dbc/models.go | 2 +- auth/sql/migrations/000003_apikeys.up.sql | 2 +- auth/sql/queries/apikeys.sql | 4 ++-- 5 files changed, 28 insertions(+), 10 deletions(-) diff --git a/auth/apikey.go b/auth/apikey.go index c258b9f1..4f2e2d07 100644 --- a/auth/apikey.go +++ b/auth/apikey.go @@ -85,10 +85,22 @@ func (h *Handler) CreateApiKey(c echo.Context) error { return err } + var user *int32 + uid, err :=GetCurrentUserId(c) + // if err, we probably are using an api key (so no user) + if err != nil { + u, _ := h.db.GetUser(context.Background(), dbc.GetUserParams{ + UseId: true, + Id: uid, + }) + user = &u[0].User.Pk + } + dbkey, err := h.db.CreateApiKey(context.Background(), dbc.CreateApiKeyParams{ Name: req.Name, Token: base64.RawURLEncoding.EncodeToString(id), Claims: req.Claims, + CreatedBy: user, }) if ErrIs(err, pgerrcode.UniqueViolation) { return echo.NewHTTPError(409, "An apikey with the same name already exists.") diff --git a/auth/dbc/apikeys.sql.go b/auth/dbc/apikeys.sql.go index 230a5367..fa4d1643 100644 --- a/auth/dbc/apikeys.sql.go +++ b/auth/dbc/apikeys.sql.go @@ -13,20 +13,26 @@ import ( ) const createApiKey = `-- name: CreateApiKey :one -insert into apikeys(name, token, claims) - values ($1, $2, $3) +insert into apikeys(name, token, claims, created_by) + values ($1, $2, $3, $4) returning pk, id, name, token, claims, created_by, created_at, last_used ` type CreateApiKeyParams struct { - Name string `json:"name"` - Token string `json:"token"` - Claims jwt.MapClaims `json:"claims"` + Name string `json:"name"` + Token string `json:"token"` + Claims jwt.MapClaims `json:"claims"` + CreatedBy *int32 `json:"createdBy"` } func (q *Queries) CreateApiKey(ctx context.Context, arg CreateApiKeyParams) (Apikey, error) { - row := q.db.QueryRow(ctx, createApiKey, arg.Name, arg.Token, arg.Claims) + row := q.db.QueryRow(ctx, createApiKey, + arg.Name, + arg.Token, + arg.Claims, + arg.CreatedBy, + ) var i Apikey err := row.Scan( &i.Pk, diff --git a/auth/dbc/models.go b/auth/dbc/models.go index b64b96aa..7bf7c38f 100644 --- a/auth/dbc/models.go +++ b/auth/dbc/models.go @@ -17,7 +17,7 @@ type Apikey struct { Name string `json:"name"` Token string `json:"token"` Claims jwt.MapClaims `json:"claims"` - CreatedBy int32 `json:"createdBy"` + CreatedBy *int32 `json:"createdBy"` CreatedAt time.Time `json:"createdAt"` LastUsed time.Time `json:"lastUsed"` } diff --git a/auth/sql/migrations/000003_apikeys.up.sql b/auth/sql/migrations/000003_apikeys.up.sql index 40e5d3d8..22ddbd86 100644 --- a/auth/sql/migrations/000003_apikeys.up.sql +++ b/auth/sql/migrations/000003_apikeys.up.sql @@ -7,7 +7,7 @@ create table apikeys( token varchar(128) not null unique, claims jsonb not null, - created_by integer not null references users(pk) on delete cascade, + created_by integer references users(pk) on delete cascade, created_at timestamptz not null default now()::timestamptz, last_used timestamptz not null default now()::timestamptz ); diff --git a/auth/sql/queries/apikeys.sql b/auth/sql/queries/apikeys.sql index 257dfe2f..634038c1 100644 --- a/auth/sql/queries/apikeys.sql +++ b/auth/sql/queries/apikeys.sql @@ -24,8 +24,8 @@ order by last_used; -- name: CreateApiKey :one -insert into apikeys(name, token, claims) - values ($1, $2, $3) +insert into apikeys(name, token, claims, created_by) + values ($1, $2, $3, $4) returning *; From fb908c95ef4be251b3ceaa8a372605d7e571a35d Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Wed, 23 Apr 2025 22:48:13 +0200 Subject: [PATCH 7/7] Fix hurl test --- auth/apikey.go | 4 ++-- auth/tests/apikey.hurl | 19 +++++++++++-------- auth/utils.go | 2 +- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/auth/apikey.go b/auth/apikey.go index 4f2e2d07..2f7736d4 100644 --- a/auth/apikey.go +++ b/auth/apikey.go @@ -86,7 +86,7 @@ func (h *Handler) CreateApiKey(c echo.Context) error { } var user *int32 - uid, err :=GetCurrentUserId(c) + uid, err := GetCurrentUserId(c) // if err, we probably are using an api key (so no user) if err != nil { u, _ := h.db.GetUser(context.Background(), dbc.GetUserParams{ @@ -174,7 +174,7 @@ func (h *Handler) ListApiKey(c echo.Context) error { } func (h *Handler) createApiJwt(apikey string) (string, error) { - info := strings.Split(apikey, "-") + info := strings.SplitN(apikey, "-", 2) if len(info) != 2 { return "", echo.NewHTTPError(http.StatusForbidden, "Invalid api key format") } diff --git a/auth/tests/apikey.hurl b/auth/tests/apikey.hurl index 20d281d6..2daaf9dc 100644 --- a/auth/tests/apikey.hurl +++ b/auth/tests/apikey.hurl @@ -4,7 +4,7 @@ POST {{host}}/keys "name": "dryflower", "claims": { "isAdmin": true, - "permssions": ["core.read"] + "permissions": ["core.read"] } } HTTP 401 @@ -16,18 +16,18 @@ X-API-KEY: hurl-1234apikey "name": "dryflower", "claims": { "isAdmin": true, - "permssions": ["core.read"] + "permissions": ["apikeys.read"] } } HTTP 201 [Captures] +id: jsonpath "$.id" token: jsonpath "$.token" GET {{host}}/jwt -Authorization: Bearer {{token}} +X-API-KEY: {{token}} HTTP 200 [Captures] -id: jsonpath "$.id" jwt: jsonpath "$.token" # Duplicates email @@ -37,22 +37,25 @@ X-API-KEY: hurl-1234apikey "name": "dryflower", "claims": { "isAdmin": true, - "permssions": ["core.read"] + "permissions": ["core.read"] } } HTTP 409 # List GET {{host}}/keys -Authorization: Bearer {{token}} +Authorization: Bearer {{jwt}} HTTP 200 [Asserts] jsonpath "$.items[0].id" == {{id}} jsonpath "$.items[0].name" == "dryflower" -jsonpath "$.items[0].claims.permissions" contains "core.read" - +jsonpath "$.items[0].claims.permissions" contains "apikeys.read" DELETE {{host}}/keys/{{id}} Authorization: Bearer {{jwt}} +HTTP 403 + +DELETE {{host}}/keys/{{id}} +X-API-KEY: hurl-1234apikey HTTP 200 diff --git a/auth/utils.go b/auth/utils.go index 942614b2..d336a7bd 100644 --- a/auth/utils.go +++ b/auth/utils.go @@ -71,7 +71,7 @@ func CheckPermissions(c echo.Context, perms []string) error { permissions_claims, ok := claims["permissions"] if !ok { - return echo.NewHTTPError(403, fmt.Sprintf("Missing permissions: %s.", ", ")) + return echo.NewHTTPError(403, fmt.Sprintf("No permissions on this account. Needs permissions: %s.", strings.Join(perms, ", "))) } permissions_int, ok := permissions_claims.([]any) if !ok {