Auth cleanups (#872)

This commit is contained in:
Zoe Roux 2025-04-05 01:25:30 +02:00 committed by GitHub
commit a903d88a66
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
42 changed files with 914 additions and 1031 deletions

View File

@ -1,4 +1,4 @@
name: RobotTests
name: HurlTests
on:
push:
branches:
@ -9,7 +9,7 @@ on:
jobs:
test:
name: Robot tests Auth
name: Hurl tests Auth
runs-on: ubuntu-latest
services:
postgres:
@ -27,13 +27,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Robot cache
uses: actions/setup-python@v5
with:
python-version: '3.9'
cache: 'pip'
- run: pip install -r requirements.txt
- uses: gacts/install-hurl@v1
- uses: actions/setup-go@v5
with:
@ -44,22 +38,18 @@ jobs:
working-directory: ./auth
run: |
go mod download
go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest
go install github.com/swaggo/swag/cmd/swag@latest
- name: Build
working-directory: ./auth
run: |
sqlc generate
swag init --parseDependency
go build -o ./keibi
- name: Run robot tests
- name: Run hurl tests
working-directory: ./auth
run: |
./keibi > logs &
wget --retry-connrefused --retry-on-http-error=502 http://localhost:4568/health
robot -d out robot
hurl --error-format long --variable host=http://localhost:4568 tests/*
env:
POSTGRES_SERVER: localhost

View File

@ -3,9 +3,12 @@
KYOO_PREFIX=/api
# either an hard-coded secret to decode jwts or empty to use keibi's public secret.
# this should only be used in tests
JWT_SECRET=
# used to verify who's making the jwt
JWT_ISSUER=$PUBLIC_URL
# keibi's server to retrieve the public jwt secret
AUHT_SERVER=http://auth:4568

View File

@ -4,7 +4,7 @@
"": {
"name": "api",
"dependencies": {
"@elysiajs/swagger": "^1.2.2",
"@elysiajs/swagger": "zoriya/elysia-swagger#build",
"blurhash": "^2.0.5",
"drizzle-kit": "^0.30.4",
"drizzle-orm": "0.39.0",
@ -27,7 +27,7 @@
"packages": {
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
"@elysiajs/swagger": ["@elysiajs/swagger@1.2.2", "", { "dependencies": { "@scalar/themes": "^0.9.52", "@scalar/types": "^0.0.12", "openapi-types": "^12.1.3", "pathe": "^1.1.2" }, "peerDependencies": { "elysia": ">= 1.2.0" } }, "sha512-DG0PbX/wzQNQ6kIpFFPCvmkkWTIbNWDS7lVLv3Puy6ONklF14B4NnbDfpYjX1hdSYKeCqKBBOuenh6jKm8tbYA=="],
"@elysiajs/swagger": ["@elysiajs/swagger@github:zoriya/elysia-swagger#ef89c17", { "dependencies": { "@scalar/themes": "^0.9.81", "@scalar/types": "^0.1.3", "openapi-types": "^12.1.3", "pathe": "^1.1.2" }, "peerDependencies": { "elysia": ">= 1.2.0" } }, "zoriya-elysia-swagger-ef89c17"],
"@emnapi/runtime": ["@emnapi/runtime@1.3.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kEBmG8KyqtxJZv+ygbEim+KCGtIq1fC22Ms3S4ziXmYKm8uyoLX0MHONVKwp+9opg390VaKRNt4a7A9NwmpNhw=="],
@ -121,15 +121,15 @@
"@petamoriken/float16": ["@petamoriken/float16@3.9.2", "", {}, "sha512-VgffxawQde93xKxT3qap3OH+meZf7VaSB5Sqd4Rqc+FP5alWbpOyan/7tRbOAvynjpG3GpdtAuGU/NdhQpmrog=="],
"@scalar/openapi-types": ["@scalar/openapi-types@0.1.1", "", {}, "sha512-NMy3QNk6ytcCoPUGJH0t4NNr36OWXgZhA3ormr3TvhX1NDgoF95wFyodGVH8xiHeUyn2/FxtETm8UBLbB5xEmg=="],
"@scalar/openapi-types": ["@scalar/openapi-types@0.1.9", "", {}, "sha512-HQQudOSQBU7ewzfnBW9LhDmBE2XOJgSfwrh5PlUB7zJup/kaRkBGNgV2wMjNz9Af/uztiU/xNrO179FysmUT+g=="],
"@scalar/themes": ["@scalar/themes@0.9.79", "", { "dependencies": { "@scalar/types": "0.1.1" } }, "sha512-zWiHCZAIjPGa8X9o/NORBPRMTMblLEz2+2RcfW9yIKNO/8H4Gz0rltiGGlJ6vX0o+qHwx7AdgfY+7njmWQR4ng=="],
"@scalar/themes": ["@scalar/themes@0.9.81", "", { "dependencies": { "@scalar/types": "0.1.3" } }, "sha512-asTgdqo8ZYibBBWVYy0503qPx3cvwDlYNuc/cLbrCmTav0MAEL4wNb/gz9iScMVSMwhdkSkL5g9LPdr2mQrHzw=="],
"@scalar/types": ["@scalar/types@0.0.12", "", { "dependencies": { "@scalar/openapi-types": "0.1.1", "@unhead/schema": "^1.9.5" } }, "sha512-XYZ36lSEx87i4gDqopQlGCOkdIITHHEvgkuJFrXFATQs9zHARop0PN0g4RZYWj+ZpCUclOcaOjbCt8JGe22mnQ=="],
"@scalar/types": ["@scalar/types@0.1.3", "", { "dependencies": { "@scalar/openapi-types": "0.1.9", "@unhead/schema": "^1.11.11", "zod": "^3.23.8" } }, "sha512-Fxtgjp5wHhTzXiyODYWIoTsTy3oFC70vme+0I7MNwd8i6D8qplFNnpURueqBuP4MglBM2ZhFv3hPLw4D69anDA=="],
"@sinclair/typebox": ["@sinclair/typebox@0.34.30", "", {}, "sha512-gFB3BiqjDxEoadW0zn+xyMVb7cLxPCoblVn2C/BKpI41WPYi2d6fwHAlynPNZ5O/Q4WEiujdnJzVtvG/Jc2CBQ=="],
"@sinclair/typebox": ["@sinclair/typebox@0.34.31", "", {}, "sha512-qQ71T9DsITbX3dVCrcBERbs11YuSMg3wZPnT472JhqhWGPdiLgyvihJXU8m+ADJtJvRdjATIiACJD22dEknBrQ=="],
"@types/node": ["@types/node@22.13.10", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw=="],
"@types/node": ["@types/node@22.13.13", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-ClsL5nMwKaBRwPcCvH8E7+nU4GxHVx1axNvMZTFHMEfNI7oahimt26P5zjVCRrjiIWj6YFXfE1v3dEp94wLcGQ=="],
"@types/pg": ["@types/pg@8.11.11", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^4.0.1" } }, "sha512-kGT1qKM8wJQ5qlawUrEkXgvMSXoV213KfMGXcwfDwUIfUHXqXYXOfS1nE1LINRJVVVx5wCm70XnFlMHaIcQAfw=="],
@ -141,7 +141,7 @@
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"bun-types": ["bun-types@1.2.5", "", { "dependencies": { "@types/node": "*", "@types/ws": "~8.5.10" } }, "sha512-3oO6LVGGRRKI4kHINx5PIdIgnLRb7l/SprhzqXapmoYkFl5m4j6EvALvbDVuuBFaamB46Ap6HCUxIXNLCGy+tg=="],
"bun-types": ["bun-types@1.2.6", "", { "dependencies": { "@types/node": "*", "@types/ws": "~8.5.10" } }, "sha512-FbCKyr5KDiPULUzN/nm5oqQs9nXCHD8dVc64BArxJadCvbNzAI6lUWGh9fSJZWeDIRD38ikceBU8Kj/Uh+53oQ=="],
"char-info": ["char-info@0.3.5", "", { "dependencies": { "node-interval-tree": "^1.3.3" } }, "sha512-gRslEBFEcuLMGLNO1EFIrdN1MMUfO+aqa7y8iWzNyAzB3mYKnTIvP+ioW3jpyeEvqA5WapVLIPINGtFjEIH4cQ=="],
@ -199,7 +199,7 @@
"pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
"pg": ["pg@8.14.0", "", { "dependencies": { "pg-connection-string": "^2.7.0", "pg-pool": "^3.8.0", "pg-protocol": "^1.8.0", "pg-types": "^2.1.0", "pgpass": "1.x" }, "optionalDependencies": { "pg-cloudflare": "^1.1.1" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-nXbVpyoaXVmdqlKEzToFf37qzyeeh7mbiXsnoWvstSqohj88yaa/I/Rq/HEVn2QPSZEuLIJa/jSpRDyzjEx4FQ=="],
"pg": ["pg@8.14.1", "", { "dependencies": { "pg-connection-string": "^2.7.0", "pg-pool": "^3.8.0", "pg-protocol": "^1.8.0", "pg-types": "^2.1.0", "pgpass": "1.x" }, "optionalDependencies": { "pg-cloudflare": "^1.1.1" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-0TdbqfjwIun9Fm/r89oB7RFQ0bLgduAhiIqIXOsyKoiC/L54DbuAAzIEN/9Op0f1Po9X7iCPXGoa/Ah+2aI8Xw=="],
"pg-cloudflare": ["pg-cloudflare@1.1.1", "", {}, "sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q=="],
@ -259,8 +259,6 @@
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
"@scalar/themes/@scalar/types": ["@scalar/types@0.1.1", "", { "dependencies": { "@scalar/openapi-types": "0.1.9", "@unhead/schema": "^1.11.11", "zod": "^3.23.8" } }, "sha512-LlUX6AmOOGoRqOMoO835V2FezM1KiO5UlvQC3poT/s7oqD6ranqwRNFxyrPz/IxClPYR+SV1yBUSNKely4ZQhQ=="],
"pg/pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
@ -307,8 +305,6 @@
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
"@scalar/themes/@scalar/types/@scalar/openapi-types": ["@scalar/openapi-types@0.1.9", "", {}, "sha512-HQQudOSQBU7ewzfnBW9LhDmBE2XOJgSfwrh5PlUB7zJup/kaRkBGNgV2wMjNz9Af/uztiU/xNrO179FysmUT+g=="],
"pg/pg-types/postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="],
"pg/pg-types/postgres-bytea": ["postgres-bytea@1.0.0", "", {}, "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w=="],

View File

@ -9,7 +9,7 @@
"format": "biome check --write ."
},
"dependencies": {
"@elysiajs/swagger": "^1.2.2",
"@elysiajs/swagger": "zoriya/elysia-swagger#build",
"blurhash": "^2.0.5",
"drizzle-kit": "^0.30.4",
"drizzle-orm": "0.39.0",

View File

@ -34,10 +34,13 @@ export const auth = new Elysia({ name: "auth" })
permissions(perms: string[]) {
return {
resolve: async ({ headers: { authorization }, error }) => {
console.log(process.env.JWT_ISSUER);
const bearer = authorization?.slice(7);
if (!bearer) return { jwt: false };
// @ts-expect-error ts can't understand that there's two overload idk why
const { payload } = await jwtVerify(bearer, jwtSecret ?? jwks);
const { payload } = await jwtVerify(bearer, jwtSecret ?? jwks, {
issuer: process.env.JWT_ISSUER,
});
// TODO: use perms
return { jwt: validator.Decode<typeof Jwt>(payload) };
},

View File

@ -1,4 +1,5 @@
import { swagger } from "@elysiajs/swagger";
import Elysia from "elysia";
import { app } from "./base";
import { processImages } from "./controllers/seed/images";
import { migrate } from "./db";
@ -9,9 +10,15 @@ await migrate();
// run image processor task in background
processImages();
app
new Elysia()
.use(
swagger({
scalarConfig: {
sources: [
{ slug: "kyoo", url: "/swagger/json" },
{ slug: "keibi", url: "http://localhost:4568/auth/swagger/doc.json" },
],
},
documentation: {
info: {
title: "Kyoo",
@ -72,6 +79,7 @@ app
},
}),
)
.use(app)
.listen(3567);
console.log(`Api running at ${app.server?.hostname}:${app.server?.port}`);

View File

@ -8,7 +8,7 @@ export const desc = {
`,
after: comment`
Id of the cursor in the pagination.
Cursor for the pagination.
You can ignore this and only use the prev/next field in the response.
`,

View File

@ -4,6 +4,16 @@
# http route prefix (will listen to $KEIBI_PREFIX/users for example)
KEIBI_PREFIX=""
# path of the private key used to sign jwts. If this is empty, a new one will be generated on startup
RSA_PRIVATE_KEY_PATH=""
# json object with the claims to add to every jwt (this is read when creating a new user)
EXTRA_CLAIMS='{}'
# json object with the claims to add to every jwt of the FIRST user (this can be used to mark the first user as admin).
# Those claims are merged with the `EXTRA_CLAIMS`.
FIRST_USER_CLAIMS='{}'
# If this is not empty, calls to `/jwt` without an `Authorization` header will still create a jwt (with `null` in `sub`)
GUEST_CLAIMS=""
# 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

View File

@ -12,7 +12,7 @@ COPY sql ./sql
RUN sqlc generate
COPY . .
RUN swag init --parseDependency
RUN swag init --parseDependency --outputTypes json,go
RUN CGO_ENABLED=0 GOOS=linux go build -o /keibi
FROM gcr.io/distroless/base-debian11

View File

@ -10,6 +10,7 @@
- Username/password login
- OIDC (login via Google, Discord, Authentik, whatever)
- Custom jwt claims (for your role/permissions handling or something else)
- Guest handling (only if using `GUEST_CLAIMS`)
- Api keys support
- Optionally [Federated](#federated)

View File

@ -1,11 +1,12 @@
package main
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/json"
"encoding/pem"
"maps"
"os"
"time"
@ -19,64 +20,77 @@ type Configuration struct {
JwtPublicKey *rsa.PublicKey
PublicUrl string
DefaultClaims jwt.MapClaims
FirstUserClaims jwt.MapClaims
GuestClaims jwt.MapClaims
ExpirationDelay time.Duration
}
var DefaultConfig = Configuration{
DefaultClaims: make(jwt.MapClaims),
FirstUserClaims: make(jwt.MapClaims),
ExpirationDelay: 30 * 24 * time.Hour,
}
const (
JwtPrivateKey = "jwt_private_key"
)
func LoadConfiguration(db *dbc.Queries) (*Configuration, error) {
ctx := context.Background()
confs, err := db.LoadConfig(ctx)
if err != nil {
return nil, err
}
ret := DefaultConfig
for _, conf := range confs {
switch conf.Key {
case JwtPrivateKey:
block, _ := pem.Decode([]byte(conf.Value))
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
return nil, err
}
ret.JwtPrivateKey = key
ret.JwtPublicKey = &key.PublicKey
}
}
ret.PublicUrl = os.Getenv("PUBLIC_URL")
ret.Prefix = os.Getenv("KEIBI_PREFIX")
if ret.JwtPrivateKey == nil {
claims := os.Getenv("EXTRA_CLAIMS")
if claims != "" {
err := json.Unmarshal([]byte(claims), &ret.DefaultClaims)
if err != nil {
return nil, err
}
}
claims = os.Getenv("FIRST_USER_CLAIMS")
if claims != "" {
err := json.Unmarshal([]byte(claims), &ret.FirstUserClaims)
if err != nil {
return nil, err
}
maps.Insert(ret.FirstUserClaims, maps.All(ret.DefaultClaims))
} else {
ret.FirstUserClaims = ret.DefaultClaims
}
claims = os.Getenv("GUEST_CLAIMS")
if claims != "" {
err := json.Unmarshal([]byte(claims), &ret.GuestClaims)
if err != nil {
return nil, err
}
}
rsa_pk_path := os.Getenv("RSA_PRIVATE_KEY_PATH")
if rsa_pk_path != "" {
privateKeyData, err := os.ReadFile(rsa_pk_path)
if err != nil {
return nil, err
}
block, _ := pem.Decode(privateKeyData)
if block == nil || block.Type != "RSA PRIVATE KEY" {
return nil, err
}
ret.JwtPrivateKey, err = x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
pkcs8Key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, err
}
ret.JwtPrivateKey = pkcs8Key.(*rsa.PrivateKey)
}
} else {
var err error
ret.JwtPrivateKey, err = rsa.GenerateKey(rand.Reader, 4096)
if err != nil {
return nil, err
}
ret.JwtPublicKey = &ret.JwtPrivateKey.PublicKey
pemd := pem.EncodeToMemory(
&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(ret.JwtPrivateKey),
},
)
_, err := db.SaveConfig(ctx, dbc.SaveConfigParams{
Key: JwtPrivateKey,
Value: string(pemd),
})
if err != nil {
return nil, err
}
}
return &ret, nil

View File

@ -1,73 +0,0 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// source: config.sql
package dbc
import (
"context"
)
const deleteConfig = `-- name: DeleteConfig :one
delete from config
where key = $1
returning
key, value
`
func (q *Queries) DeleteConfig(ctx context.Context, key string) (Config, error) {
row := q.db.QueryRow(ctx, deleteConfig, key)
var i Config
err := row.Scan(&i.Key, &i.Value)
return i, err
}
const loadConfig = `-- name: LoadConfig :many
select
key, value
from
config
`
func (q *Queries) LoadConfig(ctx context.Context) ([]Config, error) {
rows, err := q.db.Query(ctx, loadConfig)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Config
for rows.Next() {
var i Config
if err := rows.Scan(&i.Key, &i.Value); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const saveConfig = `-- name: SaveConfig :one
insert into config(key, value)
values ($1, $2)
on conflict (key)
do update set
value = excluded.value
returning
key, value
`
type SaveConfigParams struct {
Key string `json:"key"`
Value string `json:"value"`
}
func (q *Queries) SaveConfig(ctx context.Context, arg SaveConfigParams) (Config, error) {
row := q.db.QueryRow(ctx, saveConfig, arg.Key, arg.Value)
var i Config
err := row.Scan(&i.Key, &i.Value)
return i, err
}

View File

@ -11,11 +11,6 @@ import (
"github.com/google/uuid"
)
type Config struct {
Key string `json:"key"`
Value string `json:"value"`
}
type OidcHandle struct {
UserPk int32 `json:"userPk"`
Provider string `json:"provider"`

View File

@ -14,16 +14,25 @@ import (
const createUser = `-- name: CreateUser :one
insert into users(username, email, password, claims)
values ($1, $2, $3, $4)
values ($1, $2, $3, case when not exists (
select
pk, id, username, email, password, claims, created_date, last_seen
from
users) then
$4::jsonb
else
$5::jsonb
end)
returning
pk, id, username, email, password, claims, created_date, last_seen
`
type CreateUserParams struct {
Username string `json:"username"`
Email string `json:"email"`
Password *string `json:"password"`
Claims jwt.MapClaims `json:"claims"`
Username string `json:"username"`
Email string `json:"email"`
Password *string `json:"password"`
FirstClaims interface{} `json:"firstClaims"`
Claims interface{} `json:"claims"`
}
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) {
@ -31,6 +40,7 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
arg.Username,
arg.Email,
arg.Password,
arg.FirstClaims,
arg.Claims,
)
var i User

View File

@ -22,21 +22,21 @@ const docTemplate = `{
"host": "{{.Host}}",
"basePath": "{{.BasePath}}",
"paths": {
"/info": {
"/.well-known/jwks.json": {
"get": {
"description": "Get info like the public key used to sign the jwts.",
"description": "Get the jwks info, used to validate jwts.",
"produces": [
"application/json"
],
"tags": [
"jwt"
],
"summary": "Info",
"summary": "Jwks",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/main.Info"
"$ref": "#/definitions/main.JwkSet"
}
}
}
@ -62,15 +62,19 @@ const docTemplate = `{
"description": "OK",
"schema": {
"$ref": "#/definitions/main.Jwt"
},
"headers": {
"Authorization": {
"type": "string",
"description": "Jwt (same value as the returned token)"
}
}
},
"401": {
"description": "Missing session token",
"schema": {}
},
"403": {
"description": "Invalid session token (or expired)",
"schema": {}
"schema": {
"$ref": "#/definitions/main.KError"
}
}
}
}
@ -91,6 +95,7 @@ const docTemplate = `{
"parameters": [
{
"type": "string",
"example": "android tv",
"description": "The device the created session will be used on",
"name": "device",
"in": "query"
@ -108,24 +113,26 @@ const docTemplate = `{
"201": {
"description": "Created",
"schema": {
"$ref": "#/definitions/dbc.Session"
"$ref": "#/definitions/main.SessionWToken"
}
},
"400": {
"description": "Invalid login body",
"schema": {}
},
"403": {
"description": "Invalid password",
"schema": {}
"schema": {
"$ref": "#/definitions/main.KError"
}
},
"404": {
"description": "Account does not exists",
"schema": {}
"schema": {
"$ref": "#/definitions/main.KError"
}
},
"422": {
"description": "User does not have a password (registered via oidc, please login via oidc)",
"schema": {}
"schema": {
"$ref": "#/definitions/main.KError"
}
}
}
}
@ -152,21 +159,17 @@ const docTemplate = `{
"$ref": "#/definitions/main.Session"
}
},
"400": {
"description": "Invalid session id",
"schema": {}
},
"401": {
"description": "Missing jwt token",
"schema": {}
"schema": {
"$ref": "#/definitions/main.KError"
}
},
"403": {
"description": "Invalid jwt token (or expired)",
"schema": {}
},
"404": {
"description": "Session not found with specified id (if not using the /current route)",
"schema": {}
"schema": {
"$ref": "#/definitions/main.KError"
}
}
}
}
@ -185,11 +188,12 @@ const docTemplate = `{
"tags": [
"sessions"
],
"summary": "Logout",
"summary": "Delete other session",
"parameters": [
{
"type": "string",
"format": "uuid",
"example": "e05089d6-9179-4b5b-a63e-94dd5fc2a397",
"description": "The id of the session to delete",
"name": "id",
"in": "path",
@ -203,21 +207,17 @@ const docTemplate = `{
"$ref": "#/definitions/main.Session"
}
},
"400": {
"description": "Invalid session id",
"schema": {}
},
"401": {
"description": "Missing jwt token",
"schema": {}
},
"403": {
"description": "Invalid jwt token (or expired)",
"schema": {}
},
"404": {
"description": "Session not found with specified id (if not using the /current route)",
"schema": {}
"schema": {
"$ref": "#/definitions/main.KError"
}
},
"422": {
"description": "Invalid session id",
"schema": {
"$ref": "#/definitions/main.KError"
}
}
}
}
@ -245,9 +245,8 @@ const docTemplate = `{
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "used for pagination.",
"name": "afterId",
"name": "after",
"in": "query"
}
],
@ -255,12 +254,14 @@ const docTemplate = `{
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/main.User"
"$ref": "#/definitions/main.Page-main_User"
}
},
"400": {
"422": {
"description": "Invalid after id",
"schema": {}
"schema": {
"$ref": "#/definitions/main.KError"
}
}
}
},
@ -279,6 +280,7 @@ const docTemplate = `{
"parameters": [
{
"type": "string",
"example": "android",
"description": "The device the created session will be used on",
"name": "device",
"in": "query"
@ -296,16 +298,20 @@ const docTemplate = `{
"201": {
"description": "Created",
"schema": {
"$ref": "#/definitions/dbc.Session"
"$ref": "#/definitions/main.SessionWToken"
}
},
"400": {
"description": "Invalid register body",
"schema": {}
},
"409": {
"description": "Duplicated email or username",
"schema": {}
"schema": {
"$ref": "#/definitions/main.KError"
}
},
"422": {
"description": "Invalid register body",
"schema": {
"$ref": "#/definitions/main.KError"
}
}
}
}
@ -334,11 +340,15 @@ const docTemplate = `{
},
"401": {
"description": "Missing jwt token",
"schema": {}
"schema": {
"$ref": "#/definitions/main.KError"
}
},
"403": {
"description": "Invalid jwt token (or expired)",
"schema": {}
"schema": {
"$ref": "#/definitions/main.KError"
}
}
}
},
@ -405,7 +415,15 @@ const docTemplate = `{
},
"404": {
"description": "No user with the given id found",
"schema": {}
"schema": {
"$ref": "#/definitions/main.KError"
}
},
"422": {
"description": "Invalid id (not a uuid)",
"schema": {
"$ref": "#/definitions/main.KError"
}
}
}
},
@ -446,45 +464,50 @@ const docTemplate = `{
},
"404": {
"description": "Invalid user id",
"schema": {}
"schema": {
"$ref": "#/definitions/main.KError"
}
}
}
}
}
},
"definitions": {
"dbc.Session": {
"main.JwkSet": {
"type": "object",
"properties": {
"createdDate": {
"type": "string"
},
"device": {
"type": "string"
},
"id": {
"type": "string"
},
"lastUsed": {
"type": "string"
},
"pk": {
"type": "integer"
},
"token": {
"type": "string"
},
"userPk": {
"type": "integer"
}
}
},
"main.Info": {
"type": "object",
"properties": {
"publicKey": {
"description": "The public key used to sign jwt tokens. It can be used by your services to check if the jwt is valid.",
"type": "string"
"keys": {
"type": "array",
"items": {
"type": "object",
"properties": {
"e": {
"type": "string",
"example": "AQAB"
},
"key_ops": {
"type": "array",
"items": {
"type": "string"
},
"example": [
"[verify]"
]
},
"kty": {
"type": "string",
"example": "RSA"
},
"n": {
"type": "string",
"example": "oBcXcJUR-Sb8_b4qIj28LRAPxdF_6odRr52K5-ymiEkR2DOlEuXBtM-biWxPESW-U-zhfHzdVLf6ioy5xL0bJTh8BMIorkrDliN3vb81jCvyOMgZ7ATMJpMAQMmSDN7sL3U45r22FaoQufCJMQHmUsZPecdQSgj2aFBiRXxsLleYlSezdBVT_gKH-coqeYXSC_hk-ezSq4aDZ10BlDnZ-FA7-ES3T7nBmJEAU7KDAGeSvbYAfYimOW0r-Vc0xQNuwGCfzZtSexKXDbYbNwOVo3SjfCabq-gMfap_owcHbKicGBZu1LDlh7CpkmLQf_kv6GihM2LWFFh6Vwg2cltiwF22EIPlUDtYTkUR0qRkdNJaNkwV5Vv_6r3pzSmu5ovRriKtlrvJMjlTnLb4_ltsge3fw5Z34cJrsp094FbUc2O6Or4FGEXUldieJCnVRhs2_h6SDcmeMXs1zfvE5GlDnq8tZV6WMJ5Sb4jNO7rs_hTkr23_E6mVg-DdtozGfqzRzhIjPym6D_jVfR6dZv5W0sKwOHRmT7nYq-C7b2sAwmNNII296M4Rq-jn0b5pgSeMDYbIpbIA4thU8LYU0lBZp_ZVwWKG1RFZDxz3k9O5UVth2kTpTWlwn0hB1aAvgXHo6in1CScITGA72p73RbDieNnLFaCK4xUVstkWAKLqPxs"
},
"use": {
"type": "string",
"example": "sig"
}
}
}
}
}
},
@ -493,7 +516,22 @@ const docTemplate = `{
"properties": {
"token": {
"description": "The jwt token you can use for all authorized call to either keibi or other services.",
"type": "string"
"type": "string",
"example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30"
}
}
},
"main.KError": {
"type": "object",
"properties": {
"details": {},
"message": {
"type": "string",
"example": "No user found with this id"
},
"status": {
"type": "integer",
"example": 404
}
}
},
@ -506,11 +544,13 @@ const docTemplate = `{
"properties": {
"login": {
"description": "Either the email or the username.",
"type": "string"
"type": "string",
"example": "zoriya"
},
"password": {
"description": "Password of the account.",
"type": "string"
"type": "string",
"example": "password1234"
}
}
},
@ -519,16 +559,38 @@ const docTemplate = `{
"properties": {
"id": {
"description": "Id of this oidc handle.",
"type": "string"
"type": "string",
"example": "e05089d6-9179-4b5b-a63e-94dd5fc2a397"
},
"profileUrl": {
"description": "Link to the profile of the user on the external service. Null if unknown or irrelevant.",
"type": "string",
"format": "url"
"format": "url",
"example": "https://myanimelist.net/profile/zoriya"
},
"username": {
"description": "Username of the user on the external service.",
"type": "string"
"type": "string",
"example": "zoriya"
}
}
},
"main.Page-main_User": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/definitions/main.User"
}
},
"next": {
"type": "string",
"example": "https://kyoo.zoriya.dev/auth/users?after=aoeusth"
},
"this": {
"type": "string",
"example": "https://kyoo.zoriya.dev/auth/users"
}
}
},
@ -543,15 +605,18 @@ const docTemplate = `{
"email": {
"description": "Valid email that could be used for forgotten password requests. Can be used for login.",
"type": "string",
"format": "email"
"format": "email",
"example": "kyoo@zoriya.dev"
},
"password": {
"description": "Password to use.",
"type": "string"
"type": "string",
"example": "password1234"
},
"username": {
"description": "Username of the new account, can't contain @ signs. Can be used for login.",
"type": "string"
"type": "string",
"example": "zoriya"
}
}
},
@ -560,19 +625,52 @@ const docTemplate = `{
"properties": {
"createdDate": {
"description": "When was the session first opened",
"type": "string"
"type": "string",
"example": "2025-03-29T18:20:05.267Z"
},
"device": {
"description": "Device that created the session.",
"type": "string"
"type": "string",
"example": "Web - Firefox"
},
"id": {
"description": "Unique id of this session. Can be used for calls to DELETE",
"type": "string"
"type": "string",
"example": "e05089d6-9179-4b5b-a63e-94dd5fc2a397"
},
"lastUsed": {
"description": "Last date this session was used to access a service.",
"type": "string"
"type": "string",
"example": "2025-03-29T18:20:05.267Z"
}
}
},
"main.SessionWToken": {
"type": "object",
"properties": {
"createdDate": {
"description": "When was the session first opened",
"type": "string",
"example": "2025-03-29T18:20:05.267Z"
},
"device": {
"description": "Device that created the session.",
"type": "string",
"example": "Web - Firefox"
},
"id": {
"description": "Unique id of this session. Can be used for calls to DELETE",
"type": "string",
"example": "e05089d6-9179-4b5b-a63e-94dd5fc2a397"
},
"lastUsed": {
"description": "Last date this session was used to access a service.",
"type": "string",
"example": "2025-03-29T18:20:05.267Z"
},
"token": {
"type": "string",
"example": "lyHzTYm9yi+pkEv3m2tamAeeK7Dj7N3QRP7xv7dPU5q9MAe8tU4ySwYczE0RaMr4fijsA=="
}
}
},
@ -584,24 +682,31 @@ const docTemplate = `{
"type": "object",
"additionalProperties": {
"type": "string"
},
"example": {
"isAdmin": " true"
}
},
"createdDate": {
"description": "When was this account created?",
"type": "string"
"type": "string",
"example": "2025-03-29T18:20:05.267Z"
},
"email": {
"description": "Email of the user. Can be used as a login.",
"type": "string",
"format": "email"
"format": "email",
"example": "kyoo@zoriya.dev"
},
"id": {
"description": "Id of the user.",
"type": "string"
"type": "string",
"example": "e05089d6-9179-4b5b-a63e-94dd5fc2a397"
},
"lastSeen": {
"description": "When was the last time this account made any authorized request?",
"type": "string"
"type": "string",
"example": "2025-03-29T18:20:05.267Z"
},
"oidc": {
"description": "List of other login method available for this user. Access tokens wont be returned here.",
@ -612,7 +717,8 @@ const docTemplate = `{
},
"username": {
"description": "Username of the user. Can be used as a login.",
"type": "string"
"type": "string",
"example": "zoriya"
}
}
}

View File

@ -16,21 +16,21 @@
"host": "kyoo.zoriya.dev",
"basePath": "/auth",
"paths": {
"/info": {
"/.well-known/jwks.json": {
"get": {
"description": "Get info like the public key used to sign the jwts.",
"description": "Get the jwks info, used to validate jwts.",
"produces": [
"application/json"
],
"tags": [
"jwt"
],
"summary": "Info",
"summary": "Jwks",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/main.Info"
"$ref": "#/definitions/main.JwkSet"
}
}
}
@ -56,15 +56,19 @@
"description": "OK",
"schema": {
"$ref": "#/definitions/main.Jwt"
},
"headers": {
"Authorization": {
"type": "string",
"description": "Jwt (same value as the returned token)"
}
}
},
"401": {
"description": "Missing session token",
"schema": {}
},
"403": {
"description": "Invalid session token (or expired)",
"schema": {}
"schema": {
"$ref": "#/definitions/main.KError"
}
}
}
}
@ -85,6 +89,7 @@
"parameters": [
{
"type": "string",
"example": "android tv",
"description": "The device the created session will be used on",
"name": "device",
"in": "query"
@ -102,24 +107,26 @@
"201": {
"description": "Created",
"schema": {
"$ref": "#/definitions/dbc.Session"
"$ref": "#/definitions/main.SessionWToken"
}
},
"400": {
"description": "Invalid login body",
"schema": {}
},
"403": {
"description": "Invalid password",
"schema": {}
"schema": {
"$ref": "#/definitions/main.KError"
}
},
"404": {
"description": "Account does not exists",
"schema": {}
"schema": {
"$ref": "#/definitions/main.KError"
}
},
"422": {
"description": "User does not have a password (registered via oidc, please login via oidc)",
"schema": {}
"schema": {
"$ref": "#/definitions/main.KError"
}
}
}
}
@ -146,21 +153,17 @@
"$ref": "#/definitions/main.Session"
}
},
"400": {
"description": "Invalid session id",
"schema": {}
},
"401": {
"description": "Missing jwt token",
"schema": {}
"schema": {
"$ref": "#/definitions/main.KError"
}
},
"403": {
"description": "Invalid jwt token (or expired)",
"schema": {}
},
"404": {
"description": "Session not found with specified id (if not using the /current route)",
"schema": {}
"schema": {
"$ref": "#/definitions/main.KError"
}
}
}
}
@ -179,11 +182,12 @@
"tags": [
"sessions"
],
"summary": "Logout",
"summary": "Delete other session",
"parameters": [
{
"type": "string",
"format": "uuid",
"example": "e05089d6-9179-4b5b-a63e-94dd5fc2a397",
"description": "The id of the session to delete",
"name": "id",
"in": "path",
@ -197,21 +201,17 @@
"$ref": "#/definitions/main.Session"
}
},
"400": {
"description": "Invalid session id",
"schema": {}
},
"401": {
"description": "Missing jwt token",
"schema": {}
},
"403": {
"description": "Invalid jwt token (or expired)",
"schema": {}
},
"404": {
"description": "Session not found with specified id (if not using the /current route)",
"schema": {}
"schema": {
"$ref": "#/definitions/main.KError"
}
},
"422": {
"description": "Invalid session id",
"schema": {
"$ref": "#/definitions/main.KError"
}
}
}
}
@ -239,9 +239,8 @@
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "used for pagination.",
"name": "afterId",
"name": "after",
"in": "query"
}
],
@ -249,12 +248,14 @@
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/main.User"
"$ref": "#/definitions/main.Page-main_User"
}
},
"400": {
"422": {
"description": "Invalid after id",
"schema": {}
"schema": {
"$ref": "#/definitions/main.KError"
}
}
}
},
@ -273,6 +274,7 @@
"parameters": [
{
"type": "string",
"example": "android",
"description": "The device the created session will be used on",
"name": "device",
"in": "query"
@ -290,16 +292,20 @@
"201": {
"description": "Created",
"schema": {
"$ref": "#/definitions/dbc.Session"
"$ref": "#/definitions/main.SessionWToken"
}
},
"400": {
"description": "Invalid register body",
"schema": {}
},
"409": {
"description": "Duplicated email or username",
"schema": {}
"schema": {
"$ref": "#/definitions/main.KError"
}
},
"422": {
"description": "Invalid register body",
"schema": {
"$ref": "#/definitions/main.KError"
}
}
}
}
@ -328,11 +334,15 @@
},
"401": {
"description": "Missing jwt token",
"schema": {}
"schema": {
"$ref": "#/definitions/main.KError"
}
},
"403": {
"description": "Invalid jwt token (or expired)",
"schema": {}
"schema": {
"$ref": "#/definitions/main.KError"
}
}
}
},
@ -399,7 +409,15 @@
},
"404": {
"description": "No user with the given id found",
"schema": {}
"schema": {
"$ref": "#/definitions/main.KError"
}
},
"422": {
"description": "Invalid id (not a uuid)",
"schema": {
"$ref": "#/definitions/main.KError"
}
}
}
},
@ -440,45 +458,50 @@
},
"404": {
"description": "Invalid user id",
"schema": {}
"schema": {
"$ref": "#/definitions/main.KError"
}
}
}
}
}
},
"definitions": {
"dbc.Session": {
"main.JwkSet": {
"type": "object",
"properties": {
"createdDate": {
"type": "string"
},
"device": {
"type": "string"
},
"id": {
"type": "string"
},
"lastUsed": {
"type": "string"
},
"pk": {
"type": "integer"
},
"token": {
"type": "string"
},
"userPk": {
"type": "integer"
}
}
},
"main.Info": {
"type": "object",
"properties": {
"publicKey": {
"description": "The public key used to sign jwt tokens. It can be used by your services to check if the jwt is valid.",
"type": "string"
"keys": {
"type": "array",
"items": {
"type": "object",
"properties": {
"e": {
"type": "string",
"example": "AQAB"
},
"key_ops": {
"type": "array",
"items": {
"type": "string"
},
"example": [
"[verify]"
]
},
"kty": {
"type": "string",
"example": "RSA"
},
"n": {
"type": "string",
"example": "oBcXcJUR-Sb8_b4qIj28LRAPxdF_6odRr52K5-ymiEkR2DOlEuXBtM-biWxPESW-U-zhfHzdVLf6ioy5xL0bJTh8BMIorkrDliN3vb81jCvyOMgZ7ATMJpMAQMmSDN7sL3U45r22FaoQufCJMQHmUsZPecdQSgj2aFBiRXxsLleYlSezdBVT_gKH-coqeYXSC_hk-ezSq4aDZ10BlDnZ-FA7-ES3T7nBmJEAU7KDAGeSvbYAfYimOW0r-Vc0xQNuwGCfzZtSexKXDbYbNwOVo3SjfCabq-gMfap_owcHbKicGBZu1LDlh7CpkmLQf_kv6GihM2LWFFh6Vwg2cltiwF22EIPlUDtYTkUR0qRkdNJaNkwV5Vv_6r3pzSmu5ovRriKtlrvJMjlTnLb4_ltsge3fw5Z34cJrsp094FbUc2O6Or4FGEXUldieJCnVRhs2_h6SDcmeMXs1zfvE5GlDnq8tZV6WMJ5Sb4jNO7rs_hTkr23_E6mVg-DdtozGfqzRzhIjPym6D_jVfR6dZv5W0sKwOHRmT7nYq-C7b2sAwmNNII296M4Rq-jn0b5pgSeMDYbIpbIA4thU8LYU0lBZp_ZVwWKG1RFZDxz3k9O5UVth2kTpTWlwn0hB1aAvgXHo6in1CScITGA72p73RbDieNnLFaCK4xUVstkWAKLqPxs"
},
"use": {
"type": "string",
"example": "sig"
}
}
}
}
}
},
@ -487,7 +510,22 @@
"properties": {
"token": {
"description": "The jwt token you can use for all authorized call to either keibi or other services.",
"type": "string"
"type": "string",
"example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30"
}
}
},
"main.KError": {
"type": "object",
"properties": {
"details": {},
"message": {
"type": "string",
"example": "No user found with this id"
},
"status": {
"type": "integer",
"example": 404
}
}
},
@ -500,11 +538,13 @@
"properties": {
"login": {
"description": "Either the email or the username.",
"type": "string"
"type": "string",
"example": "zoriya"
},
"password": {
"description": "Password of the account.",
"type": "string"
"type": "string",
"example": "password1234"
}
}
},
@ -513,16 +553,38 @@
"properties": {
"id": {
"description": "Id of this oidc handle.",
"type": "string"
"type": "string",
"example": "e05089d6-9179-4b5b-a63e-94dd5fc2a397"
},
"profileUrl": {
"description": "Link to the profile of the user on the external service. Null if unknown or irrelevant.",
"type": "string",
"format": "url"
"format": "url",
"example": "https://myanimelist.net/profile/zoriya"
},
"username": {
"description": "Username of the user on the external service.",
"type": "string"
"type": "string",
"example": "zoriya"
}
}
},
"main.Page-main_User": {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"$ref": "#/definitions/main.User"
}
},
"next": {
"type": "string",
"example": "https://kyoo.zoriya.dev/auth/users?after=aoeusth"
},
"this": {
"type": "string",
"example": "https://kyoo.zoriya.dev/auth/users"
}
}
},
@ -537,15 +599,18 @@
"email": {
"description": "Valid email that could be used for forgotten password requests. Can be used for login.",
"type": "string",
"format": "email"
"format": "email",
"example": "kyoo@zoriya.dev"
},
"password": {
"description": "Password to use.",
"type": "string"
"type": "string",
"example": "password1234"
},
"username": {
"description": "Username of the new account, can't contain @ signs. Can be used for login.",
"type": "string"
"type": "string",
"example": "zoriya"
}
}
},
@ -554,19 +619,52 @@
"properties": {
"createdDate": {
"description": "When was the session first opened",
"type": "string"
"type": "string",
"example": "2025-03-29T18:20:05.267Z"
},
"device": {
"description": "Device that created the session.",
"type": "string"
"type": "string",
"example": "Web - Firefox"
},
"id": {
"description": "Unique id of this session. Can be used for calls to DELETE",
"type": "string"
"type": "string",
"example": "e05089d6-9179-4b5b-a63e-94dd5fc2a397"
},
"lastUsed": {
"description": "Last date this session was used to access a service.",
"type": "string"
"type": "string",
"example": "2025-03-29T18:20:05.267Z"
}
}
},
"main.SessionWToken": {
"type": "object",
"properties": {
"createdDate": {
"description": "When was the session first opened",
"type": "string",
"example": "2025-03-29T18:20:05.267Z"
},
"device": {
"description": "Device that created the session.",
"type": "string",
"example": "Web - Firefox"
},
"id": {
"description": "Unique id of this session. Can be used for calls to DELETE",
"type": "string",
"example": "e05089d6-9179-4b5b-a63e-94dd5fc2a397"
},
"lastUsed": {
"description": "Last date this session was used to access a service.",
"type": "string",
"example": "2025-03-29T18:20:05.267Z"
},
"token": {
"type": "string",
"example": "lyHzTYm9yi+pkEv3m2tamAeeK7Dj7N3QRP7xv7dPU5q9MAe8tU4ySwYczE0RaMr4fijsA=="
}
}
},
@ -578,24 +676,31 @@
"type": "object",
"additionalProperties": {
"type": "string"
},
"example": {
"isAdmin": " true"
}
},
"createdDate": {
"description": "When was this account created?",
"type": "string"
"type": "string",
"example": "2025-03-29T18:20:05.267Z"
},
"email": {
"description": "Email of the user. Can be used as a login.",
"type": "string",
"format": "email"
"format": "email",
"example": "kyoo@zoriya.dev"
},
"id": {
"description": "Id of the user.",
"type": "string"
"type": "string",
"example": "e05089d6-9179-4b5b-a63e-94dd5fc2a397"
},
"lastSeen": {
"description": "When was the last time this account made any authorized request?",
"type": "string"
"type": "string",
"example": "2025-03-29T18:20:05.267Z"
},
"oidc": {
"description": "List of other login method available for this user. Access tokens wont be returned here.",
@ -606,7 +711,8 @@
},
"username": {
"description": "Username of the user. Can be used as a login.",
"type": "string"
"type": "string",
"example": "zoriya"
}
}
}

View File

@ -1,426 +0,0 @@
basePath: /auth
definitions:
dbc.Session:
properties:
createdDate:
type: string
device:
type: string
id:
type: string
lastUsed:
type: string
pk:
type: integer
token:
type: string
userPk:
type: integer
type: object
main.Info:
properties:
publicKey:
description: The public key used to sign jwt tokens. It can be used by your
services to check if the jwt is valid.
type: string
type: object
main.Jwt:
properties:
token:
description: The jwt token you can use for all authorized call to either keibi
or other services.
type: string
type: object
main.LoginDto:
properties:
login:
description: Either the email or the username.
type: string
password:
description: Password of the account.
type: string
required:
- login
- password
type: object
main.OidcHandle:
properties:
id:
description: Id of this oidc handle.
type: string
profileUrl:
description: Link to the profile of the user on the external service. Null
if unknown or irrelevant.
format: url
type: string
username:
description: Username of the user on the external service.
type: string
type: object
main.RegisterDto:
properties:
email:
description: Valid email that could be used for forgotten password requests.
Can be used for login.
format: email
type: string
password:
description: Password to use.
type: string
username:
description: Username of the new account, can't contain @ signs. Can be used
for login.
type: string
required:
- email
- password
- username
type: object
main.Session:
properties:
createdDate:
description: When was the session first opened
type: string
device:
description: Device that created the session.
type: string
id:
description: Unique id of this session. Can be used for calls to DELETE
type: string
lastUsed:
description: Last date this session was used to access a service.
type: string
type: object
main.User:
properties:
claims:
additionalProperties:
type: string
description: List of custom claims JWT created via get /jwt will have
type: object
createdDate:
description: When was this account created?
type: string
email:
description: Email of the user. Can be used as a login.
format: email
type: string
id:
description: Id of the user.
type: string
lastSeen:
description: When was the last time this account made any authorized request?
type: string
oidc:
additionalProperties:
$ref: '#/definitions/main.OidcHandle'
description: List of other login method available for this user. Access tokens
wont be returned here.
type: object
username:
description: Username of the user. Can be used as a login.
type: string
type: object
host: kyoo.zoriya.dev
info:
contact:
name: Repository
url: https://github.com/zoriya/kyoo
description: Auth system made for kyoo.
license:
name: GPL-3.0
url: https://www.gnu.org/licenses/gpl-3.0.en.html
title: Keibi - Kyoo's auth
version: "1.0"
paths:
/info:
get:
description: Get info like the public key used to sign the jwts.
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/main.Info'
summary: Info
tags:
- jwt
/jwt:
get:
description: Convert a session token to a short lived JWT.
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/main.Jwt'
"401":
description: Missing session token
schema: {}
"403":
description: Invalid session token (or expired)
schema: {}
security:
- Token: []
summary: Get JWT
tags:
- jwt
/sessions:
post:
consumes:
- application/json
description: Login to your account and open a session
parameters:
- description: The device the created session will be used on
in: query
name: device
type: string
- description: Account informations
in: body
name: login
schema:
$ref: '#/definitions/main.LoginDto'
produces:
- application/json
responses:
"201":
description: Created
schema:
$ref: '#/definitions/dbc.Session'
"400":
description: Invalid login body
schema: {}
"403":
description: Invalid password
schema: {}
"404":
description: Account does not exists
schema: {}
"422":
description: User does not have a password (registered via oidc, please
login via oidc)
schema: {}
summary: Login
tags:
- sessions
/sessions/{id}:
delete:
description: Delete a session and logout
parameters:
- description: The id of the session to delete
format: uuid
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/main.Session'
"400":
description: Invalid session id
schema: {}
"401":
description: Missing jwt token
schema: {}
"403":
description: Invalid jwt token (or expired)
schema: {}
"404":
description: Session not found with specified id (if not using the /current
route)
schema: {}
security:
- Jwt: []
summary: Logout
tags:
- sessions
/sessions/current:
delete:
description: Delete a session and logout
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/main.Session'
"400":
description: Invalid session id
schema: {}
"401":
description: Missing jwt token
schema: {}
"403":
description: Invalid jwt token (or expired)
schema: {}
"404":
description: Session not found with specified id (if not using the /current
route)
schema: {}
security:
- Jwt: []
summary: Logout
tags:
- sessions
/users:
get:
consumes:
- application/json
description: List all users existing in this instance.
parameters:
- description: used for pagination.
format: uuid
in: query
name: afterId
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/main.User'
"400":
description: Invalid after id
schema: {}
security:
- Jwt:
- users.read
summary: List all users
tags:
- users
post:
consumes:
- application/json
description: Register as a new user and open a session for it
parameters:
- description: The device the created session will be used on
in: query
name: device
type: string
- description: Registration informations
in: body
name: user
schema:
$ref: '#/definitions/main.RegisterDto'
produces:
- application/json
responses:
"201":
description: Created
schema:
$ref: '#/definitions/dbc.Session'
"400":
description: Invalid register body
schema: {}
"409":
description: Duplicated email or username
schema: {}
summary: Register
tags:
- users
/users/{id}:
delete:
consumes:
- application/json
description: Delete an account and all it's sessions.
parameters:
- description: User id of the user to delete
format: uuid
in: path
name: id
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/main.User'
"404":
description: Invalid user id
schema: {}
security:
- Jwt:
- users.delete
summary: Delete user
tags:
- users
get:
description: Get informations about a user from it's id
parameters:
- description: The id of the user
format: uuid
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/main.User'
"404":
description: No user with the given id found
schema: {}
security:
- Jwt:
- users.read
summary: Get user
tags:
- users
/users/me:
delete:
consumes:
- application/json
description: Delete your account and all your sessions
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/main.User'
security:
- Jwt: []
summary: Delete self
tags:
- users
get:
description: Get informations about the currently connected user
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/main.User'
"401":
description: Missing jwt token
schema: {}
"403":
description: Invalid jwt token (or expired)
schema: {}
security:
- Jwt: []
summary: Get me
tags:
- users
securityDefinitions:
Jwt:
in: header
name: Authorization
type: apiKey
Token:
in: header
name: Authorization
type: apiKey
swagger: "2.0"

View File

@ -8,24 +8,22 @@ require (
github.com/alexedwards/argon2id v1.0.0
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.7.3
github.com/labstack/echo-jwt/v4 v4.2.0
github.com/jackc/pgx/v5 v5.7.4
github.com/labstack/echo-jwt/v4 v4.3.1
github.com/labstack/echo/v4 v4.13.3
github.com/lestrrat-go/jwx v1.2.30
github.com/otaxhu/problem v1.4.0
github.com/swaggo/echo-swagger v1.4.1
github.com/swaggo/swag v1.16.4
)
require (
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect
github.com/goccy/go-json v0.10.3 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/lestrrat-go/backoff/v2 v2.0.8 // indirect
github.com/lestrrat-go/blackmagic v1.0.2 // indirect
github.com/lestrrat-go/httpcc v1.0.1 // indirect
github.com/lestrrat-go/iter v1.0.2 // indirect
github.com/lestrrat-go/option v1.0.1 // indirect
github.com/otaxhu/type-mismatch-encoding v0.0.0-20241118152201-1861af90dd01 // indirect
github.com/pkg/errors v0.9.1 // indirect
)
@ -39,7 +37,7 @@ require (
github.com/go-openapi/swag v0.23.1 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.25.0
github.com/go-playground/validator/v10 v10.26.0
github.com/golang-migrate/migrate/v4 v4.18.2
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
@ -58,7 +56,7 @@ require (
github.com/valyala/fasttemplate v1.2.2 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/crypto v0.36.0 // indirect
golang.org/x/net v0.37.0 // indirect
golang.org/x/net v0.38.0 // indirect
golang.org/x/sync v0.12.0 // indirect
golang.org/x/sys v0.31.0 // indirect
golang.org/x/text v0.23.0 // indirect

View File

@ -11,6 +11,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 h1:rpfIENRNNilwHwZeG5+P150SMrnNEcHYvcCuK6dPZSg=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/dhui/dktest v0.4.4 h1:+I4s6JRE1yGuqflzwqG+aIaMdgXIorCf5P98JnaAWa8=
github.com/dhui/dktest v0.4.4/go.mod h1:4+22R4lgsdAXrDyaH4Nqx2JEz2hLp49MqQmm9HLCQhM=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
@ -47,8 +49,12 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.25.0 h1:5Dh7cjvzR7BRZadnsVOzPhWsrwUr0nmsZJxEAnFLNO8=
github.com/go-playground/validator/v10 v10.25.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus=
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
@ -70,6 +76,8 @@ github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7Ulw
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.3 h1:PO1wNKj/bTAwxSJnO1Z4Ai8j4magtqg2SLNjEDzcXQo=
github.com/jackc/pgx/v5 v5.7.3/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg=
github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
@ -80,6 +88,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/labstack/echo-jwt/v4 v4.2.0 h1:odSISV9JgcSCuhgQSV/6Io3i7nUmfM/QkBeR5GVJj5c=
github.com/labstack/echo-jwt/v4 v4.2.0/go.mod h1:MA2RqdXdEn4/uEglx0HcUOgQSyBaTh5JcaHIan3biwU=
github.com/labstack/echo-jwt/v4 v4.3.1 h1:d8+/qf8nx7RxeL46LtoIwHJsH2PNN8xXCQ/jDianycE=
github.com/labstack/echo-jwt/v4 v4.3.1/go.mod h1:yJi83kN8S/5vePVPd+7ID75P4PqPNVRs2HVeuvYJH00=
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
@ -171,6 +181,8 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=

View File

@ -15,7 +15,7 @@ import (
type Jwt struct {
// The jwt token you can use for all authorized call to either keibi or other services.
Token string `json:"token"`
Token *string `json:"token" example:"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30"`
}
// @Summary Get JWT
@ -24,22 +24,64 @@ type Jwt struct {
// @Produce json
// @Security Token
// @Success 200 {object} Jwt
// @Failure 401 {object} problem.Problem "Missing session token"
// @Failure 403 {object} problem.Problem "Invalid session token (or expired)"
// @Failure 403 {object} KError "Invalid session token (or expired)"
// @Header 200 {string} Authorization "Jwt (same value as the returned token)"
// @Router /jwt [get]
func (h *Handler) CreateJwt(c echo.Context) error {
auth := c.Request().Header.Get("Authorization")
if !strings.HasPrefix(auth, "Bearer ") {
return echo.NewHTTPError(http.StatusUnauthorized, "Missing session token")
}
token := auth[len("Bearer "):]
var jwt *string
if !strings.HasPrefix(auth, "Bearer ") {
jwt = h.createGuestJwt()
} else {
token := auth[len("Bearer "):]
tkn, err := h.createJwt(token)
if err != nil {
return err
}
jwt = &tkn
}
if jwt != nil {
c.Response().Header().Add("Authorization", fmt.Sprintf("Bearer %s", *jwt))
}
return c.JSON(http.StatusOK, Jwt{
Token: jwt,
})
}
func (h *Handler) createGuestJwt() *string {
if h.config.GuestClaims == nil {
return nil
}
claims := maps.Clone(h.config.GuestClaims)
claims["username"] = "guest"
claims["sub"] = "guest"
claims["sid"] = "guest"
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 nil
}
return &t
}
func (h *Handler) createJwt(token string) (string, error) {
session, err := h.db.GetUserFromToken(context.Background(), token)
if err != nil {
return echo.NewHTTPError(http.StatusForbidden, "Invalid token")
return "", echo.NewHTTPError(http.StatusForbidden, "Invalid token")
}
if session.LastUsed.Add(h.config.ExpirationDelay).Compare(time.Now().UTC()) < 0 {
return echo.NewHTTPError(http.StatusForbidden, "Token has expired")
return "", echo.NewHTTPError(http.StatusForbidden, "Token has expired")
}
go func() {
@ -61,19 +103,27 @@ func (h *Handler) CreateJwt(c echo.Context) error {
jwt := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
t, err := jwt.SignedString(h.config.JwtPrivateKey)
if err != nil {
return err
return "", err
}
return t, nil
}
// only used for the swagger doc
type JwkSet struct {
Keys []struct {
E string `json:"e" example:"AQAB"`
KeyOps []string `json:"key_ops" example:"[verify]"`
Kty string `json:"kty" example:"RSA"`
N string `json:"n" example:"oBcXcJUR-Sb8_b4qIj28LRAPxdF_6odRr52K5-ymiEkR2DOlEuXBtM-biWxPESW-U-zhfHzdVLf6ioy5xL0bJTh8BMIorkrDliN3vb81jCvyOMgZ7ATMJpMAQMmSDN7sL3U45r22FaoQufCJMQHmUsZPecdQSgj2aFBiRXxsLleYlSezdBVT_gKH-coqeYXSC_hk-ezSq4aDZ10BlDnZ-FA7-ES3T7nBmJEAU7KDAGeSvbYAfYimOW0r-Vc0xQNuwGCfzZtSexKXDbYbNwOVo3SjfCabq-gMfap_owcHbKicGBZu1LDlh7CpkmLQf_kv6GihM2LWFFh6Vwg2cltiwF22EIPlUDtYTkUR0qRkdNJaNkwV5Vv_6r3pzSmu5ovRriKtlrvJMjlTnLb4_ltsge3fw5Z34cJrsp094FbUc2O6Or4FGEXUldieJCnVRhs2_h6SDcmeMXs1zfvE5GlDnq8tZV6WMJ5Sb4jNO7rs_hTkr23_E6mVg-DdtozGfqzRzhIjPym6D_jVfR6dZv5W0sKwOHRmT7nYq-C7b2sAwmNNII296M4Rq-jn0b5pgSeMDYbIpbIA4thU8LYU0lBZp_ZVwWKG1RFZDxz3k9O5UVth2kTpTWlwn0hB1aAvgXHo6in1CScITGA72p73RbDieNnLFaCK4xUVstkWAKLqPxs"`
Use string `json:"use" example:"sig"`
}
c.Response().Header().Add("Authorization", fmt.Sprintf("Bearer %s", t))
return c.JSON(http.StatusOK, Jwt{
Token: t,
})
}
// @Summary Jwks
// @Description Get the jwks info, used to validate jwts.
// @Tags jwt
// @Produce json
// @Success 200 {object} jwk.Key
// @Success 200 {object} JwkSet "OK"
// @Router /.well-known/jwks.json [get]
func (h *Handler) GetJwks(c echo.Context) error {
key, err := jwk.New(h.config.JwtPublicKey)

7
auth/kerror.go Normal file
View File

@ -0,0 +1,7 @@
package main
type KError struct {
Status int `json:"status" example:"404"`
Message string `json:"message" example:"No user found with this id"`
Details any `json:"details"`
}

View File

@ -2,13 +2,14 @@ package main
import (
"context"
"encoding/base64"
"errors"
"fmt"
"net/http"
"os"
"strconv"
"strings"
"github.com/otaxhu/problem"
"github.com/zoriya/kyoo/keibi/dbc"
_ "github.com/zoriya/kyoo/keibi/docs"
@ -31,12 +32,19 @@ func ErrorHandler(err error, c echo.Context) {
if he, ok := err.(*echo.HTTPError); ok {
code = he.Code
message = fmt.Sprint(he.Message)
if message == "missing or malformed jwt" {
code = http.StatusUnauthorized
}
} else {
c.Logger().Error(err)
}
ret := problem.NewMap(code, message)
c.JSON(code, ret)
c.JSON(code, KError{
Status: code,
Message: message,
Details: nil,
})
}
type Validator struct {
@ -121,6 +129,35 @@ type Handler struct {
config *Configuration
}
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)
if err != nil {
return err
}
jwt = &tkn
}
if jwt != nil {
c.Request().Header.Set("Authorization", *jwt)
}
return next(c)
}
}
// @title Keibi - Kyoo's auth
// @version 1.0
// @description Auth system made for kyoo.
@ -165,6 +202,7 @@ func main() {
g := e.Group(conf.Prefix)
r := e.Group(conf.Prefix)
r.Use(h.TokenToJwt)
r.Use(echojwt.WithConfig(echojwt.Config{
SigningMethod: "RS256",
SigningKey: h.config.JwtPublicKey,

28
auth/page.go Normal file
View File

@ -0,0 +1,28 @@
package main
import "net/url"
type Page[T any] struct {
Items []T `json:"items"`
This string `json:"this" example:"https://kyoo.zoriya.dev/auth/users"`
Next *string `json:"next" example:"https://kyoo.zoriya.dev/auth/users?after=aoeusth"`
}
func NewPage(items []User, url *url.URL, limit int32) Page[User] {
this := url.String()
var next *string
if len(items) == int(limit) && limit > 0 {
query := url.Query()
query.Set("after", items[len(items)-1].Id.String())
url.RawQuery = query.Encode()
nextU := url.String()
next = &nextU
}
return Page[User]{
Items: items,
This: this,
Next: next,
}
}

View File

@ -1,43 +0,0 @@
*** Settings ***
Documentation Common things to handle rest requests
Library REST http://localhost:4568
*** Keywords ***
Login
[Documentation] Shortcut to login with the given username for future requests
[Arguments] ${username}
&{res}= POST /sessions {"login": "${username}", "password": "password-${username}"}
Output
Integer response status 201
String response body token
ConvertToJwt ${res.body.token}
Register
[Documentation] Shortcut to register with the given username for future requests
[Arguments] ${username}
&{res}= POST
... /users
... {"username": "${username}", "password": "password-${username}", "email": "${username}@zoriya.dev"}
Output
Integer response status 201
String response body token
ConvertToJwt ${res.body.token}
ConvertToJwt
[Documentation] Convert a session token to a jwt and set it in the header
[Arguments] ${token}
Set Headers {"Authorization": "Bearer ${token}"}
&{res}= GET /jwt
Output
Integer response status 200
String response body token
Set Headers {"Authorization": "Bearer ${res.body.token}"}
Logout
[Documentation] Logout the current user, only the local client is affected.
${res}= DELETE /sessions/current
Output
Integer response status 200
Set Headers {"Authorization": ""}

View File

@ -1,36 +0,0 @@
*** Settings ***
Documentation Tests of the /sessions route.
Resource ./auth.resource
*** Test Cases ***
Bad Account
[Documentation] Login fails if user does not exist
POST /sessions {"login": "i-don-t-exist", "password": "pass"}
Output
Integer response status 404
Invalid password
[Documentation] Login fails if password is invalid
Register invalid-password-user
POST /sessions {"login": "invalid-password-user", "password": "pass"}
Output
Integer response status 403
[Teardown] DELETE /users/me
Login
[Documentation] Create a new user and login in it
Register login-user
${res}= GET /users/me
Output
Integer response status 200
String response body username login-user
Logout
Login login-user
${me}= Get /users/me
Output
Output ${me}
Should Be Equal As Strings ${res["body"]} ${me["body"]}
[Teardown] DELETE /users/me

View File

@ -1,33 +0,0 @@
*** Settings ***
Documentation Tests of the /users route.
... Ensures that the user can authenticate on kyoo.
Resource ./auth.resource
*** Test Cases ***
Me cant be accessed without an account
Get /users/me
Output
Integer response status 401
Register
[Documentation] Create a new user and login in it
Register user-1
[Teardown] DELETE /users/me
Register Duplicates
[Documentation] If two users tries to register with the same username, it fails
Register user-duplicate
# We can't use the `Register` keyword because it assert for success
POST /users {"username": "user-duplicate", "password": "pass", "email": "mail@zoriya.dev"}
Output
Integer response status 409
[Teardown] DELETE /users/me
Delete Account
[Documentation] Check if a user can delete it's account
Register I-should-be-deleted
DELETE /users/me
Output
Integer response status 200

View File

@ -18,13 +18,18 @@ import (
type Session struct {
// Unique id of this session. Can be used for calls to DELETE
Id uuid.UUID `json:"id"`
Id uuid.UUID `json:"id" example:"e05089d6-9179-4b5b-a63e-94dd5fc2a397"`
// When was the session first opened
CreatedDate time.Time `json:"createdDate"`
CreatedDate time.Time `json:"createdDate" example:"2025-03-29T18:20:05.267Z"`
// Last date this session was used to access a service.
LastUsed time.Time `json:"lastUsed"`
LastUsed time.Time `json:"lastUsed" example:"2025-03-29T18:20:05.267Z"`
// Device that created the session.
Device *string `json:"device"`
Device *string `json:"device" example:"Web - Firefox"`
}
type SessionWToken struct {
Session
Token string `json:"token" example:"lyHzTYm9yi+pkEv3m2tamAeeK7Dj7N3QRP7xv7dPU5q9MAe8tU4ySwYczE0RaMr4fijsA=="`
}
func MapSession(ses *dbc.Session) Session {
@ -36,11 +41,23 @@ func MapSession(ses *dbc.Session) Session {
}
}
func MapSessionToken(ses *dbc.Session) SessionWToken {
return SessionWToken{
Session: Session{
Id: ses.Id,
CreatedDate: ses.CreatedDate,
LastUsed: ses.LastUsed,
Device: ses.Device,
},
Token: ses.Token,
}
}
type LoginDto struct {
// Either the email or the username.
Login string `json:"login" validate:"required"`
Login string `json:"login" validate:"required" example:"zoriya"`
// Password of the account.
Password string `json:"password" validate:"required"`
Password string `json:"password" validate:"required" example:"password1234"`
}
// @Summary Login
@ -48,19 +65,18 @@ type LoginDto struct {
// @Tags sessions
// @Accept json
// @Produce json
// @Param device query string false "The device the created session will be used on"
// @Param device query string false "The device the created session will be used on" example(android tv)
// @Param login body LoginDto false "Account informations"
// @Success 201 {object} dbc.Session
// @Failure 400 {object} problem.Problem "Invalid login body"
// @Failure 403 {object} problem.Problem "Invalid password"
// @Failure 404 {object} problem.Problem "Account does not exists"
// @Failure 422 {object} problem.Problem "User does not have a password (registered via oidc, please login via oidc)"
// @Success 201 {object} SessionWToken
// @Failure 403 {object} KError "Invalid password"
// @Failure 404 {object} KError "Account does not exists"
// @Failure 422 {object} KError "User does not have a password (registered via oidc, please login via oidc)"
// @Router /sessions [post]
func (h *Handler) Login(c echo.Context) error {
var req LoginDto
err := c.Bind(&req)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
return echo.NewHTTPError(http.StatusUnprocessableEntity, err.Error())
}
if err = c.Validate(&req); err != nil {
return err
@ -102,7 +118,7 @@ func (h *Handler) createSession(c echo.Context, user *User) error {
}
session, err := h.db.CreateSession(ctx, dbc.CreateSessionParams{
Token: base64.StdEncoding.EncodeToString(id),
Token: base64.RawURLEncoding.EncodeToString(id),
UserPk: user.Pk,
Device: device,
})
@ -117,13 +133,9 @@ func (h *Handler) createSession(c echo.Context, user *User) error {
// @Tags sessions
// @Produce json
// @Security Jwt
// @Param id path string true "The id of the session to delete" Format(uuid)
// @Success 200 {object} Session
// @Failure 400 {object} problem.Problem "Invalid session id"
// @Failure 401 {object} problem.Problem "Missing jwt token"
// @Failure 403 {object} problem.Problem "Invalid jwt token (or expired)"
// @Failure 404 {object} problem.Problem "Session not found with specified id (if not using the /current route)"
// @Router /sessions/{id} [delete]
// @Failure 401 {object} KError "Missing jwt token"
// @Failure 403 {object} KError "Invalid jwt token (or expired)"
// @Router /sessions/current [delete]
func (h *Handler) Logout(c echo.Context) error {
uid, err := GetCurrentUserId(c)
@ -135,13 +147,13 @@ func (h *Handler) Logout(c echo.Context) error {
if session == "current" {
sid, ok := c.Get("user").(*jwt.Token).Claims.(jwt.MapClaims)["sid"]
if !ok {
return echo.NewHTTPError(400, "Missing session id")
return echo.NewHTTPError(http.StatusInternalServerError, "Missing session id")
}
session = sid.(string)
}
sid, err := uuid.Parse(session)
if err != nil {
return echo.NewHTTPError(400, "Invalid session id")
return echo.NewHTTPError(422, "Invalid session id")
}
ret, err := h.db.DeleteSession(context.Background(), dbc.DeleteSessionParams{
@ -155,3 +167,15 @@ func (h *Handler) Logout(c echo.Context) error {
}
return c.JSON(200, MapSession(&ret))
}
// @Summary Delete other session
// @Description Delete a session and logout
// @Tags sessions
// @Produce json
// @Security Jwt
// @Param id path string true "The id of the session to delete" Format(uuid) Example(e05089d6-9179-4b5b-a63e-94dd5fc2a397)
// @Success 200 {object} Session
// @Failure 404 {object} KError "Session not found with specified id (if not using the /current route)"
// @Failure 422 {object} KError "Invalid session id"
// @Router /sessions/{id} [delete]
func DocOnly() {}

View File

@ -1,5 +0,0 @@
begin;
drop table config;
commit;

View File

@ -1,8 +0,0 @@
begin;
create table config(
key varchar(256) not null primary key,
value text not null
);
commit;

View File

@ -1,21 +0,0 @@
-- name: LoadConfig :many
select
*
from
config;
-- name: SaveConfig :one
insert into config(key, value)
values ($1, $2)
on conflict (key)
do update set
value = excluded.value
returning
*;
-- name: DeleteConfig :one
delete from config
where key = $1
returning
*;

View File

@ -51,7 +51,15 @@ where
-- name: CreateUser :one
insert into users(username, email, password, claims)
values ($1, $2, $3, $4)
values ($1, $2, $3, case when not exists (
select
*
from
users) then
sqlc.arg(first_claims)::jsonb
else
sqlc.arg(claims)::jsonb
end)
returning
*;

View File

@ -27,6 +27,9 @@ sql:
go_type:
import: "github.com/google/uuid"
type: "UUID"
- db_type: "jsonb"
go_type:
type: "interface{}"
- column: "users.claims"
go_type:
import: "github.com/golang-jwt/jwt/v5"

19
auth/tests/basic.hurl Normal file
View File

@ -0,0 +1,19 @@
# Bad Account (login fails if user does not exist)
POST {{host}}/sessions
{
"login": "i-don-t-exist",
"password": "pass"
}
HTTP 404
# Invalid username
POST {{host}}/sessions
{
"login": "invalid-username-user",
"password": "pass"
}
HTTP 404
# Me cant be accessed without an account
GET {{host}}/users/me
HTTP 401

View File

@ -0,0 +1,64 @@
# Register a user for invalid password test
POST {{host}}/users
{
"username": "login-user",
"password": "password-login-user",
"email": "invalid-password-user@zoriya.dev"
}
HTTP 201
[Captures]
token: jsonpath "$.token"
GET {{host}}/jwt
Authorization: Bearer {{token}}
HTTP 200
[Captures]
jwt: jsonpath "$.token"
GET {{host}}/users/me
Authorization: Bearer {{jwt}}
HTTP 200
[Captures]
register_info: body
[Asserts]
jsonpath "$.username" == "login-user"
DELETE {{host}}/sessions/current
Authorization: Bearer {{jwt}}
HTTP 200
# Ensure we can login again & /users/me is the same
POST {{host}}/sessions
{
"login": "login-user",
"password": "password-login-user"
}
HTTP 201
[Captures]
token: jsonpath "$.token"
GET {{host}}/jwt
Authorization: Bearer {{token}}
HTTP 200
[Captures]
jwt: jsonpath "$.token"
GET {{host}}/users/me
Authorization: Bearer {{jwt}}
HTTP 200
[Asserts]
jsonpath "$.username" == "login-user"
body == {{register_info}}
# Invalid password login
POST {{host}}/sessions
{
"login": "login-user",
"password": "pass-invalid"
}
HTTP 403
DELETE {{host}}/users/me
Authorization: Bearer {{jwt}}
HTTP 200

41
auth/tests/users.hurl Normal file
View File

@ -0,0 +1,41 @@
# Setup
POST {{host}}/users
{
"username": "user-1",
"password": "password-user-1",
"email": "user-1@zoriya.dev"
}
HTTP 201
[Captures]
token: jsonpath "$.token"
GET {{host}}/jwt
Authorization: Bearer {{token}}
HTTP 200
[Captures]
jwt: jsonpath "$.token"
# Duplicates usernames
POST {{host}}/users
{
"username": "user-1",
"password": "password-user-duplicate",
"email": "user-duplicate@zoriya.dev"
}
HTTP 409
# Duplicates email
POST {{host}}/users
{
"username": "user-duplicate",
"password": "pass",
"email": "user-1@zoriya.dev"
}
HTTP 409
DELETE {{host}}/users/me
Authorization: Bearer {{jwt}}
HTTP 200

View File

@ -18,37 +18,37 @@ type User struct {
// Primary key in database
Pk int32 `json:"-"`
// Id of the user.
Id uuid.UUID `json:"id"`
Id uuid.UUID `json:"id" example:"e05089d6-9179-4b5b-a63e-94dd5fc2a397"`
// Username of the user. Can be used as a login.
Username string `json:"username"`
Username string `json:"username" example:"zoriya"`
// Email of the user. Can be used as a login.
Email string `json:"email" format:"email"`
Email string `json:"email" format:"email" example:"kyoo@zoriya.dev"`
// When was this account created?
CreatedDate time.Time `json:"createdDate"`
CreatedDate time.Time `json:"createdDate" example:"2025-03-29T18:20:05.267Z"`
// When was the last time this account made any authorized request?
LastSeen time.Time `json:"lastSeen"`
LastSeen time.Time `json:"lastSeen" example:"2025-03-29T18:20:05.267Z"`
// List of custom claims JWT created via get /jwt will have
Claims jwt.MapClaims `json:"claims"`
Claims jwt.MapClaims `json:"claims" example:"isAdmin: true"`
// List of other login method available for this user. Access tokens wont be returned here.
Oidc map[string]OidcHandle `json:"oidc,omitempty"`
}
type OidcHandle struct {
// Id of this oidc handle.
Id string `json:"id"`
Id string `json:"id" example:"e05089d6-9179-4b5b-a63e-94dd5fc2a397"`
// Username of the user on the external service.
Username string `json:"username"`
Username string `json:"username" example:"zoriya"`
// Link to the profile of the user on the external service. Null if unknown or irrelevant.
ProfileUrl *string `json:"profileUrl" format:"url"`
ProfileUrl *string `json:"profileUrl" format:"url" example:"https://myanimelist.net/profile/zoriya"`
}
type RegisterDto struct {
// Username of the new account, can't contain @ signs. Can be used for login.
Username string `json:"username" validate:"required,excludes=@"`
Username string `json:"username" validate:"required,excludes=@" example:"zoriya"`
// Valid email that could be used for forgotten password requests. Can be used for login.
Email string `json:"email" validate:"required,email" format:"email"`
Email string `json:"email" validate:"required,email" format:"email" example:"kyoo@zoriya.dev"`
// Password to use.
Password string `json:"password" validate:"required"`
Password string `json:"password" validate:"required" example:"password1234"`
}
func MapDbUser(user *dbc.User) User {
@ -78,9 +78,9 @@ func MapOidc(oidc *dbc.GetUserRow) OidcHandle {
// @Accept json
// @Produce json
// @Security Jwt[users.read]
// @Param afterId query string false "used for pagination." Format(uuid)
// @Success 200 {object} User[]
// @Failure 400 {object} problem.Problem "Invalid after id"
// @Param after query string false "used for pagination."
// @Success 200 {object} Page[User]
// @Failure 422 {object} KError "Invalid after id"
// @Router /users [get]
func (h *Handler) ListUsers(c echo.Context) error {
err := CheckPermissions(c, []string{"user.read"})
@ -90,7 +90,7 @@ func (h *Handler) ListUsers(c echo.Context) error {
ctx := context.Background()
limit := int32(20)
id := c.Param("afterId")
id := c.Param("after")
var users []dbc.User
if id == "" {
@ -98,7 +98,7 @@ func (h *Handler) ListUsers(c echo.Context) error {
} else {
uid, uerr := uuid.Parse(id)
if uerr != nil {
return echo.NewHTTPError(400, "Invalid `afterId` parameter, uuid was expected")
return echo.NewHTTPError(http.StatusUnprocessableEntity, "Invalid `after` parameter, uuid was expected")
}
users, err = h.db.GetAllUsersAfter(ctx, dbc.GetAllUsersAfterParams{
Limit: limit,
@ -114,8 +114,7 @@ func (h *Handler) ListUsers(c echo.Context) error {
for _, user := range users {
ret = append(ret, MapDbUser(&user))
}
// TODO: switch to a Page
return c.JSON(200, ret)
return c.JSON(200, NewPage(ret, c.Request().URL, limit))
}
// @Summary Get user
@ -125,7 +124,8 @@ func (h *Handler) ListUsers(c echo.Context) error {
// @Security Jwt[users.read]
// @Param id path string true "The id of the user" Format(uuid)
// @Success 200 {object} User
// @Failure 404 {object} problem.Problem "No user with the given id found"
// @Failure 404 {object} KError "No user with the given id found"
// @Failure 422 {object} KError "Invalid id (not a uuid)"
// @Router /users/{id} [get]
func (h *Handler) GetUser(c echo.Context) error {
err := CheckPermissions(c, []string{"user.read"})
@ -135,7 +135,7 @@ func (h *Handler) GetUser(c echo.Context) error {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
return echo.NewHTTPError(400, "Invalid id")
return echo.NewHTTPError(http.StatusUnprocessableEntity, "Invalid id")
}
dbuser, err := h.db.GetUser(context.Background(), id)
if err != nil {
@ -158,8 +158,8 @@ func (h *Handler) GetUser(c echo.Context) error {
// @Produce json
// @Security Jwt
// @Success 200 {object} User
// @Failure 401 {object} problem.Problem "Missing jwt token"
// @Failure 403 {object} problem.Problem "Invalid jwt token (or expired)"
// @Failure 401 {object} KError "Missing jwt token"
// @Failure 403 {object} KError "Invalid jwt token (or expired)"
// @Router /users/me [get]
func (h *Handler) GetMe(c echo.Context) error {
id, err := GetCurrentUserId(c)
@ -186,17 +186,17 @@ func (h *Handler) GetMe(c echo.Context) error {
// @Tags users
// @Accept json
// @Produce json
// @Param device query string false "The device the created session will be used on"
// @Param device query string false "The device the created session will be used on" Example(android)
// @Param user body RegisterDto false "Registration informations"
// @Success 201 {object} dbc.Session
// @Failure 400 {object} problem.Problem "Invalid register body"
// @Success 409 {object} problem.Problem "Duplicated email or username"
// @Success 201 {object} SessionWToken
// @Success 409 {object} KError "Duplicated email or username"
// @Failure 422 {object} KError "Invalid register body"
// @Router /users [post]
func (h *Handler) Register(c echo.Context) error {
var req RegisterDto
err := c.Bind(&req)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
return echo.NewHTTPError(http.StatusUnprocessableEntity, err.Error())
}
if err = c.Validate(&req); err != nil {
return err
@ -208,10 +208,11 @@ func (h *Handler) Register(c echo.Context) error {
}
duser, err := h.db.CreateUser(context.Background(), dbc.CreateUserParams{
Username: req.Username,
Email: req.Email,
Password: &pass,
Claims: h.config.DefaultClaims,
Username: req.Username,
Email: req.Email,
Password: &pass,
Claims: h.config.DefaultClaims,
FirstClaims: h.config.FirstUserClaims,
})
if ErrIs(err, pgerrcode.UniqueViolation) {
return echo.NewHTTPError(409, "Email or username already taken")
@ -230,8 +231,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} problem.Problem "Invalid id format"
// @Failure 404 {object} problem.Problem "Invalid user id"
// @Failure 404 {object} KError "Invalid id format"
// @Failure 404 {object} KError "Invalid user id"
// @Router /users/{id} [delete]
func (h *Handler) DeleteUser(c echo.Context) error {
uid, err := uuid.Parse(c.Param("id"))

View File

@ -19,7 +19,7 @@ func GetCurrentUserId(c echo.Context) (uuid.UUID, error) {
}
sub, err := user.Claims.GetSubject()
if err != nil {
return uuid.UUID{}, echo.NewHTTPError(403, "Could not retrive subject")
return uuid.UUID{}, echo.NewHTTPError(403, "Could not retrieve subject")
}
ret, err := uuid.Parse(sub)
if err != nil {

View File

@ -60,8 +60,7 @@ services:
- ./auth:/app
labels:
- "traefik.enable=true"
- "traefik.http.routers.auth.rule=PathPrefix(`/auth/`)"
- "traefik.http.routers.auth.rule=PathPrefix(`/.well-known/`)"
- "traefik.http.routers.auth.rule=PathPrefix(`/auth/`) || PathPrefix(`/.well-known/`)"
api:
build:
@ -71,8 +70,6 @@ services:
depends_on:
postgres:
condition: service_healthy
auth:
condition: service_healthy
volumes:
- ./api:/app
- /app/node_modules
@ -80,11 +77,12 @@ services:
- "3567:3567"
environment:
- KYOO_PREFIX=/api
- JWT_ISSUER=${PUBLIC_URL}
env_file:
- ./.env
labels:
- "traefik.enable=true"
- "traefik.http.routers.api.rule=PathPrefix(`/api/`)"
- "traefik.http.routers.api.rule=PathPrefix(`/api/`) || PathPrefix(`/swagger`)"
- "traefik.http.routers.api.middlewares=phantom-token"
- "traefik.http.middlewares.phantom-token.forwardauth.address=http://auth:4568/auth/jwt"
- "traefik.http.middlewares.phantom-token.forwardauth.authRequestHeaders=Authorization,X-Api-Key"

View File

@ -1,2 +0,0 @@
robotframework
RESTinstance

View File

@ -11,9 +11,6 @@
dataclasses-json
msgspec
langcodes
# robotframework
# restinstance needs to be packaged
]);
dotnet = with pkgs.dotnetCorePackages;
combinePackages [
@ -40,11 +37,11 @@ in
go-migrate
sqlc
go-swag
robotframework-tidy
bun
pkg-config
node-gyp
vips
hurl
];
DOTNET_ROOT = "${dotnet}";