diff --git a/idp/dbml-error.log b/idp/dbml-error.log index 22438cb..fca0d60 100644 --- a/idp/dbml-error.log +++ b/idp/dbml-error.log @@ -31,3 +31,6 @@ undefined 2025-08-03T02:07:21.986Z undefined +2025-08-16T01:54:09.751Z +undefined + diff --git a/idp/go.mod b/idp/go.mod index 0c020e9..1f11010 100644 --- a/idp/go.mod +++ b/idp/go.mod @@ -1,45 +1,64 @@ module github.com/tugascript/devlogs/idp -go 1.24.0 +go 1.25.0 require ( github.com/biter777/countries v1.7.5 github.com/go-faker/faker/v4 v4.6.1 github.com/go-playground/validator/v10 v10.27.0 github.com/gofiber/fiber/v2 v2.52.9 - github.com/gofiber/storage/redis/v3 v3.2.0 + github.com/gofiber/storage/redis/v3 v3.4.1 github.com/golang-jwt/jwt/v5 v5.3.0 github.com/google/uuid v1.6.0 github.com/h2non/gock v1.2.0 github.com/jackc/pgx/v5 v5.7.5 github.com/joho/godotenv v1.5.1 - github.com/openbao/openbao/api/auth/approle/v2 v2.3.1 - github.com/openbao/openbao/api/v2 v2.3.1 + github.com/openbao/openbao/api/auth/approle/v2 v2.4.0 + github.com/openbao/openbao/api/v2 v2.4.0 github.com/pquerna/otp v1.5.0 - github.com/redis/go-redis/v9 v9.11.0 - golang.org/x/crypto v0.40.0 + github.com/redis/go-redis/v9 v9.12.1 + golang.org/x/crypto v0.41.0 + golang.org/x/net v0.43.0 golang.org/x/oauth2 v0.30.0 - golang.org/x/text v0.27.0 - google.golang.org/api v0.244.0 + golang.org/x/text v0.28.0 + google.golang.org/api v0.248.0 ) require ( - cloud.google.com/go/auth v0.16.3 // indirect + cloud.google.com/go/auth v0.16.5 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect - cloud.google.com/go/compute/metadata v0.7.0 // indirect - github.com/andybalholm/brotli v1.1.1 // indirect - github.com/boombuler/barcode v1.0.2 // indirect + cloud.google.com/go/compute/metadata v0.8.0 // indirect + dario.cat/mergo v1.0.2 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/andybalholm/brotli v1.2.0 // indirect + github.com/boombuler/barcode v1.1.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/cpuguy83/dockercfg v0.3.2 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/docker v28.3.3+incompatible // indirect + github.com/docker/go-connections v0.6.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/ebitengine/purego v0.8.4 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/gabriel-vasile/mimetype v1.4.9 // indirect + github.com/gabriel-vasile/mimetype v1.4.10 // indirect github.com/go-jose/go-jose/v3 v3.0.4 // indirect + github.com/go-jose/go-jose/v4 v4.1.2 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-viper/mapstructure/v2 v2.3.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/gofiber/storage/testhelpers/redis v0.0.0-20250829072152-23fd56bd1077 // indirect + github.com/gogo/protobuf v1.3.2 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect github.com/googleapis/gax-go/v2 v2.15.0 // indirect @@ -47,36 +66,62 @@ require ( github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect - github.com/hashicorp/go-retryablehttp v0.7.7 // indirect + github.com/hashicorp/go-retryablehttp v0.7.8 // indirect github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect github.com/hashicorp/go-sockaddr v1.0.7 // indirect - github.com/hashicorp/hcl v1.0.1-vault-5 // indirect + github.com/hashicorp/hcl v1.0.1-vault-7 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect + github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54 // indirect + github.com/magiconair/properties v1.8.10 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mdelapenya/tlscert v0.2.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect - github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/go-archive v0.1.0 // indirect + github.com/moby/patternmatcher v0.6.0 // indirect + github.com/moby/sys/sequential v0.6.0 // indirect + github.com/moby/sys/user v0.4.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.2 // indirect + github.com/morikuni/aec v1.0.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/philhofer/fwd v1.2.0 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/ryanuber/go-glob v1.0.0 // indirect - github.com/tinylib/msgp v1.3.0 // indirect + github.com/shirou/gopsutil/v4 v4.25.8 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + github.com/stretchr/testify v1.11.1 // indirect + github.com/testcontainers/testcontainers-go v0.38.0 // indirect + github.com/testcontainers/testcontainers-go/modules/redis v0.38.0 // indirect + github.com/tinylib/msgp v1.4.0 // indirect + github.com/tklauser/go-sysconf v0.3.15 // indirect + github.com/tklauser/numcpus v0.10.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect - github.com/valyala/fasthttp v1.62.0 // indirect + github.com/valyala/fasthttp v1.65.0 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect - go.opentelemetry.io/otel v1.36.0 // indirect - go.opentelemetry.io/otel/metric v1.36.0 // indirect - go.opentelemetry.io/otel/trace v1.36.0 // indirect - golang.org/x/net v0.42.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect + go.opentelemetry.io/otel v1.38.0 // indirect + go.opentelemetry.io/otel/metric v1.38.0 // indirect + go.opentelemetry.io/otel/trace v1.38.0 // indirect + golang.org/x/mod v0.26.0 // indirect golang.org/x/sync v0.16.0 // indirect - golang.org/x/sys v0.34.0 // indirect + golang.org/x/sys v0.35.0 // indirect golang.org/x/time v0.12.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0 // indirect - google.golang.org/grpc v1.74.2 // indirect - google.golang.org/protobuf v1.36.6 // indirect + golang.org/x/tools v0.35.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250826171959-ef028d996bc1 // indirect + google.golang.org/grpc v1.75.0 // indirect + google.golang.org/protobuf v1.36.8 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/idp/go.sum b/idp/go.sum index be6b0ee..30b891a 100644 --- a/idp/go.sum +++ b/idp/go.sum @@ -1,22 +1,22 @@ -cloud.google.com/go/auth v0.16.3 h1:kabzoQ9/bobUmnseYnBO6qQG7q4a/CffFRlJSxv2wCc= -cloud.google.com/go/auth v0.16.3/go.mod h1:NucRGjaXfzP1ltpcQ7On/VTZ0H4kWB5Jy+Y9Dnm76fA= +cloud.google.com/go/auth v0.16.5 h1:mFWNQ2FEVWAliEQWpAdH80omXFokmrnbDhUS9cBywsI= +cloud.google.com/go/auth v0.16.5/go.mod h1:utzRfHMP+Vv0mpOkTRQoWD2q3BatTOoWbA7gCc2dUhQ= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= -cloud.google.com/go/compute/metadata v0.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU= -cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo= -dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= -dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= -github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= -github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +cloud.google.com/go/compute/metadata v0.8.0 h1:HxMRIbao8w17ZX6wBnjhcDkW6lTFpgcaobyVfZWqRLA= +cloud.google.com/go/compute/metadata v0.8.0/go.mod h1:sYOGTp851OV9bOFJ9CH7elVvyzopvWQFNNghtDQ/Biw= +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= -github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/biter777/countries v1.7.5 h1:MJ+n3+rSxWQdqVJU8eBy9RqcdH6ePPn4PJHocVWUa+Q= github.com/biter777/countries v1.7.5/go.mod h1:1HSpZ526mYqKJcpT5Ti1kcGQ0L0SrXWIaptUWjFfv2E= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= -github.com/boombuler/barcode v1.0.2 h1:79yrbttoZrLGkL/oOI8hBrUKucwOL0oOjUgEguGMcJ4= -github.com/boombuler/barcode v1.0.2/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/boombuler/barcode v1.1.0 h1:ChaYjBR63fr4LFyGn8E8nt7dBSt3MiU3zMOZqFvVkHo= +github.com/boombuler/barcode v1.1.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= @@ -25,6 +25,10 @@ github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK3 github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= @@ -39,31 +43,38 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= -github.com/docker/docker v28.0.1+incompatible h1:FCHjSRdXhNRFjlHMTv4jUNlIBbTeRjrWfeFuJp7jpo0= -github.com/docker/docker v28.0.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= -github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= -github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI= +github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= -github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I= -github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw= +github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= +github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0= +github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/go-faker/faker/v4 v4.6.1 h1:xUyVpAjEtB04l6XFY0V/29oR332rOSPWV4lU8RwDt4k= github.com/go-faker/faker/v4 v4.6.1/go.mod h1:arSdxNCSt7mOhdk8tEolvHeIJ7eX4OX80wXjKKvkKBY= github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY= github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= +github.com/go-jose/go-jose/v4 v4.1.1 h1:JYhSgy4mXXzAdF3nUx3ygx347LRXJRrpgyU3adRmkAI= +github.com/go-jose/go-jose/v4 v4.1.1/go.mod h1:BdsZGqgdO3b6tTc6LSE56wcDbMMLuPsw5d4ZD5f94kA= +github.com/go-jose/go-jose/v4 v4.1.2 h1:TK/7NqRQZfgAh+Td8AlsrvtPoUyiHh0LqVvokh+1vHI= +github.com/go-jose/go-jose/v4 v4.1.2/go.mod h1:22cg9HWM1pOlnRiY+9cQYJ9XHmya1bYW8OeDM6Ku6Oo= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -74,12 +85,18 @@ github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHO github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg= github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= -github.com/go-viper/mapstructure/v2 v2.3.0 h1:27XbWsHIqhbdR5TIC911OfYvgSaW93HM+dX7970Q7jk= -github.com/go-viper/mapstructure/v2 v2.3.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gofiber/fiber/v2 v2.52.9 h1:YjKl5DOiyP3j0mO61u3NTmK7or8GzzWzCFzkboyP5cw= github.com/gofiber/fiber/v2 v2.52.9/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= -github.com/gofiber/storage/redis/v3 v3.2.0 h1:1cmxmH6ZniZcWHvMpp6LzfcSK5o7CgqiouRqrVCNY9A= -github.com/gofiber/storage/redis/v3 v3.2.0/go.mod h1:fffHK3QnjOxOUZGtq08YVNU1lqKvE+pAKJ5roSnM7FE= +github.com/gofiber/storage/redis/v3 v3.4.0 h1:FbtVgHsWkHFaogObFyNbBkNkZL9/zYxQkS1PV0rA5Ss= +github.com/gofiber/storage/redis/v3 v3.4.0/go.mod h1:5efv+XbKwSQju9j7tokMgFWZ1JwlZvSsIL4RNJSDyf0= +github.com/gofiber/storage/redis/v3 v3.4.1 h1:feZc1xv1UuW+a1qnpISPaak7r/r0SkNVFHmg9R7PJ/c= +github.com/gofiber/storage/redis/v3 v3.4.1/go.mod h1:rbycYIeewyFZ1uMf9I6t/C3RHZWIOmSRortjvyErhyA= +github.com/gofiber/storage/testhelpers/redis v0.0.0-20250815074620-1386290f7fd5 h1:vC79Z8gkydKoxsq+7+IhnTd3z2J7qs1Zi5wXTP29/C4= +github.com/gofiber/storage/testhelpers/redis v0.0.0-20250815074620-1386290f7fd5/go.mod h1:PU9dj9E5K6+TLw7pF87y4yOf5HUH6S9uxTlhuRAVMEY= +github.com/gofiber/storage/testhelpers/redis v0.0.0-20250829072152-23fd56bd1077 h1:AQiZAq2FaKjRu08sPHVO8sOnFxUk4+nrvmaJO42YlSA= +github.com/gofiber/storage/testhelpers/redis v0.0.0-20250829072152-23fd56bd1077/go.mod h1:PU9dj9E5K6+TLw7pF87y4yOf5HUH6S9uxTlhuRAVMEY= 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.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= @@ -110,16 +127,16 @@ github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB1 github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= -github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= -github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= +github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= +github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM= github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0= github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= -github.com/hashicorp/hcl v1.0.1-vault-5 h1:kI3hhbbyzr4dldA8UdTb7ZlVVlI2DACdCfz31RPDgJM= -github.com/hashicorp/hcl v1.0.1-vault-5/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= +github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I= +github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -130,12 +147,16 @@ 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/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= -github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= -github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc= +github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= +github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54 h1:mFWunSatvkQQDhpdyuFAYwyAan3hzCuma+Pz8sqvOfg= +github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= @@ -150,48 +171,56 @@ github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyua github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ= +github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo= github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= -github.com/moby/sys/sequential v0.5.0 h1:OPvI35Lzn9K04PBbCLW0g4LcFAJgHsvXsRyewg5lXtc= -github.com/moby/sys/sequential v0.5.0/go.mod h1:tH2cOOs5V9MlPiXcQzRC+eEyab644PWKGRYaaV5ZZlo= -github.com/moby/sys/user v0.1.0 h1:WmZ93f5Ux6het5iituh9x2zAG7NFY9Aqi49jjE1PaQg= -github.com/moby/sys/user v0.1.0/go.mod h1:fKJhFOnsCN6xZ5gSfbM6zaHGgDJMrqt9/reuj4T7MmU= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= -github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= -github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32 h1:W6apQkHrMkS0Muv8G/TipAy/FJl/rCYT0+EuS8+Z0z4= github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms= github.com/openbao/openbao/api/auth/approle/v2 v2.3.1 h1:g2m00OqV+T6cn9oMwkVKcw2gs0TA4uHK9yH9ASWybtE= github.com/openbao/openbao/api/auth/approle/v2 v2.3.1/go.mod h1:Bn4PFuu1mRG2Vcoz2KFdIynAtN90fa4kFUgExNEm8Cs= +github.com/openbao/openbao/api/auth/approle/v2 v2.4.0 h1:e2CScj+WeCTbh/cbap4aeAJ2XWU2CZ+x5lFfGaS3DI4= +github.com/openbao/openbao/api/auth/approle/v2 v2.4.0/go.mod h1:n77uPZESGOsxNXcnLlJKvJCvNZeIDQ9ZWsNry4dUlDA= github.com/openbao/openbao/api/v2 v2.3.1 h1:+Ho5A1jWedZonDz+HDViSOXTieotUT6w7r2Q8Sc8GNM= github.com/openbao/openbao/api/v2 v2.3.1/go.mod h1:oEeWVQSz1LeJJGwwCiPzHX6seppRh8jYXaw6W6yYvao= +github.com/openbao/openbao/api/v2 v2.4.0 h1:OcHJgexGt65qFNcpNNqM2v3otaWt8YhfD7Q5Sy6CWZc= +github.com/openbao/openbao/api/v2 v2.4.0/go.mod h1:ULxn1SwPo/txs19I1VHEBBqMspG8wiZ17qe9DMjCwP0= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= -github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1GshSTtih8C2gDs04w8dReiOGXrGLNoY= -github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= -github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs= github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= -github.com/redis/go-redis/v9 v9.11.0 h1:E3S08Gl/nJNn5vkxd2i78wZxWAPNZgUNTp8WIJUAiIs= -github.com/redis/go-redis/v9 v9.11.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= +github.com/redis/go-redis/v9 v9.12.1 h1:k5iquqv27aBtnTm2tIkROUDp8JBXhXZIVu1InSgvovg= +github.com/redis/go-redis/v9 v9.12.1/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= -github.com/shirou/gopsutil/v4 v4.25.1 h1:QSWkTc+fu9LTAWfkZwZ6j8MSUk4A2LV7rbH0ZqmLjXs= -github.com/shirou/gopsutil/v4 v4.25.1/go.mod h1:RoUCUpndaJFtT+2zsZzzmhvbfGoDCJ7nFXKJf8GqJbI= +github.com/shirou/gopsutil/v4 v4.25.7 h1:bNb2JuqKuAu3tRlPv5piSmBZyMfecwQ+t/ILq+1JqVM= +github.com/shirou/gopsutil/v4 v4.25.7/go.mod h1:XV/egmwJtd3ZQjBpJVY5kndsiOO4IRqy9TQnmm6VP7U= +github.com/shirou/gopsutil/v4 v4.25.8 h1:NnAsw9lN7587WHxjJA9ryDnqhJpFH6A+wagYWTOH970= +github.com/shirou/gopsutil/v4 v4.25.8/go.mod h1:q9QdMmfAOVIw7a+eF86P7ISEU6ka+NLgkUxlopV4RwI= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -199,71 +228,105 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/testcontainers/testcontainers-go v0.37.0 h1:L2Qc0vkTw2EHWQ08djon0D2uw7Z/PtHS/QzZZ5Ra/hg= -github.com/testcontainers/testcontainers-go v0.37.0/go.mod h1:QPzbxZhQ6Bclip9igjLFj6z0hs01bU8lrl2dHQmgFGM= -github.com/testcontainers/testcontainers-go/modules/redis v0.37.0 h1:9HIY28I9ME/Zmb+zey1p/I1mto5+5ch0wLX+nJdOsQ4= -github.com/testcontainers/testcontainers-go/modules/redis v0.37.0/go.mod h1:Abu9g/25Qv+FkYVx3U4Voaynou1c+7D0HIhaQJXvk6E= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/testcontainers/testcontainers-go v0.38.0 h1:d7uEapLcv2P8AvH8ahLqDMMxda2W9gQN1nRbHS28HBw= +github.com/testcontainers/testcontainers-go v0.38.0/go.mod h1:C52c9MoHpWO+C4aqmgSU+hxlR5jlEayWtgYrb8Pzz1w= +github.com/testcontainers/testcontainers-go/modules/redis v0.38.0 h1:289pn0BFmGqDrd6BrImZAprFef9aaPZacx07YOQaPV4= +github.com/testcontainers/testcontainers-go/modules/redis v0.38.0/go.mod h1:EcKPWRzOglnQfYe+ekA8RPEIWSNJTGwaC5oE5bQV+D0= github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww= github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0= -github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= -github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= -github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= -github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +github.com/tinylib/msgp v1.4.0 h1:SYOeDRiydzOw9kSiwdYp9UcBgPFtLU2WDHaJXyHruf8= +github.com/tinylib/msgp v1.4.0/go.mod h1:cvjFkb4RiC8qSBOPMGPSzSAx47nAsfhLVTCZZNuHv5o= +github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4= +github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4= +github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso= +github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasthttp v1.62.0 h1:8dKRBX/y2rCzyc6903Zu1+3qN0H/d2MsxPPmVNamiH0= -github.com/valyala/fasthttp v1.62.0/go.mod h1:FCINgr4GKdKqV8Q0xv8b+UxPV+H/O5nNFo3D+r54Htg= +github.com/valyala/fasthttp v1.65.0 h1:j/u3uzFEGFfRxw79iYzJN+TteTJwbYkru9uDp3d0Yf8= +github.com/valyala/fasthttp v1.65.0/go.mod h1:P/93/YkKPMsKSnATEeELUCkG8a7Y+k99uxNHVbKINr4= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= -go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg= -go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E= -go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE= -go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs= -go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs= -go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY= -go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis= -go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4= -go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w= -go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg= +go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= +go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= +go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= +go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= +go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= +go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= +go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= +go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= -golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= -golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg= +golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 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.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= -golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/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= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= -golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -275,27 +338,43 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= -golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= +golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/api v0.244.0 h1:lpkP8wVibSKr++NCD36XzTk/IzeKJ3klj7vbj+XU5pE= -google.golang.org/api v0.244.0/go.mod h1:dMVhVcylamkirHdzEBAIQWUCgqY885ivNeZYd7VAVr8= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/api v0.247.0 h1:tSd/e0QrUlLsrwMKmkbQhYVa109qIintOls2Wh6bngc= +google.golang.org/api v0.247.0/go.mod h1:r1qZOPmxXffXg6xS5uhx16Fa/UFY8QU/K4bfKrnvovM= +google.golang.org/api v0.248.0 h1:hUotakSkcwGdYUqzCRc5yGYsg4wXxpkKlW5ryVqvC1Y= +google.golang.org/api v0.248.0/go.mod h1:yAFUAF56Li7IuIQbTFoLwXTCI6XCFKueOlS7S9e4F9k= google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4= google.golang.org/genproto v0.0.0-20250603155806-513f23925822/go.mod h1:HubltRL7rMh0LfnQPkMH4NPDFEWp0jw3vixw7jEM53s= google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822 h1:oWVWY3NzT7KJppx2UKhKmzPq4SRe0LdCijVRwvGeikY= google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822/go.mod h1:h3c4v36UTKzUiuaOKQ6gr3S+0hovBtUrXzTG/i3+XEc= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0 h1:MAKi5q709QWfnkkpNQ0M12hYJ1+e8qYVDyowc4U1XZM= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7 h1:FiusG7LWj+4byqhbvmB+Q93B/mOxJLN2DTozDuZm4EU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250811230008-5f3141c8851a h1:tPE/Kp+x9dMSwUm/uM0JKK0IfdiJkwAbSMSeZBXXJXc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250811230008-5f3141c8851a/go.mod h1:gw1tLEfykwDz2ET4a12jcXt4couGAm7IwsVaTy0Sflo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250826171959-ef028d996bc1 h1:pmJpJEvT846VzausCQ5d7KreSROcDqmO388w5YbnltA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250826171959-ef028d996bc1/go.mod h1:GmFNa4BdJZ2a8G+wCe9Bg3wwThLrJun751XstdJt5Og= google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4= google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM= -google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= -google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4= +google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= +google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= +google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/idp/initial_schema.dbml b/idp/initial_schema.dbml index 9dbd92f..89a9017 100644 --- a/idp/initial_schema.dbml +++ b/idp/initial_schema.dbml @@ -59,6 +59,7 @@ Table data_encryption_keys as DEK { Ref: DEK.kek_kid > KEK.kid [delete: cascade, update: cascade] Enum token_crypto_suite { + "RS256" "ES256" "EdDSA" } @@ -76,6 +77,7 @@ Enum token_key_type { "email_verification" "password_reset" "2fa_authentication" + "dynamic_registration" } Table token_signing_keys as TS { @@ -108,27 +110,25 @@ Table token_signing_keys as TS { } Ref: TS.dek_kid > DEK.kid [delete: cascade, update: cascade] -Enum two_factor_type { - "none" - "totp" - "email" +Enum activity_status { + "active" + "suspended" + "blocked" } Table accounts as A { id serial [pk] public_id uuid [not null] - given_name varchar(50) [not null] - family_name varchar(50) [not null] - username varchar(63) [not null] + given_name varchar(100) [not null] + family_name varchar(100) [not null] + username varchar(63) [not null] // maximum length of a DNS label email varchar(250) [not null] organization varchar(50) password text version integer [not null, default: 1] email_verified boolean [not null, default: false] - - is_active boolean [not null, default: true] - two_factor_type two_factor_type [not null, default: 'none'] + activity_status activity_status [not null, default: 'active'] created_at timestamptz [not null, default: `now()`] updated_at timestamptz [not null, default: `now()`] @@ -141,6 +141,33 @@ Table accounts as A { } } +Enum two_factor_type { + "totp" + "email" +} + +Table account_2fa_configs as A2FA { + id serial [pk] + + account_id integer [not null] + account_public_id uuid [not null] + + two_factor_type two_factor_type [not null] + is_default boolean [not null, default: false] + is_active boolean [not null, default: false] + + created_at timestamptz [not null, default: `now()`] + updated_at timestamptz [not null, default: `now()`] + + Indexes { + (account_id) [name: 'account_2fa_configs_account_id_idx'] + (account_public_id) [name: 'account_2fa_configs_account_public_id_idx'] + (account_public_id, is_default) [name: 'account_2fa_configs_account_public_id_is_default_idx'] + (account_public_id, two_factor_type) [name: 'account_2fa_configs_account_public_id_two_factor_type_idx'] + } +} +Ref: A2FA.account_id > A.id [delete: cascade] + Enum totp_usage { "account" "user" @@ -263,6 +290,30 @@ Table account_data_encryption_keys as ADEK { Ref: ADEK.account_id > A.id [delete: cascade] Ref: ADEK.data_encryption_key_id > DEK.id [delete: cascade] +Table account_hmac_secrets as AHS { + id serial [pk] + + account_id integer [not null] + + secret_id varchar(22) [not null] + secret text [not null] + dek_kid varchar(22) [not null] + is_revoked boolean [not null, default: false] + expires_at timestamptz [not null] + + created_at timestamptz [not null, default: `now()`] + + Indexes { + (account_id) [name: 'account_hmac_secrets_account_id_idx'] + (secret_id) [unique, name: 'account_hmac_secrets_secret_id_uidx'] + (dek_kid) [name: 'account_hmac_secrets_dek_kid_idx'] + (account_id, secret_id) [name: 'account_hmac_secrets_account_id_secret_id_idx'] + (account_id, is_revoked, expires_at) [name: 'account_hmac_secrets_account_id_is_revoked_expires_at_idx'] + } +} +Ref: AHS.account_id > A.id [delete: cascade] +Ref: AHS.dek_kid > DEK.kid [delete: cascade, update: cascade] + Table account_totps as AT { account_id integer [not null] totp_id integer [not null] @@ -301,43 +352,21 @@ Enum account_credentials_scope { "account:users:write" "account:apps:read" "account:apps:write" + "account:apps:configs:read" + "account:apps:configs:write" "account:credentials:read" "account:credentials:write" + "account:credentials:configs:read" + "account:credentials:configs:write" "account:auth_providers:read" } Enum account_credentials_type { - "client" + "native" + "service" "mcp" } -Table account_credentials as AC { - id serial [pk] - - account_id integer [not null] - account_public_id uuid [not null] - - credentials_type account_credentials_type [not null] - scopes "account_credentials_scope[]" [not null] - token_endpoint_auth_method "auth_method" [not null] - issuers "varchar(255)[]" [not null] - - alias varchar(100) [not null] - client_id varchar(22) [not null] - - created_at timestamptz [not null, default: `now()`] - updated_at timestamptz [not null, default: `now()`] - - Indexes { - (client_id) [unique, name: 'account_credentials_client_id_uidx'] - (account_id) [name: 'account_credentials_account_id_idx'] - (account_public_id) [name: 'account_credentials_account_public_id_idx'] - (account_public_id, client_id) [name: 'account_credentials_account_public_id_client_id_idx'] - (alias, account_id) [unique, name: 'account_credentials_alias_account_id_uidx'] - } -} -Ref: AC.account_id > A.id [delete: cascade] - Enum transport { "http" "https" @@ -350,40 +379,45 @@ Enum creation_method { "dynamic_registration" } -Table account_credentials_mcps as ACM { +Table account_credentials as AC { id serial [pk] account_id integer [not null] account_public_id uuid [not null] - account_credentials_id integer [not null] - account_credentials_client_id varchar(22) [not null] - creation_method creation_method [not null] + client_id varchar(22) [not null] + name varchar(255) [not null] + domain "varchar(250)" [not null] + credentials_type account_credentials_type [not null] + scopes "account_credentials_scope[]" [not null] + token_endpoint_auth_method "auth_method" [not null] + grant_types "grant_type[]" [not null] + version integer [not null, default: 1] + transport transport [not null] + creation_method creation_method [not null] - response_types "response_type[]" [not null] - callback_uris "varchar(2048)[]" [not null] client_uri varchar(512) [not null] + redirect_uris "varchar(2048)[]" [not null] logo_uri varchar(512) [null] policy_uri varchar(512) [null] tos_uri varchar(512) [null] software_id varchar(512) [not null] software_version varchar(512) [null] - contacts "varchar(512)[]" [not null, default: '{}'] + contacts "varchar(250)[]" [not null] created_at timestamptz [not null, default: `now()`] updated_at timestamptz [not null, default: `now()`] Indexes { - (account_id) [name: 'account_credentials_mcp_account_id_idx'] - (account_public_id) [name: 'account_credentials_mcp_account_public_id_idx'] - (account_credentials_id) [unique, name: 'account_credentials_mcp_account_credentials_id_uidx'] - (account_credentials_client_id) [name: 'account_credentials_mcp_account_credentials_client_id_idx'] - (account_credentials_id, software_id) [unique, name: 'account_credentials_mcp_account_credentials_id_software_id_uidx'] + (client_id) [unique, name: 'account_credentials_client_id_uidx'] + (account_id) [name: 'account_credentials_account_id_idx'] + (account_public_id) [name: 'account_credentials_account_public_id_idx'] + (account_public_id, client_id) [name: 'account_credentials_account_public_id_client_id_idx'] + (name, account_id) [unique, name: 'account_credentials_name_account_id_uidx'] } } -Ref: ACM.account_id > A.id [delete: cascade] -Ref: ACM.account_credentials_id > AC.id [delete: cascade] +Ref: AC.account_id > A.id [delete: cascade] Table account_credentials_secrets as ACS { account_id integer [not null] @@ -532,13 +566,11 @@ Table users as U { account_id integer [not null] email varchar(250) [not null] - username varchar(250) [not null] + username varchar(63) [not null] // maximum length of a DNS label password text version integer [not null, default: 1] email_verified boolean [not null, default: false] - - is_active boolean [not null, default: true] - two_factor_type two_factor_type [not null, default: 'none'] + activity_status activity_status [not null, default: 'active'] user_data jsonb [not null, default: '{}'] @@ -555,6 +587,27 @@ Table users as U { } Ref: U.account_id > A.id [delete: cascade] +Table user_2fa_configs as U2FA { + id serial [pk] + account_id integer [not null] + + user_id integer [not null] + two_factor_type two_factor_type [not null] + is_default boolean [not null, default: false] + + created_at timestamptz [not null, default: `now()`] + updated_at timestamptz [not null, default: `now()`] + + Indexes { + (account_id) [name: 'user_2fa_configs_account_id_idx'] + (user_id) [name: 'user_2fa_configs_user_id_idx'] + (two_factor_type) [name: 'user_2fa_configs_two_factor_type_idx'] + (user_id, two_factor_type) [unique, name: 'user_2fa_configs_user_id_two_factor_type_uidx'] + } +} +Ref: U2FA.account_id > A.id [delete: cascade] +Ref: U2FA.user_id > U.id [delete: cascade] + Table user_data_encryption_keys as UDEK { user_id integer [not null] data_encryption_key_id integer [not null] @@ -721,7 +774,7 @@ Table apps as APP { account_public_id uuid [not null] app_type app_type [not null] - name varchar(100) [not null] + name varchar(255) [not null] client_id varchar(22) [not null] version integer [not null, default: 1] creation_method creation_method [not null] @@ -886,9 +939,121 @@ Enum initial_access_token_generation_method { Enum software_statement_verification_method { "manual" "jwks_uri" + "jwk_x5_parameters" +} + +Table account_dynamic_registration_configs as ADRC { + id serial [pk] + + account_id integer [not null] + account_public_id uuid [not null] + + account_credentials_types "account_credentials_type[]" [not null] + whitelisted_domains "varchar(250)[]" [not null] + require_software_statement_credential_types "account_credentials_type[]" [not null] + software_statement_verification_methods "software_statement_verification_method[]" [not null] + + require_initial_access_token_credential_types "account_credentials_type[]" [not null] + initial_access_token_generation_methods "initial_access_token_generation_method[]" [not null] + + created_at timestamptz [not null, default: `now()`] + updated_at timestamptz [not null, default: `now()`] + + Indexes { + (account_id) [unique, name: 'account_dynamic_registration_configs_account_id_uidx'] + (account_public_id) [name: 'account_dynamic_registration_configs_account_public_id_idx'] + } +} +Ref: ADRC.account_id > A.id [delete: cascade] + +Enum domain_verification_method { + "authorization_code" + "software_statement" + "dns_txt_record" +} + +Table account_dynamic_registration_domains as ADRD { + id serial [pk] + + account_id integer [not null] + account_public_id uuid [not null] + + domain varchar(250) [not null] + verified_at timestamptz [null] + verification_method domain_verification_method [not null] + + created_at timestamptz [not null, default: `now()`] + updated_at timestamptz [not null, default: `now()`] + + Indexes { + (account_id) [name: 'accounts_totps_account_id_idx'] + (account_public_id) [name: 'account_dynamic_registration_domains_account_public_id_idx'] + (domain) [name: 'account_dynamic_registration_domains_domain_idx'] + (account_public_id, domain) [unique, name: 'account_dynamic_registration_domains_account_public_id_domain_uidx'] + } +} +Ref: ADRD.account_id > A.id [delete: cascade] + +Table dynamic_registration_domain_codes as DRDC { + id serial [pk] + + account_id integer [not null] + verification_host varchar(50) [not null] + verification_code text [not null] + hmac_secret_id varchar(22) [not null] + verification_prefix varchar(70) [not null] + expires_at timestamptz [not null] + + created_at timestamptz [not null, default: `now()`] + updated_at timestamptz [not null, default: `now()`] + + Indexes { + (account_id) [name: 'account_dynamic_registration_domain_codes_account_id_idx'] + } +} +Ref: DRDC.account_id > A.id [delete: cascade] +Ref: DRDC.hmac_secret_id > AHS.secret_id [delete: cascade, update: cascade] + +Table account_dynamic_registration_domain_codes as ADRDC { + account_dynamic_registration_domain_id integer [not null] + dynamic_registration_domain_code_id integer [not null] + + account_id integer [not null] + created_at timestamptz [not null, default: `now()`] + + Indexes { + (account_dynamic_registration_domain_id, dynamic_registration_domain_code_id) [pk] + (account_id) [name: 'account_dynamic_registration_domain_codes_account_id_idx'] + (account_dynamic_registration_domain_id) [unique, name: 'account_dynamic_registration_domain_codes_account_dynamic_registration_domain_id_uidx'] + (dynamic_registration_domain_code_id) [unique, name: 'account_dynamic_registration_domain_codes_dynamic_registration_domain_code_id_uidx'] + } +} +Ref: ADRDC.account_id > A.id [delete: cascade] +Ref: ADRDC.account_dynamic_registration_domain_id > ADRD.id [delete: cascade] +Ref: ADRDC.dynamic_registration_domain_code_id > DRDC.id [delete: cascade] + +Table account_dynamic_registration_software_statement_keys as ADRSK { + id serial [pk] + + account_id integer [not null] + account_public_id uuid [not null] + credentials_key_id integer [not null] + account_dynamic_registration_domain_id integer [not null] + + created_at timestamptz [not null, default: `now()`] + + Indexes { + (account_id) [name: 'account_dynamic_registration_software_statement_keys_account_id_idx'] + (account_public_id) [name: 'account_dynamic_registration_software_statement_keys_account_public_id_idx'] + (credentials_key_id) [unique, name: 'account_dynamic_registration_software_statement_keys_credentials_key_id_uidx'] + (account_dynamic_registration_domain_id) [unique, name: 'account_dynamic_registration_software_statement_keys_account_dynamic_registration_domain_id_uidx'] + } } +Ref: ADRSK.account_id > A.id [delete: cascade] +Ref: ADRSK.credentials_key_id > CK.id [delete: cascade] +Ref: ADRSK.account_dynamic_registration_domain_id > ADRD.id [delete: cascade] -Table dynamic_registration_configs as DRC { +Table app_dynamic_registration_configs as APDRC { id serial [pk] account_id integer [not null] @@ -918,10 +1083,10 @@ Table dynamic_registration_configs as DRC { updated_at timestamptz [not null, default: `now()`] Indexes { - (account_id) [name: 'dynamic_registrations_configs_account_id_idx'] + (account_id) [name: 'app_dynamic_registration_configs_account_id_idx'] } } -Ref: DRC.account_id > A.id [delete: cascade] +Ref: APDRC.account_id > A.id [delete: cascade] Enum app_profile_type { "human" diff --git a/idp/internal/config/config.go b/idp/internal/config/config.go index c8f9f94..502c33c 100644 --- a/idp/internal/config/config.go +++ b/idp/internal/config/config.go @@ -17,32 +17,38 @@ import ( ) type Config struct { - port int64 - env string - maxProcs int64 - databaseURL string - valkeyURL string - frontendDomain string - backendDomain string - cookieSecret string - cookieName string - emailPubChannel string - encryptionSecret string - serviceID uuid.UUID - serviceName string - loggerConfig LoggerConfig - tokensConfig TokensConfig - oAuthProvidersConfig OAuthProvidersConfig - rateLimiterConfig RateLimiterConfig - openBaoConfig OpenBaoConfig - cryptoConfig CryptoConfig - distributedCache DistributedCache - kekExpirationDays int64 - dekExpirationDays int64 - jwkExpirationDays int64 - accountCCExpDays int64 - userCCExpDays int64 - appCCExpDays int64 + port int64 + env string + maxProcs int64 + databaseURL string + valkeyURL string + frontendDomain string + backendDomain string + cookieSecret string + cookieName string + sessionCookieName string + emailPubChannel string + encryptionSecret string + serviceID uuid.UUID + serviceName string + loggerConfig LoggerConfig + tokensConfig TokensConfig + oAuthProvidersConfig OAuthProvidersConfig + rateLimiterConfig RateLimiterConfig + openBaoConfig OpenBaoConfig + cryptoConfig CryptoConfig + distributedCache DistributedCache + kekExpirationDays int64 + dekExpirationDays int64 + jwkExpirationDays int64 + hmacSecretExpDays int64 + accountCCExpDays int64 + userCCExpDays int64 + appCCExpDays int64 + accountDomainVerificationHost string + appsDomainVerificationHost string + accountDomainVerificationTTL int64 + appsDomainVerificationTTL int64 } func (c *Config) Port() int64 { @@ -137,6 +143,10 @@ func (c *Config) JWKExpirationDays() int64 { return c.jwkExpirationDays } +func (c *Config) HMACSecretExpDays() int64 { + return c.hmacSecretExpDays +} + func (c *Config) AccountCCExpDays() int64 { return c.accountCCExpDays } @@ -149,7 +159,23 @@ func (c *Config) AppCCExpDays() int64 { return c.appCCExpDays } -var variables = [45]string{ +func (c *Config) AccountDomainVerificationHost() string { + return c.accountDomainVerificationHost +} + +func (c *Config) AppsDomainVerificationHost() string { + return c.appsDomainVerificationHost +} + +func (c *Config) AccountDomainVerificationTTL() int64 { + return c.accountDomainVerificationTTL +} + +func (c *Config) AppsDomainVerificationTTL() int64 { + return c.appsDomainVerificationTTL +} + +var variables = [46]string{ "PORT", "ENV", "DEBUG", @@ -172,19 +198,16 @@ var variables = [45]string{ "JWT_RESET_TTL_SEC", "JWT_2FA_TTL_SEC", "JWT_APPS_TTL_SEC", + "JWT_DYNAMIC_REGISTRATION_TTL_SEC", "OPENBAO_URL", "OPENBAO_DEV_TOKEN", "OPENBAO_ROLE_ID", "OPENBAO_SECRET_ID", "KEK_PATH", - "DEK_TTL_SEC", - "JWK_TTL_SEC", "KEK_EXPIRATION_DAYS", "DEK_EXPIRATION_DAYS", "JWK_EXPIRATION_DAYS", - "KEK_CACHE_TTL_SEC", - "DECRYPT_DEK_CACHE_TTL_SEC", - "ENCRYPT_DEK_CACHE_TTL_SEC", + "HMAC_SECRET_EXPIRATION_DAYS", "PUBLIC_JWK_CACHE_TTL_SEC", "PRIVATE_JWK_CACHE_TTL_SEC", "PUBLIC_JWKS_CACHE_TTL_SEC", @@ -195,6 +218,10 @@ var variables = [45]string{ "APP_CLIENT_CREDENTIALS_EXPIRATION_DAYS", "OAUTH_STATE_TTL_SEC", "OAUTH_CODE_TTL_SEC", + "ACCOUNT_CREDENTIALS_DOMAIN_VERIFICATION_HOST", + "ACCOUNT_CREDENTIALS_DOMAIN_VERIFICATION_TTL_SEC", + "APPS_DOMAIN_VERIFICATION_HOST", + "APPS_DOMAIN_VERIFICATION_TTL_SEC", } var optionalVariables = [10]string{ @@ -210,7 +237,7 @@ var optionalVariables = [10]string{ "MICROSOFT_CLIENT_SECRET", } -var numerics = [29]string{ +var numerics = [28]string{ "PORT", "MAX_PROCS", "JWT_ACCESS_TTL_SEC", @@ -220,16 +247,13 @@ var numerics = [29]string{ "JWT_RESET_TTL_SEC", "JWT_2FA_TTL_SEC", "JWT_APPS_TTL_SEC", + "JWT_DYNAMIC_REGISTRATION_TTL_SEC", "RATE_LIMITER_MAX", "RATE_LIMITER_EXP_SEC", - "DEK_TTL_SEC", - "JWK_TTL_SEC", "KEK_EXPIRATION_DAYS", "DEK_EXPIRATION_DAYS", "JWK_EXPIRATION_DAYS", - "KEK_CACHE_TTL_SEC", - "DECRYPT_DEK_CACHE_TTL_SEC", - "ENCRYPT_DEK_CACHE_TTL_SEC", + "HMAC_SECRET_EXPIRATION_DAYS", "PUBLIC_JWK_CACHE_TTL_SEC", "PRIVATE_JWK_CACHE_TTL_SEC", "PUBLIC_JWKS_CACHE_TTL_SEC", @@ -240,6 +264,8 @@ var numerics = [29]string{ "APP_CLIENT_CREDENTIALS_EXPIRATION_DAYS", "OAUTH_STATE_TTL_SEC", "OAUTH_CODE_TTL_SEC", + "ACCOUNT_CREDENTIALS_DOMAIN_VERIFICATION_TTL_SEC", + "APPS_DOMAIN_VERIFICATION_TTL_SEC", } func NewConfig(logger *slog.Logger, envPath string) Config { @@ -299,6 +325,7 @@ func NewConfig(logger *slog.Logger, envPath string) Config { intMap["JWT_RESET_TTL_SEC"], intMap["JWT_2FA_TTL_SEC"], intMap["JWT_APPS_TTL_SEC"], + intMap["JWT_DYNAMIC_REGISTRATION_TTL_SEC"], ), oAuthProvidersConfig: NewOAuthProviders( NewOAuthProvider(variablesMap["GITHUB_CLIENT_ID"], variablesMap["GITHUB_CLIENT_SECRET"]), @@ -319,12 +346,11 @@ func NewConfig(logger *slog.Logger, envPath string) Config { ), cryptoConfig: NewEncryptionConfig( variablesMap["KEK_PATH"], - intMap["DEK_TTL_SEC"], - intMap["JWK_TTL_SEC"], ), kekExpirationDays: intMap["KEK_EXPIRATION_DAYS"], dekExpirationDays: intMap["DEK_EXPIRATION_DAYS"], jwkExpirationDays: intMap["JWK_EXPIRATION_DAYS"], + hmacSecretExpDays: intMap["HMAC_SECRET_EXPIRATION_DAYS"], distributedCache: NewDistributedCache( intMap["KEK_CACHE_TTL_SEC"], intMap["DECRYPT_DEK_CACHE_TTL_SEC"], @@ -337,8 +363,12 @@ func NewConfig(logger *slog.Logger, envPath string) Config { intMap["OAUTH_STATE_TTL_SEC"], intMap["OAUTH_CODE_TTL_SEC"], ), - accountCCExpDays: intMap["ACCOUNT_CLIENT_CREDENTIALS_EXPIRATION_DAYS"], - userCCExpDays: intMap["USER_CLIENT_CREDENTIALS_EXPIRATION_DAYS"], - appCCExpDays: intMap["APP_CLIENT_CREDENTIALS_EXPIRATION_DAYS"], + accountCCExpDays: intMap["ACCOUNT_CLIENT_CREDENTIALS_EXPIRATION_DAYS"], + userCCExpDays: intMap["USER_CLIENT_CREDENTIALS_EXPIRATION_DAYS"], + appCCExpDays: intMap["APP_CLIENT_CREDENTIALS_EXPIRATION_DAYS"], + accountDomainVerificationHost: variablesMap["ACCOUNT_CREDENTIALS_DOMAIN_VERIFICATION_HOST"], + appsDomainVerificationHost: variablesMap["APPS_DOMAIN_VERIFICATION_HOST"], + accountDomainVerificationTTL: intMap["ACCOUNT_CREDENTIALS_DOMAIN_VERIFICATION_TTL_SEC"], + appsDomainVerificationTTL: intMap["APPS_DOMAIN_VERIFICATION_TTL_SEC"], } } diff --git a/idp/internal/config/encryption.go b/idp/internal/config/encryption.go index 75dd3ab..54e8d9a 100644 --- a/idp/internal/config/encryption.go +++ b/idp/internal/config/encryption.go @@ -8,26 +8,14 @@ package config type CryptoConfig struct { kekPath string - dekTTL int64 - jwkTTL int64 } func (cc *CryptoConfig) KEKPath() string { return cc.kekPath } -func (cc *CryptoConfig) DEKTTL() int64 { - return cc.dekTTL -} - -func (cc *CryptoConfig) JWKTTL() int64 { - return cc.jwkTTL -} - -func NewEncryptionConfig(kekPath string, dekTTL, jwkTTL int64) CryptoConfig { +func NewEncryptionConfig(kekPath string) CryptoConfig { return CryptoConfig{ kekPath: kekPath, - dekTTL: dekTTL, - jwkTTL: jwkTTL, } } diff --git a/idp/internal/config/tokens.go b/idp/internal/config/tokens.go index 96478bf..260ce6a 100644 --- a/idp/internal/config/tokens.go +++ b/idp/internal/config/tokens.go @@ -7,24 +7,26 @@ package config type TokensConfig struct { - accessTTL int64 - accountCredentialsTTL int64 - refreshTTL int64 - confirmTTL int64 - resetTTL int64 - twoFATTL int64 - appsTTL int64 + accessTTL int64 + accountCredentialsTTL int64 + refreshTTL int64 + confirmTTL int64 + resetTTL int64 + twoFATTL int64 + appsTTL int64 + dynamicRegistrationTTL int64 } -func NewTokensConfig(access, accountCredentials, refresh, confirm, reset, twoFA, apps int64) TokensConfig { +func NewTokensConfig(access, accountCredentials, refresh, confirm, reset, twoFA, apps, dynamicRegistration int64) TokensConfig { return TokensConfig{ - accessTTL: access, - accountCredentialsTTL: accountCredentials, - refreshTTL: refresh, - confirmTTL: confirm, - resetTTL: reset, - twoFATTL: twoFA, - appsTTL: apps, + accessTTL: access, + accountCredentialsTTL: accountCredentials, + refreshTTL: refresh, + confirmTTL: confirm, + resetTTL: reset, + twoFATTL: twoFA, + appsTTL: apps, + dynamicRegistrationTTL: dynamicRegistration, } } @@ -57,3 +59,7 @@ func (t TokensConfig) TwoFATTL() int64 { func (t TokensConfig) AppsTTL() int64 { return t.appsTTL } + +func (t TokensConfig) DynamicRegistrationTTL() int64 { + return t.dynamicRegistrationTTL +} diff --git a/idp/internal/controllers/account_2fa_configs.go b/idp/internal/controllers/account_2fa_configs.go new file mode 100644 index 0000000..eb5d180 --- /dev/null +++ b/idp/internal/controllers/account_2fa_configs.go @@ -0,0 +1,213 @@ +// Copyright (c) 2025 Afonso Barracha +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package controllers + +import ( + "github.com/gofiber/fiber/v2" + + "github.com/tugascript/devlogs/idp/internal/controllers/bodies" + "github.com/tugascript/devlogs/idp/internal/controllers/params" + "github.com/tugascript/devlogs/idp/internal/exceptions" + "github.com/tugascript/devlogs/idp/internal/services" +) + +const account2FAConfigsLocation = "account_2fa_configs" + +func (c *Controllers) GetDefaultAccount2FAConfig(ctx *fiber.Ctx) error { + requestID := getRequestID(ctx) + logger := c.buildLogger(requestID, account2FAConfigsLocation, "GetDefaultAccount2FAConfig") + logRequest(logger, ctx) + + accountClaims, serviceErr := getAccountClaims(ctx) + if serviceErr != nil { + return serviceErrorResponse(logger, ctx, serviceErr) + } + + account2FAConfigDTO, serviceErr := c.services.GetDefaultAccount2FAConfig(ctx.UserContext(), services.GetDefaultAccount2FAConfigOptions{ + RequestID: requestID, + AccountPublicID: accountClaims.AccountID, + }) + if serviceErr != nil { + return serviceErrorResponse(logger, ctx, serviceErr) + } + + logResponse(logger, ctx, fiber.StatusOK) + return ctx.Status(fiber.StatusOK).JSON(&account2FAConfigDTO) +} + +func (c *Controllers) GetAccount2FAConfig(ctx *fiber.Ctx) error { + requestID := getRequestID(ctx) + logger := c.buildLogger(requestID, account2FAConfigsLocation, "GetAccount2FAConfig") + logRequest(logger, ctx) + + accountClaims, serviceErr := getAccountClaims(ctx) + if serviceErr != nil { + return serviceErrorResponse(logger, ctx, serviceErr) + } + + urlParams := params.GetAccount2FAConfigURLParams{TwoFAType: ctx.Params("twoFAType")} + if err := c.validate.StructCtx(ctx.UserContext(), &urlParams); err != nil { + return validateURLParamsErrorResponse(logger, ctx, err) + } + + twoFAType, serviceErr := services.Map2FAType(urlParams.TwoFAType) + if serviceErr != nil { + return serviceErrorResponse(logger, ctx, serviceErr) + } + + account2FAConfigDTO, serviceErr := c.services.GetAccount2FAConfig(ctx.UserContext(), services.GetAccount2FAConfigOptions{ + RequestID: requestID, + AccountPublicID: accountClaims.AccountID, + TwoFAType: twoFAType, + }) + if serviceErr != nil { + return serviceErrorResponse(logger, ctx, serviceErr) + } + + logResponse(logger, ctx, fiber.StatusOK) + return ctx.Status(fiber.StatusOK).JSON(&account2FAConfigDTO) +} + +func (c *Controllers) CreateAccount2FAConfig(ctx *fiber.Ctx) error { + requestID := getRequestID(ctx) + logger := c.buildLogger(requestID, account2FAConfigsLocation, "CreateAccount2FAConfig") + logRequest(logger, ctx) + + accountClaims, serviceErr := getAccountClaims(ctx) + if serviceErr != nil { + return serviceErrorResponse(logger, ctx, serviceErr) + } + + body := new(bodies.Account2FAConfigBody) + if err := ctx.BodyParser(body); err != nil { + return parseRequestErrorResponse(logger, ctx, err) + } + if err := c.validate.StructCtx(ctx.UserContext(), body); err != nil { + return validateBodyErrorResponse(logger, ctx, err) + } + + account2FAConfigDTO, serviceErr := c.services.CreateAccount2FAConfig( + ctx.UserContext(), + services.CreateAccount2FAConfigOptions{ + RequestID: requestID, + AccountPublicID: accountClaims.AccountID, + AccountVersion: accountClaims.AccountVersion, + TwoFAType: body.TwoFAType, + IsDefault: body.IsDefault, + }) + if serviceErr != nil { + return serviceErrorResponse(logger, ctx, serviceErr) + } + + logResponse(logger, ctx, fiber.StatusCreated) + return ctx.Status(fiber.StatusCreated).JSON(&account2FAConfigDTO) +} + +func (c *Controllers) SetAccount2FAConfigDefault(ctx *fiber.Ctx) error { + requestID := getRequestID(ctx) + logger := c.buildLogger(requestID, account2FAConfigsLocation, "SetAccount2FAConfigDefault") + logRequest(logger, ctx) + + accountClaims, serviceErr := getAccountClaims(ctx) + if serviceErr != nil { + return serviceErrorResponse(logger, ctx, serviceErr) + } + + urlParams := params.GetAccount2FAConfigURLParams{TwoFAType: ctx.Params("twoFAType")} + if err := c.validate.StructCtx(ctx.UserContext(), &urlParams); err != nil { + return validateURLParamsErrorResponse(logger, ctx, err) + } + + account2FAConfigDTO, serviceErr := c.services.SetAccount2FAConfigDefault( + ctx.UserContext(), + services.SetAccount2FAConfigDefaultOptions{ + RequestID: requestID, + AccountPublicID: accountClaims.AccountID, + AccountVersion: accountClaims.AccountVersion, + TwoFAType: urlParams.TwoFAType, + }, + ) + if serviceErr != nil { + return serviceErrorResponse(logger, ctx, serviceErr) + } + + logResponse(logger, ctx, fiber.StatusOK) + return ctx.Status(fiber.StatusOK).JSON(&account2FAConfigDTO) +} + +func (c *Controllers) DeleteAccount2FAConfig(ctx *fiber.Ctx) error { + requestID := getRequestID(ctx) + logger := c.buildLogger(requestID, account2FAConfigsLocation, "DeleteAccount2FAConfig") + logRequest(logger, ctx) + + accountClaims, serviceErr := getAccountClaims(ctx) + if serviceErr != nil { + return serviceErrorResponse(logger, ctx, serviceErr) + } + + urlParams := params.GetAccount2FAConfigURLParams{TwoFAType: ctx.Params("twoFAType")} + if err := c.validate.StructCtx(ctx.UserContext(), &urlParams); err != nil { + return validateURLParamsErrorResponse(logger, ctx, err) + } + + account2FAConfigDTO, serviceErr := c.services.DeleteAccount2FAConfig(ctx.UserContext(), services.DeleteAccount2FAConfigOptions{ + RequestID: requestID, + AccountPublicID: accountClaims.AccountID, + AccountVersion: accountClaims.AccountVersion, + TwoFAType: urlParams.TwoFAType, + }) + if serviceErr != nil { + return serviceErrorResponse(logger, ctx, serviceErr) + } + + logResponse(logger, ctx, fiber.StatusOK) + return ctx.Status(fiber.StatusOK).JSON(&account2FAConfigDTO) +} + +func (c *Controllers) ConfirmDeleteAccount2FAConfig(ctx *fiber.Ctx) error { + requestID := getRequestID(ctx) + logger := c.buildLogger(requestID, account2FAConfigsLocation, "ConfirmDeleteAccount2FAConfig") + logRequest(logger, ctx) + + accountClaims, twoFAType, serviceErr := getAccounts2FAClaims(ctx) + if serviceErr != nil { + return serviceErrorResponse(logger, ctx, serviceErr) + } + + urlParams := params.GetAccount2FAConfigURLParams{TwoFAType: ctx.Params("twoFAType")} + if err := c.validate.StructCtx(ctx.UserContext(), &urlParams); err != nil { + return validateURLParamsErrorResponse(logger, ctx, err) + } + if string(twoFAType) != urlParams.TwoFAType { + return serviceErrorResponse(logger, ctx, exceptions.NewUnauthorizedError()) + } + + body := new(bodies.TwoFactorLoginBody) + if err := ctx.BodyParser(body); err != nil { + return parseRequestErrorResponse(logger, ctx, err) + } + if err := c.validate.StructCtx(ctx.UserContext(), body); err != nil { + return validateBodyErrorResponse(logger, ctx, err) + } + + authDTO, serviceErr := c.services.ConfirmDeleteAccount2FAConfig( + ctx.UserContext(), + services.ConfirmDeleteAccount2FAConfigOptions{ + RequestID: requestID, + PublicID: accountClaims.AccountID, + Version: accountClaims.AccountVersion, + TwoFAType: urlParams.TwoFAType, + Code: body.Code, + }, + ) + if serviceErr != nil { + return serviceErrorResponse(logger, ctx, serviceErr) + } + + logResponse(logger, ctx, fiber.StatusOK) + return ctx.Status(fiber.StatusOK).JSON(&authDTO) +} diff --git a/idp/internal/controllers/account_credentials.go b/idp/internal/controllers/account_credentials.go index 48e1da5..3ee4a26 100644 --- a/idp/internal/controllers/account_credentials.go +++ b/idp/internal/controllers/account_credentials.go @@ -49,10 +49,20 @@ func (c *Controllers) CreateAccountCredentials(ctx *fiber.Ctx) error { RequestID: requestID, AccountPublicID: accountClaims.AccountID, AccountVersion: accountClaims.AccountVersion, - Alias: body.Alias, + CredentialsType: body.Type, + Name: body.Name, Scopes: body.Scopes, - AuthMethod: body.AuthMethod, - Issuers: body.Issuers, + AuthMethod: body.TokenEndpointAuthMethod, + Domain: body.Domain, + ClientURI: body.ClientURI, + RedirectURIs: body.RedirectURIs, + LogoURI: body.LogoURI, + TOSURI: body.TOSURI, + PolicyURI: body.PolicyURI, + SoftwareID: body.SoftwareID, + SoftwareVersion: body.SoftwareVersion, + Algorithm: body.Algorithm, + Transport: body.Transport, }, ) if serviceErr != nil { @@ -169,9 +179,16 @@ func (c *Controllers) UpdateAccountCredentials(ctx *fiber.Ctx) error { AccountPublicID: accountClaims.AccountID, AccountVersion: accountClaims.AccountVersion, ClientID: urlParams.ClientID, + Name: body.Name, Scopes: body.Scopes, - Alias: body.Alias, - Issuers: body.Issuers, + Transport: body.Transport, + Domain: body.Domain, + ClientURI: body.ClientURI, + RedirectURIs: body.RedirectURIs, + LogoURI: body.LogoURI, + TOSURI: body.TOSURI, + PolicyURI: body.PolicyURI, + SoftwareVersion: body.SoftwareVersion, }, ) if serviceErr != nil { diff --git a/idp/internal/controllers/account_credentials_registration_domains.go b/idp/internal/controllers/account_credentials_registration_domains.go new file mode 100644 index 0000000..bd05720 --- /dev/null +++ b/idp/internal/controllers/account_credentials_registration_domains.go @@ -0,0 +1,306 @@ +// Copyright (c) 2025 Afonso Barracha +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package controllers + +import ( + "github.com/gofiber/fiber/v2" + + "github.com/tugascript/devlogs/idp/internal/controllers/bodies" + "github.com/tugascript/devlogs/idp/internal/controllers/params" + "github.com/tugascript/devlogs/idp/internal/controllers/paths" + "github.com/tugascript/devlogs/idp/internal/services" + "github.com/tugascript/devlogs/idp/internal/services/dtos" +) + +const ( + accountCredentialsRegistrationDomainsLocation string = "account_credentials_registration_domains" +) + +func (c *Controllers) CreateAccountCredentialsRegistrationDomain(ctx *fiber.Ctx) error { + requestID := getRequestID(ctx) + logger := c.buildLogger(requestID, accountCredentialsRegistrationDomainsLocation, "CreateAccountDynamicRegistrationDomain") + logRequest(logger, ctx) + + accountClaims, serviceErr := getAccountClaims(ctx) + if serviceErr != nil { + return serviceErrorResponse(logger, ctx, serviceErr) + } + + body := new(bodies.CreateDynamicRegistrationDomainBody) + if err := ctx.BodyParser(body); err != nil { + return parseRequestErrorResponse(logger, ctx, err) + } + if err := c.validate.StructCtx(ctx.UserContext(), body); err != nil { + return validateBodyErrorResponse(logger, ctx, err) + } + + domainDTO, serviceErr := c.services.CreateAccountCredentialsRegistrationDomain( + ctx.UserContext(), + services.CreateAccountCredentialsRegistrationDomainOptions{ + RequestID: requestID, + AccountPublicID: accountClaims.AccountID, + AccountVersion: accountClaims.AccountVersion, + Domain: body.Domain, + }, + ) + if serviceErr != nil { + return serviceErrorResponse(logger, ctx, serviceErr) + } + + logResponse(logger, ctx, fiber.StatusCreated) + return ctx.Status(fiber.StatusCreated).JSON(domainDTO) +} + +func (c *Controllers) ListAccountCredentialsRegistrationDomains(ctx *fiber.Ctx) error { + requestID := getRequestID(ctx) + logger := c.buildLogger(requestID, accountCredentialsRegistrationDomainsLocation, "ListAccountCredentialsRegistrationDomains") + logRequest(logger, ctx) + + accountClaims, serviceErr := getAccountClaims(ctx) + if serviceErr != nil { + return serviceErrorResponse(logger, ctx, serviceErr) + } + + queryParams := params.DynamicRegistrationDomainQueryParams{ + Limit: ctx.QueryInt("limit", 10), + Offset: ctx.QueryInt("offset", 0), + Order: ctx.Query("order", "date"), + Search: ctx.Query("search"), + } + if err := c.validate.StructCtx(ctx.UserContext(), &queryParams); err != nil { + return validateQueryParamsErrorResponse(logger, ctx, err) + } + + var domains []dtos.DynamicRegistrationDomainDTO + var count int64 + if queryParams.Search != "" { + domains, count, serviceErr = c.services.FilterAccountCredentialsRegistrationDomains( + ctx.UserContext(), + services.FilterAccountCredentialsRegistrationDomainsOptions{ + RequestID: requestID, + AccountPublicID: accountClaims.AccountID, + Search: queryParams.Search, + Limit: int32(queryParams.Limit), + Offset: int32(queryParams.Offset), + Order: queryParams.Order, + }, + ) + } else { + domains, count, serviceErr = c.services.ListAccountCredentialsRegistrationDomains( + ctx.UserContext(), + services.ListAccountCredentialsRegistrationDomainsOptions{ + RequestID: requestID, + AccountPublicID: accountClaims.AccountID, + Limit: int32(queryParams.Limit), + Offset: int32(queryParams.Offset), + Order: queryParams.Order, + }, + ) + } + if serviceErr != nil { + return serviceErrorResponse(logger, ctx, serviceErr) + } + + logResponse(logger, ctx, fiber.StatusOK) + return ctx.Status(fiber.StatusOK).JSON(dtos.NewPaginationDTO( + domains, + count, + c.backendDomain, + paths.AccountsBase+paths.CredentialsBase+paths.DynamicRegistrationBase+paths.Domains, + queryParams.Limit, + queryParams.Offset, + "order", queryParams.Order, + )) +} + +func (c *Controllers) GetAccountCredentialsRegistrationDomain(ctx *fiber.Ctx) error { + requestID := getRequestID(ctx) + logger := c.buildLogger(requestID, accountCredentialsRegistrationDomainsLocation, "GetAccountCredentialsRegistrationDomain") + logRequest(logger, ctx) + + accountClaims, serviceErr := getAccountClaims(ctx) + if serviceErr != nil { + return serviceErrorResponse(logger, ctx, serviceErr) + } + + urlParams := params.DynamicRegistrationDomainURLParams{Domain: ctx.Params("domain")} + if err := c.validate.StructCtx(ctx.UserContext(), &urlParams); err != nil { + return validateURLParamsErrorResponse(logger, ctx, err) + } + + domainDTO, serviceErr := c.services.GetAccountCredentialsRegistrationDomain( + ctx.UserContext(), + services.GetAccountCredentialsRegistrationDomainOptions{ + RequestID: requestID, + AccountPublicID: accountClaims.AccountID, + Domain: urlParams.Domain, + }, + ) + if serviceErr != nil { + return serviceErrorResponse(logger, ctx, serviceErr) + } + + logResponse(logger, ctx, fiber.StatusOK) + return ctx.Status(fiber.StatusOK).JSON(domainDTO) +} + +func (c *Controllers) DeleteAccountCredentialsRegistrationDomain(ctx *fiber.Ctx) error { + requestID := getRequestID(ctx) + logger := c.buildLogger(requestID, accountCredentialsRegistrationDomainsLocation, "DeleteAccountCredentialsRegistrationDomain") + logRequest(logger, ctx) + + accountClaims, serviceErr := getAccountClaims(ctx) + if serviceErr != nil { + return serviceErrorResponse(logger, ctx, serviceErr) + } + + urlParams := params.DynamicRegistrationDomainURLParams{Domain: ctx.Params("domain")} + if err := c.validate.StructCtx(ctx.UserContext(), &urlParams); err != nil { + return validateURLParamsErrorResponse(logger, ctx, err) + } + + if serviceErr := c.services.DeleteAccountCredentialsRegistrationDomain( + ctx.UserContext(), + services.DeleteAccountCredentialsRegistrationDomainOptions{ + RequestID: requestID, + AccountPublicID: accountClaims.AccountID, + Domain: urlParams.Domain, + }, + ); serviceErr != nil { + return serviceErrorResponse(logger, ctx, serviceErr) + } + + logResponse(logger, ctx, fiber.StatusNoContent) + return ctx.SendStatus(fiber.StatusNoContent) +} + +func (c *Controllers) VerifyAccountCredentialsRegistrationDomain(ctx *fiber.Ctx) error { + requestID := getRequestID(ctx) + logger := c.buildLogger(requestID, accountCredentialsRegistrationDomainsLocation, "VerifyAccountCredentialsRegistrationDomain") + logRequest(logger, ctx) + + accountClaims, serviceErr := getAccountClaims(ctx) + if serviceErr != nil { + return serviceErrorResponse(logger, ctx, serviceErr) + } + + urlParams := params.DynamicRegistrationDomainURLParams{Domain: ctx.Params("domain")} + if err := c.validate.StructCtx(ctx.UserContext(), &urlParams); err != nil { + return validateURLParamsErrorResponse(logger, ctx, err) + } + + domainDTO, serviceErr := c.services.VerifyAccountCredentialsRegistrationDomain( + ctx.UserContext(), + services.VerifyAccountCredentialsRegistrationDomainOptions{ + RequestID: requestID, + AccountPublicID: accountClaims.AccountID, + Domain: urlParams.Domain, + }, + ) + if serviceErr != nil { + return serviceErrorResponse(logger, ctx, serviceErr) + } + + logResponse(logger, ctx, fiber.StatusOK) + return ctx.Status(fiber.StatusOK).JSON(domainDTO) +} + +func (c *Controllers) UpsertAccountCredentialsRegistrationDomainCode(ctx *fiber.Ctx) error { + requestID := getRequestID(ctx) + logger := c.buildLogger(requestID, accountCredentialsRegistrationDomainsLocation, "UpsertAccountCredentialsRegistrationDomain") + logRequest(logger, ctx) + + accountClaims, serviceErr := getAccountClaims(ctx) + if serviceErr != nil { + return serviceErrorResponse(logger, ctx, serviceErr) + } + + body := new(bodies.CreateDynamicRegistrationDomainBody) + if err := ctx.BodyParser(body); err != nil { + return parseRequestErrorResponse(logger, ctx, err) + } + if err := c.validate.StructCtx(ctx.UserContext(), body); err != nil { + return validateBodyErrorResponse(logger, ctx, err) + } + + domainDTO, serviceErr := c.services.SaveAccountCredentialsRegistrationDomainCode( + ctx.UserContext(), + services.SaveAccountCredentialsRegistrationDomainCodeOptions{ + RequestID: requestID, + AccountPublicID: accountClaims.AccountID, + AccountVersion: accountClaims.AccountVersion, + Domain: body.Domain, + }, + ) + if serviceErr != nil { + return serviceErrorResponse(logger, ctx, serviceErr) + } + + logResponse(logger, ctx, fiber.StatusOK) + return ctx.Status(fiber.StatusOK).JSON(domainDTO) +} + +func (c *Controllers) GetAccountCredentialsRegistrationDomainCode(ctx *fiber.Ctx) error { + requestID := getRequestID(ctx) + logger := c.buildLogger(requestID, accountCredentialsRegistrationDomainsLocation, "GetAccountCredentialsRegistrationDomainCode") + logRequest(logger, ctx) + + accountClaims, serviceErr := getAccountClaims(ctx) + if serviceErr != nil { + return serviceErrorResponse(logger, ctx, serviceErr) + } + + urlParams := params.DynamicRegistrationDomainURLParams{Domain: ctx.Params("domain")} + if err := c.validate.StructCtx(ctx.UserContext(), &urlParams); err != nil { + return validateURLParamsErrorResponse(logger, ctx, err) + } + + codeDTO, serviceErr := c.services.GetAccountCredentialsRegistrationDomainCode( + ctx.UserContext(), + services.GetAccountCredentialsRegistrationDomainCodeOptions{ + RequestID: requestID, + AccountPublicID: accountClaims.AccountID, + Domain: urlParams.Domain, + }, + ) + if serviceErr != nil { + return serviceErrorResponse(logger, ctx, serviceErr) + } + + logResponse(logger, ctx, fiber.StatusOK) + return ctx.Status(fiber.StatusOK).JSON(codeDTO) +} + +func (c *Controllers) DeleteAccountCredentialsRegistrationDomainCode(ctx *fiber.Ctx) error { + requestID := getRequestID(ctx) + logger := c.buildLogger(requestID, accountCredentialsRegistrationDomainsLocation, "DeleteAccountCredentialsRegistrationDomainCode") + logRequest(logger, ctx) + + accountClaims, serviceErr := getAccountClaims(ctx) + if serviceErr != nil { + return serviceErrorResponse(logger, ctx, serviceErr) + } + + urlParams := params.DynamicRegistrationDomainURLParams{Domain: ctx.Params("domain")} + if err := c.validate.StructCtx(ctx.UserContext(), &urlParams); err != nil { + return validateURLParamsErrorResponse(logger, ctx, err) + } + + if serviceErr := c.services.DeleteAccountCredentialsRegistrationDomainCode( + ctx.UserContext(), + services.DeleteAccountCredentialsRegistrationDomainCodeOptions{ + RequestID: requestID, + AccountPublicID: accountClaims.AccountID, + Domain: urlParams.Domain, + }, + ); serviceErr != nil { + return serviceErrorResponse(logger, ctx, serviceErr) + } + + logResponse(logger, ctx, fiber.StatusNoContent) + return ctx.SendStatus(fiber.StatusNoContent) +} diff --git a/idp/internal/controllers/account_dynamic_registration_configs.go b/idp/internal/controllers/account_dynamic_registration_configs.go new file mode 100644 index 0000000..cc1cd10 --- /dev/null +++ b/idp/internal/controllers/account_dynamic_registration_configs.go @@ -0,0 +1,126 @@ +// Copyright (c) 2025 Afonso Barracha +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package controllers + +import ( + "github.com/gofiber/fiber/v2" + + "github.com/tugascript/devlogs/idp/internal/controllers/bodies" + "github.com/tugascript/devlogs/idp/internal/services" +) + +const ( + accountDynamicRegistrationConfigsLocation string = "account_dynamic_registration_configs" +) + +func (c *Controllers) UpsertAccountDynamicRegistrationConfig(ctx *fiber.Ctx) error { + requestID := getRequestID(ctx) + logger := c.buildLogger( + requestID, + accountDynamicRegistrationConfigsLocation, + "UpsertAccountDynamicRegistrationConfig", + ) + logRequest(logger, ctx) + + accountClaims, serviceErr := getAccountClaims(ctx) + if serviceErr != nil { + return serviceErrorResponse(logger, ctx, serviceErr) + } + + body := new(bodies.AccountDynamicRegistrationConfigBody) + if err := ctx.BodyParser(body); err != nil { + return parseRequestErrorResponse(logger, ctx, err) + } + if err := c.validate.StructCtx(ctx.UserContext(), body); err != nil { + return validateBodyErrorResponse(logger, ctx, err) + } + + dto, created, serviceErr := c.services.SaveAccountDynamicRegistrationConfig( + ctx.UserContext(), + services.SaveAccountDynamicRegistrationConfigOptions{ + RequestID: requestID, + AccountPublicID: accountClaims.AccountID, + AccountVersion: accountClaims.AccountVersion, + AccountCredentialsTypes: body.AccountCredentialsTypes, + WhitelistedDomains: body.WhitelistedDomains, + RequireSoftwareStatementCredentialTypes: body.RequireSoftwareStatementCredentialTypes, + SoftwareStatementVerificationMethods: body.SoftwareStatementVerificationMethods, + RequireInitialAccessTokenCredentialTypes: body.RequireInitialAccessTokenCredentialTypes, + InitialAccessTokenGenerationMethods: body.InitialAccessTokenGenerationMethods, + }, + ) + if serviceErr != nil { + return serviceErrorResponse(logger, ctx, serviceErr) + } + + if created { + logResponse(logger, ctx, fiber.StatusCreated) + return ctx.Status(fiber.StatusCreated).JSON(&dto) + } + + logResponse(logger, ctx, fiber.StatusOK) + return ctx.Status(fiber.StatusOK).JSON(&dto) +} + +func (c *Controllers) GetAccountDynamicRegistrationConfig(ctx *fiber.Ctx) error { + requestID := getRequestID(ctx) + logger := c.buildLogger( + requestID, + accountDynamicRegistrationConfigsLocation, + "GetAccountDynamicRegistrationConfig", + ) + logRequest(logger, ctx) + + accountClaims, serviceErr := getAccountClaims(ctx) + if serviceErr != nil { + return serviceErrorResponse(logger, ctx, serviceErr) + } + + dto, serviceErr := c.services.GetAccountDynamicRegistrationConfig( + ctx.UserContext(), + services.GetAccountDynamicRegistrationConfigOptions{ + RequestID: requestID, + AccountPublicID: accountClaims.AccountID, + }, + ) + if serviceErr != nil { + return serviceErrorResponse(logger, ctx, serviceErr) + } + + logResponse(logger, ctx, fiber.StatusOK) + return ctx.Status(fiber.StatusOK).JSON(&dto) +} + +func (c *Controllers) DeleteAccountDynamicRegistrationConfig(ctx *fiber.Ctx) error { + requestID := getRequestID(ctx) + logger := c.buildLogger( + requestID, + accountDynamicRegistrationConfigsLocation, + "DeleteAccountDynamicRegistrationConfig", + ) + logRequest(logger, ctx) + + accountClaims, serviceErr := getAccountClaims(ctx) + if serviceErr != nil { + return serviceErrorResponse(logger, ctx, serviceErr) + } + + serviceErr = c.services.DeleteAccountDynamicRegistrationConfig( + ctx.UserContext(), + services.DeleteAccountDynamicRegistrationConfigOptions{ + RequestID: requestID, + AccountPublicID: accountClaims.AccountID, + AccountVersion: accountClaims.AccountVersion, + }, + ) + if serviceErr != nil { + return serviceErrorResponse(logger, ctx, serviceErr) + } + + logResponse(logger, ctx, fiber.StatusNoContent) + return ctx.SendStatus(fiber.StatusNoContent) +} diff --git a/idp/internal/controllers/apps.go b/idp/internal/controllers/apps.go index ecea21d..b067e64 100644 --- a/idp/internal/controllers/apps.go +++ b/idp/internal/controllers/apps.go @@ -10,6 +10,7 @@ import ( "fmt" "github.com/gofiber/fiber/v2" + "github.com/tugascript/devlogs/idp/internal/controllers/bodies" "github.com/tugascript/devlogs/idp/internal/controllers/params" "github.com/tugascript/devlogs/idp/internal/controllers/paths" @@ -41,6 +42,7 @@ func (c *Controllers) createWebApp( baseBody *bodies.CreateAppBodyBase, ) error { logger := c.buildLogger(requestID, appsLocation, "createWebApp") + logRequest(logger, ctx) body := new(bodies.CreateAppBodyWeb) if err := ctx.BodyParser(body); err != nil { diff --git a/idp/internal/controllers/auth.go b/idp/internal/controllers/auth.go index d53e8c6..1550168 100644 --- a/idp/internal/controllers/auth.go +++ b/idp/internal/controllers/auth.go @@ -11,6 +11,7 @@ import ( "github.com/tugascript/devlogs/idp/internal/controllers/bodies" "github.com/tugascript/devlogs/idp/internal/controllers/params" + "github.com/tugascript/devlogs/idp/internal/controllers/paths" "github.com/tugascript/devlogs/idp/internal/exceptions" "github.com/tugascript/devlogs/idp/internal/services" "github.com/tugascript/devlogs/idp/internal/services/dtos" @@ -20,11 +21,11 @@ const authLocation string = "auth" func (c *Controllers) saveAccountRefreshCookie(ctx *fiber.Ctx, token string) { ctx.Cookie(&fiber.Cookie{ - Name: c.refreshCookieName, + Name: c.cookieName + refreshCookieSuffix, Value: token, - Path: "/auth", + Path: paths.V1, HTTPOnly: true, - SameSite: "None", + SameSite: fiber.CookieSameSiteNoneMode, Secure: true, MaxAge: int(c.services.GetRefreshTTL()), }) @@ -32,11 +33,12 @@ func (c *Controllers) saveAccountRefreshCookie(ctx *fiber.Ctx, token string) { func (c *Controllers) clearAccountRefreshCookie(ctx *fiber.Ctx) { ctx.Cookie(&fiber.Cookie{ - Name: c.refreshCookieName, + Name: c.cookieName + refreshCookieSuffix, Value: "", + Path: paths.V1 + paths.AuthBase, HTTPOnly: true, Secure: true, - SameSite: "None", + SameSite: fiber.CookieSameSiteNoneMode, MaxAge: -1, }) } @@ -131,7 +133,7 @@ func (c *Controllers) TwoFactorLoginAccount(ctx *fiber.Ctx) error { logger := c.buildLogger(requestID, authLocation, "TwoFactorLoginAccount") logRequest(logger, ctx) - accountClaims, serviceErr := getAccountClaims(ctx) + accountClaims, twoFAType, serviceErr := getAccounts2FAClaims(ctx) if serviceErr != nil { return serviceErrorResponse(logger, ctx, serviceErr) } @@ -144,11 +146,12 @@ func (c *Controllers) TwoFactorLoginAccount(ctx *fiber.Ctx) error { return validateBodyErrorResponse(logger, ctx, err) } - authDTO, serviceErr := c.services.TwoFactorLoginAccount(ctx.UserContext(), services.TwoFactorLoginAccountOptions{ - RequestID: requestID, - PublicID: accountClaims.AccountID, - Version: accountClaims.AccountVersion, - Code: body.Code, + authDTO, serviceErr := c.services.VerifyAccount2FA(ctx.UserContext(), services.VerifyAccount2FAOptions{ + RequestID: requestID, + AccountPublicID: accountClaims.AccountID, + AccountVersion: accountClaims.AccountVersion, + TwoFAType: twoFAType, + Code: body.Code, }) if serviceErr != nil { return serviceErrorResponse(logger, ctx, serviceErr) @@ -195,7 +198,7 @@ func (c *Controllers) LogoutAccount(ctx *fiber.Ctx) error { requestID := getRequestID(ctx) logger := c.buildLogger(requestID, authLocation, "LogoutAccount") - refreshToken := ctx.Cookies(c.refreshCookieName) + refreshToken := ctx.Cookies(c.cookieName + refreshCookieSuffix) if refreshToken == "" { body := new(bodies.RefreshTokenBody) if err := ctx.BodyParser(body); err != nil { @@ -215,6 +218,7 @@ func (c *Controllers) LogoutAccount(ctx *fiber.Ctx) error { return serviceErrorResponse(logger, ctx, serviceErr) } + c.clearAccountRefreshCookie(ctx) logResponse(logger, ctx, fiber.StatusNoContent) return ctx.SendStatus(fiber.StatusNoContent) } @@ -224,7 +228,8 @@ func (c *Controllers) RefreshAccount(ctx *fiber.Ctx) error { logger := c.buildLogger(requestID, authLocation, "RefreshAccount") logRequest(logger, ctx) - refreshToken := ctx.Cookies(c.refreshCookieName) + refreshToken := ctx.Cookies(c.cookieName + refreshCookieSuffix) + isCookie := true if refreshToken == "" { body := new(bodies.RefreshTokenBody) if err := ctx.BodyParser(body); err != nil { @@ -234,6 +239,7 @@ func (c *Controllers) RefreshAccount(ctx *fiber.Ctx) error { return validateBodyErrorResponse(logger, ctx, err) } + isCookie = false refreshToken = body.RefreshToken } @@ -242,6 +248,9 @@ func (c *Controllers) RefreshAccount(ctx *fiber.Ctx) error { RefreshToken: refreshToken, }) if serviceErr != nil { + if isCookie { + c.clearAccountRefreshCookie(ctx) + } return serviceErrorResponse(logger, ctx, serviceErr) } @@ -358,73 +367,3 @@ func (c *Controllers) GetAccountAuthProvider(ctx *fiber.Ctx) error { logResponse(logger, ctx, fiber.StatusOK) return ctx.Status(fiber.StatusOK).JSON(&authProviderDTO) } - -func (c *Controllers) UpdateAccount2FA(ctx *fiber.Ctx) error { - requestID := getRequestID(ctx) - logger := c.buildLogger(requestID, authLocation, "UpdateAccount2FA") - logRequest(logger, ctx) - - accountClaims, serviceErr := getAccountClaims(ctx) - if serviceErr != nil { - return serviceErrorResponse(logger, ctx, serviceErr) - } - - body := new(bodies.Update2FABody) - if err := ctx.BodyParser(body); err != nil { - return parseRequestErrorResponse(logger, ctx, err) - } - if err := c.validate.StructCtx(ctx.UserContext(), body); err != nil { - return validateBodyErrorResponse(logger, ctx, err) - } - - authDTO, serviceErr := c.services.UpdateAccount2FA(ctx.UserContext(), services.UpdateAccount2FAOptions{ - RequestID: requestID, - PublicID: accountClaims.AccountID, - Version: accountClaims.AccountVersion, - TwoFactorType: body.TwoFactorType, - Password: body.Password, - }) - if serviceErr != nil { - return serviceErrorResponse(logger, ctx, serviceErr) - } - - if authDTO.RefreshToken != "" { - c.saveAccountRefreshCookie(ctx, authDTO.RefreshToken) - } - - logResponse(logger, ctx, fiber.StatusOK) - return ctx.Status(fiber.StatusOK).JSON(&authDTO) -} - -func (c *Controllers) ConfirmUpdateAccount2FA(ctx *fiber.Ctx) error { - requestID := getRequestID(ctx) - logger := c.buildLogger(requestID, authLocation, "ConfirmUpdateAccount2FAUpdate") - logRequest(logger, ctx) - - accountClaims, serviceErr := getAccountClaims(ctx) - if serviceErr != nil { - return serviceErrorResponse(logger, ctx, serviceErr) - } - - body := new(bodies.TwoFactorLoginBody) - if err := ctx.BodyParser(body); err != nil { - return parseRequestErrorResponse(logger, ctx, err) - } - if err := c.validate.StructCtx(ctx.UserContext(), body); err != nil { - return validateBodyErrorResponse(logger, ctx, err) - } - - authDTO, serviceErr := c.services.ConfirmUpdateAccount2FAUpdate(ctx.UserContext(), services.ConfirmUpdateAccount2FAUpdateOptions{ - RequestID: requestID, - PublicID: accountClaims.AccountID, - Version: accountClaims.AccountVersion, - Code: body.Code, - }) - if serviceErr != nil { - return serviceErrorResponse(logger, ctx, serviceErr) - } - - logResponse(logger, ctx, fiber.StatusOK) - c.saveAccountRefreshCookie(ctx, authDTO.RefreshToken) - return ctx.Status(fiber.StatusOK).JSON(&authDTO) -} diff --git a/idp/internal/controllers/bodies/account_2fa_configs.go b/idp/internal/controllers/bodies/account_2fa_configs.go new file mode 100644 index 0000000..57b2540 --- /dev/null +++ b/idp/internal/controllers/bodies/account_2fa_configs.go @@ -0,0 +1,12 @@ +// Copyright (c) 2025 Afonso Barracha +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package bodies + +type Account2FAConfigBody struct { + TwoFAType string `json:"two_factor_type" validate:"required,oneof=email totp"` + IsDefault bool `json:"is_default" validate:"required,boolean"` +} diff --git a/idp/internal/controllers/bodies/account_credentials.go b/idp/internal/controllers/bodies/account_credentials.go index e30d344..e8b6a0a 100644 --- a/idp/internal/controllers/bodies/account_credentials.go +++ b/idp/internal/controllers/bodies/account_credentials.go @@ -7,15 +7,31 @@ package bodies type CreateAccountCredentialsBody struct { - Scopes []string `json:"scopes" validate:"required,unique,dive,oneof=account:admin account:users:read account:users:write account:apps:read account:apps:write account:credentials:read account:credentials:write account:auth_providers:read"` - Alias string `json:"alias" validate:"required,min=1,max=50,slug"` - AuthMethod string `json:"auth_method" validate:"required,oneof=client_secret_basic client_secret_post client_secret_jwt private_key_jwt"` - Issuers []string `json:"issuers,omitempty" validate:"required_if=AuthMethod private_key_jwt,unique,dive,url"` - Algorithm string `json:"algorithm,omitempty" validate:"omitempty,oneof=ES256 EdDSA"` + Type string `json:"type" validate:"required,oneof=native service mcp"` + Name string `json:"name" validate:"required,min=1,max=255"` + Scopes []string `json:"scopes" validate:"required,unique,dive,oneof=email profile account:admin account:users:read account:users:write account:apps:read account:apps:write account:credentials:read account:credentials:write account:auth_providers:read"` + Transport string `json:"transport,omitempty" validate:"required_if=Type mcp,oneof=http https stdio streamable_http"` + TokenEndpointAuthMethod string `json:"token_endpoint_auth_method" validate:"required,oneof=client_secret_basic client_secret_post client_secret_jwt private_key_jwt"` + Domain string `json:"domain,omitempty" validate:"omitempty,fqdn,max=250"` + ClientURI string `json:"client_uri" validate:"required,uri"` + RedirectURIs []string `json:"redirect_uris,omitempty" validate:"omitempty,unique,dive,uri"` + LogoURI string `json:"logo_uri,omitempty" validate:"omitempty,uri"` + TOSURI string `json:"tos_uri,omitempty" validate:"omitempty,uri"` + PolicyURI string `json:"policy_uri,omitempty" validate:"omitempty,uri"` + SoftwareID string `json:"software_id" validate:"required,min=1,max=100"` + SoftwareVersion string `json:"software_version" validate:"required,min=1,max=100"` + Algorithm string `json:"algorithm,omitempty" validate:"omitempty,oneof=ES256 EdDSA"` } type UpdateAccountCredentialsBody struct { - Scopes []string `json:"scopes" validate:"required,unique,dive,oneof=account:admin account:users:read account:users:write account:apps:read account:apps:write account:credentials:read account:credentials:write account:auth_providers:read"` - Alias string `json:"alias" validate:"required,min=1,max=50,slug"` - Issuers []string `json:"issuers" validate:"required,unique,dive,url"` + Name string `json:"name" validate:"required,min=1,max=255"` + Scopes []string `json:"scopes" validate:"required,unique,dive,oneof=account:admin account:users:read account:users:write account:apps:read account:apps:write account:credentials:read account:credentials:write account:auth_providers:read"` + Transport string `json:"transport,omitempty" validate:"omitempty,oneof=http https"` + Domain string `json:"domain,omitempty" validate:"omitempty,fqdn,max=250"` + ClientURI string `json:"client_uri" validate:"required,uri"` + RedirectURIs []string `json:"redirect_uris,omitempty" validate:"omitempty,unique,dive,uri"` + LogoURI string `json:"logo_uri,omitempty" validate:"omitempty,uri"` + TOSURI string `json:"tos_uri,omitempty" validate:"omitempty,uri"` + PolicyURI string `json:"policy_uri,omitempty" validate:"omitempty,uri"` + SoftwareVersion string `json:"software_version,omitempty" validate:"omitempty,min=1,max=100"` } diff --git a/idp/internal/controllers/bodies/account_dynamics_registration_configs.go b/idp/internal/controllers/bodies/account_dynamics_registration_configs.go new file mode 100644 index 0000000..409f3a2 --- /dev/null +++ b/idp/internal/controllers/bodies/account_dynamics_registration_configs.go @@ -0,0 +1,16 @@ +// Copyright (c) 2025 Afonso Barracha +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package bodies + +type AccountDynamicRegistrationConfigBody struct { + AccountCredentialsTypes []string `json:"account_credentials_types" validate:"required,unique,min=1,max=3,oneof=native service mcp"` + WhitelistedDomains []string `json:"whitelisted_domains" validate:"omitempty,unique,min=1,max=250,dive,fqdn"` + RequireSoftwareStatementCredentialTypes []string `json:"require_software_statement_credential_types" validate:"omitempty,unique,min=1,max=3,oneof=native service mcp"` + SoftwareStatementVerificationMethods []string `json:"software_statement_verification_methods" validate:"omitempty,unique,min=1,max=2,oneof=manual jwks_uri"` + RequireInitialAccessTokenCredentialTypes []string `json:"require_initial_access_token_credential_types" validate:"omitempty,unique,min=1,max=3,oneof=native service mcp"` + InitialAccessTokenGenerationMethods []string `json:"initial_access_token_generation_methods" validate:"omitempty,unique,min=1,max=2,oneof=manual authorization_code"` +} diff --git a/idp/internal/controllers/bodies/accounts.go b/idp/internal/controllers/bodies/accounts.go index fb2d17f..5b41cf4 100644 --- a/idp/internal/controllers/bodies/accounts.go +++ b/idp/internal/controllers/bodies/accounts.go @@ -7,6 +7,6 @@ package bodies type UpdateAccountBody struct { - GivenName string `json:"given_name" validate:"required,min=2,max=50"` - FamilyName string `json:"family_name" validate:"required,min=2,max=50"` + GivenName string `json:"given_name" validate:"required,min=2,max=100"` + FamilyName string `json:"family_name" validate:"required,min=2,max=100"` } diff --git a/idp/internal/controllers/bodies/apps.go b/idp/internal/controllers/bodies/apps.go index 169864a..ad13063 100644 --- a/idp/internal/controllers/bodies/apps.go +++ b/idp/internal/controllers/bodies/apps.go @@ -8,7 +8,7 @@ package bodies type CreateAppBodyBase struct { Type string `json:"type" validate:"required,oneof=web spa native backend device service mcp"` - Name string `json:"name" validate:"required,min=3,max=50"` + Name string `json:"name" validate:"required,min=1,max=255"` Domain string `json:"domain" validate:"omitempty,fqdn,max=250"` ClientURI string `json:"client_uri" validate:"required,url"` LogoURI string `json:"logo_uri,omitempty" validate:"omitempty,url"` @@ -25,7 +25,7 @@ type CreateAppBodyBase struct { } type UpdateAppBodyBase struct { - Name string `json:"name" validate:"required,max=50,min=3"` + Name string `json:"name" validate:"required,max=255,min=1"` Domain string `json:"domain" validate:"omitempty,fqdn,max=250"` ClientURI string `json:"client_uri" validate:"required,url"` LogoURI string `json:"logo_uri,omitempty" validate:"omitempty,url"` diff --git a/idp/internal/controllers/bodies/auth.go b/idp/internal/controllers/bodies/auth.go index 87fd7f0..8131f6b 100644 --- a/idp/internal/controllers/bodies/auth.go +++ b/idp/internal/controllers/bodies/auth.go @@ -7,9 +7,9 @@ package bodies type RegisterAccountBody struct { - Email string `json:"email" validate:"required,email"` - GivenName string `json:"given_name" validate:"required,min=2,max=50"` - FamilyName string `json:"family_name" validate:"required,min=2,max=50"` + Email string `json:"email" validate:"required,email,max=250"` + GivenName string `json:"given_name" validate:"required,min=2,max=100"` + FamilyName string `json:"family_name" validate:"required,min=2,max=100"` Username string `json:"username,omitempty" validate:"omitempty,min=3,max=63,slug"` Password string `json:"password" validate:"required,min=8,max=100,password"` Password2 string `json:"password2" validate:"required,eqfield=Password"` diff --git a/idp/internal/controllers/bodies/common_auth.go b/idp/internal/controllers/bodies/common_auth.go index 5821947..4900f2b 100644 --- a/idp/internal/controllers/bodies/common_auth.go +++ b/idp/internal/controllers/bodies/common_auth.go @@ -15,7 +15,7 @@ type ConfirmationTokenBody struct { } type LoginBody struct { - Email string `json:"email" validate:"required,email"` + Email string `json:"email" validate:"required,email,max=250"` Password string `json:"password" validate:"required,min=1"` } diff --git a/idp/internal/controllers/bodies/dynamic_registration_domains.go b/idp/internal/controllers/bodies/dynamic_registration_domains.go new file mode 100644 index 0000000..8966c1b --- /dev/null +++ b/idp/internal/controllers/bodies/dynamic_registration_domains.go @@ -0,0 +1,11 @@ +// Copyright (c) 2025 Afonso Barracha +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package bodies + +type CreateDynamicRegistrationDomainBody struct { + Domain string `json:"domain" validate:"required,fqdn,max=250"` +} diff --git a/idp/internal/controllers/bodies/oauth_dynamic_registration.go b/idp/internal/controllers/bodies/oauth_dynamic_registration.go new file mode 100644 index 0000000..3ef4948 --- /dev/null +++ b/idp/internal/controllers/bodies/oauth_dynamic_registration.go @@ -0,0 +1,53 @@ +// Copyright (c) 2025 Afonso Barracha +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package bodies + +type OAuthDynamicClientRegistrationBody struct { + RedirectURIs []string `json:"redirect_uris" validate:"required,min=1,dive,uri"` + TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty" validate:"omitempty,oneof=none client_secret_basic client_secret_post client_secret_jwt private_key_jwt"` + ResponseTypes []string `json:"response_types,omitempty" validate:"omitempty,dive,oneof=none code"` + GrantTypes []string `json:"grant_types,omitempty" validate:"omitempty,min=1,dive,oneof=authorization_code refresh_token client_credentials urn:ietf:params:oauth:grant-type:jwt-bearer"` + ApplicationType string `json:"application_type" validate:"required,oneof=native service mcp"` + ClientName string `json:"client_name" validate:"required,min=1,max=255"` + ClientURI string `json:"client_uri" validate:"required,url"` + LogoURI string `json:"logo_uri,omitempty" validate:"omitempty,url"` + Scope string `json:"scope" validate:"required,multiple_scope"` + Contacts []string `json:"contacts,omitempty" validate:"omitempty,unique,dive,email"` + TOSURI string `json:"tos_uri,omitempty" validate:"omitempty,url"` + PolicyURI string `json:"policy_uri,omitempty" validate:"omitempty,url"` + JWKsURI string `json:"jwks_uri,omitempty" validate:"omitempty,url"` + JWKs []string `json:"jwks,omitempty" validate:"omitempty,json"` + SoftwareID string `json:"software_id,omitempty" validate:"omitempty,max=250"` + SoftwareVersion string `json:"software_version,omitempty" validate:"omitempty,max=250"` +} + +type OAuthDynamicRegistrationIATAuthHiddenFieldsBody struct { + CSRFToken string `json:"csrf_token" validate:"required,min=21,base64rawurl"` + ClientID string `json:"client_id" validate:"required,fqdn"` + ResponseType string `json:"response_type" validate:"required,oneof=code"` + CodeChallenge string `json:"code_challenge" validate:"required,min=1"` + CodeChallengeMethod string `json:"code_challenge_method" validate:"omitempty,oneof=plain s256 S256"` + State string `json:"state" validate:"required,min=1"` + RedirectURI string `json:"redirect_uri" validate:"required,uri"` +} + +type OAuthDynamicRegistrationIATTokenBody struct { + ClientID string `json:"client_id" validate:"required,fqdn"` + GrantType string `json:"grant_type" validate:"required,eq=authorization_code"` + Code string `json:"code" validate:"required,min=1"` + CodeVerifier string `json:"code_verifier" validate:"required,min=1"` +} + +type OAuthDynamicRegistrationIATExtAppleUserBody struct { + Email string `json:"email" validate:"required,email"` +} + +type OAuthDynamicRegistrationIATExtAppleBody struct { + Code string `json:"code" validate:"required,min=1"` + State string `json:"state" validate:"required,min=1"` + User string `json:"user" validate:"required,json"` +} diff --git a/idp/internal/controllers/bodies/users.go b/idp/internal/controllers/bodies/users.go index 6876773..7275f41 100644 --- a/idp/internal/controllers/bodies/users.go +++ b/idp/internal/controllers/bodies/users.go @@ -3,15 +3,15 @@ package bodies type UserData = map[string]any type CreateUserBody struct { - Email string `json:"email" validate:"required,email"` - Username string `json:"username,omitempty" validate:"omitempty,min=3,max=100,slug"` + Email string `json:"email" validate:"required,email,max=250"` + Username string `json:"username,omitempty" validate:"omitempty,min=3,max=63,slug"` Password string `json:"password" validate:"required,min=8,max=100,password"` UserData } type UpdateUserBody struct { - Email string `json:"email" validate:"omitempty,email"` - Username string `json:"username,omitempty" validate:"omitempty,min=3,max=100,slug"` + Email string `json:"email" validate:"omitempty,email,max=250"` + Username string `json:"username,omitempty" validate:"omitempty,min=3,max=63,slug"` IsActive bool `json:"is_active"` UserData } diff --git a/idp/internal/controllers/controllers.go b/idp/internal/controllers/controllers.go index 667cc6e..f454d74 100644 --- a/idp/internal/controllers/controllers.go +++ b/idp/internal/controllers/controllers.go @@ -16,28 +16,28 @@ import ( ) type Controllers struct { - logger *slog.Logger - services *services.Services - validate *validator.Validate - frontendDomain string - backendDomain string - refreshCookieName string + logger *slog.Logger + services *services.Services + validate *validator.Validate + frontendDomain string + backendDomain string + cookieName string } func NewControllers( logger *slog.Logger, services *services.Services, validate *validator.Validate, - frontendDomain, - backendDomain, - refreshCookieName string, + frontendDomain string, + backendDomain string, + cookieName string, ) *Controllers { return &Controllers{ - logger: logger.With(utils.BaseLayer, utils.ControllersLogLayer), - services: services, - validate: validate, - frontendDomain: frontendDomain, - backendDomain: backendDomain, - refreshCookieName: refreshCookieName, + logger: logger.With(utils.BaseLayer, utils.ControllersLogLayer), + services: services, + validate: validate, + frontendDomain: frontendDomain, + backendDomain: backendDomain, + cookieName: cookieName, } } diff --git a/idp/internal/controllers/helpers.go b/idp/internal/controllers/helpers.go index b56a8ae..3e3d1a4 100644 --- a/idp/internal/controllers/helpers.go +++ b/idp/internal/controllers/helpers.go @@ -10,10 +10,12 @@ import ( "errors" "fmt" "log/slog" + "net/url" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" "github.com/google/uuid" + "github.com/tugascript/devlogs/idp/internal/services/templates" "github.com/tugascript/devlogs/idp/internal/exceptions" "github.com/tugascript/devlogs/idp/internal/utils" @@ -22,6 +24,8 @@ import ( const ( cacheControlNoStore string = "no-store, no-cache, must-revalidate, private" + refreshCookieSuffix = "_rt" + grantTypeRefresh string = "refresh_token" grantTypeAuthorization string = "authorization_code" grantTypeClientCredentials string = "client_credentials" @@ -59,33 +63,34 @@ func logResponse(logger *slog.Logger, ctx *fiber.Ctx, status int) { ) } -func validateErrorResponse(logger *slog.Logger, ctx *fiber.Ctx, location string, err error) error { - logger.WarnContext(ctx.UserContext(), "Failed to validate request", "error", err) - logResponse(logger, ctx, fiber.StatusBadRequest) - +func validationErrorException(location string, err error) *exceptions.ValidationErrorResponse { var errs validator.ValidationErrors ok := errors.As(err, &errs) if !ok { - return ctx. - Status(fiber.StatusBadRequest). - JSON(exceptions.NewEmptyValidationErrorResponse(location)) + return exceptions.NewEmptyValidationErrorResponse(location) } + return exceptions.ValidationErrorResponseFromErr(&errs, location) +} + +func validateErrorJSONResponse(logger *slog.Logger, ctx *fiber.Ctx, location string, err error) error { + logger.WarnContext(ctx.UserContext(), "Failed to validate request", "error", err) + logResponse(logger, ctx, fiber.StatusBadRequest) return ctx. Status(fiber.StatusBadRequest). - JSON(exceptions.ValidationErrorResponseFromErr(&errs, location)) + JSON(validationErrorException(location, err)) } func validateBodyErrorResponse(logger *slog.Logger, ctx *fiber.Ctx, err error) error { - return validateErrorResponse(logger, ctx, exceptions.ValidationResponseLocationBody, err) + return validateErrorJSONResponse(logger, ctx, exceptions.ValidationResponseLocationBody, err) } func validateURLParamsErrorResponse(logger *slog.Logger, ctx *fiber.Ctx, err error) error { - return validateErrorResponse(logger, ctx, exceptions.ValidationResponseLocationParams, err) + return validateErrorJSONResponse(logger, ctx, exceptions.ValidationResponseLocationParams, err) } func validateQueryParamsErrorResponse(logger *slog.Logger, ctx *fiber.Ctx, err error) error { - return validateErrorResponse(logger, ctx, exceptions.ValidationResponseLocationQuery, err) + return validateErrorJSONResponse(logger, ctx, exceptions.ValidationResponseLocationQuery, err) } func serviceErrorResponse(logger *slog.Logger, ctx *fiber.Ctx, serviceErr *exceptions.ServiceError) error { @@ -103,6 +108,27 @@ func serviceErrorWithFieldsResponse(logger *slog.Logger, ctx *fiber.Ctx, service )) } +func serviceErrorHTMLResponse(logger *slog.Logger, ctx *fiber.Ctx, serviceErr *exceptions.ServiceError) error { + status := exceptions.NewRequestErrorStatus(serviceErr.Code) + errHtml, err := templates.BuildErrorTemplate( + templates.ErrorTemplateOptions{ + Status: status, + ErrorCode: serviceErr.Code, + MessageTitle: serviceErr.Message, + }, + ) + if err != nil { + logger.ErrorContext(ctx.UserContext(), "Failed to build error template", "error", err) + logResponse(logger, ctx, fiber.StatusInternalServerError) + return ctx.Status(fiber.StatusInternalServerError). + Type("html"). + SendString(templates.InternalServerErrorTemplate) + } + + logResponse(logger, ctx, status) + return ctx.Status(status).Type("html").SendString(errHtml) +} + func oauthErrorResponse(logger *slog.Logger, ctx *fiber.Ctx, message string) error { resErr := exceptions.NewOAuthError(message) @@ -130,3 +156,37 @@ func parseRequestErrorResponse(logger *slog.Logger, ctx *fiber.Ctx, err error) e Status(fiber.StatusBadRequest). JSON(exceptions.NewEmptyValidationErrorResponse(exceptions.ValidationResponseLocationBody)) } + +func (c *Controllers) redirectErrorCallback( + logger *slog.Logger, + ctx *fiber.Ctx, + redirectURI string, + state string, + errMsg string, +) error { + qPrams := make(url.Values) + qPrams.Add("error", errMsg) + if state != "" { + qPrams.Add("state", state) + } + qPrams.Add("iss", fmt.Sprintf("https://%s", c.backendDomain)) + logResponse(logger, ctx, fiber.StatusFound) + return ctx.Redirect(redirectURI+"?"+qPrams.Encode(), fiber.StatusFound) +} + +func (c *Controllers) redirectServiceErrorCallback( + logger *slog.Logger, + ctx *fiber.Ctx, + redirectURI string, + state string, + serviceErr *exceptions.ServiceError, +) error { + switch serviceErr.Code { + case exceptions.CodeUnauthorized, exceptions.CodeForbidden: + return c.redirectErrorCallback(logger, ctx, redirectURI, state, exceptions.OAuthErrorAccessDenied) + case exceptions.CodeNotFound, exceptions.CodeValidation: + return c.redirectErrorCallback(logger, ctx, redirectURI, state, exceptions.OAuthErrorInvalidRequest) + default: + return c.redirectErrorCallback(logger, ctx, redirectURI, state, exceptions.OAuthServerError) + } +} diff --git a/idp/internal/controllers/middleware.go b/idp/internal/controllers/middleware.go index ef93a3c..7840ec5 100644 --- a/idp/internal/controllers/middleware.go +++ b/idp/internal/controllers/middleware.go @@ -111,16 +111,17 @@ func (c *Controllers) AccountAccessClaimsMiddleware(ctx *fiber.Ctx) error { } func (c *Controllers) TwoFAAccessClaimsMiddleware(ctx *fiber.Ctx) error { + requestID := getRequestID(ctx) logger := c.buildLogger(getRequestID(ctx), middlewareLocation, "TwoFAAccessClaimsMiddleware") authHeader := ctx.Get("Authorization") if authHeader == "" { return serviceErrorResponse(logger, ctx, exceptions.NewUnauthorizedError()) } - accountClaims, serviceErr := c.services.Process2FAAuthHeader( + accountClaims, twoFAType, serviceErr := c.services.Process2FAAuthHeader( ctx.UserContext(), services.ProcessAuthHeaderOptions{ - RequestID: getRequestID(ctx), + RequestID: requestID, AuthHeader: authHeader, }, ) @@ -129,6 +130,7 @@ func (c *Controllers) TwoFAAccessClaimsMiddleware(ctx *fiber.Ctx) error { } ctx.Locals("account", accountClaims) + ctx.Locals("twoFAType", twoFAType) return ctx.Next() } @@ -195,10 +197,14 @@ func (c *Controllers) AdminScopeMiddleware(ctx *fiber.Ctx) error { return ctx.Next() } -func processHost(host string) (string, error) { +func processHost(backendDomain string, host string) (string, error) { + if !strings.HasSuffix(host, "."+backendDomain) { + return "", errors.New("invalid host") + } + hostArr := strings.Split(host, ".") - if len(hostArr) < 2 { - return "", errors.New("host must contain at least two parts") + if len(hostArr) < 3 { + return "", errors.New("host must contain at least three parts") } username := hostArr[0] @@ -212,12 +218,13 @@ func processHost(host string) (string, error) { func (c *Controllers) AccountHostMiddleware(ctx *fiber.Ctx) error { requestID := getRequestID(ctx) logger := c.buildLogger(requestID, middlewareLocation, "AccountHostMiddleware") - host := ctx.Get("Host") + host := ctx.Hostname() if host == "" { + logger.DebugContext(ctx.UserContext(), "no host found") return serviceErrorResponse(logger, ctx, exceptions.NewNotFoundError()) } - username, err := processHost(host) + username, err := processHost(c.backendDomain, host) if err != nil { logger.DebugContext(ctx.UserContext(), "invalid host", "error", err) return serviceErrorResponse(logger, ctx, exceptions.NewNotFoundError()) @@ -245,7 +252,6 @@ func (c *Controllers) AccountHostMiddleware(ctx *fiber.Ctx) error { func getAccountClaims(ctx *fiber.Ctx) (tokens.AccountClaims, *exceptions.ServiceError) { account, ok := ctx.Locals("account").(tokens.AccountClaims) - if !ok || account.AccountID == uuid.Nil { return tokens.AccountClaims{}, exceptions.NewUnauthorizedError() } @@ -253,6 +259,20 @@ func getAccountClaims(ctx *fiber.Ctx) (tokens.AccountClaims, *exceptions.Service return account, nil } +func getAccounts2FAClaims(ctx *fiber.Ctx) (tokens.AccountClaims, tokens.TwoFAType, *exceptions.ServiceError) { + account, ok := ctx.Locals("account").(tokens.AccountClaims) + if !ok || account.AccountID == uuid.Nil { + return tokens.AccountClaims{}, "", exceptions.NewUnauthorizedError() + } + + twoFAType, ok := ctx.Locals("twoFAType").(tokens.TwoFAType) + if !ok || twoFAType == "" { + return tokens.AccountClaims{}, "", exceptions.NewUnauthorizedError() + } + + return account, twoFAType, nil +} + func getScopes(ctx *fiber.Ctx) ([]tokens.AccountScope, *exceptions.ServiceError) { scopes, ok := ctx.Locals("scopes").([]tokens.AccountScope) if !ok || scopes == nil { @@ -290,20 +310,6 @@ func getUserAccessClaims(ctx *fiber.Ctx) (tokens.UserAuthClaims, tokens.AppClaim return user, app, scopes, nil } -func getUserPurposeClaims(ctx *fiber.Ctx) (tokens.UserPurposeClaims, tokens.AppClaims, *exceptions.ServiceError) { - user, ok := ctx.Locals("user").(tokens.UserPurposeClaims) - if !ok || user.UserID == uuid.Nil { - return tokens.UserPurposeClaims{}, tokens.AppClaims{}, exceptions.NewUnauthorizedError() - } - - app, ok := ctx.Locals("app").(tokens.AppClaims) - if !ok || app.ClientID == "" { - return tokens.UserPurposeClaims{}, tokens.AppClaims{}, exceptions.NewUnauthorizedError() - } - - return user, app, nil -} - func getHostAccount(ctx *fiber.Ctx) (string, int32, *exceptions.ServiceError) { accountUsername, ok := ctx.Locals("accountUsername").(string) if !ok || accountUsername == "" { diff --git a/idp/internal/controllers/oauth.go b/idp/internal/controllers/oauth.go index eb41f98..8e57b77 100644 --- a/idp/internal/controllers/oauth.go +++ b/idp/internal/controllers/oauth.go @@ -10,6 +10,7 @@ import ( "encoding/json" "fmt" "log/slog" + "net/url" "github.com/gofiber/fiber/v2" @@ -30,6 +31,38 @@ func formatAccountRedirectURL(backendDomain, provider string) string { return fmt.Sprintf("https://%s/v1/auth/oauth2/%s/callback", backendDomain, provider) } +func (c *Controllers) errorCallback(logger *slog.Logger, ctx *fiber.Ctx, state string, errStr string) error { + qPrams := make(url.Values) + qPrams.Add("error", errStr) + if state != "" { + qPrams.Add("state", state) + } + + qPrams.Add("iss", fmt.Sprintf("https://%s", c.backendDomain)) + ctx.Set(fiber.HeaderCacheControl, cacheControlNoStore) + logResponse(logger, ctx, fiber.StatusFound) + return ctx.Redirect( + fmt.Sprintf("https://%s/auth/callback?error=%s", c.frontendDomain, qPrams.Encode()), + fiber.StatusFound, + ) +} + +func (c *Controllers) serviceErrorCallback( + logger *slog.Logger, + ctx *fiber.Ctx, + state string, + serviceErr *exceptions.ServiceError, +) error { + switch serviceErr.Code { + case exceptions.CodeUnauthorized, exceptions.CodeForbidden: + return c.errorCallback(logger, ctx, state, exceptions.OAuthErrorAccessDenied) + case exceptions.CodeNotFound, exceptions.CodeValidation: + return c.errorCallback(logger, ctx, state, exceptions.OAuthErrorInvalidRequest) + default: + return c.errorCallback(logger, ctx, state, exceptions.OAuthServerError) + } +} + func (c *Controllers) AccountOAuthURL(ctx *fiber.Ctx) error { requestID := getRequestID(ctx) logger := c.buildLogger(requestID, oauthLocation, "AccountOAuthURL") @@ -43,10 +76,10 @@ func (c *Controllers) AccountOAuthURL(ctx *fiber.Ctx) error { State: ctx.Query("state"), } if err := c.validate.StructCtx(ctx.UserContext(), qPrms); err != nil { - return validateQueryParamsErrorResponse(logger, ctx, err) + return c.errorCallback(logger, ctx, qPrms.State, exceptions.OAuthErrorInvalidRequest) } - url, serviceErr := c.services.AccountOAuthURL(ctx.UserContext(), services.AccountOAuthURLOptions{ + oAuthURL, serviceErr := c.services.AccountOAuthURL(ctx.UserContext(), services.AccountOAuthURLOptions{ RequestID: requestID, Provider: qPrms.ClientID, RedirectURL: formatAccountRedirectURL(c.backendDomain, qPrms.ClientID), @@ -55,11 +88,11 @@ func (c *Controllers) AccountOAuthURL(ctx *fiber.Ctx) error { State: qPrms.State, }) if serviceErr != nil { - return serviceErrorResponse(logger, ctx, serviceErr) + return c.serviceErrorCallback(logger, ctx, qPrms.State, serviceErr) } logResponse(logger, ctx, fiber.StatusFound) - return ctx.Redirect(url, fiber.StatusFound) + return ctx.Redirect(oAuthURL, fiber.StatusFound) } func (c *Controllers) acceptCallback(logger *slog.Logger, ctx *fiber.Ctx, oauthParams string) error { @@ -71,15 +104,6 @@ func (c *Controllers) acceptCallback(logger *slog.Logger, ctx *fiber.Ctx, oauthP ) } -func (c *Controllers) errorCallback(logger *slog.Logger, ctx *fiber.Ctx, errStr string) error { - ctx.Set(fiber.HeaderCacheControl, cacheControlNoStore) - logResponse(logger, ctx, fiber.StatusFound) - return ctx.Redirect( - fmt.Sprintf("https://%s/auth/callback?error=%s", c.frontendDomain, errStr), - fiber.StatusFound, - ) -} - func (c *Controllers) AccountOAuthCallback(ctx *fiber.Ctx) error { requestID := getRequestID(ctx) logger := c.buildLogger(requestID, oauthLocation, "AccountOAuthCallback") @@ -90,35 +114,28 @@ func (c *Controllers) AccountOAuthCallback(ctx *fiber.Ctx) error { return validateURLParamsErrorResponse(logger, ctx, err) } - queryParams := params.OAuthCallbackQueryParams{ + qPrms := params.OAuthCallbackQueryParams{ Code: ctx.Query("code"), State: ctx.Query("state"), } - if err := c.validate.StructCtx(ctx.UserContext(), queryParams); err != nil { + if err := c.validate.StructCtx(ctx.UserContext(), &qPrms); err != nil { errQuery := ctx.Query("error") if errQuery != "" { - return c.errorCallback(logger, ctx, errQuery) + return c.errorCallback(logger, ctx, qPrms.State, errQuery) } - return c.errorCallback(logger, ctx, exceptions.OAuthErrorInvalidRequest) + return c.errorCallback(logger, ctx, qPrms.State, exceptions.OAuthErrorInvalidRequest) } oauthParams, serviceErr := c.services.ExtLoginAccount(ctx.UserContext(), services.ExtLoginAccountOptions{ RequestID: requestID, Provider: urlParams.Provider, - Code: queryParams.Code, - State: queryParams.State, + Code: qPrms.Code, + State: qPrms.State, RedirectURL: formatAccountRedirectURL(c.backendDomain, urlParams.Provider), }) if serviceErr != nil { - switch serviceErr.Code { - case exceptions.CodeUnauthorized, exceptions.CodeForbidden: - return c.errorCallback(logger, ctx, exceptions.OAuthErrorAccessDenied) - case exceptions.CodeNotFound, exceptions.CodeValidation: - return c.errorCallback(logger, ctx, exceptions.OAuthErrorInvalidRequest) - default: - return c.errorCallback(logger, ctx, exceptions.OAuthServerError) - } + return c.serviceErrorCallback(logger, ctx, qPrms.State, serviceErr) } return c.acceptCallback(logger, ctx, oauthParams) @@ -130,24 +147,24 @@ func (c *Controllers) AccountAppleCallback(ctx *fiber.Ctx) error { logRequest(logger, ctx) if ctx.Get("Content-Type") != "application/x-www-form-urlencoded" { - return c.errorCallback(logger, ctx, exceptions.OAuthErrorInvalidRequest) + return c.errorCallback(logger, ctx, "", exceptions.OAuthErrorInvalidRequest) } body := new(bodies.AppleLoginBody) if err := ctx.BodyParser(body); err != nil { - return c.errorCallback(logger, ctx, exceptions.OAuthErrorInvalidRequest) + return c.errorCallback(logger, ctx, body.State, exceptions.OAuthErrorInvalidRequest) } if err := c.validate.StructCtx(ctx.UserContext(), body); err != nil { - return c.errorCallback(logger, ctx, exceptions.OAuthErrorInvalidRequest) + return c.errorCallback(logger, ctx, body.State, exceptions.OAuthErrorInvalidRequest) } user := new(bodies.AppleUser) if err := json.Unmarshal([]byte(body.User), user); err != nil { - return c.errorCallback(logger, ctx, exceptions.OAuthErrorInvalidScope) + return c.errorCallback(logger, ctx, body.State, exceptions.OAuthErrorInvalidScope) } if err := c.validate.StructCtx(ctx.UserContext(), user); err != nil { logger.WarnContext(ctx.UserContext(), "Failed to parse apple user data") - return c.errorCallback(logger, ctx, exceptions.OAuthErrorInvalidScope) + return c.errorCallback(logger, ctx, body.State, exceptions.OAuthErrorInvalidScope) } oauthParams, serviceErr := c.services.AppleLoginAccount(ctx.UserContext(), services.AppleLoginAccountOptions{ @@ -159,14 +176,7 @@ func (c *Controllers) AccountAppleCallback(ctx *fiber.Ctx) error { State: body.State, }) if serviceErr != nil { - switch serviceErr.Code { - case exceptions.CodeUnauthorized, exceptions.CodeForbidden: - return c.errorCallback(logger, ctx, exceptions.OAuthErrorAccessDenied) - case exceptions.CodeNotFound, exceptions.CodeValidation: - return c.errorCallback(logger, ctx, exceptions.OAuthErrorInvalidRequest) - default: - return c.errorCallback(logger, ctx, exceptions.OAuthServerError) - } + return c.serviceErrorCallback(logger, ctx, body.State, serviceErr) } return c.acceptCallback(logger, ctx, oauthParams) @@ -393,18 +403,10 @@ func (c *Controllers) AccountOAuthToken(ctx *fiber.Ctx) error { logRequest(logger, ctx) if ctx.Get("Content-Type") != "application/x-www-form-urlencoded" { - return serviceErrorResponse(logger, ctx, exceptions.NewUnsupportedMediaTypeError( - "Content-Type must be application/x-www-form-urlencoded", - )) + return oauthErrorResponse(logger, ctx, exceptions.OAuthErrorInvalidRequest) } grantType := ctx.FormValue("grant_type") - if grantType == "" { - logger.WarnContext(ctx.UserContext(), "Missing grant_type") - logResponse(logger, ctx, fiber.StatusBadRequest) - - } - switch grantType { case grantTypeRefresh: return c.accountRefreshToken(ctx, requestID) diff --git a/idp/internal/controllers/oauth_dynamic_registration.go b/idp/internal/controllers/oauth_dynamic_registration.go new file mode 100644 index 0000000..e7a49d1 --- /dev/null +++ b/idp/internal/controllers/oauth_dynamic_registration.go @@ -0,0 +1,676 @@ +// Copyright (c) 2025 Afonso Barracha +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package controllers + +import ( + "encoding/json" + "fmt" + + "github.com/gofiber/fiber/v2" + + "github.com/tugascript/devlogs/idp/internal/controllers/bodies" + "github.com/tugascript/devlogs/idp/internal/controllers/params" + "github.com/tugascript/devlogs/idp/internal/controllers/paths" + "github.com/tugascript/devlogs/idp/internal/exceptions" + "github.com/tugascript/devlogs/idp/internal/services" + "github.com/tugascript/devlogs/idp/internal/utils" +) + +const ( + oauthDynamicRegistration string = "oauth_dynamic_registration" + + accountsIATCookieSuffix string = "_acc_iat" + accountsIAT2FACookieSuffix string = "_acc_iat_2fa" +) + +func (c *Controllers) OAuthDynamicRegistrationIATAuth(ctx *fiber.Ctx) error { + requestID := getRequestID(ctx) + logger := c.buildLogger(requestID, oauthDynamicRegistration, "OAuthDynamicRegistrationIATAuth") + logRequest(logger, ctx) + + baseQPrms := params.OAuthDynamicRegistrationIATAuthBaseQueryParams{ + ClientID: ctx.Query("client_id"), + RedirectURI: ctx.Query("redirect_uri"), + } + if err := c.validate.StructCtx(ctx.UserContext(), baseQPrms); err != nil { + return serviceErrorHTMLResponse(logger, ctx, exceptions.NewForbiddenError()) + } + + responseType := ctx.Query("response_type") + state := ctx.Query("state") + if responseType != "code" { + return c.redirectErrorCallback(logger, ctx, baseQPrms.RedirectURI, state, exceptions.OAuthErrorUnsupportedResponseType) + } + + qPrms := params.OAuthDynamicRegistrationIATAuthQueryParams{ + ResponseType: responseType, + Challenge: ctx.Query("code_challenge"), + ChallengeMethod: ctx.Query("code_challenge_method"), + State: state, + } + if err := c.validate.StructCtx(ctx.UserContext(), qPrms); err != nil { + return c.redirectErrorCallback(logger, ctx, baseQPrms.RedirectURI, state, exceptions.OAuthErrorInvalidRequest) + } + + sessionKey := ctx.Cookies(c.cookieName + accountsIATCookieSuffix) + if sessionKey != "" { + // This ensures that the key is only used once + c.removeAccountIATCookie(ctx) + } + + redirectURL, serviceErr := c.services.InitiateOAuthDynamicRegistrationIATAuth( + ctx.UserContext(), + services.InitiateOAuthDynamicRegistrationIATAuthOptions{ + RequestID: requestID, + Domain: baseQPrms.ClientID, + State: qPrms.State, + SessionKey: sessionKey, + RefreshToken: ctx.Cookies(c.cookieName + refreshCookieSuffix), + Challenge: qPrms.Challenge, + ChallengeMethod: qPrms.ChallengeMethod, + RedirectURI: baseQPrms.RedirectURI, + BackendDomain: c.backendDomain, + }, + ) + if serviceErr != nil { + return c.redirectServiceErrorCallback(logger, ctx, baseQPrms.RedirectURI, state, serviceErr) + } + + logResponse(logger, ctx, fiber.StatusFound) + return ctx.Redirect(redirectURL, fiber.StatusFound) +} + +func (c *Controllers) OAuthDynamicRegistrationIATLoginGet(ctx *fiber.Ctx) error { + requestID := getRequestID(ctx) + logger := c.buildLogger(requestID, oauthDynamicRegistration, "OAuthDynamicRegistrationIATLoginGet") + logRequest(logger, ctx) + + uPrms := params.OAuthDynamicRegistrationIATAuthURLParams{ + ACCClientID: ctx.Params("accClientID"), + } + if err := c.validate.StructCtx(ctx.UserContext(), &uPrms); err != nil { + return serviceErrorHTMLResponse(logger, ctx, exceptions.NewNotFoundError()) + } + + baseQPrms := params.OAuthDynamicRegistrationIATAuthBaseQueryParams{ + ClientID: ctx.Query("client_id"), + RedirectURI: ctx.Query("redirect_uri"), + } + if err := c.validate.StructCtx(ctx.UserContext(), baseQPrms); err != nil { + return serviceErrorHTMLResponse(logger, ctx, exceptions.NewForbiddenError()) + } + + qPrms := params.OAuthDynamicRegistrationIATAuthQueryParams{ + ResponseType: ctx.Query("response_type"), + Challenge: ctx.Query("code_challenge"), + ChallengeMethod: ctx.Query("code_challenge_method"), + State: ctx.Query("state"), + } + if err := c.validate.StructCtx(ctx.UserContext(), qPrms); err != nil { + return serviceErrorHTMLResponse(logger, ctx, exceptions.NewForbiddenError()) + } + + loginHTML, serviceErr := c.services.OAuthDynamicRegistrationIATAuthRender( + ctx.UserContext(), + services.OAuthDynamicRegistrationIATAuthRenderOptions{ + RequestID: requestID, + ACCClientID: uPrms.ACCClientID, + State: qPrms.State, + Domain: baseQPrms.ClientID, + CodeChallenge: qPrms.Challenge, + CodeChallengeMethod: qPrms.ChallengeMethod, + RedirectURI: baseQPrms.RedirectURI, + }, + ) + if serviceErr != nil { + return serviceErrorHTMLResponse(logger, ctx, serviceErr) + } + + logResponse(logger, ctx, fiber.StatusOK) + return ctx.Status(fiber.StatusOK).Type("html").SendString(loginHTML) +} + +func (c *Controllers) saveAccountIATCookie( + ctx *fiber.Ctx, + sessionKey string, +) { + ctx.Cookie(&fiber.Cookie{ + Name: c.cookieName + accountsIATCookieSuffix, + Value: sessionKey, + Path: paths.V1 + paths.AccountsBase + paths.CredentialsBase + paths.InitialAccessToken + paths.OAuthAuth, + HTTPOnly: true, + SameSite: fiber.CookieSameSiteLaxMode, + Secure: true, + MaxAge: int(c.services.GetOAuthCodeTTL()), + }) +} + +func (c *Controllers) removeAccountIATCookie(ctx *fiber.Ctx) { + ctx.Cookie(&fiber.Cookie{ + Name: c.cookieName + accountsIATCookieSuffix, + Value: "", + Path: paths.V1 + paths.AccountsBase + paths.CredentialsBase + paths.InitialAccessToken + paths.OAuthAuth, + HTTPOnly: true, + Secure: true, + SameSite: fiber.CookieSameSiteNoneMode, + MaxAge: -1, + }) +} + +func (c *Controllers) saveAccountIAT2FACookie(ctx *fiber.Ctx, sessionID, clientID string) { + ctx.Cookie(&fiber.Cookie{ + Name: c.cookieName + accountsIAT2FACookieSuffix, + Value: sessionID, + Path: paths.AccountsBase + paths.CredentialsBase + paths.InitialAccessToken + "/" + clientID + paths.OAuthAuth, + HTTPOnly: true, + SameSite: fiber.CookieSameSiteLaxMode, + Secure: true, + MaxAge: int(c.services.GetOAuthCodeTTL()), + }) +} + +func (c *Controllers) removeAccountIAT2FACookie(ctx *fiber.Ctx, clientID string) { + ctx.Cookie(&fiber.Cookie{ + Name: c.cookieName + accountsIAT2FACookieSuffix, + Value: "", + Path: paths.AccountsBase + paths.CredentialsBase + paths.InitialAccessToken + "/" + clientID + paths.OAuthAuth, + HTTPOnly: true, + SameSite: fiber.CookieSameSiteLaxMode, + Secure: true, + MaxAge: -1, + }) +} + +func (c *Controllers) OAuthDynamicRegistrationIATLoginPost(ctx *fiber.Ctx) error { + requestID := getRequestID(ctx) + logger := c.buildLogger(requestID, oauthDynamicRegistration, "OAuthDynamicRegistrationIATLoginPost") + logRequest(logger, ctx) + + uPrms := params.OAuthDynamicRegistrationIATAuthURLParams{ + ACCClientID: ctx.Params("accClientID"), + } + if err := c.validate.StructCtx(ctx.UserContext(), &uPrms); err != nil { + return serviceErrorHTMLResponse(logger, ctx, exceptions.NewNotFoundError()) + } + + if ctx.Get("Content-Type") != "application/x-www-form-urlencoded" { + return serviceErrorHTMLResponse(logger, ctx, exceptions.NewUnsupportedMediaTypeError("Only application/x-www-form-urlencoded is supported")) + } + + hiddenFields := bodies.OAuthDynamicRegistrationIATAuthHiddenFieldsBody{ + CSRFToken: ctx.FormValue("csrf_token"), + ClientID: ctx.FormValue("client_id"), + ResponseType: ctx.FormValue("response_type"), + CodeChallenge: ctx.FormValue("code_challenge"), + CodeChallengeMethod: ctx.FormValue("code_challenge_method"), + State: ctx.FormValue("state"), + RedirectURI: ctx.FormValue("redirect_uri"), + } + if err := c.validate.StructCtx(ctx.UserContext(), &hiddenFields); err != nil { + return serviceErrorHTMLResponse(logger, ctx, exceptions.NewForbiddenError()) + } + + loginBody := bodies.LoginBody{ + Email: ctx.FormValue("email"), + Password: ctx.FormValue("password"), + } + if err := c.validate.StructCtx(ctx.UserContext(), &loginBody); err != nil { + valErr := validationErrorException(exceptions.ValidationResponseLocationBody, err) + loginHTML, serviceErr := c.services.OAuthDynamicRegistrationIATAuthReRender( + ctx.UserContext(), + services.OAuthDynamicRegistrationIATAuthReRenderOptions{ + RequestID: requestID, + Errors: utils.MapSlice(valErr.Fields, func(t *exceptions.FieldError) string { + return fmt.Sprintf("%s %s", t.Param, t.Message) + }), + CSRFToken: hiddenFields.CSRFToken, + ACCClientID: uPrms.ACCClientID, + State: hiddenFields.State, + Domain: hiddenFields.ClientID, + CodeChallenge: hiddenFields.CodeChallenge, + CodeChallengeMethod: hiddenFields.CodeChallengeMethod, + RedirectURI: hiddenFields.RedirectURI, + }, + ) + if serviceErr != nil { + return serviceErrorHTMLResponse(logger, ctx, serviceErr) + } + + logResponse(logger, ctx, fiber.StatusOK) + return ctx. + Status(fiber.StatusOK). + Type("html"). + SendString(loginHTML) + } + + redirectURL, sessionKey, loggedIn, serviceErr := c.services.OAuthDynamicRegistrationIATLogin( + ctx.UserContext(), + services.OAuthDynamicRegistrationIATLoginOptions{ + RequestID: requestID, + ACCClientID: uPrms.ACCClientID, + Domain: hiddenFields.ClientID, + CSRFToken: hiddenFields.CSRFToken, + CodeChallenge: hiddenFields.CodeChallenge, + CodeChallengeMethod: hiddenFields.CodeChallengeMethod, + State: hiddenFields.State, + RedirectURI: hiddenFields.RedirectURI, + Email: loginBody.Email, + Password: loginBody.Password, + BackendDomain: c.backendDomain, + }, + ) + if serviceErr != nil { + if serviceErr.Code == exceptions.CodeUnauthorized { + loginHTML, serviceErr := c.services.OAuthDynamicRegistrationIATAuthReRender( + ctx.UserContext(), + services.OAuthDynamicRegistrationIATAuthReRenderOptions{ + RequestID: requestID, + Errors: []string{"Invalid credentials"}, + CSRFToken: hiddenFields.CSRFToken, + ACCClientID: uPrms.ACCClientID, + State: hiddenFields.State, + Domain: hiddenFields.ClientID, + CodeChallenge: hiddenFields.CodeChallenge, + CodeChallengeMethod: hiddenFields.CodeChallengeMethod, + RedirectURI: hiddenFields.RedirectURI, + }, + ) + if serviceErr != nil { + return serviceErrorHTMLResponse(logger, ctx, serviceErr) + } + + logResponse(logger, ctx, fiber.StatusOK) + return ctx. + Status(fiber.StatusOK). + Type("html"). + SendString(loginHTML) + } + + return serviceErrorHTMLResponse(logger, ctx, serviceErr) + } + + if loggedIn { + c.saveAccountIAT2FACookie(ctx, sessionKey, uPrms.ACCClientID) + logResponse(logger, ctx, fiber.StatusSeeOther) + return ctx.Redirect(redirectURL, fiber.StatusSeeOther) + } + + c.saveAccountIATCookie(ctx, sessionKey) + logResponse(logger, ctx, fiber.StatusSeeOther) + return ctx.Redirect(redirectURL, fiber.StatusSeeOther) +} + +func (c *Controllers) OAuthDynamicRegistrationIAT2FAGet(ctx *fiber.Ctx) error { + requestID := getRequestID(ctx) + logger := c.buildLogger(requestID, oauthDynamicRegistration, "OAuthDynamicRegistrationIAT2FAGet") + logRequest(logger, ctx) + + uPrms := params.OAuthDynamicRegistrationIATAuthURLParams{ + ACCClientID: ctx.Params("accClientID"), + } + if err := c.validate.StructCtx(ctx.UserContext(), &uPrms); err != nil { + return serviceErrorHTMLResponse(logger, ctx, exceptions.NewNotFoundError()) + } + + baseQPrms := params.OAuthDynamicRegistrationIATAuthBaseQueryParams{ + ClientID: ctx.Query("client_id"), + RedirectURI: ctx.Query("redirect_uri"), + } + if err := c.validate.StructCtx(ctx.UserContext(), baseQPrms); err != nil { + return serviceErrorHTMLResponse(logger, ctx, exceptions.NewForbiddenError()) + } + + qPrms := params.OAuthDynamicRegistrationIATAuthQueryParams{ + ResponseType: ctx.Query("response_type"), + Challenge: ctx.Query("code_challenge"), + ChallengeMethod: ctx.Query("code_challenge_method"), + State: ctx.Query("state"), + } + if err := c.validate.StructCtx(ctx.UserContext(), qPrms); err != nil { + return serviceErrorHTMLResponse(logger, ctx, exceptions.NewForbiddenError()) + } + + sessionID := ctx.Cookies(c.cookieName + accountsIAT2FACookieSuffix) + if sessionID == "" { + return serviceErrorHTMLResponse(logger, ctx, exceptions.NewUnauthorizedError()) + } + + twoFAHTML, serviceErr := c.services.OAuthDynamicRegistrationIAT2FARender( + ctx.UserContext(), + services.OAuthDynamicRegistrationIAT2FARenderOptions{ + RequestID: requestID, + Domain: baseQPrms.ClientID, + ACCClientID: uPrms.ACCClientID, + SessionID: sessionID, + Challenge: qPrms.Challenge, + ChallengeMethod: qPrms.ChallengeMethod, + State: qPrms.State, + RedirectURI: baseQPrms.RedirectURI, + }, + ) + if serviceErr != nil { + return serviceErrorHTMLResponse(logger, ctx, serviceErr) + } + + logResponse(logger, ctx, fiber.StatusOK) + return ctx.Status(fiber.StatusOK).Type("html").SendString(twoFAHTML) +} + +func (c *Controllers) OAuthDynamicRegistrationIAT2FAPost(ctx *fiber.Ctx) error { + requestID := getRequestID(ctx) + logger := c.buildLogger(requestID, oauthDynamicRegistration, "OAuthDynamicRegistrationIAT2FAPost") + logRequest(logger, ctx) + + uPrms := params.OAuthDynamicRegistrationIATAuthURLParams{ + ACCClientID: ctx.Params("accClientID"), + } + if err := c.validate.StructCtx(ctx.UserContext(), &uPrms); err != nil { + return serviceErrorHTMLResponse(logger, ctx, exceptions.NewNotFoundError()) + } + + sessionID := ctx.Cookies(c.cookieName + accountsIAT2FACookieSuffix) + if sessionID == "" { + return serviceErrorHTMLResponse(logger, ctx, exceptions.NewUnauthorizedError()) + } + + if ctx.Get("Content-Type") != "application/x-www-form-urlencoded" { + return serviceErrorHTMLResponse(logger, ctx, exceptions.NewUnsupportedMediaTypeError("Only application/x-www-form-urlencoded is supported")) + } + + hiddenFields := bodies.OAuthDynamicRegistrationIATAuthHiddenFieldsBody{ + CSRFToken: ctx.FormValue("csrf_token"), + ClientID: ctx.FormValue("client_id"), + ResponseType: ctx.FormValue("response_type"), + CodeChallenge: ctx.FormValue("code_challenge"), + CodeChallengeMethod: ctx.FormValue("code_challenge_method"), + State: ctx.FormValue("state"), + RedirectURI: ctx.FormValue("redirect_uri"), + } + if err := c.validate.StructCtx(ctx.UserContext(), &hiddenFields); err != nil { + return serviceErrorHTMLResponse(logger, ctx, exceptions.NewForbiddenError()) + } + + twoFABody := bodies.TwoFactorLoginBody{ + Code: ctx.FormValue("code"), + } + if err := c.validate.StructCtx(ctx.UserContext(), &twoFABody); err != nil { + valErr := validationErrorException(exceptions.ValidationResponseLocationBody, err) + twoFAHTML, serviceErr := c.services.OAuthDynamicRegistrationIAT2FAReRender( + ctx.UserContext(), + services.OAuthDynamicRegistrationIAT2FAReRenderOptions{ + RequestID: requestID, + Domain: hiddenFields.ClientID, + ACCClientID: uPrms.ACCClientID, + SessionID: sessionID, + Errors: utils.MapSlice(valErr.Fields, func(t *exceptions.FieldError) string { + return fmt.Sprintf("%s %s", t.Param, t.Message) + }), + CSRFToken: hiddenFields.CSRFToken, + Challenge: hiddenFields.CodeChallenge, + ChallengeMethod: hiddenFields.CodeChallengeMethod, + State: hiddenFields.State, + RedirectURI: hiddenFields.RedirectURI, + }, + ) + if serviceErr != nil { + return serviceErrorHTMLResponse(logger, ctx, serviceErr) + } + + logResponse(logger, ctx, fiber.StatusOK) + return ctx. + Status(fiber.StatusOK). + Type("html"). + SendString(twoFAHTML) + } + + redirectURL, sessionKey, serviceErr := c.services.OAuthDynamicRegistrationIATVerify2FACode( + ctx.UserContext(), + services.OAuthDynamicRegistrationIATVerify2FACodeOptions{ + RequestID: requestID, + ACCClientID: uPrms.ACCClientID, + Domain: hiddenFields.ClientID, + SessionID: sessionID, + CSRFToken: hiddenFields.CSRFToken, + Code: twoFABody.Code, + BackendDomain: c.backendDomain, + }, + ) + if serviceErr != nil { + if serviceErr.Code == exceptions.CodeUnauthorized { + twoFAHTML, serviceErr := c.services.OAuthDynamicRegistrationIAT2FAReRender( + ctx.UserContext(), + services.OAuthDynamicRegistrationIAT2FAReRenderOptions{ + RequestID: requestID, + Domain: hiddenFields.ClientID, + ACCClientID: uPrms.ACCClientID, + SessionID: sessionID, + Errors: []string{"Invalid 2FA code"}, + CSRFToken: hiddenFields.CSRFToken, + Challenge: hiddenFields.CodeChallenge, + ChallengeMethod: hiddenFields.CodeChallengeMethod, + State: hiddenFields.State, + RedirectURI: hiddenFields.RedirectURI, + }, + ) + if serviceErr != nil { + return serviceErrorHTMLResponse(logger, ctx, serviceErr) + } + + logResponse(logger, ctx, fiber.StatusOK) + return ctx. + Status(fiber.StatusOK). + Type("html"). + SendString(twoFAHTML) + } + + return serviceErrorHTMLResponse(logger, ctx, serviceErr) + } + + c.removeAccountIAT2FACookie(ctx, uPrms.ACCClientID) + c.saveAccountIATCookie(ctx, sessionKey) + logResponse(logger, ctx, fiber.StatusSeeOther) + return ctx.Redirect(redirectURL, fiber.StatusSeeOther) +} + +func (c *Controllers) OAuthDynamicRegistrationIATExtAuthGet(ctx *fiber.Ctx) error { + requestID := getRequestID(ctx) + logger := c.buildLogger(requestID, oauthDynamicRegistration, "OAuthDynamicRegistrationIATExtAuthGet") + logRequest(logger, ctx) + + uPrms := params.OAuthDynamicRegistrationIATExtAuthURLParams{ + ACCClientID: ctx.Params("accClientID"), + Provider: ctx.Params("provider"), + } + if err := c.validate.StructCtx(ctx.UserContext(), &uPrms); err != nil { + return serviceErrorHTMLResponse(logger, ctx, exceptions.NewNotFoundError()) + } + + baseQPrms := params.OAuthDynamicRegistrationIATAuthBaseQueryParams{ + ClientID: ctx.Query("client_id"), + RedirectURI: ctx.Query("redirect_uri"), + } + if err := c.validate.StructCtx(ctx.UserContext(), baseQPrms); err != nil { + return serviceErrorHTMLResponse(logger, ctx, exceptions.NewForbiddenError()) + } + + responseType := ctx.Query("response_type") + state := ctx.Query("state") + if responseType != "code" { + return c.redirectErrorCallback(logger, ctx, baseQPrms.RedirectURI, state, exceptions.OAuthErrorUnsupportedResponseType) + } + + qPrms := params.OAuthDynamicRegistrationIATAuthQueryParams{ + ResponseType: responseType, + Challenge: ctx.Query("code_challenge"), + ChallengeMethod: ctx.Query("code_challenge_method"), + State: state, + } + if err := c.validate.StructCtx(ctx.UserContext(), qPrms); err != nil { + return c.redirectErrorCallback(logger, ctx, baseQPrms.RedirectURI, state, exceptions.OAuthErrorInvalidRequest) + } + + authURL, serviceErr := c.services.OAuthDynamicRegistrationIATExtGet( + ctx.UserContext(), + services.OAuthDynamicRegistrationIATExtGetOptions{ + RequestID: requestID, + ACCClientID: uPrms.ACCClientID, + Provider: uPrms.Provider, + Domain: baseQPrms.ClientID, + CallbackURL: baseQPrms.RedirectURI, + RedirectURI: baseQPrms.RedirectURI, + State: qPrms.State, + BackendDomain: c.backendDomain, + }, + ) + if serviceErr != nil { + return serviceErrorHTMLResponse(logger, ctx, serviceErr) + } + + logResponse(logger, ctx, fiber.StatusFound) + return ctx.Redirect(authURL, fiber.StatusFound) +} + +func (c *Controllers) OAuthDynamicRegistrationIATExtCB(ctx *fiber.Ctx) error { + requestID := getRequestID(ctx) + logger := c.buildLogger(requestID, oauthDynamicRegistration, "OAuthDynamicRegistrationIATExtCB") + logRequest(logger, ctx) + + uPrms := params.OAuthDynamicRegistrationIATExtAuthURLParams{ + ACCClientID: ctx.Params("accClientID"), + Provider: ctx.Params("provider"), + } + if err := c.validate.StructCtx(ctx.UserContext(), &uPrms); err != nil { + return serviceErrorHTMLResponse(logger, ctx, exceptions.NewNotFoundError()) + } + + qPrms := params.OAuthCallbackQueryParams{ + Code: ctx.Query("code"), + State: ctx.Query("state"), + } + if err := c.validate.StructCtx(ctx.UserContext(), &qPrms); err != nil { + return serviceErrorHTMLResponse(logger, ctx, exceptions.NewForbiddenError()) + } + + cbURL, serviceErr := c.services.OAuthDynamicRegistrationIATExtCB( + ctx.UserContext(), + services.OAuthDynamicRegistrationIATExtCBOptions{ + RequestID: requestID, + ACCClientID: uPrms.ACCClientID, + Provider: uPrms.Provider, + State: qPrms.State, + Code: qPrms.Code, + RedirectURL: "https://" + c.backendDomain + paths.V1 + paths.AccountsBase + + paths.CredentialsBase + paths.DynamicRegistrationBase + paths.InitialAccessToken + + "/" + uPrms.ACCClientID + paths.OAuthAuth + paths.InitialAccessTokenAuthEXT + "/" + + uPrms.Provider + paths.InitialAccessTokenCallback, + BackendDomain: c.backendDomain, + }, + ) + if serviceErr != nil { + return serviceErrorHTMLResponse(logger, ctx, serviceErr) + } + + logResponse(logger, ctx, fiber.StatusFound) + return ctx.Redirect(cbURL, fiber.StatusFound) +} + +func (c *Controllers) OAuthDynamicRegistrationIATExtAppleCB(ctx *fiber.Ctx) error { + requestID := getRequestID(ctx) + logger := c.buildLogger(requestID, oauthDynamicRegistration, "OAuthDynamicRegistrationIATExtAppleCB") + logRequest(logger, ctx) + + uPrms := params.OAuthDynamicRegistrationIATExtAppleURLParams{ + ACCClientID: ctx.Params("accClientID"), + } + if err := c.validate.StructCtx(ctx.UserContext(), &uPrms); err != nil { + return serviceErrorHTMLResponse(logger, ctx, exceptions.NewNotFoundError()) + } + + if ctx.Get("Content-Type") != "application/x-www-form-urlencoded" { + return serviceErrorHTMLResponse(logger, ctx, exceptions.NewUnsupportedMediaTypeError("Only application/x-www-form-urlencoded is supported")) + } + + qPrms := bodies.OAuthDynamicRegistrationIATExtAppleBody{ + Code: ctx.FormValue("code"), + State: ctx.FormValue("state"), + User: ctx.FormValue("user"), + } + if err := c.validate.StructCtx(ctx.UserContext(), &qPrms); err != nil { + return serviceErrorHTMLResponse(logger, ctx, exceptions.NewForbiddenError()) + } + + user := new(bodies.OAuthDynamicRegistrationIATExtAppleUserBody) + if err := json.Unmarshal([]byte(qPrms.User), user); err != nil { + return serviceErrorHTMLResponse(logger, ctx, exceptions.NewForbiddenError()) + } + if err := c.validate.StructCtx(ctx.UserContext(), user); err != nil { + return serviceErrorHTMLResponse(logger, ctx, exceptions.NewForbiddenError()) + } + + cbURL, serviceErr := c.services.OAuthDynamicRegistrationIATExtAppleCB( + ctx.UserContext(), + services.OAuthDynamicRegistrationIATExtAppleCBOptions{ + RequestID: requestID, + ACCClientID: uPrms.ACCClientID, + Email: user.Email, + Code: qPrms.Code, + State: qPrms.State, + RedirectURL: "https://" + c.backendDomain + paths.V1 + paths.AccountsBase + + paths.CredentialsBase + paths.DynamicRegistrationBase + paths.InitialAccessToken + + "/" + uPrms.ACCClientID + paths.OAuthAuth + paths.InitialAccessTokenAuthEXT + "/" + + services.AuthProviderApple + paths.InitialAccessTokenCallback, + BackendDomain: c.backendDomain, + }, + ) + if serviceErr != nil { + return serviceErrorHTMLResponse(logger, ctx, serviceErr) + } + + logResponse(logger, ctx, fiber.StatusFound) + return ctx.Redirect(cbURL, fiber.StatusFound) +} + +func (c *Controllers) OAuthDynamicRegistrationIATToken(ctx *fiber.Ctx) error { + requestID := getRequestID(ctx) + logger := c.buildLogger(requestID, oauthDynamicRegistration, "OAuthDynamicRegistrationIATToken") + logRequest(logger, ctx) + + if ctx.Get("Content-Type") != "application/x-www-form-urlencoded" { + return oauthErrorResponse(logger, ctx, exceptions.OAuthErrorInvalidRequest) + } + + grantType := ctx.Get("grant_type") + if grantType != "authorization_code" { + return oauthErrorResponse(logger, ctx, exceptions.OAuthErrorUnsupportedGrantType) + } + + body := bodies.OAuthDynamicRegistrationIATTokenBody{ + GrantType: grantType, + Code: ctx.FormValue("code"), + ClientID: ctx.FormValue("client_id"), + CodeVerifier: ctx.FormValue("code_verifier"), + } + if err := c.validate.StructCtx(ctx.UserContext(), &body); err != nil { + return oauthErrorResponse(logger, ctx, exceptions.OAuthErrorInvalidRequest) + } + + authDTO, serviceErr := c.services.VerifyOAuthDynamicRegistrationIATCode( + ctx.UserContext(), + services.VerifyOAuthDynamicRegistrationIATCodeOptions{ + RequestID: requestID, + Code: body.Code, + CodeVerifier: body.CodeVerifier, + Domain: body.ClientID, + }, + ) + if serviceErr != nil { + return oauthErrorResponseMapper(logger, ctx, serviceErr) + } + + logResponse(logger, ctx, fiber.StatusOK) + return ctx.Status(fiber.StatusOK).JSON(authDTO) +} diff --git a/idp/internal/controllers/params/account_2fa_configs.go b/idp/internal/controllers/params/account_2fa_configs.go new file mode 100644 index 0000000..a4c8bb3 --- /dev/null +++ b/idp/internal/controllers/params/account_2fa_configs.go @@ -0,0 +1,11 @@ +// Copyright (c) 2025 Afonso Barracha +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package params + +type GetAccount2FAConfigURLParams struct { + TwoFAType string `validate:"required,oneof=email totp"` +} diff --git a/idp/internal/controllers/params/dynamic_registration_domains.go b/idp/internal/controllers/params/dynamic_registration_domains.go new file mode 100644 index 0000000..e6a88b3 --- /dev/null +++ b/idp/internal/controllers/params/dynamic_registration_domains.go @@ -0,0 +1,18 @@ +// Copyright (c) 2025 Afonso Barracha +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package params + +type DynamicRegistrationDomainURLParams struct { + Domain string `validate:"required,fqdn,max=250"` +} + +type DynamicRegistrationDomainQueryParams struct { + Limit int `validate:"min=1,max=100"` + Offset int `validate:"min=0"` + Order string `validate:"oneof=date domain"` + Search string `validate:"omitempty,min=1,max=250"` +} diff --git a/idp/internal/controllers/params/oauth.go b/idp/internal/controllers/params/oauth.go index 6a29b19..0763796 100644 --- a/idp/internal/controllers/params/oauth.go +++ b/idp/internal/controllers/params/oauth.go @@ -1,3 +1,9 @@ +// Copyright (c) 2025 Afonso Barracha +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + package params type OAuthQueryParams struct { diff --git a/idp/internal/controllers/params/oauth_dynamic_registration.go b/idp/internal/controllers/params/oauth_dynamic_registration.go new file mode 100644 index 0000000..5b8f8fa --- /dev/null +++ b/idp/internal/controllers/params/oauth_dynamic_registration.go @@ -0,0 +1,32 @@ +// Copyright (c) 2025 Afonso Barracha +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package params + +type OAuthDynamicRegistrationIATAuthBaseQueryParams struct { + ClientID string `validate:"required,fqdn"` + RedirectURI string `validate:"required,uri"` +} + +type OAuthDynamicRegistrationIATAuthQueryParams struct { + ResponseType string `validate:"required,oneof=code"` + Challenge string `validate:"required,min=1"` + ChallengeMethod string `validate:"omitempty,oneof=plain s256 S256"` + State string `validate:"required,min=1"` +} + +type OAuthDynamicRegistrationIATAuthURLParams struct { + ACCClientID string `validate:"required,min=22,max=22,alphanum"` +} + +type OAuthDynamicRegistrationIATExtAuthURLParams struct { + ACCClientID string `validate:"required,min=22,max=22,alphanum"` + Provider string `validate:"required,oneof=facebook github google microsoft"` +} + +type OAuthDynamicRegistrationIATExtAppleURLParams struct { + ACCClientID string `validate:"required,min=22,max=22,alphanum"` +} diff --git a/idp/internal/controllers/paths/common.go b/idp/internal/controllers/paths/common.go index 3029f3d..4e20871 100644 --- a/idp/internal/controllers/paths/common.go +++ b/idp/internal/controllers/paths/common.go @@ -8,7 +8,9 @@ package paths const ( Base string = "/" + V1 string = "/v1" Keys string = "/keys" Confirm string = "/confirm" Recover string = "/recover" + Config string = "/config" ) diff --git a/idp/internal/controllers/paths/domains.go b/idp/internal/controllers/paths/domains.go new file mode 100644 index 0000000..4112b24 --- /dev/null +++ b/idp/internal/controllers/paths/domains.go @@ -0,0 +1,15 @@ +// Copyright (c) 2025 Afonso Barracha +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package paths + +const ( + Domains string = "/domains" + + SingleDomain string = "/:domain" + VerifyDomain string = "/:domain/verify" + DomainCode string = "/:domain/code" +) diff --git a/idp/internal/controllers/paths/dynamic_registration.go b/idp/internal/controllers/paths/dynamic_registration.go new file mode 100644 index 0000000..82ddc25 --- /dev/null +++ b/idp/internal/controllers/paths/dynamic_registration.go @@ -0,0 +1,16 @@ +// Copyright (c) 2025 Afonso Barracha +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package paths + +const ( + DynamicRegistrationBase string = "/dynamic-registration" + InitialAccessToken string = "/initial-access-token" + InitialAccessTokenAuthEXT string = "/ext" + InitialAccessTokenCallback string = "/callback" + InitialAccessTokenProvider string = "/:provider" + InitialAccessTokenSingle string = "/:accClientID" +) diff --git a/idp/internal/controllers/paths/oauth.go b/idp/internal/controllers/paths/oauth.go index 6703810..61ddfeb 100644 --- a/idp/internal/controllers/paths/oauth.go +++ b/idp/internal/controllers/paths/oauth.go @@ -14,6 +14,7 @@ const ( OAuthUserInfo string = "/userinfo" OAuthToken string = "/token" OAuthRevoke string = "/revoke" + OAuthRegister string = "/register" OAuthIntrospect string = "/introspect" OAuthDeviceAuth string = "/auth/device" diff --git a/idp/internal/controllers/paths/two_fa.go b/idp/internal/controllers/paths/two_fa.go new file mode 100644 index 0000000..1ee357b --- /dev/null +++ b/idp/internal/controllers/paths/two_fa.go @@ -0,0 +1,12 @@ +// Copyright (c) 2025 Afonso Barracha +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package paths + +const ( + TwoFASingle string = "/:twoFAType" + TwoFADefault string = "/default" +) diff --git a/idp/internal/controllers/users_auth.go b/idp/internal/controllers/users_auth.go index d160bdc..0d8a4c6 100644 --- a/idp/internal/controllers/users_auth.go +++ b/idp/internal/controllers/users_auth.go @@ -152,46 +152,7 @@ func (c *Controllers) LoginUser(ctx *fiber.Ctx) error { return ctx.Status(fiber.StatusOK).JSON(&authDTO) } -func (c *Controllers) TwoFactorLoginUser(ctx *fiber.Ctx) error { - requestID := getRequestID(ctx) - logger := c.buildLogger(requestID, usersAuthLocation, "TwoFactorLoginUser") - logRequest(logger, ctx) - - accountUsername, accountID, serviceErr := getHostAccount(ctx) - if serviceErr != nil { - return serviceErrorResponse(logger, ctx, serviceErr) - } - - userClaims, appClaims, serviceErr := getUserPurposeClaims(ctx) - if serviceErr != nil { - return serviceErrorResponse(logger, ctx, serviceErr) - } - - body := new(bodies.TwoFactorLoginBody) - if err := ctx.BodyParser(body); err != nil { - return parseRequestErrorResponse(logger, ctx, err) - } - if err := c.validate.StructCtx(ctx.UserContext(), body); err != nil { - return validateBodyErrorResponse(logger, ctx, err) - } - - authDTO, serviceErr := c.services.TwoFactorLoginUser(ctx.UserContext(), services.TwoFactorLoginUserOptions{ - RequestID: requestID, - AccountID: accountID, - AccountUsername: accountUsername, - AppClientID: appClaims.ClientID, - AppVersion: appClaims.Version, - UserPublicID: userClaims.UserID, - UserVersion: userClaims.UserVersion, - Code: body.Code, - }) - if serviceErr != nil { - return serviceErrorResponse(logger, ctx, serviceErr) - } - - logResponse(logger, ctx, fiber.StatusOK) - return ctx.Status(fiber.StatusOK).JSON(&authDTO) -} +// TODO: Add 2FA Login func (c *Controllers) LogoutUser(ctx *fiber.Ctx) error { requestID := getRequestID(ctx) diff --git a/idp/internal/exceptions/controllers.go b/idp/internal/exceptions/controllers.go index 156b1d8..54d0742 100644 --- a/idp/internal/exceptions/controllers.go +++ b/idp/internal/exceptions/controllers.go @@ -24,13 +24,14 @@ const ( StatusForbidden string = "Forbidden" StatusValidation string = "Validation" - OAuthErrorInvalidRequest string = "invalid_request" - OAuthErrorInvalidGrant string = "invalid_grant" - OAuthErrorUnauthorizedClient string = "unauthorized_client" - OAuthErrorAccessDenied string = "access_denied" - OAuthServerError string = "server_error" - OAuthErrorInvalidScope string = "invalid_scope" - OAuthErrorUnsupportedGrantType string = "unsupported_grant_type" + OAuthErrorInvalidRequest string = "invalid_request" + OAuthErrorInvalidGrant string = "invalid_grant" + OAuthErrorUnauthorizedClient string = "unauthorized_client" + OAuthErrorAccessDenied string = "access_denied" + OAuthServerError string = "server_error" + OAuthErrorInvalidScope string = "invalid_scope" + OAuthErrorUnsupportedGrantType string = "unsupported_grant_type" + OAuthErrorUnsupportedResponseType string = "unsupported_response_type" ) type ErrorResponse struct { @@ -233,7 +234,7 @@ func buildFieldErrorMessage(tag string, val any) string { } } -func ValidationErrorResponseFromErr(err *validator.ValidationErrors, location string) ValidationErrorResponse { +func ValidationErrorResponseFromErr(err *validator.ValidationErrors, location string) *ValidationErrorResponse { fields := make([]FieldError, len(*err)) for i, field := range *err { @@ -245,7 +246,7 @@ func ValidationErrorResponseFromErr(err *validator.ValidationErrors, location st } } - return ValidationErrorResponse{ + return &ValidationErrorResponse{ Code: StatusValidation, Message: ValidationResponseMessage, Fields: fields, diff --git a/idp/internal/exceptions/services.go b/idp/internal/exceptions/services.go index a66ecc5..c2191b6 100644 --- a/idp/internal/exceptions/services.go +++ b/idp/internal/exceptions/services.go @@ -67,6 +67,10 @@ func NewValidationError(message string) *ServiceError { return NewError(CodeValidation, message) } +func NewNotFoundValidationError(message string) *ServiceError { + return NewError(CodeNotFound, message) +} + func NewInternalServerError() *ServiceError { return NewError(CodeInternalServerError, MessageUnknown) } @@ -87,6 +91,10 @@ func NewForbiddenError() *ServiceError { return NewError(CodeForbidden, MessageForbidden) } +func NewForbiddenValidationError(message string) *ServiceError { + return NewError(CodeForbidden, message) +} + func (e *ServiceError) Error() string { return e.Message } diff --git a/idp/internal/providers/cache/account_credentials_dynamic_registration.go b/idp/internal/providers/cache/account_credentials_dynamic_registration.go new file mode 100644 index 0000000..f776a48 --- /dev/null +++ b/idp/internal/providers/cache/account_credentials_dynamic_registration.go @@ -0,0 +1,814 @@ +// Copyright (c) 2025 Afonso Barracha +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package cache + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/tugascript/devlogs/idp/internal/utils" +) + +const ( + accountCredentialsDynamicRegistrationLocation string = "account_credentials_dynamic_registration" + + accountCredentialsDynamicRegistrationIATPrefix string = "account_credentials_dynamic_registration_iat" + + csrfTokenByteLen int = 16 + sessionKeyByteLen int = 32 +) + +func buildAccountCredentialsDynamicRegistrationIATAuthCacheKey(clientID string) string { + return fmt.Sprintf("%s:auth:%s", accountCredentialsDynamicRegistrationIATPrefix, clientID) +} + +type AccountCredentialsDynamicRegistrationIATAuthData struct { + RedirectURI string `json:"redirect_uri"` + Domain string `json:"domain"` + State string `json:"state"` + Challenge string `json:"challenge"` +} + +type SaveAccountCredentialsDynamicRegistrationIATAuthOptions struct { + Domain string + RequestID string + State string + RedirectURI string + Challenge string +} + +func (c *Cache) SaveAccountCredentialsDynamicRegistrationIATAuth( + ctx context.Context, + opts SaveAccountCredentialsDynamicRegistrationIATAuthOptions, +) (string, error) { + logger := utils.BuildLogger(c.logger, utils.LoggerOptions{ + Location: accountCredentialsDynamicRegistrationLocation, + Method: "SaveAccountCredentialsDynamicRegistrationIATAuth", + RequestID: opts.RequestID, + }).With( + "redirectUri", opts.RedirectURI, + ) + logger.DebugContext(ctx, "Saving account credentials dynamic registration IAT sessions...") + + data := AccountCredentialsDynamicRegistrationIATAuthData{ + State: opts.State, + Domain: opts.Domain, + RedirectURI: opts.RedirectURI, + Challenge: opts.Challenge, + } + dataBytes, err := json.Marshal(data) + if err != nil { + logger.ErrorContext(ctx, "Failed to marshal account credentials dynamic registration IAT data", "error", err) + return "", err + } + + clientID := utils.Base62UUID() + return clientID, c.storage.SetWithContext( + ctx, + buildAccountCredentialsDynamicRegistrationIATAuthCacheKey(clientID), + dataBytes, + c.oauthStateTTL, + ) +} + +type GetAccountCredentialsDynamicRegistrationIATAuthOptions struct { + RequestID string + ClientID string +} + +func (c *Cache) GetAccountCredentialsDynamicRegistrationAuthIAT( + ctx context.Context, + opts GetAccountCredentialsDynamicRegistrationIATAuthOptions, +) (AccountCredentialsDynamicRegistrationIATAuthData, bool, error) { + logger := utils.BuildLogger(c.logger, utils.LoggerOptions{ + Location: accountCredentialsDynamicRegistrationLocation, + Method: "GetAccountCredentialsDynamicRegistrationAuthIAT", + RequestID: opts.RequestID, + }).With( + "clientId", opts.ClientID, + ) + logger.DebugContext(ctx, "Getting account credentials dynamic registration IAT...") + + data, err := c.storage.GetWithContext(ctx, buildAccountCredentialsDynamicRegistrationIATAuthCacheKey(opts.ClientID)) + if err != nil { + logger.ErrorContext(ctx, "Failed to get account credentials dynamic registration IAT", "error", err) + return AccountCredentialsDynamicRegistrationIATAuthData{}, false, err + } + if data == nil { + logger.DebugContext(ctx, "Account credentials dynamic registration IAT not found") + return AccountCredentialsDynamicRegistrationIATAuthData{}, false, nil + } + + var authData AccountCredentialsDynamicRegistrationIATAuthData + if err := json.Unmarshal(data, &authData); err != nil { + logger.ErrorContext(ctx, "Failed to unmarshal account credentials dynamic registration IAT data", "error", err) + return AccountCredentialsDynamicRegistrationIATAuthData{}, false, err + } + + return authData, true, nil +} + +type DeleteAccountCredentialsDynamicRegistrationIATAuthOptions struct { + RequestID string + ClientID string +} + +func (c *Cache) DeleteAccountCredentialsDynamicRegistrationIATAuth( + ctx context.Context, + opts DeleteAccountCredentialsDynamicRegistrationIATAuthOptions, +) error { + logger := utils.BuildLogger(c.logger, utils.LoggerOptions{ + Location: accountCredentialsDynamicRegistrationLocation, + Method: "DeleteAccountCredentialsDynamicRegistrationIATAuth", + RequestID: opts.RequestID, + }).With( + "clientId", opts.ClientID, + ) + logger.DebugContext(ctx, "Deleting account credentials dynamic registration IAT...") + + return c.storage.DeleteWithContext(ctx, buildAccountCredentialsDynamicRegistrationIATAuthCacheKey(opts.ClientID)) +} + +func buildAccountCredentialsDynamicRegistrationIATLoginCSRFKey(domain, clientID string) string { + return fmt.Sprintf("%s:login:%s:%s", accountCredentialsDynamicRegistrationIATPrefix, domain, clientID) +} + +type SaveAccountCredentialsDynamicRegistrationIATLoginCSRFOptions struct { + RequestID string + ClientID string + Domain string +} + +func (c *Cache) SaveAccountCredentialsDynamicRegistrationIATLoginCSRF( + ctx context.Context, + opts SaveAccountCredentialsDynamicRegistrationIATLoginCSRFOptions, +) (string, error) { + logger := utils.BuildLogger(c.logger, utils.LoggerOptions{ + Location: accountCredentialsDynamicRegistrationLocation, + Method: "SaveAccountCredentialsDynamicRegistrationIATLoginCSRF", + RequestID: opts.RequestID, + }).With( + "clientId", opts.ClientID, + "domain", opts.Domain, + ) + logger.DebugContext(ctx, "Saving account credentials dynamic registration IAT login CSRF token...") + + csrfToken, err := utils.GenerateBase64Secret(csrfTokenByteLen) + if err != nil { + logger.ErrorContext(ctx, "Error generating CSRF token", "error", err) + return "", err + } + + return csrfToken, c.storage.SetWithContext( + ctx, + buildAccountCredentialsDynamicRegistrationIATLoginCSRFKey(opts.Domain, opts.ClientID), + []byte(utils.Sha256HashHex(csrfToken)), + c.oauthStateTTL, + ) +} + +type VerifyAccountCredentialsDynamicRegistrationIATLoginCSRFOptions struct { + RequestID string + ClientID string + Domain string + CSRFToken string +} + +func (c *Cache) VerifyAccountCredentialsDynamicRegistrationIATLoginCSRF( + ctx context.Context, + opts VerifyAccountCredentialsDynamicRegistrationIATLoginCSRFOptions, +) (bool, error) { + logger := utils.BuildLogger(c.logger, utils.LoggerOptions{ + Location: accountCredentialsDynamicRegistrationLocation, + Method: "VerifyAccountCredentialsDynamicRegistrationIATLoginCSRF", + RequestID: opts.RequestID, + }).With( + "clientId", opts.ClientID, + "domain", opts.Domain, + ) + logger.DebugContext(ctx, "Verifying account credentials dynamic registration IAT login CSRF token...") + + hashedCSRFToken, err := c.storage.GetWithContext(ctx, buildAccountCredentialsDynamicRegistrationIATLoginCSRFKey(opts.Domain, opts.ClientID)) + if err != nil { + logger.ErrorContext(ctx, "Failed to get account credentials dynamic registration IAT login CSRF token", "error", err) + return false, err + } + if hashedCSRFToken == nil { + logger.DebugContext(ctx, "Account credentials dynamic registration IAT login CSRF token not found") + return false, nil + } + + ok, err := utils.CompareShaHex(opts.CSRFToken, string(hashedCSRFToken)) + if err != nil { + logger.ErrorContext(ctx, "Error comparing CSRF token", "error", err) + return false, err + } + if !ok { + logger.DebugContext(ctx, "Invalid CSRF token") + return false, nil + } + if err := c.storage.DeleteWithContext( + ctx, + buildAccountCredentialsDynamicRegistrationIATLoginCSRFKey(opts.Domain, opts.ClientID), + ); err != nil { + logger.ErrorContext(ctx, "Error deleting CSRF token", "error", err) + return false, err + } + + return true, nil +} + +type AccountCredentialsDynamicRegistrationIAT2FAData struct { + AccountPublicID uuid.UUID `json:"account_public_id"` + AccountVersion int32 `json:"account_version"` + RedirectURI string `json:"redirect_uri"` + ClientID string `json:"clientId"` + Domain string `json:"domain"` + State string `json:"state"` + TwoFAType string `json:"two_factor_type"` +} + +func buildAccountCredentialsDynamicRegistrationIAT2FACacheKey(sessionID string) string { + return fmt.Sprintf("%s:2fa:%s", accountCredentialsDynamicRegistrationIATPrefix, utils.Sha256HashHex(sessionID)) +} + +type SaveAccountCredentialsDynamicRegistrationIAT2FAOptions struct { + RequestID string + AccountPublicID uuid.UUID + AccountVersion int32 + RedirectURI string + Domain string + ClientID string + State string + TwoFAType string + TwoFATTL int64 +} + +func (c *Cache) SaveAccountCredentialsDynamicRegistrationIAT2FA( + ctx context.Context, + opts SaveAccountCredentialsDynamicRegistrationIAT2FAOptions, +) (string, error) { + logger := utils.BuildLogger(c.logger, utils.LoggerOptions{ + Location: accountCredentialsDynamicRegistrationLocation, + Method: "SaveAccountCredentialsDynamicRegistrationIAT2FA", + RequestID: opts.RequestID, + }).With( + "accountPublicId", opts.AccountPublicID, + "domain", opts.Domain, + ) + logger.DebugContext(ctx, "Saving account credentials dynamic registration IAT2FA...") + + sessionId := utils.Base64UUID() + data := AccountCredentialsDynamicRegistrationIAT2FAData{ + AccountPublicID: opts.AccountPublicID, + AccountVersion: opts.AccountVersion, + RedirectURI: opts.RedirectURI, + Domain: opts.Domain, + ClientID: opts.ClientID, + State: opts.State, + TwoFAType: opts.TwoFAType, + } + dataBytes, err := json.Marshal(data) + if err != nil { + logger.ErrorContext(ctx, "Failed to marshal account credentials dynamic registration IAT2FA data", "error", err) + return "", err + } + + return sessionId, c.storage.SetWithContext( + ctx, + buildAccountCredentialsDynamicRegistrationIAT2FACacheKey(sessionId), + dataBytes, + time.Duration(opts.TwoFATTL)*time.Second, + ) +} + +type GetAccountCredentialsDynamicRegistrationIAT2FAOptions struct { + RequestID string + SessionID string +} + +func (c *Cache) GetAccountCredentialsDynamicRegistrationIAT2FA( + ctx context.Context, + opts GetAccountCredentialsDynamicRegistrationIAT2FAOptions, +) (AccountCredentialsDynamicRegistrationIAT2FAData, bool, error) { + logger := utils.BuildLogger(c.logger, utils.LoggerOptions{ + Location: accountCredentialsDynamicRegistrationLocation, + Method: "GetAccountCredentialsDynamicRegistrationIAT2FA", + RequestID: opts.RequestID, + }).With( + "sessionId", opts.SessionID, + ) + logger.DebugContext(ctx, "Getting account credentials dynamic registration IAT2FA...") + + data, err := c.storage.GetWithContext(ctx, buildAccountCredentialsDynamicRegistrationIAT2FACacheKey(opts.SessionID)) + if err != nil { + logger.ErrorContext(ctx, "Failed to get account credentials dynamic registration IAT2FA", "error", err) + return AccountCredentialsDynamicRegistrationIAT2FAData{}, false, err + } + if data == nil { + logger.DebugContext(ctx, "Account credentials dynamic registration IAT2FA not found") + return AccountCredentialsDynamicRegistrationIAT2FAData{}, false, nil + } + + var twoFAData AccountCredentialsDynamicRegistrationIAT2FAData + if err := json.Unmarshal(data, &twoFAData); err != nil { + logger.ErrorContext(ctx, "Failed to unmarshal account credentials dynamic registration IAT2FA data", "error", err) + return AccountCredentialsDynamicRegistrationIAT2FAData{}, false, err + } + + return twoFAData, true, nil +} + +func buildAccountCredentialsDynamicRegistrationIAT2FACSRFCacheKey(sessionID string) string { + return fmt.Sprintf("%s:2fa-csrf:%s", accountCredentialsDynamicRegistrationIATPrefix, utils.Sha256HashHex(sessionID)) +} + +type SaveAccountCredentialsDynamicRegistrationIAT2FACSRFTokenOptions struct { + RequestID string + SessionID string + TwoFATTL int64 +} + +func (c *Cache) SaveAccountCredentialsDynamicRegistrationIAT2FACSRFToken( + ctx context.Context, + opts SaveAccountCredentialsDynamicRegistrationIAT2FACSRFTokenOptions, +) (string, error) { + logger := utils.BuildLogger(c.logger, utils.LoggerOptions{ + Location: accountCredentialsDynamicRegistrationLocation, + Method: "SaveAccountCredentialsDynamicRegistrationIAT2FACSRFToken", + RequestID: opts.RequestID, + }).With( + "sessionId", opts.SessionID, + ) + logger.DebugContext(ctx, "Saving account credentials dynamic registration IAT2FA CSRF token...") + + csrfToken, err := utils.GenerateBase64Secret(csrfTokenByteLen) + if err != nil { + logger.ErrorContext(ctx, "Error generating CSRF token", "error", err) + return "", err + } + + return csrfToken, c.storage.SetWithContext( + ctx, + buildAccountCredentialsDynamicRegistrationIAT2FACSRFCacheKey(opts.SessionID), + []byte(utils.Sha256HashHex(csrfToken)), + time.Duration(opts.TwoFATTL)*time.Second, + ) +} + +type VerifyAccountCredentialsDynamicRegistrationIAT2FACSRFTokenOptions struct { + RequestID string + SessionID string + CSRFToken string +} + +func (c *Cache) VerifyAccountCredentialsDynamicRegistrationIAT2FACSRFToken( + ctx context.Context, + opts VerifyAccountCredentialsDynamicRegistrationIAT2FACSRFTokenOptions, +) (bool, error) { + logger := utils.BuildLogger(c.logger, utils.LoggerOptions{ + Location: accountCredentialsDynamicRegistrationLocation, + Method: "VerifyAccountCredentialsDynamicRegistrationIAT2FACSRFToken", + RequestID: opts.RequestID, + }).With( + "sessionId", opts.SessionID, + ) + logger.DebugContext(ctx, "Verifying account credentials dynamic registration IAT2FA CSRF token...") + + hashedCSRFToken, err := c.storage.GetWithContext(ctx, buildAccountCredentialsDynamicRegistrationIAT2FACSRFCacheKey(opts.SessionID)) + if err != nil { + logger.ErrorContext(ctx, "Failed to get account credentials dynamic registration IAT2FA CSRF token", "error", err) + return false, err + } + if hashedCSRFToken == nil { + logger.DebugContext(ctx, "Account credentials dynamic registration IAT2FA CSRF token not found") + return false, nil + } + + ok, err := utils.CompareShaHex(opts.CSRFToken, string(hashedCSRFToken)) + if err != nil { + logger.ErrorContext(ctx, "Error comparing CSRF token", "error", err) + return false, err + } + if !ok { + logger.DebugContext(ctx, "Invalid CSRF token") + return false, nil + } + if err := c.storage.DeleteWithContext( + ctx, + buildAccountCredentialsDynamicRegistrationIAT2FACSRFCacheKey(opts.SessionID), + ); err != nil { + logger.ErrorContext(ctx, "Error deleting CSRF token", "error", err) + return false, err + } + + return true, nil +} + +type DeleteAccountCredentialsDynamicRegistrationIAT2FACSRFTokenOptions struct { + RequestID string + SessionID string +} + +func (c *Cache) DeleteAccountCredentialsDynamicRegistrationIAT2FACSRFToken( + ctx context.Context, + opts DeleteAccountCredentialsDynamicRegistrationIAT2FACSRFTokenOptions, +) error { + logger := utils.BuildLogger(c.logger, utils.LoggerOptions{ + Location: accountCredentialsDynamicRegistrationLocation, + Method: "DeleteAccountCredentialsDynamicRegistrationIAT2FACSRFToken", + RequestID: opts.RequestID, + }) + logger.DebugContext(ctx, "Deleting account credentials dynamic registration IAT2FA CSRF token...") + return c.storage.DeleteWithContext( + ctx, + buildAccountCredentialsDynamicRegistrationIAT2FACSRFCacheKey(opts.SessionID), + ) +} + +func buildAccountCredentialsDynamicRegistrationIATCodeCacheKey(codeID string) string { + return fmt.Sprintf("%s:code:%s", accountCredentialsDynamicRegistrationIATPrefix, codeID) +} + +type AccountCredentialsDynamicRegistrationIATCodeData struct { + AccountPublicID uuid.UUID `json:"account_public_id"` + AccountVersion int32 `json:"account_version"` + Domain string `json:"domain"` + ClientID string `json:"client_id"` + Challenge string `json:"challenge"` + Code string `json:"code"` +} + +type GenerateAccountCredentialsRegistrationIATCodeOptions struct { + RequestID string + ClientID string + AccountPublicID uuid.UUID + AccountVersion int32 + Domain string + Challenge string +} + +func (c *Cache) GenerateAccountCredentialsRegistrationIATCode( + ctx context.Context, + opts GenerateAccountCredentialsRegistrationIATCodeOptions, +) (string, error) { + logger := utils.BuildLogger(c.logger, utils.LoggerOptions{ + Location: accountCredentialsDynamicRegistrationLocation, + Method: "GenerateAccountCredentialsRegistrationIATCode", + RequestID: opts.RequestID, + }).With( + "clientId", opts.ClientID, + "accountPublicId", opts.AccountPublicID, + "domain", opts.Domain, + ) + logger.DebugContext(ctx, "Generating account credentials registration IAT code...") + + codeID := utils.Base62UUID() + code, err := utils.GenerateBase62Secret(codeByteLen) + if err != nil { + logger.ErrorContext(ctx, "Error generating OAuth code", "error", err) + return "", err + } + + data := AccountCredentialsDynamicRegistrationIATCodeData{ + AccountPublicID: opts.AccountPublicID, + AccountVersion: opts.AccountVersion, + Domain: opts.Domain, + ClientID: opts.ClientID, + Code: utils.Sha256HashHex(code), + Challenge: opts.Challenge, + } + dataBytes, err := json.Marshal(data) + if err != nil { + logger.ErrorContext(ctx, "Failed to marshal account credentials registration IAT code data", "error", err) + return "", err + } + + if err := c.storage.SetWithContext( + ctx, + buildAccountCredentialsDynamicRegistrationIATCodeCacheKey(codeID), + dataBytes, + c.oauthCodeTTL, + ); err != nil { + logger.ErrorContext(ctx, "Failed to set account credentials registration IAT code in cache", "error", err) + return "", err + } + + return fmt.Sprintf("%s-%s", codeID, code), nil +} + +type VerifyAccountCredentialsRegistrationIATCodeOptions struct { + RequestID string + Code string +} + +func (c *Cache) VerifyAccountCredentialsRegistrationIATCode( + ctx context.Context, + opts VerifyAccountCredentialsRegistrationIATCodeOptions, +) (AccountCredentialsDynamicRegistrationIATCodeData, bool, error) { + logger := utils.BuildLogger(c.logger, utils.LoggerOptions{ + Location: accountCredentialsDynamicRegistrationLocation, + Method: "VerifyOAuthDynamicRegistrationIATCode", + RequestID: opts.RequestID, + }) + logger.DebugContext(ctx, "Verifying account credentials registration IAT code...") + + if len(opts.Code) < 45 { + logger.DebugContext(ctx, "Invalid account credentials registration IAT code length") + return AccountCredentialsDynamicRegistrationIATCodeData{}, false, nil + } + + parts := strings.Split(opts.Code, "-") + if len(parts) != 2 { + logger.WarnContext(ctx, "Invalid account credentials registration IAT code format") + return AccountCredentialsDynamicRegistrationIATCodeData{}, false, nil + } + + data, err := c.storage.GetWithContext(ctx, buildAccountCredentialsDynamicRegistrationIATCodeCacheKey(parts[0])) + if err != nil { + logger.ErrorContext(ctx, "Failed to get account credentials registration IAT code from cache", "error", err) + return AccountCredentialsDynamicRegistrationIATCodeData{}, false, err + } + if data == nil { + logger.DebugContext(ctx, "Account credentials registration IAT code not found") + return AccountCredentialsDynamicRegistrationIATCodeData{}, false, nil + } + + var codeData AccountCredentialsDynamicRegistrationIATCodeData + if err := json.Unmarshal(data, &codeData); err != nil { + logger.ErrorContext(ctx, "Failed to unmarshal account credentials registration IAT code data", "error", err) + return AccountCredentialsDynamicRegistrationIATCodeData{}, false, err + } + + ok, err := utils.CompareShaHex(parts[1], codeData.Code) + if err != nil { + logger.ErrorContext(ctx, "Error comparing OAuth code", "error", err) + return AccountCredentialsDynamicRegistrationIATCodeData{}, false, err + } + if !ok { + logger.DebugContext(ctx, "Invalid OAuth code") + return AccountCredentialsDynamicRegistrationIATCodeData{}, false, nil + } + if err := c.storage.DeleteWithContext( + ctx, + buildAccountCredentialsDynamicRegistrationIATCodeCacheKey(parts[0]), + ); err != nil { + logger.ErrorContext(ctx, "Error deleting OAuth code", "error", err) + return AccountCredentialsDynamicRegistrationIATCodeData{}, false, err + } + return codeData, true, nil +} + +type AccountCredentialsDynamicRegistrationSessionData struct { + AccountPublicID uuid.UUID `json:"account_public_id"` + AccountVersion int32 `json:"account_version"` + SessionKey string `json:"session_key"` +} + +func buildAccountCredentialsDynamicRegistrationSessionCacheKey(domain string, clientID string) string { + return fmt.Sprintf("%s:session:%s:%s", accountCredentialsDynamicRegistrationIATPrefix, domain, clientID) +} + +func formatSessionKey(clientID, sessionKey string) string { + return fmt.Sprintf("%s.%s", clientID, sessionKey) +} + +func parseSessionKey(sessionKey string) (string, string, bool) { + parts := strings.Split(sessionKey, ".") + if len(parts) != 2 { + return "", "", false + } + return parts[0], parts[1], true +} + +type CreateAccountCredentialsRegistrationSessionKeyOptions struct { + RequestID string + ClientID string + Domain string + AccountPublicID uuid.UUID + AccountVersion int32 +} + +func (c *Cache) CreateAccountCredentialsRegistrationSessionKey( + ctx context.Context, + opts CreateAccountCredentialsRegistrationSessionKeyOptions, +) (string, error) { + logger := utils.BuildLogger(c.logger, utils.LoggerOptions{ + Location: accountCredentialsDynamicRegistrationLocation, + Method: "CreateAccountCredentialsRegistrationSessionKey", + RequestID: opts.RequestID, + }).With( + "clientId", opts.ClientID, + "domain", opts.Domain, + "accountPublicId", opts.AccountPublicID, + ) + logger.DebugContext(ctx, "Creating account credentials registration session key...") + + sessionKey, err := utils.GenerateBase64Secret(sessionKeyByteLen) + if err != nil { + logger.ErrorContext(ctx, "Error generating session key", "error", err) + return "", err + } + + data := AccountCredentialsDynamicRegistrationSessionData{ + AccountPublicID: opts.AccountPublicID, + AccountVersion: opts.AccountVersion, + SessionKey: utils.Sha256HashHex(sessionKey), + } + dataBytes, err := json.Marshal(data) + if err != nil { + logger.ErrorContext(ctx, "Failed to marshal account credentials registration session data", "error", err) + return "", err + } + + if err := c.storage.SetWithContext( + ctx, + buildAccountCredentialsDynamicRegistrationSessionCacheKey(opts.Domain, opts.ClientID), + dataBytes, + c.oauthCodeTTL, + ); err != nil { + logger.ErrorContext(ctx, "Failed to set account credentials registration session in cache", "error", err) + return "", err + } + + return formatSessionKey(opts.ClientID, sessionKey), nil +} + +type VerifyAccountCredentialsRegistrationSessionKeyOptions struct { + RequestID string + Domain string + SessionKey string +} + +func (c *Cache) VerifyAccountCredentialsRegistrationSessionKey( + ctx context.Context, + opts VerifyAccountCredentialsRegistrationSessionKeyOptions, +) (AccountCredentialsDynamicRegistrationSessionData, string, bool, bool, error) { + logger := utils.BuildLogger(c.logger, utils.LoggerOptions{ + Location: accountCredentialsDynamicRegistrationLocation, + Method: "VerifyAccountCredentialsRegistrationSessionKey", + RequestID: opts.RequestID, + }).With( + "domain", opts.Domain, + ) + logger.DebugContext(ctx, "Verifying account credentials registration session key...") + + clientID, sessionKey, ok := parseSessionKey(opts.SessionKey) + if !ok { + logger.DebugContext(ctx, "Invalid account credentials registration session key format") + return AccountCredentialsDynamicRegistrationSessionData{}, "", false, true, nil + } + + data, err := c.storage.GetWithContext(ctx, buildAccountCredentialsDynamicRegistrationSessionCacheKey(opts.Domain, clientID)) + if err != nil { + logger.ErrorContext(ctx, "Failed to get account credentials registration session from cache", "error", err) + return AccountCredentialsDynamicRegistrationSessionData{}, "", false, false, err + } + if data == nil { + logger.DebugContext(ctx, "Account credentials registration session not found") + return AccountCredentialsDynamicRegistrationSessionData{}, "", false, false, nil + } + + var sessionData AccountCredentialsDynamicRegistrationSessionData + if err := json.Unmarshal(data, &sessionData); err != nil { + logger.ErrorContext(ctx, "Failed to unmarshal account credentials registration session data", "error", err) + return AccountCredentialsDynamicRegistrationSessionData{}, "", false, false, err + } + + ok, err = utils.CompareShaHex(sessionKey, sessionData.SessionKey) + if err != nil { + logger.ErrorContext(ctx, "Error comparing session key", "error", err) + return AccountCredentialsDynamicRegistrationSessionData{}, "", false, false, err + } + if !ok { + logger.DebugContext(ctx, "Invalid session key") + return AccountCredentialsDynamicRegistrationSessionData{}, clientID, false, true, nil + } + + return sessionData, clientID, true, true, nil +} + +type DeleteAccountCredentialsRegistrationSessionKeyOptions struct { + RequestID string + Domain string + ClientID string +} + +func (c *Cache) DeleteAccountCredentialsRegistrationSessionKey( + ctx context.Context, + opts DeleteAccountCredentialsRegistrationSessionKeyOptions, +) error { + logger := utils.BuildLogger(c.logger, utils.LoggerOptions{ + Location: accountCredentialsDynamicRegistrationLocation, + Method: "DeleteAccountCredentialsRegistrationSessionKey", + RequestID: opts.RequestID, + }).With( + "domain", opts.Domain, + "clientId", opts.ClientID, + ) + logger.DebugContext(ctx, "Deleting account credentials registration session key...") + + return c.storage.DeleteWithContext( + ctx, + buildAccountCredentialsDynamicRegistrationSessionCacheKey(opts.Domain, opts.ClientID), + ) +} + +func buildAccountCredentialsDynamicRegistrationIATExtAuthCacheKey(provider, state string) string { + return fmt.Sprintf("%s:ext-auth:%s:%s", accountCredentialsDynamicRegistrationIATPrefix, provider, utils.Sha256HashHex(state)) +} + +type AccountCredentialsDynamicRegistrationIATExtAuthData struct { + ClientID string `json:"client_id"` + Domain string `json:"domain"` + RequestState string `json:"request_state"` +} + +type SaveAccountCredentialsDynamicRegistrationIATExtAuthOptions struct { + RequestID string + ClientID string + Domain string + Provider string + State string + RequestState string +} + +func (c *Cache) SaveAccountCredentialsDynamicRegistrationIATExtAuth( + ctx context.Context, + opts SaveAccountCredentialsDynamicRegistrationIATExtAuthOptions, +) error { + logger := utils.BuildLogger(c.logger, utils.LoggerOptions{ + Location: accountCredentialsDynamicRegistrationLocation, + Method: "SaveAccountCredentialsDynamicRegistrationIATExtAuth", + RequestID: opts.RequestID, + }).With( + "clientId", opts.ClientID, + "provider", opts.Provider, + ) + logger.DebugContext(ctx, "Saving account credentials dynamic registration IAT external auth...") + + data := AccountCredentialsDynamicRegistrationIATExtAuthData{ + ClientID: opts.ClientID, + Domain: opts.Domain, + RequestState: opts.RequestState, + } + dataBytes, err := json.Marshal(data) + if err != nil { + logger.ErrorContext(ctx, "Failed to marshal account credentials dynamic registration IAT external auth data", "error", err) + return err + } + + return c.storage.SetWithContext( + ctx, + buildAccountCredentialsDynamicRegistrationIATExtAuthCacheKey(opts.Provider, opts.State), + dataBytes, + c.oauthStateTTL, + ) +} + +type GetAccountCredentialsDynamicRegistrationIATExtAuthOptions struct { + RequestID string + Provider string + State string +} + +func (c *Cache) GetAccountCredentialsDynamicRegistrationIATExtAuth( + ctx context.Context, + opts GetAccountCredentialsDynamicRegistrationIATExtAuthOptions, +) (AccountCredentialsDynamicRegistrationIATExtAuthData, bool, error) { + logger := utils.BuildLogger(c.logger, utils.LoggerOptions{ + Location: accountCredentialsDynamicRegistrationLocation, + Method: "GetAccountCredentialsDynamicRegistrationIATExtAuth", + RequestID: opts.RequestID, + }).With( + "provider", opts.Provider, + ) + logger.DebugContext(ctx, "Getting account credentials dynamic registration IAT external auth...") + + data, err := c.storage.GetWithContext(ctx, buildAccountCredentialsDynamicRegistrationIATExtAuthCacheKey(opts.Provider, opts.State)) + if err != nil { + logger.ErrorContext(ctx, "Failed to get account credentials dynamic registration IAT external auth", "error", err) + return AccountCredentialsDynamicRegistrationIATExtAuthData{}, false, err + } + if data == nil { + logger.DebugContext(ctx, "Account credentials dynamic registration IAT external auth not found") + return AccountCredentialsDynamicRegistrationIATExtAuthData{}, false, nil + } + + var authData AccountCredentialsDynamicRegistrationIATExtAuthData + if err := json.Unmarshal(data, &authData); err != nil { + logger.ErrorContext(ctx, "Failed to unmarshal account credentials dynamic registration IAT external auth data", "error", err) + return AccountCredentialsDynamicRegistrationIATExtAuthData{}, false, err + } + + return authData, true, nil +} diff --git a/idp/internal/providers/cache/account_username.go b/idp/internal/providers/cache/account_username.go index 4922a0a..34c20d1 100644 --- a/idp/internal/providers/cache/account_username.go +++ b/idp/internal/providers/cache/account_username.go @@ -28,7 +28,8 @@ func (c *Cache) AddAccountUsername(ctx context.Context, opts AddAccountUsernameO }).With("accountId", opts.ID) logger.DebugContext(ctx, "Adding account username...") - return c.storage.Set( + return c.storage.SetWithContext( + ctx, fmt.Sprintf("%s:%s", accountUsernamePrefix, opts.Username), []byte(strconv.Itoa(int(opts.ID))), c.accountUsernameTTL, @@ -51,7 +52,7 @@ func (c *Cache) GetAccountIDByUsername( }).With("username", opts.Username) logger.DebugContext(ctx, "Getting account username...") - val, err := c.storage.Get(fmt.Sprintf("%s:%s", accountUsernamePrefix, opts.Username)) + val, err := c.storage.GetWithContext(ctx, fmt.Sprintf("%s:%s", accountUsernamePrefix, opts.Username)) if err != nil { return 0, err } diff --git a/idp/internal/providers/cache/cache.go b/idp/internal/providers/cache/cache.go index cc101b9..25b8df8 100644 --- a/idp/internal/providers/cache/cache.go +++ b/idp/internal/providers/cache/cache.go @@ -32,6 +32,7 @@ type Cache struct { wellKnownTTL time.Duration oauthStateTTL time.Duration oauthCodeTTL time.Duration + oauthCodeSec int64 } func NewCache( @@ -61,9 +62,14 @@ func NewCache( wellKnownTTL: time.Duration(wellKnownTTL) * time.Second, oauthStateTTL: time.Duration(oauthStateTTL) * time.Second, oauthCodeTTL: time.Duration(oauthCodeTTL) * time.Second, + oauthCodeSec: oauthCodeTTL, } } +func (c *Cache) OAuthCodeTTL() int64 { + return c.oauthCodeSec +} + func (c *Cache) ResetCache() error { return c.storage.Reset() } diff --git a/idp/internal/providers/cache/dek.go b/idp/internal/providers/cache/dek.go index bfc0481..e7cb9ed 100644 --- a/idp/internal/providers/cache/dek.go +++ b/idp/internal/providers/cache/dek.go @@ -75,7 +75,7 @@ func (c *Cache) SaveEncDEK(ctx context.Context, opts SaveEncDEKOptions) error { return err } - if err := c.storage.Set(buildEncDEKKey(opts.Suffix), dekData, c.dekEncTTL); err != nil { + if err := c.storage.SetWithContext(ctx, buildEncDEKKey(opts.Suffix), dekData, c.dekEncTTL); err != nil { logger.ErrorContext(ctx, "Error caching DEK", "error", err) return err } @@ -97,7 +97,7 @@ func (c *Cache) GetEncDEK(ctx context.Context, opts GetEncDEKOptions) (string, s }).With("suffix", opts.Suffix) logger.DebugContext(ctx, "Getting DEK...") - dekData, err := c.storage.Get(buildEncDEKKey(opts.Suffix)) + dekData, err := c.storage.GetWithContext(ctx, buildEncDEKKey(opts.Suffix)) if err != nil { logger.ErrorContext(ctx, "Error getting DEK", "error", err) return "", "", uuid.Nil, false, err @@ -163,7 +163,8 @@ func (c *Cache) SaveDecDEK(ctx context.Context, opts SaveDecDEKOptions) error { logger.DebugContext(ctx, "Caching DEK...") decDEKValue := buildDecDEKValue(opts.DEK, opts.KEKid, opts.ExpiresAt) - if err := c.storage.Set( + if err := c.storage.SetWithContext( + ctx, buildDecDEKKey(opts.Prefix, opts.KID), []byte(decDEKValue), c.dekDecTTL, @@ -190,7 +191,7 @@ func (c *Cache) GetDecDEK(ctx context.Context, opts GetDecDEKOptions) (string, u }).With("dekKID", opts.KID) logger.DebugContext(ctx, "Getting DEK...") - dekData, err := c.storage.Get(buildDecDEKKey(opts.Prefix, opts.KID)) + dekData, err := c.storage.GetWithContext(ctx, buildDecDEKKey(opts.Prefix, opts.KID)) if err != nil { logger.ErrorContext(ctx, "Error getting DEK", "error", err) return "", uuid.Nil, time.Time{}, false, err diff --git a/idp/internal/providers/cache/jwk.go b/idp/internal/providers/cache/jwk.go index 2ea8ec7..14cec32 100644 --- a/idp/internal/providers/cache/jwk.go +++ b/idp/internal/providers/cache/jwk.go @@ -45,7 +45,7 @@ func (c *Cache) SavePublicJWK(ctx context.Context, opts SavePublicJWKOptions) er logger.DebugContext(ctx, "Saving JWK...") key := buildJWKKey(opts.Prefix, opts.CryptoSuite, opts.KeyID) - if err := c.storage.Set(key, opts.PublicKey, c.publicJWKTTL); err != nil { + if err := c.storage.SetWithContext(ctx, key, opts.PublicKey, c.publicJWKTTL); err != nil { logger.ErrorContext(ctx, "Error caching JWK", "error", err) return err } @@ -71,7 +71,7 @@ func (c *Cache) GetJWK(ctx context.Context, opts GetJWKOptions) (utils.JWK, bool logger.DebugContext(ctx, "Getting JWK...") key := buildJWKKey(opts.Prefix, opts.CryptoSuite, opts.KeyID) - val, err := c.storage.Get(key) + val, err := c.storage.GetWithContext(ctx, key) if err != nil { logger.ErrorContext(ctx, "Error getting JWK", "error", err) return nil, false, err @@ -123,7 +123,8 @@ func (c *Cache) SaveJWKPrivateKey(ctx context.Context, opts SaveJWKPrivateKeyOpt }).With("kid", opts.KID) logger.DebugContext(ctx, "Saving JWK private key...") - if err := c.storage.Set( + if err := c.storage.SetWithContext( + ctx, buildJWKPrivateKeyKey(opts.CryptoSuite, opts.Suffix), encodeJWKPrivateKeyData(opts.KID, opts.EncPrivKey), c.privateJWKTTL, @@ -153,7 +154,7 @@ func (c *Cache) GetJWKPrivateKey( logger.DebugContext(ctx, "Getting JWK private key...") key := buildJWKPrivateKeyKey(opts.CryptoSuite, opts.Suffix) - val, err := c.storage.Get(key) + val, err := c.storage.GetWithContext(ctx, key) if err != nil { logger.ErrorContext(ctx, "Error getting JWK private key", "error", err) return "", "", false, err @@ -196,7 +197,8 @@ func (c *Cache) SavePublicJWKs( return "", err } - if err := c.storage.Set( + if err := c.storage.SetWithContext( + ctx, fmt.Sprintf("%s:%s:%s", jwkPrefix, opts.Prefix, jwkPublicSuffix), jwksBytes, c.publicJWKsTTL, @@ -225,7 +227,7 @@ func (c *Cache) GetPublicJWKs( logger.DebugContext(ctx, "Getting public JWKs...") key := fmt.Sprintf("%s:%s:%s", jwkPrefix, opts.Prefix, jwkPublicSuffix) - val, err := c.storage.Get(key) + val, err := c.storage.GetWithContext(ctx, key) if err != nil { logger.ErrorContext(ctx, "Error getting public JWKs", "error", err) return "", nil, false, err diff --git a/idp/internal/providers/cache/kek.go b/idp/internal/providers/cache/kek.go index 64c9e2d..748228f 100644 --- a/idp/internal/providers/cache/kek.go +++ b/idp/internal/providers/cache/kek.go @@ -38,7 +38,7 @@ func (c *Cache) SaveKEKUUID(ctx context.Context, opts SaveKEKUUIDOptions) error }) logger.DebugContext(ctx, "Caching KEK UUID...") - return c.storage.Set(buildKEKKey(opts.Prefix), opts.KID[:], c.kekTTL) + return c.storage.SetWithContext(ctx, buildKEKKey(opts.Prefix), opts.KID[:], c.kekTTL) } type GetKEKUUIDOptions struct { @@ -54,7 +54,7 @@ func (c *Cache) GetKEKUUID(ctx context.Context, opts GetKEKUUIDOptions) (uuid.UU }) logger.DebugContext(ctx, "Getting KEK UUID...") - kid, err := c.storage.Get(buildKEKKey(opts.Prefix)) + kid, err := c.storage.GetWithContext(ctx, buildKEKKey(opts.Prefix)) if err != nil { return uuid.Nil, false, err } diff --git a/idp/internal/providers/cache/oauth_code.go b/idp/internal/providers/cache/oauth_code.go index b47cc7b..0cb92b6 100644 --- a/idp/internal/providers/cache/oauth_code.go +++ b/idp/internal/providers/cache/oauth_code.go @@ -8,8 +8,6 @@ package cache import ( "context" - "crypto/sha256" - "encoding/hex" "encoding/json" "fmt" "strings" @@ -20,8 +18,14 @@ import ( const ( oauthCodePrefix string = "oauth_code" oauthCodeLocation string = "oauth_code" + + codeByteLen int = 16 ) +func buildOAuthCodeKey(codeID string) string { + return fmt.Sprintf("%s:%s", oauthCodePrefix, utils.Sha256HashHex(codeID)) +} + type OAuthCodeData struct { Email string `json:"email"` GivenName string `json:"given_name"` @@ -49,8 +53,12 @@ func (c *Cache) GenerateOAuthCode(ctx context.Context, opts GenerateOAuthCodeOpt logger.DebugContext(ctx, "Generating OAuth code...") codeID := utils.Base62UUID() - code := utils.Base62UUID() - key := fmt.Sprintf("%s:%s", oauthCodePrefix, codeID) + code, err := utils.GenerateBase62Secret(codeByteLen) + if err != nil { + logger.ErrorContext(ctx, "Error generating OAuth code", "error", err) + return "", err + } + key := buildOAuthCodeKey(codeID) data := OAuthCodeData{ Email: opts.Email, @@ -58,7 +66,7 @@ func (c *Cache) GenerateOAuthCode(ctx context.Context, opts GenerateOAuthCodeOpt FamilyName: opts.FamilyName, Provider: opts.Provider, Challenge: opts.Challenge, - Code: utils.Sha256HashHex([]byte(code)), + Code: utils.Sha256HashHex(code), } val, err := json.Marshal(data) if err != nil { @@ -66,7 +74,7 @@ func (c *Cache) GenerateOAuthCode(ctx context.Context, opts GenerateOAuthCodeOpt return "", err } - if err := c.storage.Set(key, val, c.oauthCodeTTL); err != nil { + if err := c.storage.SetWithContext(ctx, key, val, c.oauthCodeTTL); err != nil { logger.ErrorContext(ctx, "Error caching OAuth code", "error", err) return "", err } @@ -98,8 +106,8 @@ func (c *Cache) VerifyOAuthCode(ctx context.Context, opts VerifyOAuthCodeOptions return OAuthCodeData{}, false, nil } - key := fmt.Sprintf("%s:%s", oauthCodePrefix, parts[0]) - valByte, err := c.storage.Get(key) + key := buildOAuthCodeKey(parts[0]) + valByte, err := c.storage.GetWithContext(ctx, key) if err != nil { logger.ErrorContext(ctx, "Error getting OAuth code", "error", err) return OAuthCodeData{}, false, err @@ -115,19 +123,16 @@ func (c *Cache) VerifyOAuthCode(ctx context.Context, opts VerifyOAuthCodeOptions return OAuthCodeData{}, false, err } - decodedHashedCode, err := hex.DecodeString(data.Code) + ok, err := utils.CompareShaHex(parts[1], data.Code) if err != nil { - logger.ErrorContext(ctx, "Error decoding OAuth code hash", "error", err) + logger.ErrorContext(ctx, "Error comparing OAuth code", "error", err) return OAuthCodeData{}, false, err } - - hashedCode := sha256.Sum256([]byte(parts[1])) - if !utils.CompareSha256(hashedCode[:], decodedHashedCode) { - logger.DebugContext(ctx, "OAuth code does not match") + if !ok { + logger.DebugContext(ctx, "Invalid OAuth code") return OAuthCodeData{}, false, nil } - - if err := c.storage.Delete(key); err != nil { + if err := c.storage.DeleteWithContext(ctx, key); err != nil { logger.ErrorContext(ctx, "Error delete OAuth code", "error", err) return OAuthCodeData{}, false, err } diff --git a/idp/internal/providers/cache/oauth_state.go b/idp/internal/providers/cache/oauth_state.go index a081de7..c1ed941 100644 --- a/idp/internal/providers/cache/oauth_state.go +++ b/idp/internal/providers/cache/oauth_state.go @@ -19,7 +19,7 @@ const ( ) func buildOAuthStateKey(state string) string { - return oauthStatePrefix + ":" + utils.Sha256HashHex([]byte(state)) + return oauthStatePrefix + ":" + utils.Sha256HashHex(state) } type SaveOAuthStateDataOptions struct { @@ -55,7 +55,8 @@ func (c *Cache) SaveOAuthStateData(ctx context.Context, opts SaveOAuthStateDataO return err } - return c.storage.Set( + return c.storage.SetWithContext( + ctx, buildOAuthStateKey(opts.State), dataBytes, c.oauthStateTTL, @@ -78,7 +79,7 @@ func (c *Cache) GetOAuthState( }) logger.DebugContext(ctx, "Getting OAuth state...") key := buildOAuthStateKey(opts.State) - valByte, err := c.storage.Get(key) + valByte, err := c.storage.GetWithContext(ctx, key) if err != nil { logger.ErrorContext(ctx, "Error verifying OAuth state", "error", err) @@ -94,7 +95,7 @@ func (c *Cache) GetOAuthState( logger.ErrorContext(ctx, "Error unmarshalling OAuth state data", "error", err) return OAuthStateData{}, false, err } - if err := c.storage.Delete(key); err != nil { + if err := c.storage.DeleteWithContext(ctx, key); err != nil { logger.ErrorContext(ctx, "Error deleting OAuth state", "error", err) return OAuthStateData{}, false, err } diff --git a/idp/internal/providers/cache/response.go b/idp/internal/providers/cache/response.go index 6dd2e57..1958f83 100644 --- a/idp/internal/providers/cache/response.go +++ b/idp/internal/providers/cache/response.go @@ -41,7 +41,7 @@ func SaveResponse[T any]( return "", err } - if err := c.storage.Set(opts.Key, responseBytes, time.Duration(opts.TTL)*time.Second); err != nil { + if err := c.storage.SetWithContext(ctx, opts.Key, responseBytes, time.Duration(opts.TTL)*time.Second); err != nil { logger.ErrorContext(ctx, "Error saving response", "error", err) return "", err } @@ -68,7 +68,7 @@ func GetResponse[T any]( logger.DebugContext(ctx, "Getting cached response...") var response T - responseBytes, err := c.storage.Get(opts.Key) + responseBytes, err := c.storage.GetWithContext(ctx, opts.Key) if err != nil { logger.ErrorContext(ctx, "Error getting cached response", "error", err) return response, "", err diff --git a/idp/internal/providers/cache/sensitive_requests.go b/idp/internal/providers/cache/sensitive_requests.go index bf5c3f8..de8f7db 100644 --- a/idp/internal/providers/cache/sensitive_requests.go +++ b/idp/internal/providers/cache/sensitive_requests.go @@ -13,7 +13,6 @@ import ( "github.com/google/uuid" - "github.com/tugascript/devlogs/idp/internal/providers/database" "github.com/tugascript/devlogs/idp/internal/utils" ) @@ -28,8 +27,8 @@ const ( emailUpdatePrefix string = "email_update" passwordUpdatePrefix string = "password_update" deleteAccountPrefix string = "delete_account" - twoFactorUpdatePrefix string = "two_factor_update" usernameUpdatePrefix string = "username_update" + twoFactorDeletePrefix string = "two_factor_delete" ) type SaveUpdateEmailRequestOptions struct { @@ -54,7 +53,7 @@ func (c *Cache) SaveUpdateEmailRequest(ctx context.Context, opts SaveUpdateEmail key := fmt.Sprintf("%s:%s:%s", emailUpdatePrefix, opts.PrefixType, opts.PublicID.String()) val := []byte(opts.Email) exp := time.Duration(opts.DurationSeconds) * time.Second - if err := c.storage.Set(key, val, exp); err != nil { + if err := c.storage.SetWithContext(ctx, key, val, exp); err != nil { logger.ErrorContext(ctx, "Error caching update email request", "error", err) return err } @@ -80,7 +79,7 @@ func (c *Cache) GetUpdateEmailRequest(ctx context.Context, opts GetUpdateEmailRe logger.DebugContext(ctx, "Getting update email request...") key := fmt.Sprintf("%s:%s:%s", emailUpdatePrefix, opts.PrefixType, opts.PublicID.String()) - valByte, err := c.storage.Get(key) + valByte, err := c.storage.GetWithContext(ctx, key) if err != nil { logger.ErrorContext(ctx, "Error getting the update email request", "error", err) return "", false, err @@ -121,7 +120,7 @@ func (c *Cache) SaveUpdatePasswordRequest(ctx context.Context, opts SaveUpdatePa key := fmt.Sprintf("%s:%s:%s", passwordUpdatePrefix, opts.PrefixType, opts.PublicID.String()) val := []byte(hashedPassword) exp := time.Duration(opts.DurationSeconds) * time.Second - if err := c.storage.Set(key, val, exp); err != nil { + if err := c.storage.SetWithContext(ctx, key, val, exp); err != nil { logger.ErrorContext(ctx, "Error caching update password request", "error", err) return err } @@ -147,7 +146,7 @@ func (c *Cache) GetUpdatePasswordRequest(ctx context.Context, opts GetUpdatePass logger.DebugContext(ctx, "Getting update password request...") key := fmt.Sprintf("%s:%s:%s", passwordUpdatePrefix, opts.PrefixType, opts.PublicID.String()) - valByte, err := c.storage.Get(key) + valByte, err := c.storage.GetWithContext(ctx, key) if err != nil { logger.ErrorContext(ctx, "Error getting the update password request", "error", err) return "", false, err @@ -181,7 +180,7 @@ func (c *Cache) SaveDeleteAccountRequest(ctx context.Context, opts SaveDeleteAcc key := fmt.Sprintf("%s:%s:%s", deleteAccountPrefix, opts.PrefixType, opts.PublicID.String()) val := []byte("1") exp := time.Duration(opts.DurationSeconds) * time.Second - if err := c.storage.Set(key, val, exp); err != nil { + if err := c.storage.SetWithContext(ctx, key, val, exp); err != nil { logger.ErrorContext(ctx, "Error caching delete account request", "error", err) return err } @@ -207,7 +206,7 @@ func (c *Cache) GetDeleteAccountRequest(ctx context.Context, opts GetDeleteAccou logger.DebugContext(ctx, "Getting delete account request...") key := fmt.Sprintf("%s:%s:%s", deleteAccountPrefix, opts.PrefixType, opts.PublicID.String()) - val, err := c.storage.Get(key) + val, err := c.storage.GetWithContext(ctx, key) if err != nil { logger.ErrorContext(ctx, "Error getting the delete account request", "error", err) return false, err @@ -219,129 +218,162 @@ func (c *Cache) GetDeleteAccountRequest(ctx context.Context, opts GetDeleteAccou return true, nil } -type SaveTwoFactorUpdateRequestOptions struct { +type SaveUpdateUsernameRequestOptions struct { RequestID string PrefixType SensitiveRequestPrefixType PublicID uuid.UUID - TwoFactorType database.TwoFactorType + Username string DurationSeconds int64 } -func (c *Cache) SaveTwoFactorUpdateRequest(ctx context.Context, opts SaveTwoFactorUpdateRequestOptions) error { +func (c *Cache) SaveUpdateUsernameRequest(ctx context.Context, opts SaveUpdateUsernameRequestOptions) error { logger := utils.BuildLogger(c.logger, utils.LoggerOptions{ Location: sensitiveRequestsLocation, - Method: "SaveTwoFactorUpdateRequest", + Method: "SaveUpdateUsernameRequest", RequestID: opts.RequestID, }).With( "prefixType", opts.PrefixType, "publicID", opts.PublicID, - "twoFactorType", opts.TwoFactorType, + "username", opts.Username, ) - logger.DebugContext(ctx, "Saving two-factor update request...") + logger.DebugContext(ctx, "Saving update username request...") - key := fmt.Sprintf("%s:%s:%s", twoFactorUpdatePrefix, opts.PrefixType, opts.PublicID.String()) - val := []byte(opts.TwoFactorType) + key := fmt.Sprintf("%s:%s:%s", usernameUpdatePrefix, opts.PrefixType, opts.PublicID.String()) + val := []byte(opts.Username) exp := time.Duration(opts.DurationSeconds) * time.Second - if err := c.storage.Set(key, val, exp); err != nil { - logger.ErrorContext(ctx, "Error caching two-factor update request", "error", err) + if err := c.storage.SetWithContext(ctx, key, val, exp); err != nil { + logger.ErrorContext(ctx, "Error caching update username request", "error", err) return err } return nil } -type GetTwoFactorUpdateRequestOptions struct { +type GetUpdateUsernameRequestOptions struct { RequestID string PrefixType SensitiveRequestPrefixType PublicID uuid.UUID } -func (c *Cache) GetTwoFactorUpdateRequest( - ctx context.Context, - opts GetTwoFactorUpdateRequestOptions, -) (database.TwoFactorType, error) { +func (c *Cache) GetUpdateUsernameRequest(ctx context.Context, opts GetUpdateUsernameRequestOptions) (string, error) { logger := utils.BuildLogger(c.logger, utils.LoggerOptions{ Location: sensitiveRequestsLocation, - Method: "GetTwoFactorUpdateRequest", + Method: "GetUpdateUsernameRequest", RequestID: opts.RequestID, }).With( "prefixType", opts.PrefixType, "publicID", opts.PublicID, ) - logger.DebugContext(ctx, "Getting two-factor update request...") + logger.DebugContext(ctx, "Getting update username request...") - key := fmt.Sprintf("%s:%s:%s", twoFactorUpdatePrefix, opts.PrefixType, opts.PublicID.String()) - val, err := c.storage.Get(key) + key := fmt.Sprintf("%s:%s:%s", usernameUpdatePrefix, opts.PrefixType, opts.PublicID.String()) + val, err := c.storage.GetWithContext(ctx, key) if err != nil { - logger.ErrorContext(ctx, "Error getting the two-factor update request", "error", err) + logger.ErrorContext(ctx, "Error getting the update username request", "error", err) return "", err } if val == nil { - logger.DebugContext(ctx, "Two-factor update request not found") + logger.DebugContext(ctx, "Update username request not found") return "", nil } - return database.TwoFactorType(val), nil + return string(val), nil } -type SaveUpdateUsernameRequestOptions struct { - RequestID string - PrefixType SensitiveRequestPrefixType - PublicID uuid.UUID - Username string - DurationSeconds int64 +type SaveDelete2FAConfigRequestOptions struct { + RequestID string + PrefixType SensitiveRequestPrefixType + PublicID uuid.UUID + TwoFAType string + TTL int64 } -func (c *Cache) SaveUpdateUsernameRequest(ctx context.Context, opts SaveUpdateUsernameRequestOptions) error { +func (c *Cache) SaveDelete2FAConfigRequest( + ctx context.Context, + opts SaveDelete2FAConfigRequestOptions, +) (string, error) { logger := utils.BuildLogger(c.logger, utils.LoggerOptions{ Location: sensitiveRequestsLocation, - Method: "SaveUpdateUsernameRequest", + Method: "SaveDelete2FAConfigRequest", RequestID: opts.RequestID, }).With( "prefixType", opts.PrefixType, "publicID", opts.PublicID, - "username", opts.Username, + "twoFAType", opts.TwoFAType, ) - logger.DebugContext(ctx, "Saving update username request...") + logger.DebugContext(ctx, "Saving delete 2FA config request...") + + var code, hashedCode string + if opts.TwoFAType == "email" { + var err error + code, err = generate2FACode() + if err != nil { + logger.ErrorContext(ctx, "Error generating 2FA code", "error", err) + return "", err + } + + hashedCode = utils.Sha256HashHex(code) + } - key := fmt.Sprintf("%s:%s:%s", usernameUpdatePrefix, opts.PrefixType, opts.PublicID.String()) - val := []byte(opts.Username) - exp := time.Duration(opts.DurationSeconds) * time.Second - if err := c.storage.Set(key, val, exp); err != nil { - logger.ErrorContext(ctx, "Error caching update username request", "error", err) - return err + key := fmt.Sprintf("%s:%s:%s:%s", twoFactorDeletePrefix, opts.PrefixType, opts.PublicID.String(), opts.TwoFAType) + if err := c.storage.SetWithContext(ctx, key, []byte(hashedCode), time.Duration(opts.TTL)*time.Second); err != nil { + logger.ErrorContext(ctx, "Error caching delete 2FA config request", "error", err) + return "", err } - return nil + return code, nil } -type GetUpdateUsernameRequestOptions struct { +type VerifyDelete2FAConfigRequestOptions struct { RequestID string PrefixType SensitiveRequestPrefixType PublicID uuid.UUID + TwoFAType string + Code string } -func (c *Cache) GetUpdateUsernameRequest(ctx context.Context, opts GetUpdateUsernameRequestOptions) (string, error) { +func (c *Cache) VerifyDelete2FAConfigRequest( + ctx context.Context, + opts VerifyDelete2FAConfigRequestOptions, +) (bool, error) { logger := utils.BuildLogger(c.logger, utils.LoggerOptions{ Location: sensitiveRequestsLocation, - Method: "GetUpdateUsernameRequest", + Method: "VerifyDelete2FAConfigRequest", RequestID: opts.RequestID, }).With( "prefixType", opts.PrefixType, "publicID", opts.PublicID, + "twoFAType", opts.TwoFAType, ) - logger.DebugContext(ctx, "Getting update username request...") + logger.DebugContext(ctx, "Verifying delete 2FA config request...") - key := fmt.Sprintf("%s:%s:%s", usernameUpdatePrefix, opts.PrefixType, opts.PublicID.String()) - val, err := c.storage.Get(key) + key := fmt.Sprintf("%s:%s:%s:%s", twoFactorDeletePrefix, opts.PrefixType, opts.PublicID.String(), opts.TwoFAType) + val, err := c.storage.GetWithContext(ctx, key) if err != nil { - logger.ErrorContext(ctx, "Error getting the update username request", "error", err) - return "", err + logger.ErrorContext(ctx, "Error getting the delete 2FA config request", "error", err) + return false, err } if val == nil { - logger.DebugContext(ctx, "Update username request not found") - return "", nil + logger.DebugContext(ctx, "Delete 2FA config request not found") + return false, nil } - return string(val), nil + if opts.TwoFAType == "email" { + ok, err := utils.CompareShaHex(opts.Code, string(val)) + if err != nil { + logger.ErrorContext(ctx, "Error comparing delete 2FA config request", "error", err) + return false, err + } + if !ok { + logger.DebugContext(ctx, "Delete 2FA config request does not match") + return false, nil + } + } + + if err := c.storage.DeleteWithContext(ctx, key); err != nil { + logger.ErrorContext(ctx, "Error deleting delete 2FA config request", "error", err) + return true, err + } + + return true, nil } diff --git a/idp/internal/providers/cache/two_factor.go b/idp/internal/providers/cache/two_factor.go index 9a1e252..14a47da 100644 --- a/idp/internal/providers/cache/two_factor.go +++ b/idp/internal/providers/cache/two_factor.go @@ -23,13 +23,17 @@ const ( twoFactorUserPrefix string = "user" ) -func generateCode() (string, error) { - const codeLength = 6 - const digits = "0123456789" +const ( + codeLength int = 6 + digits string = "0123456789" + digitsLen int64 = 10 +) + +func generate2FACode() (string, error) { code := make([]byte, codeLength) for i := 0; i < codeLength; i++ { - num, err := rand.Int(rand.Reader, big.NewInt(int64(len(digits)))) + num, err := rand.Int(rand.Reader, big.NewInt(digitsLen)) if err != nil { return "", err } @@ -65,22 +69,17 @@ func (c *Cache) AddTwoFactorCode(ctx context.Context, opts AddTwoFactorCodeOptio ) logger.DebugContext(ctx, "Adding two factor code...") - code, err := generateCode() + code, err := generate2FACode() if err != nil { logger.ErrorContext(ctx, "Error generating two factor code", "error", err) return "", err } - hashedCode, err := utils.Argon2HashString(code) - if err != nil { - logger.ErrorContext(ctx, "Error hashing two factor code", "error", err) - return "", err - } - + hashedCode := utils.Sha256HashHex(code) key := generateKey(opts.AccountID, opts.UserID) val := []byte(hashedCode) exp := time.Duration(opts.TTL) * time.Second - if err := c.storage.Set(key, val, exp); err != nil { + if err := c.storage.SetWithContext(ctx, key, val, exp); err != nil { logger.ErrorContext(ctx, "Error setting two factor code", "error", err) return "", err } @@ -107,7 +106,7 @@ func (c *Cache) VerifyTwoFactorCode(ctx context.Context, opts VerifyTwoFactorCod logger.DebugContext(ctx, "Verifying two factor code...") key := generateKey(opts.AccountID, opts.UserID) - valByte, err := c.storage.Get(key) + valByte, err := c.storage.GetWithContext(ctx, key) if err != nil { logger.ErrorContext(ctx, "Error verifying two factor code", "error", err) return false, err @@ -117,17 +116,16 @@ func (c *Cache) VerifyTwoFactorCode(ctx context.Context, opts VerifyTwoFactorCod return false, nil } - ok, err := utils.Argon2CompareHash(opts.Code, string(valByte)) + ok, err := utils.CompareShaHex(opts.Code, string(valByte)) if err != nil { - logger.ErrorContext(ctx, "Failed to compare code and its hash") + logger.ErrorContext(ctx, "Error comparing two factor code", "error", err) return false, err } if !ok { - logger.WarnContext(ctx, "Invalid code") + logger.DebugContext(ctx, "Two factor code does not match") return false, nil } - - if err := c.storage.Delete(key); err != nil { + if err := c.storage.DeleteWithContext(ctx, key); err != nil { logger.ErrorContext(ctx, "Error deleting two factor code", "error", err) return true, err } diff --git a/idp/internal/providers/cache/well_known.go b/idp/internal/providers/cache/well_known.go index 6c09320..0f76ae9 100644 --- a/idp/internal/providers/cache/well_known.go +++ b/idp/internal/providers/cache/well_known.go @@ -46,7 +46,8 @@ func (c *Cache) AddWellKnownOIDCConfig( return "", err } - if err := c.storage.Set( + if err := c.storage.SetWithContext( + ctx, fmt.Sprintf("%s:%s", wellKnownOIDCConfigPrefix, opts.AccountUsername), oidcConfigBytes, time.Duration(wellKnownOIDCConfigTTL)*time.Second, @@ -74,7 +75,8 @@ func (c *Cache) GetWellKnownOIDCConfig( }).With("accountUsername", opts.AccountUsername) logger.DebugContext(ctx, "Getting well known OIDC config...") - oidcConfigBytes, err := c.storage.Get( + oidcConfigBytes, err := c.storage.GetWithContext( + ctx, fmt.Sprintf("%s:%s", wellKnownOIDCConfigPrefix, opts.AccountUsername), ) if err != nil { diff --git a/idp/internal/providers/crypto/encryption.go b/idp/internal/providers/crypto/encryption.go index 8691bda..5326841 100644 --- a/idp/internal/providers/crypto/encryption.go +++ b/idp/internal/providers/crypto/encryption.go @@ -8,7 +8,6 @@ package crypto import ( "log/slog" - "time" openbao "github.com/openbao/openbao/api/v2" @@ -23,8 +22,6 @@ type Crypto struct { opLogical *openbao.Logical serviceName string kekPath string - dekTTL time.Duration - jwkTTL time.Duration } func NewCrypto( @@ -38,7 +35,5 @@ func NewCrypto( opLogical: op.Logical(), kekPath: encCfg.KEKPath(), serviceName: utils.Capitalized(serviceName), - dekTTL: time.Duration(encCfg.DEKTTL()) * time.Second, - jwkTTL: time.Duration(encCfg.JWKTTL()) * time.Second, } } diff --git a/idp/internal/providers/crypto/hmac.go b/idp/internal/providers/crypto/hmac.go new file mode 100644 index 0000000..3e30718 --- /dev/null +++ b/idp/internal/providers/crypto/hmac.go @@ -0,0 +1,211 @@ +// Copyright (c) 2025 Afonso Barracha +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package crypto + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + + "github.com/tugascript/devlogs/idp/internal/exceptions" + "github.com/tugascript/devlogs/idp/internal/utils" +) + +const ( + hmacLocation string = "hmac" + + hmacSecretByteLength int = 32 +) + +func encodeHMACSecret(secret []byte) string { + return base64.StdEncoding.EncodeToString(secret) +} + +func decodeHMACSecret(secret string) ([]byte, error) { + return base64.StdEncoding.DecodeString(secret) +} + +type SecretID = string + +type StoreHMACSecret = func(dekID string, secretID SecretID, encryptedSecret string) (int32, *exceptions.ServiceError) + +type GenerateHMACSecretOptions struct { + RequestID string + StoreFN StoreHMACSecret + GetDEKfn GetDEKtoEncrypt +} + +func (e *Crypto) GenerateHMACSecret(ctx context.Context, opts GenerateHMACSecretOptions) (string, *exceptions.ServiceError) { + logger := utils.BuildLogger(e.logger, utils.LoggerOptions{ + Location: hmacLocation, + Method: "GenerateHMACSecret", + RequestID: opts.RequestID, + }) + logger.DebugContext(ctx, "Generating HMAC secret...") + + secretBytes, err := utils.GenerateRandomBytes(hmacSecretByteLength) + if err != nil { + logger.ErrorContext(ctx, "Failed to generate HMAC secret", "error", err) + return "", exceptions.NewInternalServerError() + } + secretID := utils.ExtractSecretID(secretBytes) + + dekID, encryptedSecret, serviceErr := e.EncryptWithDEK(ctx, EncryptWithDEKOptions{ + RequestID: opts.RequestID, + GetDEKfn: opts.GetDEKfn, + PlainText: encodeHMACSecret(secretBytes), + }) + if serviceErr != nil { + logger.ErrorContext(ctx, "Failed to encrypt HMAC secret", "serviceError", serviceErr) + return "", exceptions.NewInternalServerError() + } + + dbID, serviceErr := opts.StoreFN(dekID, secretID, encryptedSecret) + if serviceErr != nil { + logger.ErrorContext(ctx, "Failed to store HMAC secret", "serviceError", serviceErr) + return "", exceptions.NewInternalServerError() + } + + logger.InfoContext(ctx, "HMAC secret generated and stored successfully", "dekID", dekID, "dbID", dbID) + return secretID, nil +} + +func encodeHMACData(data []byte) string { + return hex.EncodeToString(data) +} + +func decodeHMACData(data string) ([]byte, error) { + return hex.DecodeString(data) +} + +type GetHMACSecretFN = func() (string, DEKCiphertext, *exceptions.ServiceError) + +type StoreHashedData = func(secretID string, hashedData string) *exceptions.ServiceError + +type HMACSha256HashOptions struct { + RequestID string + PlainText string + GetHMACSecretFN GetHMACSecretFN + StoreHashedDataFN StoreHashedData + GetDecryptDEKfn GetDEKtoDecrypt + GetEncryptDEKfn GetDEKtoEncrypt + StoreReEncryptedHMACSecretFN StoreReEncryptedData +} + +func (e *Crypto) HMACSha256Hash(ctx context.Context, opts HMACSha256HashOptions) *exceptions.ServiceError { + logger := utils.BuildLogger(e.logger, utils.LoggerOptions{ + Location: hmacLocation, + Method: "HMACSha256Hash", + RequestID: opts.RequestID, + }) + logger.DebugContext(ctx, "Calculating HMAC SHA256...") + + secretID, dekCiphertext, serviceErr := opts.GetHMACSecretFN() + if serviceErr != nil { + logger.ErrorContext(ctx, "Failed to get HMAC secret", "serviceError", serviceErr) + return serviceErr + } + + encodedSecret, serviceErr := e.DecryptWithDEK(ctx, DecryptWithDEKOptions{ + RequestID: opts.RequestID, + GetDecryptDEKfn: opts.GetDecryptDEKfn, + GetEncryptDEKfn: opts.GetEncryptDEKfn, + StoreReEncryptedDataFn: opts.StoreReEncryptedHMACSecretFN, + EntityID: secretID, + Ciphertext: dekCiphertext, + }) + if serviceErr != nil { + logger.ErrorContext(ctx, "Failed to decrypt HMAC secret", "serviceError", serviceErr) + return serviceErr + } + + secret, err := decodeHMACSecret(encodedSecret) + if err != nil { + logger.ErrorContext(ctx, "Failed to decode HMAC secret", "error", err) + return exceptions.NewInternalServerError() + } + + mac := hmac.New(sha256.New, secret) + mac.Write([]byte(opts.PlainText)) + if serviceErr := opts.StoreHashedDataFN(secretID, encodeHMACData(mac.Sum(nil))); serviceErr != nil { + logger.ErrorContext(ctx, "Failed to store hashed data", "serviceError", serviceErr) + return exceptions.NewInternalServerError() + } + + return nil +} + +type GetHMACSecretByIDfn = func(secretID SecretID) (DEKCiphertext, *exceptions.ServiceError) + +type GetHashedSecretFN = func() (SecretID, string, *exceptions.ServiceError) + +type HMACSha256CompareHashOptions struct { + RequestID string + PlainText string + HashedSecretFN GetHashedSecretFN + GetHMACSecretByIDFN GetHMACSecretByIDfn + GetDecryptDEKfn GetDEKtoDecrypt + GetEncryptDEKfn GetDEKtoEncrypt + StoreReEncryptedHMACSecretFN StoreReEncryptedData +} + +func (e *Crypto) HMACSha256CompareHash(ctx context.Context, opts HMACSha256CompareHashOptions) *exceptions.ServiceError { + logger := utils.BuildLogger(e.logger, utils.LoggerOptions{ + Location: hmacLocation, + Method: "HMACSha256CompareHash", + RequestID: opts.RequestID, + }) + logger.DebugContext(ctx, "Comparing HMAC SHA256...") + + secretID, hashedSecret, serviceErr := opts.HashedSecretFN() + if serviceErr != nil { + logger.ErrorContext(ctx, "Failed to get hashed secret", "serviceError", serviceErr) + return serviceErr + } + + dekCiphertext, serviceErr := opts.GetHMACSecretByIDFN(secretID) + if serviceErr != nil { + logger.ErrorContext(ctx, "Failed to get HMAC secret by ID", "serviceError", serviceErr) + return serviceErr + } + + encodedSecret, serviceErr := e.DecryptWithDEK(ctx, DecryptWithDEKOptions{ + RequestID: opts.RequestID, + GetDecryptDEKfn: opts.GetDecryptDEKfn, + GetEncryptDEKfn: opts.GetEncryptDEKfn, + StoreReEncryptedDataFn: opts.StoreReEncryptedHMACSecretFN, + EntityID: secretID, + Ciphertext: dekCiphertext, + }) + if serviceErr != nil { + logger.ErrorContext(ctx, "Failed to decrypt HMAC secret", "serviceError", serviceErr) + return serviceErr + } + + secret, err := decodeHMACSecret(encodedSecret) + if err != nil { + logger.ErrorContext(ctx, "Failed to decode HMAC secret", "error", err) + return exceptions.NewInternalServerError() + } + + hashedSecretBytes, err := decodeHMACData(hashedSecret) + if err != nil { + logger.ErrorContext(ctx, "Failed to decode hashed secret", "error", err) + return exceptions.NewInternalServerError() + } + + mac := hmac.New(sha256.New, secret) + mac.Write([]byte(opts.PlainText)) + if !utils.CompareSha256(mac.Sum(nil), hashedSecretBytes) { + logger.WarnContext(ctx, "HMAC SHA256 hash mismatch", "plainText", opts.PlainText, "hashedSecret", hashedSecret) + return exceptions.NewUnauthorizedError() + } + + return nil +} diff --git a/idp/internal/providers/database/account_2fa_configs.sql.go b/idp/internal/providers/database/account_2fa_configs.sql.go new file mode 100644 index 0000000..9d84eae --- /dev/null +++ b/idp/internal/providers/database/account_2fa_configs.sql.go @@ -0,0 +1,196 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: account_2fa_configs.sql + +package database + +import ( + "context" + + "github.com/google/uuid" +) + +const countAccount2FAConfigsByAccountID = `-- name: CountAccount2FAConfigsByAccountID :one +SELECT COUNT(*) FROM "account_2fa_configs" +WHERE "account_id" = $1 +LIMIT 1 +` + +func (q *Queries) CountAccount2FAConfigsByAccountID(ctx context.Context, accountID int32) (int64, error) { + row := q.db.QueryRow(ctx, countAccount2FAConfigsByAccountID, accountID) + var count int64 + err := row.Scan(&count) + return count, err +} + +const createAccount2FAConfig = `-- name: CreateAccount2FAConfig :one + +INSERT INTO "account_2fa_configs" ( + "account_id", + "account_public_id", + "two_factor_type", + "is_default" +) VALUES ( + $1, + $2, + $3, + $4 +) RETURNING id, account_id, account_public_id, two_factor_type, is_default, is_active, created_at, updated_at +` + +type CreateAccount2FAConfigParams struct { + AccountID int32 + AccountPublicID uuid.UUID + TwoFactorType TwoFactorType + IsDefault bool +} + +// Copyright (c) 2025 Afonso Barracha +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. +func (q *Queries) CreateAccount2FAConfig(ctx context.Context, arg CreateAccount2FAConfigParams) (Account2faConfig, error) { + row := q.db.QueryRow(ctx, createAccount2FAConfig, + arg.AccountID, + arg.AccountPublicID, + arg.TwoFactorType, + arg.IsDefault, + ) + var i Account2faConfig + err := row.Scan( + &i.ID, + &i.AccountID, + &i.AccountPublicID, + &i.TwoFactorType, + &i.IsDefault, + &i.IsActive, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const deleteAccount2FAConfig = `-- name: DeleteAccount2FAConfig :exec +DELETE FROM "account_2fa_configs" +WHERE "id" = $1 +` + +func (q *Queries) DeleteAccount2FAConfig(ctx context.Context, id int32) error { + _, err := q.db.Exec(ctx, deleteAccount2FAConfig, id) + return err +} + +const findAccount2FAConfigByAccountPublicIDAndType = `-- name: FindAccount2FAConfigByAccountPublicIDAndType :one +SELECT id, account_id, account_public_id, two_factor_type, is_default, is_active, created_at, updated_at FROM "account_2fa_configs" +WHERE "account_public_id" = $1 AND "two_factor_type" = $2 +LIMIT 1 +` + +type FindAccount2FAConfigByAccountPublicIDAndTypeParams struct { + AccountPublicID uuid.UUID + TwoFactorType TwoFactorType +} + +func (q *Queries) FindAccount2FAConfigByAccountPublicIDAndType(ctx context.Context, arg FindAccount2FAConfigByAccountPublicIDAndTypeParams) (Account2faConfig, error) { + row := q.db.QueryRow(ctx, findAccount2FAConfigByAccountPublicIDAndType, arg.AccountPublicID, arg.TwoFactorType) + var i Account2faConfig + err := row.Scan( + &i.ID, + &i.AccountID, + &i.AccountPublicID, + &i.TwoFactorType, + &i.IsDefault, + &i.IsActive, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const findAccount2FAConfigsByAccountPublicID = `-- name: FindAccount2FAConfigsByAccountPublicID :many +SELECT id, account_id, account_public_id, two_factor_type, is_default, is_active, created_at, updated_at FROM "account_2fa_configs" +WHERE "account_public_id" = $1 +ORDER BY "id" DESC +` + +func (q *Queries) FindAccount2FAConfigsByAccountPublicID(ctx context.Context, accountPublicID uuid.UUID) ([]Account2faConfig, error) { + rows, err := q.db.Query(ctx, findAccount2FAConfigsByAccountPublicID, accountPublicID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Account2faConfig{} + for rows.Next() { + var i Account2faConfig + if err := rows.Scan( + &i.ID, + &i.AccountID, + &i.AccountPublicID, + &i.TwoFactorType, + &i.IsDefault, + &i.IsActive, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const findDefaultAccount2FAConfigByAccountPublicID = `-- name: FindDefaultAccount2FAConfigByAccountPublicID :one +SELECT id, account_id, account_public_id, two_factor_type, is_default, is_active, created_at, updated_at FROM "account_2fa_configs" +WHERE "account_public_id" = $1 AND "is_default" = true +LIMIT 1 +` + +func (q *Queries) FindDefaultAccount2FAConfigByAccountPublicID(ctx context.Context, accountPublicID uuid.UUID) (Account2faConfig, error) { + row := q.db.QueryRow(ctx, findDefaultAccount2FAConfigByAccountPublicID, accountPublicID) + var i Account2faConfig + err := row.Scan( + &i.ID, + &i.AccountID, + &i.AccountPublicID, + &i.TwoFactorType, + &i.IsDefault, + &i.IsActive, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const updateAccount2FAConfig = `-- name: UpdateAccount2FAConfig :one +UPDATE "account_2fa_configs" SET + "is_default" = $2, + "updated_at" = now() +WHERE "id" = $1 +RETURNING id, account_id, account_public_id, two_factor_type, is_default, is_active, created_at, updated_at +` + +type UpdateAccount2FAConfigParams struct { + ID int32 + IsDefault bool +} + +func (q *Queries) UpdateAccount2FAConfig(ctx context.Context, arg UpdateAccount2FAConfigParams) (Account2faConfig, error) { + row := q.db.QueryRow(ctx, updateAccount2FAConfig, arg.ID, arg.IsDefault) + var i Account2faConfig + err := row.Scan( + &i.ID, + &i.AccountID, + &i.AccountPublicID, + &i.TwoFactorType, + &i.IsDefault, + &i.IsActive, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/idp/internal/providers/database/account_credential_secrets.sql.go b/idp/internal/providers/database/account_credential_secrets.sql.go index be6eae8..2ea3eca 100644 --- a/idp/internal/providers/database/account_credential_secrets.sql.go +++ b/idp/internal/providers/database/account_credential_secrets.sql.go @@ -100,7 +100,7 @@ func (q *Queries) FindAccountCredentialSecretByAccountCredentialIDAndCredentials } const findAccountCredentialsSecretAccountByAccountCredentialIDAndSecretID = `-- name: FindAccountCredentialsSecretAccountByAccountCredentialIDAndSecretID :one -SELECT a.id, a.public_id, a.given_name, a.family_name, a.username, a.email, a.organization, a.password, a.version, a.email_verified, a.is_active, a.two_factor_type, a.created_at, a.updated_at FROM "accounts" AS "a" +SELECT a.id, a.public_id, a.given_name, a.family_name, a.username, a.email, a.organization, a.password, a.version, a.email_verified, a.activity_status, a.created_at, a.updated_at FROM "accounts" AS "a" LEFT JOIN "account_credentials_secrets" AS "acs" ON "acs"."account_id" = "a"."id" WHERE "acs"."account_credentials_id" = $1 AND @@ -127,8 +127,7 @@ func (q *Queries) FindAccountCredentialsSecretAccountByAccountCredentialIDAndSec &i.Password, &i.Version, &i.EmailVerified, - &i.IsActive, - &i.TwoFactorType, + &i.ActivityStatus, &i.CreatedAt, &i.UpdatedAt, ) diff --git a/idp/internal/providers/database/account_credentials.sql.go b/idp/internal/providers/database/account_credentials.sql.go index caac08d..ef4b466 100644 --- a/idp/internal/providers/database/account_credentials.sql.go +++ b/idp/internal/providers/database/account_credentials.sql.go @@ -9,6 +9,7 @@ import ( "context" "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" ) const countAccountCredentialsByAccountPublicID = `-- name: CountAccountCredentialsByAccountPublicID :one @@ -24,18 +25,36 @@ func (q *Queries) CountAccountCredentialsByAccountPublicID(ctx context.Context, return count, err } -const countAccountCredentialsByAliasAndAccountID = `-- name: CountAccountCredentialsByAliasAndAccountID :one +const countAccountCredentialsByAccountPublicIDAndClientID = `-- name: CountAccountCredentialsByAccountPublicIDAndClientID :one SELECT COUNT(*) FROM "account_credentials" -WHERE "account_id" = $1 AND "alias" = $2 +WHERE "account_public_id" = $1 AND "client_id" = $2 +LIMIT 1 +` + +type CountAccountCredentialsByAccountPublicIDAndClientIDParams struct { + AccountPublicID uuid.UUID + ClientID string +} + +func (q *Queries) CountAccountCredentialsByAccountPublicIDAndClientID(ctx context.Context, arg CountAccountCredentialsByAccountPublicIDAndClientIDParams) (int64, error) { + row := q.db.QueryRow(ctx, countAccountCredentialsByAccountPublicIDAndClientID, arg.AccountPublicID, arg.ClientID) + var count int64 + err := row.Scan(&count) + return count, err +} + +const countAccountCredentialsByNameAndAccountID = `-- name: CountAccountCredentialsByNameAndAccountID :one +SELECT COUNT(*) FROM "account_credentials" +WHERE "account_id" = $1 AND "name" = $2 ` -type CountAccountCredentialsByAliasAndAccountIDParams struct { +type CountAccountCredentialsByNameAndAccountIDParams struct { AccountID int32 - Alias string + Name string } -func (q *Queries) CountAccountCredentialsByAliasAndAccountID(ctx context.Context, arg CountAccountCredentialsByAliasAndAccountIDParams) (int64, error) { - row := q.db.QueryRow(ctx, countAccountCredentialsByAliasAndAccountID, arg.AccountID, arg.Alias) +func (q *Queries) CountAccountCredentialsByNameAndAccountID(ctx context.Context, arg CountAccountCredentialsByNameAndAccountIDParams) (int64, error) { + row := q.db.QueryRow(ctx, countAccountCredentialsByNameAndAccountID, arg.AccountID, arg.Name) var count int64 err := row.Scan(&count) return count, err @@ -47,10 +66,20 @@ INSERT INTO "account_credentials" ( "account_id", "account_public_id", "credentials_type", - "alias", + "name", "scopes", "token_endpoint_auth_method", - "issuers" + "domain", + "client_uri", + "redirect_uris", + "logo_uri", + "policy_uri", + "tos_uri", + "software_id", + "software_version", + "contacts", + "creation_method", + "transport" ) VALUES ( $1, $2, @@ -59,8 +88,18 @@ INSERT INTO "account_credentials" ( $5, $6, $7, - $8 -) RETURNING id, account_id, account_public_id, credentials_type, scopes, token_endpoint_auth_method, issuers, alias, client_id, created_at, updated_at + $8, + $9, + $10, + $11, + $12, + $13, + $14, + $15, + $16, + $17, + $18 +) RETURNING id, account_id, account_public_id, client_id, name, domain, credentials_type, scopes, token_endpoint_auth_method, grant_types, version, transport, creation_method, client_uri, redirect_uris, logo_uri, policy_uri, tos_uri, software_id, software_version, contacts, created_at, updated_at ` type CreateAccountCredentialsParams struct { @@ -68,10 +107,20 @@ type CreateAccountCredentialsParams struct { AccountID int32 AccountPublicID uuid.UUID CredentialsType AccountCredentialsType - Alias string + Name string Scopes []AccountCredentialsScope TokenEndpointAuthMethod AuthMethod - Issuers []string + Domain string + ClientUri string + RedirectUris []string + LogoUri pgtype.Text + PolicyUri pgtype.Text + TosUri pgtype.Text + SoftwareID string + SoftwareVersion pgtype.Text + Contacts []string + CreationMethod CreationMethod + Transport Transport } func (q *Queries) CreateAccountCredentials(ctx context.Context, arg CreateAccountCredentialsParams) (AccountCredential, error) { @@ -80,22 +129,44 @@ func (q *Queries) CreateAccountCredentials(ctx context.Context, arg CreateAccoun arg.AccountID, arg.AccountPublicID, arg.CredentialsType, - arg.Alias, + arg.Name, arg.Scopes, arg.TokenEndpointAuthMethod, - arg.Issuers, + arg.Domain, + arg.ClientUri, + arg.RedirectUris, + arg.LogoUri, + arg.PolicyUri, + arg.TosUri, + arg.SoftwareID, + arg.SoftwareVersion, + arg.Contacts, + arg.CreationMethod, + arg.Transport, ) var i AccountCredential err := row.Scan( &i.ID, &i.AccountID, &i.AccountPublicID, + &i.ClientID, + &i.Name, + &i.Domain, &i.CredentialsType, &i.Scopes, &i.TokenEndpointAuthMethod, - &i.Issuers, - &i.Alias, - &i.ClientID, + &i.GrantTypes, + &i.Version, + &i.Transport, + &i.CreationMethod, + &i.ClientUri, + &i.RedirectUris, + &i.LogoUri, + &i.PolicyUri, + &i.TosUri, + &i.SoftwareID, + &i.SoftwareVersion, + &i.Contacts, &i.CreatedAt, &i.UpdatedAt, ) @@ -122,7 +193,7 @@ func (q *Queries) DeleteAllAccountCredentials(ctx context.Context) error { } const findAccountCredentialsByAccountPublicIDAndClientID = `-- name: FindAccountCredentialsByAccountPublicIDAndClientID :one -SELECT id, account_id, account_public_id, credentials_type, scopes, token_endpoint_auth_method, issuers, alias, client_id, created_at, updated_at FROM "account_credentials" +SELECT id, account_id, account_public_id, client_id, name, domain, credentials_type, scopes, token_endpoint_auth_method, grant_types, version, transport, creation_method, client_uri, redirect_uris, logo_uri, policy_uri, tos_uri, software_id, software_version, contacts, created_at, updated_at FROM "account_credentials" WHERE "account_public_id" = $1 AND "client_id" = $2 LIMIT 1 ` @@ -139,12 +210,24 @@ func (q *Queries) FindAccountCredentialsByAccountPublicIDAndClientID(ctx context &i.ID, &i.AccountID, &i.AccountPublicID, + &i.ClientID, + &i.Name, + &i.Domain, &i.CredentialsType, &i.Scopes, &i.TokenEndpointAuthMethod, - &i.Issuers, - &i.Alias, - &i.ClientID, + &i.GrantTypes, + &i.Version, + &i.Transport, + &i.CreationMethod, + &i.ClientUri, + &i.RedirectUris, + &i.LogoUri, + &i.PolicyUri, + &i.TosUri, + &i.SoftwareID, + &i.SoftwareVersion, + &i.Contacts, &i.CreatedAt, &i.UpdatedAt, ) @@ -153,7 +236,7 @@ func (q *Queries) FindAccountCredentialsByAccountPublicIDAndClientID(ctx context const findAccountCredentialsByClientID = `-- name: FindAccountCredentialsByClientID :one -SELECT id, account_id, account_public_id, credentials_type, scopes, token_endpoint_auth_method, issuers, alias, client_id, created_at, updated_at FROM "account_credentials" +SELECT id, account_id, account_public_id, client_id, name, domain, credentials_type, scopes, token_endpoint_auth_method, grant_types, version, transport, creation_method, client_uri, redirect_uris, logo_uri, policy_uri, tos_uri, software_id, software_version, contacts, created_at, updated_at FROM "account_credentials" WHERE "client_id" = $1 LIMIT 1 ` @@ -170,12 +253,24 @@ func (q *Queries) FindAccountCredentialsByClientID(ctx context.Context, clientID &i.ID, &i.AccountID, &i.AccountPublicID, + &i.ClientID, + &i.Name, + &i.Domain, &i.CredentialsType, &i.Scopes, &i.TokenEndpointAuthMethod, - &i.Issuers, - &i.Alias, - &i.ClientID, + &i.GrantTypes, + &i.Version, + &i.Transport, + &i.CreationMethod, + &i.ClientUri, + &i.RedirectUris, + &i.LogoUri, + &i.PolicyUri, + &i.TosUri, + &i.SoftwareID, + &i.SoftwareVersion, + &i.Contacts, &i.CreatedAt, &i.UpdatedAt, ) @@ -183,7 +278,7 @@ func (q *Queries) FindAccountCredentialsByClientID(ctx context.Context, clientID } const findPaginatedAccountCredentialsByAccountPublicID = `-- name: FindPaginatedAccountCredentialsByAccountPublicID :many -SELECT id, account_id, account_public_id, credentials_type, scopes, token_endpoint_auth_method, issuers, alias, client_id, created_at, updated_at FROM "account_credentials" +SELECT id, account_id, account_public_id, client_id, name, domain, credentials_type, scopes, token_endpoint_auth_method, grant_types, version, transport, creation_method, client_uri, redirect_uris, logo_uri, policy_uri, tos_uri, software_id, software_version, contacts, created_at, updated_at FROM "account_credentials" WHERE "account_public_id" = $1 ORDER BY "id" DESC OFFSET $2 LIMIT $3 @@ -208,12 +303,24 @@ func (q *Queries) FindPaginatedAccountCredentialsByAccountPublicID(ctx context.C &i.ID, &i.AccountID, &i.AccountPublicID, + &i.ClientID, + &i.Name, + &i.Domain, &i.CredentialsType, &i.Scopes, &i.TokenEndpointAuthMethod, - &i.Issuers, - &i.Alias, - &i.ClientID, + &i.GrantTypes, + &i.Version, + &i.Transport, + &i.CreationMethod, + &i.ClientUri, + &i.RedirectUris, + &i.LogoUri, + &i.PolicyUri, + &i.TosUri, + &i.SoftwareID, + &i.SoftwareVersion, + &i.Contacts, &i.CreatedAt, &i.UpdatedAt, ); err != nil { @@ -230,38 +337,75 @@ func (q *Queries) FindPaginatedAccountCredentialsByAccountPublicID(ctx context.C const updateAccountCredentials = `-- name: UpdateAccountCredentials :one UPDATE "account_credentials" SET "scopes" = $2, - "alias" = $3, - "issuers" = $4, + "name" = $3, + "domain" = $4, + "client_uri" = $5, + "redirect_uris" = $6, + "logo_uri" = $7, + "policy_uri" = $8, + "tos_uri" = $9, + "software_version" = $10, + "contacts" = $11, + "transport" = $12, + "version" = "version" + 1, "updated_at" = now() WHERE "id" = $1 -RETURNING id, account_id, account_public_id, credentials_type, scopes, token_endpoint_auth_method, issuers, alias, client_id, created_at, updated_at +RETURNING id, account_id, account_public_id, client_id, name, domain, credentials_type, scopes, token_endpoint_auth_method, grant_types, version, transport, creation_method, client_uri, redirect_uris, logo_uri, policy_uri, tos_uri, software_id, software_version, contacts, created_at, updated_at ` type UpdateAccountCredentialsParams struct { - ID int32 - Scopes []AccountCredentialsScope - Alias string - Issuers []string + ID int32 + Scopes []AccountCredentialsScope + Name string + Domain string + ClientUri string + RedirectUris []string + LogoUri pgtype.Text + PolicyUri pgtype.Text + TosUri pgtype.Text + SoftwareVersion pgtype.Text + Contacts []string + Transport Transport } func (q *Queries) UpdateAccountCredentials(ctx context.Context, arg UpdateAccountCredentialsParams) (AccountCredential, error) { row := q.db.QueryRow(ctx, updateAccountCredentials, arg.ID, arg.Scopes, - arg.Alias, - arg.Issuers, + arg.Name, + arg.Domain, + arg.ClientUri, + arg.RedirectUris, + arg.LogoUri, + arg.PolicyUri, + arg.TosUri, + arg.SoftwareVersion, + arg.Contacts, + arg.Transport, ) var i AccountCredential err := row.Scan( &i.ID, &i.AccountID, &i.AccountPublicID, + &i.ClientID, + &i.Name, + &i.Domain, &i.CredentialsType, &i.Scopes, &i.TokenEndpointAuthMethod, - &i.Issuers, - &i.Alias, - &i.ClientID, + &i.GrantTypes, + &i.Version, + &i.Transport, + &i.CreationMethod, + &i.ClientUri, + &i.RedirectUris, + &i.LogoUri, + &i.PolicyUri, + &i.TosUri, + &i.SoftwareID, + &i.SoftwareVersion, + &i.Contacts, &i.CreatedAt, &i.UpdatedAt, ) diff --git a/idp/internal/providers/database/account_credentials_keys.sql.go b/idp/internal/providers/database/account_credentials_keys.sql.go index aa5dc79..26f8441 100644 --- a/idp/internal/providers/database/account_credentials_keys.sql.go +++ b/idp/internal/providers/database/account_credentials_keys.sql.go @@ -99,7 +99,7 @@ func (q *Queries) FindAccountCredentialKeyByAccountCredentialIDAndPublicKID(ctx } const findAccountCredentialsKeyAccountByAccountCredentialIDAndJWKKID = `-- name: FindAccountCredentialsKeyAccountByAccountCredentialIDAndJWKKID :one -SELECT a.id, a.public_id, a.given_name, a.family_name, a.username, a.email, a.organization, a.password, a.version, a.email_verified, a.is_active, a.two_factor_type, a.created_at, a.updated_at FROM "accounts" AS "a" +SELECT a.id, a.public_id, a.given_name, a.family_name, a.username, a.email, a.organization, a.password, a.version, a.email_verified, a.activity_status, a.created_at, a.updated_at FROM "accounts" AS "a" LEFT JOIN "account_credentials_keys" AS "ack" ON "ack"."account_id" = "a"."id" WHERE "ack"."account_credentials_id" = $1 AND @@ -126,8 +126,7 @@ func (q *Queries) FindAccountCredentialsKeyAccountByAccountCredentialIDAndJWKKID &i.Password, &i.Version, &i.EmailVerified, - &i.IsActive, - &i.TwoFactorType, + &i.ActivityStatus, &i.CreatedAt, &i.UpdatedAt, ) diff --git a/idp/internal/providers/database/account_dynamic_registration_configs.sql.go b/idp/internal/providers/database/account_dynamic_registration_configs.sql.go new file mode 100644 index 0000000..8a0e69a --- /dev/null +++ b/idp/internal/providers/database/account_dynamic_registration_configs.sql.go @@ -0,0 +1,185 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: account_dynamic_registration_configs.sql + +package database + +import ( + "context" + + "github.com/google/uuid" +) + +const createAccountDynamicRegistrationConfig = `-- name: CreateAccountDynamicRegistrationConfig :one + +INSERT INTO "account_dynamic_registration_configs" ( + "account_id", + "account_public_id", + "account_credentials_types", + "whitelisted_domains", + "require_software_statement_credential_types", + "software_statement_verification_methods", + "require_initial_access_token_credential_types", + "initial_access_token_generation_methods" +) VALUES ( + $1, + $2, + $3, + $4, + $5, + $6, + $7, + $8 +) RETURNING id, account_id, account_public_id, account_credentials_types, whitelisted_domains, require_software_statement_credential_types, software_statement_verification_methods, require_initial_access_token_credential_types, initial_access_token_generation_methods, created_at, updated_at +` + +type CreateAccountDynamicRegistrationConfigParams struct { + AccountID int32 + AccountPublicID uuid.UUID + AccountCredentialsTypes []AccountCredentialsType + WhitelistedDomains []string + RequireSoftwareStatementCredentialTypes []AccountCredentialsType + SoftwareStatementVerificationMethods []SoftwareStatementVerificationMethod + RequireInitialAccessTokenCredentialTypes []AccountCredentialsType + InitialAccessTokenGenerationMethods []InitialAccessTokenGenerationMethod +} + +// Copyright (c) 2025 Afonso Barracha +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. +func (q *Queries) CreateAccountDynamicRegistrationConfig(ctx context.Context, arg CreateAccountDynamicRegistrationConfigParams) (AccountDynamicRegistrationConfig, error) { + row := q.db.QueryRow(ctx, createAccountDynamicRegistrationConfig, + arg.AccountID, + arg.AccountPublicID, + arg.AccountCredentialsTypes, + arg.WhitelistedDomains, + arg.RequireSoftwareStatementCredentialTypes, + arg.SoftwareStatementVerificationMethods, + arg.RequireInitialAccessTokenCredentialTypes, + arg.InitialAccessTokenGenerationMethods, + ) + var i AccountDynamicRegistrationConfig + err := row.Scan( + &i.ID, + &i.AccountID, + &i.AccountPublicID, + &i.AccountCredentialsTypes, + &i.WhitelistedDomains, + &i.RequireSoftwareStatementCredentialTypes, + &i.SoftwareStatementVerificationMethods, + &i.RequireInitialAccessTokenCredentialTypes, + &i.InitialAccessTokenGenerationMethods, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const deleteAccountDynamicRegistrationConfig = `-- name: DeleteAccountDynamicRegistrationConfig :exec +DELETE FROM "account_dynamic_registration_configs" WHERE "id" = $1 +` + +func (q *Queries) DeleteAccountDynamicRegistrationConfig(ctx context.Context, id int32) error { + _, err := q.db.Exec(ctx, deleteAccountDynamicRegistrationConfig, id) + return err +} + +const findAccountDynamicRegistrationConfigByAccountID = `-- name: FindAccountDynamicRegistrationConfigByAccountID :one +SELECT id, account_id, account_public_id, account_credentials_types, whitelisted_domains, require_software_statement_credential_types, software_statement_verification_methods, require_initial_access_token_credential_types, initial_access_token_generation_methods, created_at, updated_at FROM "account_dynamic_registration_configs" +WHERE "account_id" = $1 LIMIT 1 +` + +func (q *Queries) FindAccountDynamicRegistrationConfigByAccountID(ctx context.Context, accountID int32) (AccountDynamicRegistrationConfig, error) { + row := q.db.QueryRow(ctx, findAccountDynamicRegistrationConfigByAccountID, accountID) + var i AccountDynamicRegistrationConfig + err := row.Scan( + &i.ID, + &i.AccountID, + &i.AccountPublicID, + &i.AccountCredentialsTypes, + &i.WhitelistedDomains, + &i.RequireSoftwareStatementCredentialTypes, + &i.SoftwareStatementVerificationMethods, + &i.RequireInitialAccessTokenCredentialTypes, + &i.InitialAccessTokenGenerationMethods, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const findAccountDynamicRegistrationConfigByAccountPublicID = `-- name: FindAccountDynamicRegistrationConfigByAccountPublicID :one +SELECT id, account_id, account_public_id, account_credentials_types, whitelisted_domains, require_software_statement_credential_types, software_statement_verification_methods, require_initial_access_token_credential_types, initial_access_token_generation_methods, created_at, updated_at FROM "account_dynamic_registration_configs" +WHERE "account_public_id" = $1 LIMIT 1 +` + +func (q *Queries) FindAccountDynamicRegistrationConfigByAccountPublicID(ctx context.Context, accountPublicID uuid.UUID) (AccountDynamicRegistrationConfig, error) { + row := q.db.QueryRow(ctx, findAccountDynamicRegistrationConfigByAccountPublicID, accountPublicID) + var i AccountDynamicRegistrationConfig + err := row.Scan( + &i.ID, + &i.AccountID, + &i.AccountPublicID, + &i.AccountCredentialsTypes, + &i.WhitelistedDomains, + &i.RequireSoftwareStatementCredentialTypes, + &i.SoftwareStatementVerificationMethods, + &i.RequireInitialAccessTokenCredentialTypes, + &i.InitialAccessTokenGenerationMethods, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const updateAccountDynamicRegistrationConfig = `-- name: UpdateAccountDynamicRegistrationConfig :one +UPDATE "account_dynamic_registration_configs" SET + "account_credentials_types" = $2, + "whitelisted_domains" = $3, + "require_software_statement_credential_types" = $4, + "software_statement_verification_methods" = $5, + "require_initial_access_token_credential_types" = $6, + "initial_access_token_generation_methods" = $7 +WHERE "id" = $1 +RETURNING id, account_id, account_public_id, account_credentials_types, whitelisted_domains, require_software_statement_credential_types, software_statement_verification_methods, require_initial_access_token_credential_types, initial_access_token_generation_methods, created_at, updated_at +` + +type UpdateAccountDynamicRegistrationConfigParams struct { + ID int32 + AccountCredentialsTypes []AccountCredentialsType + WhitelistedDomains []string + RequireSoftwareStatementCredentialTypes []AccountCredentialsType + SoftwareStatementVerificationMethods []SoftwareStatementVerificationMethod + RequireInitialAccessTokenCredentialTypes []AccountCredentialsType + InitialAccessTokenGenerationMethods []InitialAccessTokenGenerationMethod +} + +func (q *Queries) UpdateAccountDynamicRegistrationConfig(ctx context.Context, arg UpdateAccountDynamicRegistrationConfigParams) (AccountDynamicRegistrationConfig, error) { + row := q.db.QueryRow(ctx, updateAccountDynamicRegistrationConfig, + arg.ID, + arg.AccountCredentialsTypes, + arg.WhitelistedDomains, + arg.RequireSoftwareStatementCredentialTypes, + arg.SoftwareStatementVerificationMethods, + arg.RequireInitialAccessTokenCredentialTypes, + arg.InitialAccessTokenGenerationMethods, + ) + var i AccountDynamicRegistrationConfig + err := row.Scan( + &i.ID, + &i.AccountID, + &i.AccountPublicID, + &i.AccountCredentialsTypes, + &i.WhitelistedDomains, + &i.RequireSoftwareStatementCredentialTypes, + &i.SoftwareStatementVerificationMethods, + &i.RequireInitialAccessTokenCredentialTypes, + &i.InitialAccessTokenGenerationMethods, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/idp/internal/providers/database/account_dynamic_registration_domain_codes.sql.go b/idp/internal/providers/database/account_dynamic_registration_domain_codes.sql.go new file mode 100644 index 0000000..1abe6eb --- /dev/null +++ b/idp/internal/providers/database/account_dynamic_registration_domain_codes.sql.go @@ -0,0 +1,63 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: account_dynamic_registration_domain_codes.sql + +package database + +import ( + "context" +) + +const createAccountDynamicRegistrationDomainCode = `-- name: CreateAccountDynamicRegistrationDomainCode :exec + +INSERT INTO "account_dynamic_registration_domain_codes" ( + "account_dynamic_registration_domain_id", + "dynamic_registration_domain_code_id", + "account_id" +) VALUES ( + $1, + $2, + $3 +) +` + +type CreateAccountDynamicRegistrationDomainCodeParams struct { + AccountDynamicRegistrationDomainID int32 + DynamicRegistrationDomainCodeID int32 + AccountID int32 +} + +// Copyright (c) 2025 Afonso Barracha +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. +func (q *Queries) CreateAccountDynamicRegistrationDomainCode(ctx context.Context, arg CreateAccountDynamicRegistrationDomainCodeParams) error { + _, err := q.db.Exec(ctx, createAccountDynamicRegistrationDomainCode, arg.AccountDynamicRegistrationDomainID, arg.DynamicRegistrationDomainCodeID, arg.AccountID) + return err +} + +const findDynamicRegistrationDomainCodeByAccountDynamicRegistrationDomainID = `-- name: FindDynamicRegistrationDomainCodeByAccountDynamicRegistrationDomainID :one +SELECT d.id, d.account_id, d.verification_host, d.verification_code, d.hmac_secret_id, d.verification_prefix, d.expires_at, d.created_at, d.updated_at FROM "dynamic_registration_domain_codes" "d" +LEFT JOIN "account_dynamic_registration_domain_codes" "a" ON "d"."id" = "a"."dynamic_registration_domain_code_id" +WHERE "a"."account_dynamic_registration_domain_id" = $1 +LIMIT 1 +` + +func (q *Queries) FindDynamicRegistrationDomainCodeByAccountDynamicRegistrationDomainID(ctx context.Context, accountDynamicRegistrationDomainID int32) (DynamicRegistrationDomainCode, error) { + row := q.db.QueryRow(ctx, findDynamicRegistrationDomainCodeByAccountDynamicRegistrationDomainID, accountDynamicRegistrationDomainID) + var i DynamicRegistrationDomainCode + err := row.Scan( + &i.ID, + &i.AccountID, + &i.VerificationHost, + &i.VerificationCode, + &i.HmacSecretID, + &i.VerificationPrefix, + &i.ExpiresAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/idp/internal/providers/database/account_dynamic_registration_domains.sql.go b/idp/internal/providers/database/account_dynamic_registration_domains.sql.go new file mode 100644 index 0000000..4137186 --- /dev/null +++ b/idp/internal/providers/database/account_dynamic_registration_domains.sql.go @@ -0,0 +1,408 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: account_dynamic_registration_domains.sql + +package database + +import ( + "context" + + "github.com/google/uuid" +) + +const countAccountDynamicRegistrationDomainsByAccountPublicID = `-- name: CountAccountDynamicRegistrationDomainsByAccountPublicID :one +SELECT COUNT(*) FROM "account_dynamic_registration_domains" +WHERE "account_public_id" = $1 +` + +func (q *Queries) CountAccountDynamicRegistrationDomainsByAccountPublicID(ctx context.Context, accountPublicID uuid.UUID) (int64, error) { + row := q.db.QueryRow(ctx, countAccountDynamicRegistrationDomainsByAccountPublicID, accountPublicID) + var count int64 + err := row.Scan(&count) + return count, err +} + +const countFilteredAccountDynamicRegistrationDomainsByAccountPublicID = `-- name: CountFilteredAccountDynamicRegistrationDomainsByAccountPublicID :one +SELECT COUNT(*) FROM "account_dynamic_registration_domains" +WHERE + "account_public_id" = $1 AND + "domain" ILIKE $2 +LIMIT 1 +` + +type CountFilteredAccountDynamicRegistrationDomainsByAccountPublicIDParams struct { + AccountPublicID uuid.UUID + Domain string +} + +func (q *Queries) CountFilteredAccountDynamicRegistrationDomainsByAccountPublicID(ctx context.Context, arg CountFilteredAccountDynamicRegistrationDomainsByAccountPublicIDParams) (int64, error) { + row := q.db.QueryRow(ctx, countFilteredAccountDynamicRegistrationDomainsByAccountPublicID, arg.AccountPublicID, arg.Domain) + var count int64 + err := row.Scan(&count) + return count, err +} + +const countVerifiedAccountDynamicRegistrationDomainsByDomain = `-- name: CountVerifiedAccountDynamicRegistrationDomainsByDomain :one +SELECT COUNT(*) FROM "account_dynamic_registration_domains" +WHERE "domain" = $1 AND "verified_at" IS NOT NULL +LIMIT 1 +` + +func (q *Queries) CountVerifiedAccountDynamicRegistrationDomainsByDomain(ctx context.Context, domain string) (int64, error) { + row := q.db.QueryRow(ctx, countVerifiedAccountDynamicRegistrationDomainsByDomain, domain) + var count int64 + err := row.Scan(&count) + return count, err +} + +const countVerifiedAccountDynamicRegistrationDomainsByDomainAndAccountPublicID = `-- name: CountVerifiedAccountDynamicRegistrationDomainsByDomainAndAccountPublicID :one +SELECT COUNT(*) FROM "account_dynamic_registration_domains" +WHERE + "account_public_id" = $1 AND + "domain" = $2 AND + "verified_at" IS NOT NULL +LIMIT 1 +` + +type CountVerifiedAccountDynamicRegistrationDomainsByDomainAndAccountPublicIDParams struct { + AccountPublicID uuid.UUID + Domain string +} + +func (q *Queries) CountVerifiedAccountDynamicRegistrationDomainsByDomainAndAccountPublicID(ctx context.Context, arg CountVerifiedAccountDynamicRegistrationDomainsByDomainAndAccountPublicIDParams) (int64, error) { + row := q.db.QueryRow(ctx, countVerifiedAccountDynamicRegistrationDomainsByDomainAndAccountPublicID, arg.AccountPublicID, arg.Domain) + var count int64 + err := row.Scan(&count) + return count, err +} + +const countVerifiedAccountDynamicRegistrationDomainsByDomains = `-- name: CountVerifiedAccountDynamicRegistrationDomainsByDomains :one +SELECT COUNT(*) FROM "account_dynamic_registration_domains" +WHERE "domain" IN ($1) AND "verified_at" IS NOT NULL +LIMIT 1 +` + +func (q *Queries) CountVerifiedAccountDynamicRegistrationDomainsByDomains(ctx context.Context, domains []string) (int64, error) { + row := q.db.QueryRow(ctx, countVerifiedAccountDynamicRegistrationDomainsByDomains, domains) + var count int64 + err := row.Scan(&count) + return count, err +} + +const countVerifiedAccountDynamicRegistrationDomainsByDomainsAndAccountPublicID = `-- name: CountVerifiedAccountDynamicRegistrationDomainsByDomainsAndAccountPublicID :one +SELECT COUNT(*) FROM "account_dynamic_registration_domains" +WHERE + "account_public_id" = $1 AND + "domain" IN ($2) AND + "verified_at" IS NOT NULL +LIMIT 1 +` + +type CountVerifiedAccountDynamicRegistrationDomainsByDomainsAndAccountPublicIDParams struct { + AccountPublicID uuid.UUID + Domains []string +} + +func (q *Queries) CountVerifiedAccountDynamicRegistrationDomainsByDomainsAndAccountPublicID(ctx context.Context, arg CountVerifiedAccountDynamicRegistrationDomainsByDomainsAndAccountPublicIDParams) (int64, error) { + row := q.db.QueryRow(ctx, countVerifiedAccountDynamicRegistrationDomainsByDomainsAndAccountPublicID, arg.AccountPublicID, arg.Domains) + var count int64 + err := row.Scan(&count) + return count, err +} + +const createAccountDynamicRegistrationDomain = `-- name: CreateAccountDynamicRegistrationDomain :one + +INSERT INTO "account_dynamic_registration_domains" ( + "account_id", + "account_public_id", + "domain", + "verification_method" +) VALUES ( + $1, + $2, + $3, + $4 +) RETURNING id, account_id, account_public_id, domain, verified_at, verification_method, created_at, updated_at +` + +type CreateAccountDynamicRegistrationDomainParams struct { + AccountID int32 + AccountPublicID uuid.UUID + Domain string + VerificationMethod DomainVerificationMethod +} + +// Copyright (c) 2025 Afonso Barracha +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. +func (q *Queries) CreateAccountDynamicRegistrationDomain(ctx context.Context, arg CreateAccountDynamicRegistrationDomainParams) (AccountDynamicRegistrationDomain, error) { + row := q.db.QueryRow(ctx, createAccountDynamicRegistrationDomain, + arg.AccountID, + arg.AccountPublicID, + arg.Domain, + arg.VerificationMethod, + ) + var i AccountDynamicRegistrationDomain + err := row.Scan( + &i.ID, + &i.AccountID, + &i.AccountPublicID, + &i.Domain, + &i.VerifiedAt, + &i.VerificationMethod, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const deleteAccountDynamicRegistrationDomain = `-- name: DeleteAccountDynamicRegistrationDomain :exec +DELETE FROM "account_dynamic_registration_domains" +WHERE "id" = $1 +` + +func (q *Queries) DeleteAccountDynamicRegistrationDomain(ctx context.Context, id int32) error { + _, err := q.db.Exec(ctx, deleteAccountDynamicRegistrationDomain, id) + return err +} + +const filterAccountDynamicRegistrationDomainsByAccountPublicIDOrderedByDomain = `-- name: FilterAccountDynamicRegistrationDomainsByAccountPublicIDOrderedByDomain :many +SELECT id, account_id, account_public_id, domain, verified_at, verification_method, created_at, updated_at FROM "account_dynamic_registration_domains" +WHERE + "account_public_id" = $1 AND + "domain" ILIKE $2 +ORDER BY "domain" ASC +LIMIT $3 OFFSET $4 +` + +type FilterAccountDynamicRegistrationDomainsByAccountPublicIDOrderedByDomainParams struct { + AccountPublicID uuid.UUID + Domain string + Limit int32 + Offset int32 +} + +func (q *Queries) FilterAccountDynamicRegistrationDomainsByAccountPublicIDOrderedByDomain(ctx context.Context, arg FilterAccountDynamicRegistrationDomainsByAccountPublicIDOrderedByDomainParams) ([]AccountDynamicRegistrationDomain, error) { + rows, err := q.db.Query(ctx, filterAccountDynamicRegistrationDomainsByAccountPublicIDOrderedByDomain, + arg.AccountPublicID, + arg.Domain, + arg.Limit, + arg.Offset, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []AccountDynamicRegistrationDomain{} + for rows.Next() { + var i AccountDynamicRegistrationDomain + if err := rows.Scan( + &i.ID, + &i.AccountID, + &i.AccountPublicID, + &i.Domain, + &i.VerifiedAt, + &i.VerificationMethod, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const filterAccountDynamicRegistrationDomainsByAccountPublicIDOrderedByID = `-- name: FilterAccountDynamicRegistrationDomainsByAccountPublicIDOrderedByID :many +SELECT id, account_id, account_public_id, domain, verified_at, verification_method, created_at, updated_at FROM "account_dynamic_registration_domains" +WHERE + "account_public_id" = $1 AND + "domain" ILIKE $2 +ORDER BY "id" DESC +LIMIT $3 OFFSET $4 +` + +type FilterAccountDynamicRegistrationDomainsByAccountPublicIDOrderedByIDParams struct { + AccountPublicID uuid.UUID + Domain string + Limit int32 + Offset int32 +} + +func (q *Queries) FilterAccountDynamicRegistrationDomainsByAccountPublicIDOrderedByID(ctx context.Context, arg FilterAccountDynamicRegistrationDomainsByAccountPublicIDOrderedByIDParams) ([]AccountDynamicRegistrationDomain, error) { + rows, err := q.db.Query(ctx, filterAccountDynamicRegistrationDomainsByAccountPublicIDOrderedByID, + arg.AccountPublicID, + arg.Domain, + arg.Limit, + arg.Offset, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []AccountDynamicRegistrationDomain{} + for rows.Next() { + var i AccountDynamicRegistrationDomain + if err := rows.Scan( + &i.ID, + &i.AccountID, + &i.AccountPublicID, + &i.Domain, + &i.VerifiedAt, + &i.VerificationMethod, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const findAccountDynamicRegistrationDomainByAccountPublicIDAndDomain = `-- name: FindAccountDynamicRegistrationDomainByAccountPublicIDAndDomain :one +SELECT id, account_id, account_public_id, domain, verified_at, verification_method, created_at, updated_at FROM "account_dynamic_registration_domains" WHERE "account_public_id" = $1 AND "domain" = $2 LIMIT 1 +` + +type FindAccountDynamicRegistrationDomainByAccountPublicIDAndDomainParams struct { + AccountPublicID uuid.UUID + Domain string +} + +func (q *Queries) FindAccountDynamicRegistrationDomainByAccountPublicIDAndDomain(ctx context.Context, arg FindAccountDynamicRegistrationDomainByAccountPublicIDAndDomainParams) (AccountDynamicRegistrationDomain, error) { + row := q.db.QueryRow(ctx, findAccountDynamicRegistrationDomainByAccountPublicIDAndDomain, arg.AccountPublicID, arg.Domain) + var i AccountDynamicRegistrationDomain + err := row.Scan( + &i.ID, + &i.AccountID, + &i.AccountPublicID, + &i.Domain, + &i.VerifiedAt, + &i.VerificationMethod, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const findPaginatedAccountDynamicRegistrationDomainsByAccountPublicIDOrderedByDomain = `-- name: FindPaginatedAccountDynamicRegistrationDomainsByAccountPublicIDOrderedByDomain :many +SELECT id, account_id, account_public_id, domain, verified_at, verification_method, created_at, updated_at FROM "account_dynamic_registration_domains" +WHERE "account_public_id" = $1 +ORDER BY "domain" ASC +LIMIT $2 OFFSET $3 +` + +type FindPaginatedAccountDynamicRegistrationDomainsByAccountPublicIDOrderedByDomainParams struct { + AccountPublicID uuid.UUID + Limit int32 + Offset int32 +} + +func (q *Queries) FindPaginatedAccountDynamicRegistrationDomainsByAccountPublicIDOrderedByDomain(ctx context.Context, arg FindPaginatedAccountDynamicRegistrationDomainsByAccountPublicIDOrderedByDomainParams) ([]AccountDynamicRegistrationDomain, error) { + rows, err := q.db.Query(ctx, findPaginatedAccountDynamicRegistrationDomainsByAccountPublicIDOrderedByDomain, arg.AccountPublicID, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + items := []AccountDynamicRegistrationDomain{} + for rows.Next() { + var i AccountDynamicRegistrationDomain + if err := rows.Scan( + &i.ID, + &i.AccountID, + &i.AccountPublicID, + &i.Domain, + &i.VerifiedAt, + &i.VerificationMethod, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const findPaginatedAccountDynamicRegistrationDomainsByAccountPublicIDOrderedByID = `-- name: FindPaginatedAccountDynamicRegistrationDomainsByAccountPublicIDOrderedByID :many +SELECT id, account_id, account_public_id, domain, verified_at, verification_method, created_at, updated_at FROM "account_dynamic_registration_domains" +WHERE "account_public_id" = $1 +ORDER BY "id" DESC +LIMIT $2 OFFSET $3 +` + +type FindPaginatedAccountDynamicRegistrationDomainsByAccountPublicIDOrderedByIDParams struct { + AccountPublicID uuid.UUID + Limit int32 + Offset int32 +} + +func (q *Queries) FindPaginatedAccountDynamicRegistrationDomainsByAccountPublicIDOrderedByID(ctx context.Context, arg FindPaginatedAccountDynamicRegistrationDomainsByAccountPublicIDOrderedByIDParams) ([]AccountDynamicRegistrationDomain, error) { + rows, err := q.db.Query(ctx, findPaginatedAccountDynamicRegistrationDomainsByAccountPublicIDOrderedByID, arg.AccountPublicID, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + items := []AccountDynamicRegistrationDomain{} + for rows.Next() { + var i AccountDynamicRegistrationDomain + if err := rows.Scan( + &i.ID, + &i.AccountID, + &i.AccountPublicID, + &i.Domain, + &i.VerifiedAt, + &i.VerificationMethod, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const verifyAccountDynamicRegistrationDomain = `-- name: VerifyAccountDynamicRegistrationDomain :one +UPDATE "account_dynamic_registration_domains" +SET + "verified_at" = NOW(), + "verification_method" = $2 +WHERE "id" = $1 RETURNING id, account_id, account_public_id, domain, verified_at, verification_method, created_at, updated_at +` + +type VerifyAccountDynamicRegistrationDomainParams struct { + ID int32 + VerificationMethod DomainVerificationMethod +} + +func (q *Queries) VerifyAccountDynamicRegistrationDomain(ctx context.Context, arg VerifyAccountDynamicRegistrationDomainParams) (AccountDynamicRegistrationDomain, error) { + row := q.db.QueryRow(ctx, verifyAccountDynamicRegistrationDomain, arg.ID, arg.VerificationMethod) + var i AccountDynamicRegistrationDomain + err := row.Scan( + &i.ID, + &i.AccountID, + &i.AccountPublicID, + &i.Domain, + &i.VerifiedAt, + &i.VerificationMethod, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/idp/internal/providers/database/account_hmac_secrets.sql.go b/idp/internal/providers/database/account_hmac_secrets.sql.go new file mode 100644 index 0000000..6a63a35 --- /dev/null +++ b/idp/internal/providers/database/account_hmac_secrets.sql.go @@ -0,0 +1,125 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: account_hmac_secrets.sql + +package database + +import ( + "context" + "time" +) + +const createAccountHMACSecret = `-- name: CreateAccountHMACSecret :one + +INSERT INTO "account_hmac_secrets" ( + "account_id", + "secret_id", + "secret", + "dek_kid", + "expires_at" +) VALUES ( + $1, + $2, + $3, + $4, + $5 +) RETURNING "id" +` + +type CreateAccountHMACSecretParams struct { + AccountID int32 + SecretID string + Secret string + DekKid string + ExpiresAt time.Time +} + +// Copyright (c) 2025 Afonso Barracha +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. +func (q *Queries) CreateAccountHMACSecret(ctx context.Context, arg CreateAccountHMACSecretParams) (int32, error) { + row := q.db.QueryRow(ctx, createAccountHMACSecret, + arg.AccountID, + arg.SecretID, + arg.Secret, + arg.DekKid, + arg.ExpiresAt, + ) + var id int32 + err := row.Scan(&id) + return id, err +} + +const findAccountHMACSecretByAccountIDAndSecretID = `-- name: FindAccountHMACSecretByAccountIDAndSecretID :one +SELECT id, account_id, secret_id, secret, dek_kid, is_revoked, expires_at, created_at FROM "account_hmac_secrets" +WHERE "account_id" = $1 AND "secret_id" = $2 +LIMIT 1 +` + +type FindAccountHMACSecretByAccountIDAndSecretIDParams struct { + AccountID int32 + SecretID string +} + +func (q *Queries) FindAccountHMACSecretByAccountIDAndSecretID(ctx context.Context, arg FindAccountHMACSecretByAccountIDAndSecretIDParams) (AccountHmacSecret, error) { + row := q.db.QueryRow(ctx, findAccountHMACSecretByAccountIDAndSecretID, arg.AccountID, arg.SecretID) + var i AccountHmacSecret + err := row.Scan( + &i.ID, + &i.AccountID, + &i.SecretID, + &i.Secret, + &i.DekKid, + &i.IsRevoked, + &i.ExpiresAt, + &i.CreatedAt, + ) + return i, err +} + +const findValidHMACSecretByAccountID = `-- name: FindValidHMACSecretByAccountID :one +SELECT id, account_id, secret_id, secret, dek_kid, is_revoked, expires_at, created_at FROM "account_hmac_secrets" +WHERE + "account_id" = $1 AND + "is_revoked" = false AND + "expires_at" > now() +LIMIT 1 +` + +func (q *Queries) FindValidHMACSecretByAccountID(ctx context.Context, accountID int32) (AccountHmacSecret, error) { + row := q.db.QueryRow(ctx, findValidHMACSecretByAccountID, accountID) + var i AccountHmacSecret + err := row.Scan( + &i.ID, + &i.AccountID, + &i.SecretID, + &i.Secret, + &i.DekKid, + &i.IsRevoked, + &i.ExpiresAt, + &i.CreatedAt, + ) + return i, err +} + +const updateAccountHMACSecret = `-- name: UpdateAccountHMACSecret :exec +UPDATE "account_hmac_secrets" SET + "secret" = $2, + "dek_kid" = $3, + "updated_at" = now() +WHERE "id" = $1 +` + +type UpdateAccountHMACSecretParams struct { + ID int32 + Secret string + DekKid string +} + +func (q *Queries) UpdateAccountHMACSecret(ctx context.Context, arg UpdateAccountHMACSecretParams) error { + _, err := q.db.Exec(ctx, updateAccountHMACSecret, arg.ID, arg.Secret, arg.DekKid) + return err +} diff --git a/idp/internal/providers/database/accounts.sql.go b/idp/internal/providers/database/accounts.sql.go index 83ed343..8bef59b 100644 --- a/idp/internal/providers/database/accounts.sql.go +++ b/idp/internal/providers/database/accounts.sql.go @@ -18,7 +18,7 @@ UPDATE "accounts" SET "version" = "version" + 1, "updated_at" = now() WHERE "id" = $1 -RETURNING id, public_id, given_name, family_name, username, email, organization, password, version, email_verified, is_active, two_factor_type, created_at, updated_at +RETURNING id, public_id, given_name, family_name, username, email, organization, password, version, email_verified, activity_status, created_at, updated_at ` func (q *Queries) ConfirmAccount(ctx context.Context, id int32) (Account, error) { @@ -35,8 +35,7 @@ func (q *Queries) ConfirmAccount(ctx context.Context, id int32) (Account, error) &i.Password, &i.Version, &i.EmailVerified, - &i.IsActive, - &i.TwoFactorType, + &i.ActivityStatus, &i.CreatedAt, &i.UpdatedAt, ) @@ -83,7 +82,7 @@ INSERT INTO "accounts" ( $4, $5, $6 -) RETURNING id, public_id, given_name, family_name, username, email, organization, password, version, email_verified, is_active, two_factor_type, created_at, updated_at +) RETURNING id, public_id, given_name, family_name, username, email, organization, password, version, email_verified, activity_status, created_at, updated_at ` type CreateAccountWithPasswordParams struct { @@ -121,8 +120,7 @@ func (q *Queries) CreateAccountWithPassword(ctx context.Context, arg CreateAccou &i.Password, &i.Version, &i.EmailVerified, - &i.IsActive, - &i.TwoFactorType, + &i.ActivityStatus, &i.CreatedAt, &i.UpdatedAt, ) @@ -146,7 +144,7 @@ INSERT INTO "accounts" ( $5, 2, true -) RETURNING id, public_id, given_name, family_name, username, email, organization, password, version, email_verified, is_active, two_factor_type, created_at, updated_at +) RETURNING id, public_id, given_name, family_name, username, email, organization, password, version, email_verified, activity_status, created_at, updated_at ` type CreateAccountWithoutPasswordParams struct { @@ -177,8 +175,7 @@ func (q *Queries) CreateAccountWithoutPassword(ctx context.Context, arg CreateAc &i.Password, &i.Version, &i.EmailVerified, - &i.IsActive, - &i.TwoFactorType, + &i.ActivityStatus, &i.CreatedAt, &i.UpdatedAt, ) @@ -205,7 +202,7 @@ func (q *Queries) DeleteAllAccounts(ctx context.Context) error { } const findAccountByEmail = `-- name: FindAccountByEmail :one -SELECT id, public_id, given_name, family_name, username, email, organization, password, version, email_verified, is_active, two_factor_type, created_at, updated_at FROM "accounts" +SELECT id, public_id, given_name, family_name, username, email, organization, password, version, email_verified, activity_status, created_at, updated_at FROM "accounts" WHERE "email" = $1 LIMIT 1 ` @@ -223,8 +220,7 @@ func (q *Queries) FindAccountByEmail(ctx context.Context, email string) (Account &i.Password, &i.Version, &i.EmailVerified, - &i.IsActive, - &i.TwoFactorType, + &i.ActivityStatus, &i.CreatedAt, &i.UpdatedAt, ) @@ -232,7 +228,7 @@ func (q *Queries) FindAccountByEmail(ctx context.Context, email string) (Account } const findAccountById = `-- name: FindAccountById :one -SELECT id, public_id, given_name, family_name, username, email, organization, password, version, email_verified, is_active, two_factor_type, created_at, updated_at FROM "accounts" +SELECT id, public_id, given_name, family_name, username, email, organization, password, version, email_verified, activity_status, created_at, updated_at FROM "accounts" WHERE "id" = $1 LIMIT 1 ` @@ -250,8 +246,7 @@ func (q *Queries) FindAccountById(ctx context.Context, id int32) (Account, error &i.Password, &i.Version, &i.EmailVerified, - &i.IsActive, - &i.TwoFactorType, + &i.ActivityStatus, &i.CreatedAt, &i.UpdatedAt, ) @@ -259,7 +254,7 @@ func (q *Queries) FindAccountById(ctx context.Context, id int32) (Account, error } const findAccountByPublicID = `-- name: FindAccountByPublicID :one -SELECT id, public_id, given_name, family_name, username, email, organization, password, version, email_verified, is_active, two_factor_type, created_at, updated_at FROM "accounts" +SELECT id, public_id, given_name, family_name, username, email, organization, password, version, email_verified, activity_status, created_at, updated_at FROM "accounts" WHERE "public_id" = $1 LIMIT 1 ` @@ -277,8 +272,7 @@ func (q *Queries) FindAccountByPublicID(ctx context.Context, publicID uuid.UUID) &i.Password, &i.Version, &i.EmailVerified, - &i.IsActive, - &i.TwoFactorType, + &i.ActivityStatus, &i.CreatedAt, &i.UpdatedAt, ) @@ -286,7 +280,7 @@ func (q *Queries) FindAccountByPublicID(ctx context.Context, publicID uuid.UUID) } const findAccountByPublicIDAndVersion = `-- name: FindAccountByPublicIDAndVersion :one -SELECT id, public_id, given_name, family_name, username, email, organization, password, version, email_verified, is_active, two_factor_type, created_at, updated_at FROM "accounts" +SELECT id, public_id, given_name, family_name, username, email, organization, password, version, email_verified, activity_status, created_at, updated_at FROM "accounts" WHERE "public_id" = $1 AND "version" = $2 LIMIT 1 ` @@ -309,8 +303,7 @@ func (q *Queries) FindAccountByPublicIDAndVersion(ctx context.Context, arg FindA &i.Password, &i.Version, &i.EmailVerified, - &i.IsActive, - &i.TwoFactorType, + &i.ActivityStatus, &i.CreatedAt, &i.UpdatedAt, ) @@ -352,7 +345,7 @@ UPDATE "accounts" SET "family_name" = $2, "updated_at" = now() WHERE "id" = $3 -RETURNING id, public_id, given_name, family_name, username, email, organization, password, version, email_verified, is_active, two_factor_type, created_at, updated_at +RETURNING id, public_id, given_name, family_name, username, email, organization, password, version, email_verified, activity_status, created_at, updated_at ` type UpdateAccountParams struct { @@ -375,8 +368,7 @@ func (q *Queries) UpdateAccount(ctx context.Context, arg UpdateAccountParams) (A &i.Password, &i.Version, &i.EmailVerified, - &i.IsActive, - &i.TwoFactorType, + &i.ActivityStatus, &i.CreatedAt, &i.UpdatedAt, ) @@ -389,7 +381,7 @@ UPDATE "accounts" SET "version" = "version" + 1, "updated_at" = now() WHERE "id" = $2 -RETURNING id, public_id, given_name, family_name, username, email, organization, password, version, email_verified, is_active, two_factor_type, created_at, updated_at +RETURNING id, public_id, given_name, family_name, username, email, organization, password, version, email_verified, activity_status, created_at, updated_at ` type UpdateAccountEmailParams struct { @@ -411,8 +403,7 @@ func (q *Queries) UpdateAccountEmail(ctx context.Context, arg UpdateAccountEmail &i.Password, &i.Version, &i.EmailVerified, - &i.IsActive, - &i.TwoFactorType, + &i.ActivityStatus, &i.CreatedAt, &i.UpdatedAt, ) @@ -425,7 +416,7 @@ UPDATE "accounts" SET "version" = "version" + 1, "updated_at" = now() WHERE "id" = $2 -RETURNING id, public_id, given_name, family_name, username, email, organization, password, version, email_verified, is_active, two_factor_type, created_at, updated_at +RETURNING id, public_id, given_name, family_name, username, email, organization, password, version, email_verified, activity_status, created_at, updated_at ` type UpdateAccountPasswordParams struct { @@ -447,39 +438,20 @@ func (q *Queries) UpdateAccountPassword(ctx context.Context, arg UpdateAccountPa &i.Password, &i.Version, &i.EmailVerified, - &i.IsActive, - &i.TwoFactorType, + &i.ActivityStatus, &i.CreatedAt, &i.UpdatedAt, ) return i, err } -const updateAccountTwoFactorType = `-- name: UpdateAccountTwoFactorType :exec -UPDATE "accounts" SET - "two_factor_type" = $1, - "version" = "version" + 1, - "updated_at" = now() -WHERE "id" = $2 -` - -type UpdateAccountTwoFactorTypeParams struct { - TwoFactorType TwoFactorType - ID int32 -} - -func (q *Queries) UpdateAccountTwoFactorType(ctx context.Context, arg UpdateAccountTwoFactorTypeParams) error { - _, err := q.db.Exec(ctx, updateAccountTwoFactorType, arg.TwoFactorType, arg.ID) - return err -} - const updateAccountUsername = `-- name: UpdateAccountUsername :one UPDATE "accounts" SET "username" = $1, "version" = "version" + 1, "updated_at" = now() WHERE "id" = $2 -RETURNING id, public_id, given_name, family_name, username, email, organization, password, version, email_verified, is_active, two_factor_type, created_at, updated_at +RETURNING id, public_id, given_name, family_name, username, email, organization, password, version, email_verified, activity_status, created_at, updated_at ` type UpdateAccountUsernameParams struct { @@ -501,8 +473,36 @@ func (q *Queries) UpdateAccountUsername(ctx context.Context, arg UpdateAccountUs &i.Password, &i.Version, &i.EmailVerified, - &i.IsActive, - &i.TwoFactorType, + &i.ActivityStatus, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const updateAccountVersion = `-- name: UpdateAccountVersion :one +UPDATE "accounts" SET + "version" = "version" + 1, + "updated_at" = now() +WHERE "id" = $1 +RETURNING id, public_id, given_name, family_name, username, email, organization, password, version, email_verified, activity_status, created_at, updated_at +` + +func (q *Queries) UpdateAccountVersion(ctx context.Context, id int32) (Account, error) { + row := q.db.QueryRow(ctx, updateAccountVersion, id) + var i Account + err := row.Scan( + &i.ID, + &i.PublicID, + &i.GivenName, + &i.FamilyName, + &i.Username, + &i.Email, + &i.Organization, + &i.Password, + &i.Version, + &i.EmailVerified, + &i.ActivityStatus, &i.CreatedAt, &i.UpdatedAt, ) diff --git a/idp/internal/providers/database/apps.sql.go b/idp/internal/providers/database/apps.sql.go index 5146727..cb76eb8 100644 --- a/idp/internal/providers/database/apps.sql.go +++ b/idp/internal/providers/database/apps.sql.go @@ -43,6 +43,24 @@ func (q *Queries) CountAppsByAccountPublicID(ctx context.Context, accountPublicI return count, err } +const countAppsByClientIDAndAccountPublicID = `-- name: CountAppsByClientIDAndAccountPublicID :one +SELECT COUNT(*) FROM "apps" +WHERE "client_id" = $1 AND "account_public_id" = $2 +LIMIT 1 +` + +type CountAppsByClientIDAndAccountPublicIDParams struct { + ClientID string + AccountPublicID uuid.UUID +} + +func (q *Queries) CountAppsByClientIDAndAccountPublicID(ctx context.Context, arg CountAppsByClientIDAndAccountPublicIDParams) (int64, error) { + row := q.db.QueryRow(ctx, countAppsByClientIDAndAccountPublicID, arg.ClientID, arg.AccountPublicID) + var count int64 + err := row.Scan(&count) + return count, err +} + const countFilteredAppsByNameAndByAccountPublicID = `-- name: CountFilteredAppsByNameAndByAccountPublicID :one SELECT COUNT(*) FROM "apps" WHERE "account_public_id" = $1 AND "name" ILIKE $2 @@ -1129,7 +1147,7 @@ SET "name" = $2, "logo_uri" = $5, "tos_uri" = $6, "policy_uri" = $7, - "software_id" = $8, + "auth_providers" = $8, "software_version" = $9, "contacts" = $10, "domain" = $11, @@ -1137,7 +1155,6 @@ SET "name" = $2, "redirect_uris" = $13, "allow_user_registration" = $14, "response_types" = $15, - "auth_providers" = $16, "version" = "version" + 1, "updated_at" = now() WHERE "id" = $1 @@ -1152,7 +1169,7 @@ type UpdateAppParams struct { LogoUri pgtype.Text TosUri pgtype.Text PolicyUri pgtype.Text - SoftwareID string + AuthProviders []AuthProvider SoftwareVersion pgtype.Text Contacts []string Domain string @@ -1160,7 +1177,6 @@ type UpdateAppParams struct { RedirectUris []string AllowUserRegistration bool ResponseTypes []ResponseType - AuthProviders []AuthProvider } func (q *Queries) UpdateApp(ctx context.Context, arg UpdateAppParams) (App, error) { @@ -1172,7 +1188,7 @@ func (q *Queries) UpdateApp(ctx context.Context, arg UpdateAppParams) (App, erro arg.LogoUri, arg.TosUri, arg.PolicyUri, - arg.SoftwareID, + arg.AuthProviders, arg.SoftwareVersion, arg.Contacts, arg.Domain, @@ -1180,7 +1196,6 @@ func (q *Queries) UpdateApp(ctx context.Context, arg UpdateAppParams) (App, erro arg.RedirectUris, arg.AllowUserRegistration, arg.ResponseTypes, - arg.AuthProviders, ) var i App err := row.Scan( diff --git a/idp/internal/providers/database/dynamic_registration_domain_codes.sql.go b/idp/internal/providers/database/dynamic_registration_domain_codes.sql.go new file mode 100644 index 0000000..0c7de03 --- /dev/null +++ b/idp/internal/providers/database/dynamic_registration_domain_codes.sql.go @@ -0,0 +1,99 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: dynamic_registration_domain_codes.sql + +package database + +import ( + "context" + "time" +) + +const createDynamicRegistrationDomainCode = `-- name: CreateDynamicRegistrationDomainCode :one + +INSERT INTO "dynamic_registration_domain_codes" ( + "account_id", + "verification_host", + "verification_code", + "verification_prefix", + "hmac_secret_id", + "expires_at" +) VALUES ( + $1, + $2, + $3, + $4, + $5, + $6 +) RETURNING "id" +` + +type CreateDynamicRegistrationDomainCodeParams struct { + AccountID int32 + VerificationHost string + VerificationCode string + VerificationPrefix string + HmacSecretID string + ExpiresAt time.Time +} + +// Copyright (c) 2025 Afonso Barracha +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. +func (q *Queries) CreateDynamicRegistrationDomainCode(ctx context.Context, arg CreateDynamicRegistrationDomainCodeParams) (int32, error) { + row := q.db.QueryRow(ctx, createDynamicRegistrationDomainCode, + arg.AccountID, + arg.VerificationHost, + arg.VerificationCode, + arg.VerificationPrefix, + arg.HmacSecretID, + arg.ExpiresAt, + ) + var id int32 + err := row.Scan(&id) + return id, err +} + +const deleteDynamicRegistrationDomainCode = `-- name: DeleteDynamicRegistrationDomainCode :exec +DELETE FROM "dynamic_registration_domain_codes" +WHERE "id" = $1 +` + +func (q *Queries) DeleteDynamicRegistrationDomainCode(ctx context.Context, id int32) error { + _, err := q.db.Exec(ctx, deleteDynamicRegistrationDomainCode, id) + return err +} + +const updateDynamicRegistrationDomainCode = `-- name: UpdateDynamicRegistrationDomainCode :exec +UPDATE "dynamic_registration_domain_codes" SET + "verification_host" = $2, + "verification_code" = $3, + "verification_prefix" = $4, + "hmac_secret_id" = $5, + "expires_at" = $6 +WHERE "id" = $1 +` + +type UpdateDynamicRegistrationDomainCodeParams struct { + ID int32 + VerificationHost string + VerificationCode string + VerificationPrefix string + HmacSecretID string + ExpiresAt time.Time +} + +func (q *Queries) UpdateDynamicRegistrationDomainCode(ctx context.Context, arg UpdateDynamicRegistrationDomainCodeParams) error { + _, err := q.db.Exec(ctx, updateDynamicRegistrationDomainCode, + arg.ID, + arg.VerificationHost, + arg.VerificationCode, + arg.VerificationPrefix, + arg.HmacSecretID, + arg.ExpiresAt, + ) + return err +} diff --git a/idp/internal/providers/database/migrations/20241213231542_create_initial_schema.up.sql b/idp/internal/providers/database/migrations/20241213231542_create_initial_schema.up.sql index f705b51..c5150f3 100644 --- a/idp/internal/providers/database/migrations/20241213231542_create_initial_schema.up.sql +++ b/idp/internal/providers/database/migrations/20241213231542_create_initial_schema.up.sql @@ -1,6 +1,6 @@ -- SQL dump generated using DBML (dbml.dbdiagram.io) -- Database: PostgreSQL --- Generated at: 2025-08-10T08:33:39.963Z +-- Generated at: 2025-09-06T04:54:24.517Z CREATE TYPE "kek_usage" AS ENUM ( 'global', @@ -14,6 +14,7 @@ CREATE TYPE "dek_usage" AS ENUM ( ); CREATE TYPE "token_crypto_suite" AS ENUM ( + 'RS256', 'ES256', 'EdDSA' ); @@ -30,11 +31,17 @@ CREATE TYPE "token_key_type" AS ENUM ( 'client_credentials', 'email_verification', 'password_reset', - '2fa_authentication' + '2fa_authentication', + 'dynamic_registration' +); + +CREATE TYPE "activity_status" AS ENUM ( + 'active', + 'suspended', + 'blocked' ); CREATE TYPE "two_factor_type" AS ENUM ( - 'none', 'totp', 'email' ); @@ -77,13 +84,18 @@ CREATE TYPE "account_credentials_scope" AS ENUM ( 'account:users:write', 'account:apps:read', 'account:apps:write', + 'account:apps:configs:read', + 'account:apps:configs:write', 'account:credentials:read', 'account:credentials:write', + 'account:credentials:configs:read', + 'account:credentials:configs:write', 'account:auth_providers:read' ); CREATE TYPE "account_credentials_type" AS ENUM ( - 'client', + 'native', + 'service', 'mcp' ); @@ -170,7 +182,14 @@ CREATE TYPE "initial_access_token_generation_method" AS ENUM ( CREATE TYPE "software_statement_verification_method" AS ENUM ( 'manual', - 'jwks_uri' + 'jwks_uri', + 'jwk_x5_parameters' +); + +CREATE TYPE "domain_verification_method" AS ENUM ( + 'authorization_code', + 'software_statement', + 'dns_txt_record' ); CREATE TYPE "app_profile_type" AS ENUM ( @@ -226,16 +245,26 @@ CREATE TABLE "token_signing_keys" ( CREATE TABLE "accounts" ( "id" serial PRIMARY KEY, "public_id" uuid NOT NULL, - "given_name" varchar(50) NOT NULL, - "family_name" varchar(50) NOT NULL, + "given_name" varchar(100) NOT NULL, + "family_name" varchar(100) NOT NULL, "username" varchar(63) NOT NULL, "email" varchar(250) NOT NULL, "organization" varchar(50), "password" text, "version" integer NOT NULL DEFAULT 1, "email_verified" boolean NOT NULL DEFAULT false, - "is_active" boolean NOT NULL DEFAULT true, - "two_factor_type" two_factor_type NOT NULL DEFAULT 'none', + "activity_status" activity_status NOT NULL DEFAULT 'active', + "created_at" timestamptz NOT NULL DEFAULT (now()), + "updated_at" timestamptz NOT NULL DEFAULT (now()) +); + +CREATE TABLE "account_2fa_configs" ( + "id" serial PRIMARY KEY, + "account_id" integer NOT NULL, + "account_public_id" uuid NOT NULL, + "two_factor_type" two_factor_type NOT NULL, + "is_default" boolean NOT NULL DEFAULT false, + "is_active" boolean NOT NULL DEFAULT false, "created_at" timestamptz NOT NULL DEFAULT (now()), "updated_at" timestamptz NOT NULL DEFAULT (now()) ); @@ -293,6 +322,17 @@ CREATE TABLE "account_data_encryption_keys" ( PRIMARY KEY ("account_id", "data_encryption_key_id") ); +CREATE TABLE "account_hmac_secrets" ( + "id" serial PRIMARY KEY, + "account_id" integer NOT NULL, + "secret_id" varchar(22) NOT NULL, + "secret" text NOT NULL, + "dek_kid" varchar(22) NOT NULL, + "is_revoked" boolean NOT NULL DEFAULT false, + "expires_at" timestamptz NOT NULL, + "created_at" timestamptz NOT NULL DEFAULT (now()) +); + CREATE TABLE "account_totps" ( "account_id" integer NOT NULL, "totp_id" integer NOT NULL, @@ -304,33 +344,24 @@ CREATE TABLE "account_credentials" ( "id" serial PRIMARY KEY, "account_id" integer NOT NULL, "account_public_id" uuid NOT NULL, + "client_id" varchar(22) NOT NULL, + "name" varchar(255) NOT NULL, + "domain" varchar(250) NOT NULL, "credentials_type" account_credentials_type NOT NULL, "scopes" account_credentials_scope[] NOT NULL, "token_endpoint_auth_method" auth_method NOT NULL, - "issuers" varchar(255)[] NOT NULL, - "alias" varchar(100) NOT NULL, - "client_id" varchar(22) NOT NULL, - "created_at" timestamptz NOT NULL DEFAULT (now()), - "updated_at" timestamptz NOT NULL DEFAULT (now()) -); - -CREATE TABLE "account_credentials_mcps" ( - "id" serial PRIMARY KEY, - "account_id" integer NOT NULL, - "account_public_id" uuid NOT NULL, - "account_credentials_id" integer NOT NULL, - "account_credentials_client_id" varchar(22) NOT NULL, - "creation_method" creation_method NOT NULL, + "grant_types" grant_type[] NOT NULL, + "version" integer NOT NULL DEFAULT 1, "transport" transport NOT NULL, - "response_types" response_type[] NOT NULL, - "callback_uris" varchar(2048)[] NOT NULL, + "creation_method" creation_method NOT NULL, "client_uri" varchar(512) NOT NULL, + "redirect_uris" varchar(2048)[] NOT NULL, "logo_uri" varchar(512), "policy_uri" varchar(512), "tos_uri" varchar(512), "software_id" varchar(512) NOT NULL, "software_version" varchar(512), - "contacts" varchar(512)[] NOT NULL DEFAULT '{}', + "contacts" varchar(250)[] NOT NULL, "created_at" timestamptz NOT NULL DEFAULT (now()), "updated_at" timestamptz NOT NULL DEFAULT (now()) ); @@ -387,17 +418,26 @@ CREATE TABLE "users" ( "public_id" uuid NOT NULL, "account_id" integer NOT NULL, "email" varchar(250) NOT NULL, - "username" varchar(250) NOT NULL, + "username" varchar(63) NOT NULL, "password" text, "version" integer NOT NULL DEFAULT 1, "email_verified" boolean NOT NULL DEFAULT false, - "is_active" boolean NOT NULL DEFAULT true, - "two_factor_type" two_factor_type NOT NULL DEFAULT 'none', + "activity_status" activity_status NOT NULL DEFAULT 'active', "user_data" jsonb NOT NULL DEFAULT '{}', "created_at" timestamptz NOT NULL DEFAULT (now()), "updated_at" timestamptz NOT NULL DEFAULT (now()) ); +CREATE TABLE "user_2fa_configs" ( + "id" serial PRIMARY KEY, + "account_id" integer NOT NULL, + "user_id" integer NOT NULL, + "two_factor_type" two_factor_type NOT NULL, + "is_default" boolean NOT NULL DEFAULT false, + "created_at" timestamptz NOT NULL DEFAULT (now()), + "updated_at" timestamptz NOT NULL DEFAULT (now()) +); + CREATE TABLE "user_data_encryption_keys" ( "user_id" integer NOT NULL, "data_encryption_key_id" integer NOT NULL, @@ -460,7 +500,7 @@ CREATE TABLE "apps" ( "account_id" integer NOT NULL, "account_public_id" uuid NOT NULL, "app_type" app_type NOT NULL, - "name" varchar(100) NOT NULL, + "name" varchar(255) NOT NULL, "client_id" varchar(22) NOT NULL, "version" integer NOT NULL DEFAULT 1, "creation_method" creation_method NOT NULL, @@ -539,7 +579,61 @@ CREATE TABLE "app_designs" ( "updated_at" timestamptz NOT NULL DEFAULT (now()) ); -CREATE TABLE "dynamic_registration_configs" ( +CREATE TABLE "account_dynamic_registration_configs" ( + "id" serial PRIMARY KEY, + "account_id" integer NOT NULL, + "account_public_id" uuid NOT NULL, + "account_credentials_types" account_credentials_type[] NOT NULL, + "whitelisted_domains" varchar(250)[] NOT NULL, + "require_software_statement_credential_types" account_credentials_type[] NOT NULL, + "software_statement_verification_methods" software_statement_verification_method[] NOT NULL, + "require_initial_access_token_credential_types" account_credentials_type[] NOT NULL, + "initial_access_token_generation_methods" initial_access_token_generation_method[] NOT NULL, + "created_at" timestamptz NOT NULL DEFAULT (now()), + "updated_at" timestamptz NOT NULL DEFAULT (now()) +); + +CREATE TABLE "account_dynamic_registration_domains" ( + "id" serial PRIMARY KEY, + "account_id" integer NOT NULL, + "account_public_id" uuid NOT NULL, + "domain" varchar(250) NOT NULL, + "verified_at" timestamptz, + "verification_method" domain_verification_method NOT NULL, + "created_at" timestamptz NOT NULL DEFAULT (now()), + "updated_at" timestamptz NOT NULL DEFAULT (now()) +); + +CREATE TABLE "dynamic_registration_domain_codes" ( + "id" serial PRIMARY KEY, + "account_id" integer NOT NULL, + "verification_host" varchar(50) NOT NULL, + "verification_code" text NOT NULL, + "hmac_secret_id" varchar(22) NOT NULL, + "verification_prefix" varchar(70) NOT NULL, + "expires_at" timestamptz NOT NULL, + "created_at" timestamptz NOT NULL DEFAULT (now()), + "updated_at" timestamptz NOT NULL DEFAULT (now()) +); + +CREATE TABLE "account_dynamic_registration_domain_codes" ( + "account_dynamic_registration_domain_id" integer NOT NULL, + "dynamic_registration_domain_code_id" integer NOT NULL, + "account_id" integer NOT NULL, + "created_at" timestamptz NOT NULL DEFAULT (now()), + PRIMARY KEY ("account_dynamic_registration_domain_id", "dynamic_registration_domain_code_id") +); + +CREATE TABLE "account_dynamic_registration_software_statement_keys" ( + "id" serial PRIMARY KEY, + "account_id" integer NOT NULL, + "account_public_id" uuid NOT NULL, + "credentials_key_id" integer NOT NULL, + "account_dynamic_registration_domain_id" integer NOT NULL, + "created_at" timestamptz NOT NULL DEFAULT (now()) +); + +CREATE TABLE "app_dynamic_registration_configs" ( "id" serial PRIMARY KEY, "account_id" integer NOT NULL, "allowed_app_types" app_type[] NOT NULL, @@ -621,6 +715,14 @@ CREATE INDEX "accounts_public_id_version_idx" ON "accounts" ("public_id", "versi CREATE UNIQUE INDEX "accounts_username_uidx" ON "accounts" ("username"); +CREATE INDEX "account_2fa_configs_account_id_idx" ON "account_2fa_configs" ("account_id"); + +CREATE INDEX "account_2fa_configs_account_public_id_idx" ON "account_2fa_configs" ("account_public_id"); + +CREATE INDEX "account_2fa_configs_account_public_id_is_default_idx" ON "account_2fa_configs" ("account_public_id", "is_default"); + +CREATE INDEX "account_2fa_configs_account_public_id_two_factor_type_idx" ON "account_2fa_configs" ("account_public_id", "two_factor_type"); + CREATE INDEX "accounts_totps_dek_kid_idx" ON "totps" ("dek_kid"); CREATE INDEX "accounts_totps_account_id_idx" ON "totps" ("account_id"); @@ -657,6 +759,16 @@ CREATE UNIQUE INDEX "account_data_encryption_keys_data_encryption_key_id_uidx" O CREATE UNIQUE INDEX "account_data_encryption_keys_account_id_data_encryption_key_id_uidx" ON "account_data_encryption_keys" ("account_id", "data_encryption_key_id"); +CREATE INDEX "account_hmac_secrets_account_id_idx" ON "account_hmac_secrets" ("account_id"); + +CREATE UNIQUE INDEX "account_hmac_secrets_secret_id_uidx" ON "account_hmac_secrets" ("secret_id"); + +CREATE INDEX "account_hmac_secrets_dek_kid_idx" ON "account_hmac_secrets" ("dek_kid"); + +CREATE INDEX "account_hmac_secrets_account_id_secret_id_idx" ON "account_hmac_secrets" ("account_id", "secret_id"); + +CREATE INDEX "account_hmac_secrets_account_id_is_revoked_expires_at_idx" ON "account_hmac_secrets" ("account_id", "is_revoked", "expires_at"); + CREATE UNIQUE INDEX "accounts_totps_account_id_uidx" ON "account_totps" ("account_id"); CREATE UNIQUE INDEX "accounts_totps_totp_id_uidx" ON "account_totps" ("totp_id"); @@ -671,17 +783,7 @@ CREATE INDEX "account_credentials_account_public_id_idx" ON "account_credentials CREATE INDEX "account_credentials_account_public_id_client_id_idx" ON "account_credentials" ("account_public_id", "client_id"); -CREATE UNIQUE INDEX "account_credentials_alias_account_id_uidx" ON "account_credentials" ("alias", "account_id"); - -CREATE INDEX "account_credentials_mcp_account_id_idx" ON "account_credentials_mcps" ("account_id"); - -CREATE INDEX "account_credentials_mcp_account_public_id_idx" ON "account_credentials_mcps" ("account_public_id"); - -CREATE UNIQUE INDEX "account_credentials_mcp_account_credentials_id_uidx" ON "account_credentials_mcps" ("account_credentials_id"); - -CREATE INDEX "account_credentials_mcp_account_credentials_client_id_idx" ON "account_credentials_mcps" ("account_credentials_client_id"); - -CREATE UNIQUE INDEX "account_credentials_mcp_account_credentials_id_software_id_uidx" ON "account_credentials_mcps" ("account_credentials_id", "software_id"); +CREATE UNIQUE INDEX "account_credentials_name_account_id_uidx" ON "account_credentials" ("name", "account_id"); CREATE INDEX "account_credential_secrets_account_id_idx" ON "account_credentials_secrets" ("account_id"); @@ -729,6 +831,14 @@ CREATE UNIQUE INDEX "users_public_id_uidx" ON "users" ("public_id"); CREATE INDEX "users_public_id_version_idx" ON "users" ("public_id", "version"); +CREATE INDEX "user_2fa_configs_account_id_idx" ON "user_2fa_configs" ("account_id"); + +CREATE INDEX "user_2fa_configs_user_id_idx" ON "user_2fa_configs" ("user_id"); + +CREATE INDEX "user_2fa_configs_two_factor_type_idx" ON "user_2fa_configs" ("two_factor_type"); + +CREATE UNIQUE INDEX "user_2fa_configs_user_id_two_factor_type_uidx" ON "user_2fa_configs" ("user_id", "two_factor_type"); + CREATE INDEX "user_data_encryption_keys_user_id_idx" ON "user_data_encryption_keys" ("user_id"); CREATE UNIQUE INDEX "user_data_encryption_keys_data_encryption_key_id_uidx" ON "user_data_encryption_keys" ("data_encryption_key_id"); @@ -833,7 +943,35 @@ CREATE INDEX "app_designs_account_id_idx" ON "app_designs" ("account_id"); CREATE UNIQUE INDEX "app_designs_app_id_uidx" ON "app_designs" ("app_id"); -CREATE INDEX "dynamic_registrations_configs_account_id_idx" ON "dynamic_registration_configs" ("account_id"); +CREATE UNIQUE INDEX "account_dynamic_registration_configs_account_id_uidx" ON "account_dynamic_registration_configs" ("account_id"); + +CREATE INDEX "account_dynamic_registration_configs_account_public_id_idx" ON "account_dynamic_registration_configs" ("account_public_id"); + +CREATE INDEX "accounts_totps_account_id_idx" ON "account_dynamic_registration_domains" ("account_id"); + +CREATE INDEX "account_dynamic_registration_domains_account_public_id_idx" ON "account_dynamic_registration_domains" ("account_public_id"); + +CREATE INDEX "account_dynamic_registration_domains_domain_idx" ON "account_dynamic_registration_domains" ("domain"); + +CREATE UNIQUE INDEX "account_dynamic_registration_domains_account_public_id_domain_uidx" ON "account_dynamic_registration_domains" ("account_public_id", "domain"); + +CREATE INDEX "account_dynamic_registration_domain_codes_account_id_idx" ON "dynamic_registration_domain_codes" ("account_id"); + +CREATE INDEX "account_dynamic_registration_domain_codes_account_id_idx" ON "account_dynamic_registration_domain_codes" ("account_id"); + +CREATE UNIQUE INDEX "account_dynamic_registration_domain_codes_account_dynamic_registration_domain_id_uidx" ON "account_dynamic_registration_domain_codes" ("account_dynamic_registration_domain_id"); + +CREATE UNIQUE INDEX "account_dynamic_registration_domain_codes_dynamic_registration_domain_code_id_uidx" ON "account_dynamic_registration_domain_codes" ("dynamic_registration_domain_code_id"); + +CREATE INDEX "account_dynamic_registration_software_statement_keys_account_id_idx" ON "account_dynamic_registration_software_statement_keys" ("account_id"); + +CREATE INDEX "account_dynamic_registration_software_statement_keys_account_public_id_idx" ON "account_dynamic_registration_software_statement_keys" ("account_public_id"); + +CREATE UNIQUE INDEX "account_dynamic_registration_software_statement_keys_credentials_key_id_uidx" ON "account_dynamic_registration_software_statement_keys" ("credentials_key_id"); + +CREATE UNIQUE INDEX "account_dynamic_registration_software_statement_keys_account_dynamic_registration_domain_id_uidx" ON "account_dynamic_registration_software_statement_keys" ("account_dynamic_registration_domain_id"); + +CREATE INDEX "app_dynamic_registration_configs_account_id_idx" ON "app_dynamic_registration_configs" ("account_id"); CREATE INDEX "user_profiles_app_id_idx" ON "app_profiles" ("app_id"); @@ -853,6 +991,8 @@ ALTER TABLE "data_encryption_keys" ADD FOREIGN KEY ("kek_kid") REFERENCES "key_e ALTER TABLE "token_signing_keys" ADD FOREIGN KEY ("dek_kid") REFERENCES "data_encryption_keys" ("kid") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "account_2fa_configs" ADD FOREIGN KEY ("account_id") REFERENCES "accounts" ("id") ON DELETE CASCADE; + ALTER TABLE "totps" ADD FOREIGN KEY ("dek_kid") REFERENCES "data_encryption_keys" ("kid") ON DELETE CASCADE ON UPDATE CASCADE; ALTER TABLE "totps" ADD FOREIGN KEY ("account_id") REFERENCES "accounts" ("id") ON DELETE CASCADE; @@ -871,16 +1011,16 @@ ALTER TABLE "account_data_encryption_keys" ADD FOREIGN KEY ("account_id") REFERE ALTER TABLE "account_data_encryption_keys" ADD FOREIGN KEY ("data_encryption_key_id") REFERENCES "data_encryption_keys" ("id") ON DELETE CASCADE; +ALTER TABLE "account_hmac_secrets" ADD FOREIGN KEY ("account_id") REFERENCES "accounts" ("id") ON DELETE CASCADE; + +ALTER TABLE "account_hmac_secrets" ADD FOREIGN KEY ("dek_kid") REFERENCES "data_encryption_keys" ("kid") ON DELETE CASCADE ON UPDATE CASCADE; + ALTER TABLE "account_totps" ADD FOREIGN KEY ("account_id") REFERENCES "accounts" ("id") ON DELETE CASCADE; ALTER TABLE "account_totps" ADD FOREIGN KEY ("totp_id") REFERENCES "totps" ("id") ON DELETE CASCADE; ALTER TABLE "account_credentials" ADD FOREIGN KEY ("account_id") REFERENCES "accounts" ("id") ON DELETE CASCADE; -ALTER TABLE "account_credentials_mcps" ADD FOREIGN KEY ("account_id") REFERENCES "accounts" ("id") ON DELETE CASCADE; - -ALTER TABLE "account_credentials_mcps" ADD FOREIGN KEY ("account_credentials_id") REFERENCES "account_credentials" ("id") ON DELETE CASCADE; - ALTER TABLE "account_credentials_secrets" ADD FOREIGN KEY ("account_id") REFERENCES "accounts" ("id") ON DELETE CASCADE; ALTER TABLE "account_credentials_secrets" ADD FOREIGN KEY ("credentials_secret_id") REFERENCES "credentials_secrets" ("id") ON DELETE CASCADE; @@ -901,6 +1041,10 @@ ALTER TABLE "account_token_signing_keys" ADD FOREIGN KEY ("token_signing_key_id" ALTER TABLE "users" ADD FOREIGN KEY ("account_id") REFERENCES "accounts" ("id") ON DELETE CASCADE; +ALTER TABLE "user_2fa_configs" ADD FOREIGN KEY ("account_id") REFERENCES "accounts" ("id") ON DELETE CASCADE; + +ALTER TABLE "user_2fa_configs" ADD FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE; + ALTER TABLE "user_data_encryption_keys" ADD FOREIGN KEY ("account_id") REFERENCES "accounts" ("id") ON DELETE CASCADE; ALTER TABLE "user_data_encryption_keys" ADD FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE; @@ -967,7 +1111,27 @@ ALTER TABLE "app_designs" ADD FOREIGN KEY ("account_id") REFERENCES "accounts" ( ALTER TABLE "app_designs" ADD FOREIGN KEY ("app_id") REFERENCES "apps" ("id") ON DELETE CASCADE; -ALTER TABLE "dynamic_registration_configs" ADD FOREIGN KEY ("account_id") REFERENCES "accounts" ("id") ON DELETE CASCADE; +ALTER TABLE "account_dynamic_registration_configs" ADD FOREIGN KEY ("account_id") REFERENCES "accounts" ("id") ON DELETE CASCADE; + +ALTER TABLE "account_dynamic_registration_domains" ADD FOREIGN KEY ("account_id") REFERENCES "accounts" ("id") ON DELETE CASCADE; + +ALTER TABLE "dynamic_registration_domain_codes" ADD FOREIGN KEY ("account_id") REFERENCES "accounts" ("id") ON DELETE CASCADE; + +ALTER TABLE "dynamic_registration_domain_codes" ADD FOREIGN KEY ("hmac_secret_id") REFERENCES "account_hmac_secrets" ("secret_id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "account_dynamic_registration_domain_codes" ADD FOREIGN KEY ("account_id") REFERENCES "accounts" ("id") ON DELETE CASCADE; + +ALTER TABLE "account_dynamic_registration_domain_codes" ADD FOREIGN KEY ("account_dynamic_registration_domain_id") REFERENCES "account_dynamic_registration_domains" ("id") ON DELETE CASCADE; + +ALTER TABLE "account_dynamic_registration_domain_codes" ADD FOREIGN KEY ("dynamic_registration_domain_code_id") REFERENCES "dynamic_registration_domain_codes" ("id") ON DELETE CASCADE; + +ALTER TABLE "account_dynamic_registration_software_statement_keys" ADD FOREIGN KEY ("account_id") REFERENCES "accounts" ("id") ON DELETE CASCADE; + +ALTER TABLE "account_dynamic_registration_software_statement_keys" ADD FOREIGN KEY ("credentials_key_id") REFERENCES "credentials_keys" ("id") ON DELETE CASCADE; + +ALTER TABLE "account_dynamic_registration_software_statement_keys" ADD FOREIGN KEY ("account_dynamic_registration_domain_id") REFERENCES "account_dynamic_registration_domains" ("id") ON DELETE CASCADE; + +ALTER TABLE "app_dynamic_registration_configs" ADD FOREIGN KEY ("account_id") REFERENCES "accounts" ("id") ON DELETE CASCADE; ALTER TABLE "app_profiles" ADD FOREIGN KEY ("app_id") REFERENCES "apps" ("id") ON DELETE CASCADE; diff --git a/idp/internal/providers/database/models.go b/idp/internal/providers/database/models.go index 8e2103a..251a769 100644 --- a/idp/internal/providers/database/models.go +++ b/idp/internal/providers/database/models.go @@ -16,16 +16,20 @@ import ( type AccountCredentialsScope string const ( - AccountCredentialsScopeEmail AccountCredentialsScope = "email" - AccountCredentialsScopeProfile AccountCredentialsScope = "profile" - AccountCredentialsScopeAccountAdmin AccountCredentialsScope = "account:admin" - AccountCredentialsScopeAccountUsersRead AccountCredentialsScope = "account:users:read" - AccountCredentialsScopeAccountUsersWrite AccountCredentialsScope = "account:users:write" - AccountCredentialsScopeAccountAppsRead AccountCredentialsScope = "account:apps:read" - AccountCredentialsScopeAccountAppsWrite AccountCredentialsScope = "account:apps:write" - AccountCredentialsScopeAccountCredentialsRead AccountCredentialsScope = "account:credentials:read" - AccountCredentialsScopeAccountCredentialsWrite AccountCredentialsScope = "account:credentials:write" - AccountCredentialsScopeAccountAuthProvidersRead AccountCredentialsScope = "account:auth_providers:read" + AccountCredentialsScopeEmail AccountCredentialsScope = "email" + AccountCredentialsScopeProfile AccountCredentialsScope = "profile" + AccountCredentialsScopeAccountAdmin AccountCredentialsScope = "account:admin" + AccountCredentialsScopeAccountUsersRead AccountCredentialsScope = "account:users:read" + AccountCredentialsScopeAccountUsersWrite AccountCredentialsScope = "account:users:write" + AccountCredentialsScopeAccountAppsRead AccountCredentialsScope = "account:apps:read" + AccountCredentialsScopeAccountAppsWrite AccountCredentialsScope = "account:apps:write" + AccountCredentialsScopeAccountAppsConfigsRead AccountCredentialsScope = "account:apps:configs:read" + AccountCredentialsScopeAccountAppsConfigsWrite AccountCredentialsScope = "account:apps:configs:write" + AccountCredentialsScopeAccountCredentialsRead AccountCredentialsScope = "account:credentials:read" + AccountCredentialsScopeAccountCredentialsWrite AccountCredentialsScope = "account:credentials:write" + AccountCredentialsScopeAccountCredentialsConfigsRead AccountCredentialsScope = "account:credentials:configs:read" + AccountCredentialsScopeAccountCredentialsConfigsWrite AccountCredentialsScope = "account:credentials:configs:write" + AccountCredentialsScopeAccountAuthProvidersRead AccountCredentialsScope = "account:auth_providers:read" ) func (e *AccountCredentialsScope) Scan(src interface{}) error { @@ -66,8 +70,9 @@ func (ns NullAccountCredentialsScope) Value() (driver.Value, error) { type AccountCredentialsType string const ( - AccountCredentialsTypeClient AccountCredentialsType = "client" - AccountCredentialsTypeMcp AccountCredentialsType = "mcp" + AccountCredentialsTypeNative AccountCredentialsType = "native" + AccountCredentialsTypeService AccountCredentialsType = "service" + AccountCredentialsTypeMcp AccountCredentialsType = "mcp" ) func (e *AccountCredentialsType) Scan(src interface{}) error { @@ -105,6 +110,49 @@ func (ns NullAccountCredentialsType) Value() (driver.Value, error) { return string(ns.AccountCredentialsType), nil } +type ActivityStatus string + +const ( + ActivityStatusActive ActivityStatus = "active" + ActivityStatusSuspended ActivityStatus = "suspended" + ActivityStatusBlocked ActivityStatus = "blocked" +) + +func (e *ActivityStatus) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = ActivityStatus(s) + case string: + *e = ActivityStatus(s) + default: + return fmt.Errorf("unsupported scan type for ActivityStatus: %T", src) + } + return nil +} + +type NullActivityStatus struct { + ActivityStatus ActivityStatus + Valid bool // Valid is true if ActivityStatus is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullActivityStatus) Scan(value interface{}) error { + if value == nil { + ns.ActivityStatus, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.ActivityStatus.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullActivityStatus) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.ActivityStatus), nil +} + type AppProfileType string const ( @@ -517,6 +565,49 @@ func (ns NullDekUsage) Value() (driver.Value, error) { return string(ns.DekUsage), nil } +type DomainVerificationMethod string + +const ( + DomainVerificationMethodAuthorizationCode DomainVerificationMethod = "authorization_code" + DomainVerificationMethodSoftwareStatement DomainVerificationMethod = "software_statement" + DomainVerificationMethodDnsTxtRecord DomainVerificationMethod = "dns_txt_record" +) + +func (e *DomainVerificationMethod) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = DomainVerificationMethod(s) + case string: + *e = DomainVerificationMethod(s) + default: + return fmt.Errorf("unsupported scan type for DomainVerificationMethod: %T", src) + } + return nil +} + +type NullDomainVerificationMethod struct { + DomainVerificationMethod DomainVerificationMethod + Valid bool // Valid is true if DomainVerificationMethod is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullDomainVerificationMethod) Scan(value interface{}) error { + if value == nil { + ns.DomainVerificationMethod, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.DomainVerificationMethod.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullDomainVerificationMethod) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.DomainVerificationMethod), nil +} + type GrantType string const ( @@ -779,8 +870,9 @@ func (ns NullSecretStorageMode) Value() (driver.Value, error) { type SoftwareStatementVerificationMethod string const ( - SoftwareStatementVerificationMethodManual SoftwareStatementVerificationMethod = "manual" - SoftwareStatementVerificationMethodJwksUri SoftwareStatementVerificationMethod = "jwks_uri" + SoftwareStatementVerificationMethodManual SoftwareStatementVerificationMethod = "manual" + SoftwareStatementVerificationMethodJwksUri SoftwareStatementVerificationMethod = "jwks_uri" + SoftwareStatementVerificationMethodJwkX5Parameters SoftwareStatementVerificationMethod = "jwk_x5_parameters" ) func (e *SoftwareStatementVerificationMethod) Scan(src interface{}) error { @@ -821,6 +913,7 @@ func (ns NullSoftwareStatementVerificationMethod) Value() (driver.Value, error) type TokenCryptoSuite string const ( + TokenCryptoSuiteRS256 TokenCryptoSuite = "RS256" TokenCryptoSuiteES256 TokenCryptoSuite = "ES256" TokenCryptoSuiteEdDSA TokenCryptoSuite = "EdDSA" ) @@ -863,13 +956,14 @@ func (ns NullTokenCryptoSuite) Value() (driver.Value, error) { type TokenKeyType string const ( - TokenKeyTypeAccess TokenKeyType = "access" - TokenKeyTypeRefresh TokenKeyType = "refresh" - TokenKeyTypeIDToken TokenKeyType = "id_token" - TokenKeyTypeClientCredentials TokenKeyType = "client_credentials" - TokenKeyTypeEmailVerification TokenKeyType = "email_verification" - TokenKeyTypePasswordReset TokenKeyType = "password_reset" - TokenKeyType2faAuthentication TokenKeyType = "2fa_authentication" + TokenKeyTypeAccess TokenKeyType = "access" + TokenKeyTypeRefresh TokenKeyType = "refresh" + TokenKeyTypeIDToken TokenKeyType = "id_token" + TokenKeyTypeClientCredentials TokenKeyType = "client_credentials" + TokenKeyTypeEmailVerification TokenKeyType = "email_verification" + TokenKeyTypePasswordReset TokenKeyType = "password_reset" + TokenKeyType2faAuthentication TokenKeyType = "2fa_authentication" + TokenKeyTypeDynamicRegistration TokenKeyType = "dynamic_registration" ) func (e *TokenKeyType) Scan(src interface{}) error { @@ -1080,7 +1174,6 @@ func (ns NullTransport) Value() (driver.Value, error) { type TwoFactorType string const ( - TwoFactorTypeNone TwoFactorType = "none" TwoFactorTypeTotp TwoFactorType = "totp" TwoFactorTypeEmail TwoFactorType = "email" ) @@ -1121,20 +1214,30 @@ func (ns NullTwoFactorType) Value() (driver.Value, error) { } type Account struct { - ID int32 - PublicID uuid.UUID - GivenName string - FamilyName string - Username string - Email string - Organization pgtype.Text - Password pgtype.Text - Version int32 - EmailVerified bool - IsActive bool - TwoFactorType TwoFactorType - CreatedAt time.Time - UpdatedAt time.Time + ID int32 + PublicID uuid.UUID + GivenName string + FamilyName string + Username string + Email string + Organization pgtype.Text + Password pgtype.Text + Version int32 + EmailVerified bool + ActivityStatus ActivityStatus + CreatedAt time.Time + UpdatedAt time.Time +} + +type Account2faConfig struct { + ID int32 + AccountID int32 + AccountPublicID uuid.UUID + TwoFactorType TwoFactorType + IsDefault bool + IsActive bool + CreatedAt time.Time + UpdatedAt time.Time } type AccountAuthProvider struct { @@ -1150,12 +1253,24 @@ type AccountCredential struct { ID int32 AccountID int32 AccountPublicID uuid.UUID + ClientID string + Name string + Domain string CredentialsType AccountCredentialsType Scopes []AccountCredentialsScope TokenEndpointAuthMethod AuthMethod - Issuers []string - Alias string - ClientID string + GrantTypes []GrantType + Version int32 + Transport Transport + CreationMethod CreationMethod + ClientUri string + RedirectUris []string + LogoUri pgtype.Text + PolicyUri pgtype.Text + TosUri pgtype.Text + SoftwareID string + SoftwareVersion pgtype.Text + Contacts []string CreatedAt time.Time UpdatedAt time.Time } @@ -1169,27 +1284,6 @@ type AccountCredentialsKey struct { CreatedAt time.Time } -type AccountCredentialsMcp struct { - ID int32 - AccountID int32 - AccountPublicID uuid.UUID - AccountCredentialsID int32 - AccountCredentialsClientID string - CreationMethod CreationMethod - Transport Transport - ResponseTypes []ResponseType - CallbackUris []string - ClientUri string - LogoUri pgtype.Text - PolicyUri pgtype.Text - TosUri pgtype.Text - SoftwareID string - SoftwareVersion pgtype.Text - Contacts []string - CreatedAt time.Time - UpdatedAt time.Time -} - type AccountCredentialsSecret struct { AccountID int32 CredentialsSecretID int32 @@ -1205,6 +1299,58 @@ type AccountDataEncryptionKey struct { CreatedAt time.Time } +type AccountDynamicRegistrationConfig struct { + ID int32 + AccountID int32 + AccountPublicID uuid.UUID + AccountCredentialsTypes []AccountCredentialsType + WhitelistedDomains []string + RequireSoftwareStatementCredentialTypes []AccountCredentialsType + SoftwareStatementVerificationMethods []SoftwareStatementVerificationMethod + RequireInitialAccessTokenCredentialTypes []AccountCredentialsType + InitialAccessTokenGenerationMethods []InitialAccessTokenGenerationMethod + CreatedAt time.Time + UpdatedAt time.Time +} + +type AccountDynamicRegistrationDomain struct { + ID int32 + AccountID int32 + AccountPublicID uuid.UUID + Domain string + VerifiedAt pgtype.Timestamptz + VerificationMethod DomainVerificationMethod + CreatedAt time.Time + UpdatedAt time.Time +} + +type AccountDynamicRegistrationDomainCode struct { + AccountDynamicRegistrationDomainID int32 + DynamicRegistrationDomainCodeID int32 + AccountID int32 + CreatedAt time.Time +} + +type AccountDynamicRegistrationSoftwareStatementKey struct { + ID int32 + AccountID int32 + AccountPublicID uuid.UUID + CredentialsKeyID int32 + AccountDynamicRegistrationDomainID int32 + CreatedAt time.Time +} + +type AccountHmacSecret struct { + ID int32 + AccountID int32 + SecretID string + Secret string + DekKid string + IsRevoked bool + ExpiresAt time.Time + CreatedAt time.Time +} + type AccountKeyEncryptionKey struct { AccountID int32 KeyEncryptionKeyID int32 @@ -1271,6 +1417,30 @@ type AppDesign struct { UpdatedAt time.Time } +type AppDynamicRegistrationConfig struct { + ID int32 + AccountID int32 + AllowedAppTypes []AppType + WhitelistedDomains []string + DefaultAllowUserRegistration bool + DefaultAuthProviders []AuthProvider + DefaultUsernameColumn AppUsernameColumn + DefaultAllowedScopes []Scopes + DefaultScopes []Scopes + RequireSoftwareStatementAppTypes []AppType + SoftwareStatementVerificationMethods []SoftwareStatementVerificationMethod + RequireInitialAccessTokenAppTypes []AppType + InitialAccessTokenGenerationMethods []InitialAccessTokenGenerationMethod + InitialAccessTokenTtl int32 + InitialAccessTokenMaxUses int32 + AllowedGrantTypes []GrantType + AllowedResponseTypes []ResponseType + AllowedTokenEndpointAuthMethods []AuthMethod + MaxRedirectUris int32 + CreatedAt time.Time + UpdatedAt time.Time +} + type AppKey struct { AppID int32 CredentialsKeyID int32 @@ -1351,28 +1521,16 @@ type DataEncryptionKey struct { UpdatedAt time.Time } -type DynamicRegistrationConfig struct { - ID int32 - AccountID int32 - AllowedAppTypes []AppType - WhitelistedDomains []string - DefaultAllowUserRegistration bool - DefaultAuthProviders []AuthProvider - DefaultUsernameColumn AppUsernameColumn - DefaultAllowedScopes []Scopes - DefaultScopes []Scopes - RequireSoftwareStatementAppTypes []AppType - SoftwareStatementVerificationMethods []SoftwareStatementVerificationMethod - RequireInitialAccessTokenAppTypes []AppType - InitialAccessTokenGenerationMethods []InitialAccessTokenGenerationMethod - InitialAccessTokenTtl int32 - InitialAccessTokenMaxUses int32 - AllowedGrantTypes []GrantType - AllowedResponseTypes []ResponseType - AllowedTokenEndpointAuthMethods []AuthMethod - MaxRedirectUris int32 - CreatedAt time.Time - UpdatedAt time.Time +type DynamicRegistrationDomainCode struct { + ID int32 + AccountID int32 + VerificationHost string + VerificationCode string + HmacSecretID string + VerificationPrefix string + ExpiresAt time.Time + CreatedAt time.Time + UpdatedAt time.Time } type KeyEncryptionKey struct { @@ -1437,17 +1595,26 @@ type Totp struct { } type User struct { + ID int32 + PublicID uuid.UUID + AccountID int32 + Email string + Username string + Password pgtype.Text + Version int32 + EmailVerified bool + ActivityStatus ActivityStatus + UserData []byte + CreatedAt time.Time + UpdatedAt time.Time +} + +type User2faConfig struct { ID int32 - PublicID uuid.UUID AccountID int32 - Email string - Username string - Password pgtype.Text - Version int32 - EmailVerified bool - IsActive bool + UserID int32 TwoFactorType TwoFactorType - UserData []byte + IsDefault bool CreatedAt time.Time UpdatedAt time.Time } diff --git a/idp/internal/providers/database/queries/account_2fa_configs.sql b/idp/internal/providers/database/queries/account_2fa_configs.sql new file mode 100644 index 0000000..b32432f --- /dev/null +++ b/idp/internal/providers/database/queries/account_2fa_configs.sql @@ -0,0 +1,49 @@ +-- Copyright (c) 2025 Afonso Barracha +-- +-- This Source Code Form is subject to the terms of the Mozilla Public +-- License, v. 2.0. If a copy of the MPL was not distributed with this +-- file, You can obtain one at https://mozilla.org/MPL/2.0/. + +-- name: CreateAccount2FAConfig :one +INSERT INTO "account_2fa_configs" ( + "account_id", + "account_public_id", + "two_factor_type", + "is_default" +) VALUES ( + $1, + $2, + $3, + $4 +) RETURNING *; + +-- name: FindDefaultAccount2FAConfigByAccountPublicID :one +SELECT * FROM "account_2fa_configs" +WHERE "account_public_id" = $1 AND "is_default" = true +LIMIT 1; + +-- name: FindAccount2FAConfigByAccountPublicIDAndType :one +SELECT * FROM "account_2fa_configs" +WHERE "account_public_id" = $1 AND "two_factor_type" = $2 +LIMIT 1; + +-- name: FindAccount2FAConfigsByAccountPublicID :many +SELECT * FROM "account_2fa_configs" +WHERE "account_public_id" = $1 +ORDER BY "id" DESC; + +-- name: UpdateAccount2FAConfig :one +UPDATE "account_2fa_configs" SET + "is_default" = $2, + "updated_at" = now() +WHERE "id" = $1 +RETURNING *; + +-- name: DeleteAccount2FAConfig :exec +DELETE FROM "account_2fa_configs" +WHERE "id" = $1; + +-- name: CountAccount2FAConfigsByAccountID :one +SELECT COUNT(*) FROM "account_2fa_configs" +WHERE "account_id" = $1 +LIMIT 1; \ No newline at end of file diff --git a/idp/internal/providers/database/queries/account_credentials.sql b/idp/internal/providers/database/queries/account_credentials.sql index 93411b8..c47e40c 100644 --- a/idp/internal/providers/database/queries/account_credentials.sql +++ b/idp/internal/providers/database/queries/account_credentials.sql @@ -14,16 +14,31 @@ SELECT * FROM "account_credentials" WHERE "account_public_id" = $1 AND "client_id" = $2 LIMIT 1; +-- name: CountAccountCredentialsByAccountPublicIDAndClientID :one +SELECT COUNT(*) FROM "account_credentials" +WHERE "account_public_id" = $1 AND "client_id" = $2 +LIMIT 1; + -- name: CreateAccountCredentials :one INSERT INTO "account_credentials" ( "client_id", "account_id", "account_public_id", "credentials_type", - "alias", + "name", "scopes", "token_endpoint_auth_method", - "issuers" + "domain", + "client_uri", + "redirect_uris", + "logo_uri", + "policy_uri", + "tos_uri", + "software_id", + "software_version", + "contacts", + "creation_method", + "transport" ) VALUES ( $1, $2, @@ -32,21 +47,40 @@ INSERT INTO "account_credentials" ( $5, $6, $7, - $8 + $8, + $9, + $10, + $11, + $12, + $13, + $14, + $15, + $16, + $17, + $18 ) RETURNING *; -- name: UpdateAccountCredentials :one UPDATE "account_credentials" SET "scopes" = $2, - "alias" = $3, - "issuers" = $4, + "name" = $3, + "domain" = $4, + "client_uri" = $5, + "redirect_uris" = $6, + "logo_uri" = $7, + "policy_uri" = $8, + "tos_uri" = $9, + "software_version" = $10, + "contacts" = $11, + "transport" = $12, + "version" = "version" + 1, "updated_at" = now() WHERE "id" = $1 RETURNING *; --- name: CountAccountCredentialsByAliasAndAccountID :one +-- name: CountAccountCredentialsByNameAndAccountID :one SELECT COUNT(*) FROM "account_credentials" -WHERE "account_id" = $1 AND "alias" = $2; +WHERE "account_id" = $1 AND "name" = $2; -- name: DeleteAccountCredentials :exec DELETE FROM "account_credentials" diff --git a/idp/internal/providers/database/queries/account_dynamic_registration_configs.sql b/idp/internal/providers/database/queries/account_dynamic_registration_configs.sql new file mode 100644 index 0000000..697d1a3 --- /dev/null +++ b/idp/internal/providers/database/queries/account_dynamic_registration_configs.sql @@ -0,0 +1,48 @@ +-- Copyright (c) 2025 Afonso Barracha +-- +-- This Source Code Form is subject to the terms of the Mozilla Public +-- License, v. 2.0. If a copy of the MPL was not distributed with this +-- file, You can obtain one at https://mozilla.org/MPL/2.0/. + +-- name: CreateAccountDynamicRegistrationConfig :one +INSERT INTO "account_dynamic_registration_configs" ( + "account_id", + "account_public_id", + "account_credentials_types", + "whitelisted_domains", + "require_software_statement_credential_types", + "software_statement_verification_methods", + "require_initial_access_token_credential_types", + "initial_access_token_generation_methods" +) VALUES ( + $1, + $2, + $3, + $4, + $5, + $6, + $7, + $8 +) RETURNING *; + +-- name: UpdateAccountDynamicRegistrationConfig :one +UPDATE "account_dynamic_registration_configs" SET + "account_credentials_types" = $2, + "whitelisted_domains" = $3, + "require_software_statement_credential_types" = $4, + "software_statement_verification_methods" = $5, + "require_initial_access_token_credential_types" = $6, + "initial_access_token_generation_methods" = $7 +WHERE "id" = $1 +RETURNING *; + +-- name: FindAccountDynamicRegistrationConfigByAccountID :one +SELECT * FROM "account_dynamic_registration_configs" +WHERE "account_id" = $1 LIMIT 1; + +-- name: FindAccountDynamicRegistrationConfigByAccountPublicID :one +SELECT * FROM "account_dynamic_registration_configs" +WHERE "account_public_id" = $1 LIMIT 1; + +-- name: DeleteAccountDynamicRegistrationConfig :exec +DELETE FROM "account_dynamic_registration_configs" WHERE "id" = $1; \ No newline at end of file diff --git a/idp/internal/providers/database/queries/account_dynamic_registration_domain_codes.sql b/idp/internal/providers/database/queries/account_dynamic_registration_domain_codes.sql new file mode 100644 index 0000000..a92cd0f --- /dev/null +++ b/idp/internal/providers/database/queries/account_dynamic_registration_domain_codes.sql @@ -0,0 +1,22 @@ +-- Copyright (c) 2025 Afonso Barracha +-- +-- This Source Code Form is subject to the terms of the Mozilla Public +-- License, v. 2.0. If a copy of the MPL was not distributed with this +-- file, You can obtain one at https://mozilla.org/MPL/2.0/. + +-- name: CreateAccountDynamicRegistrationDomainCode :exec +INSERT INTO "account_dynamic_registration_domain_codes" ( + "account_dynamic_registration_domain_id", + "dynamic_registration_domain_code_id", + "account_id" +) VALUES ( + $1, + $2, + $3 +); + +-- name: FindDynamicRegistrationDomainCodeByAccountDynamicRegistrationDomainID :one +SELECT "d".* FROM "dynamic_registration_domain_codes" "d" +LEFT JOIN "account_dynamic_registration_domain_codes" "a" ON "d"."id" = "a"."dynamic_registration_domain_code_id" +WHERE "a"."account_dynamic_registration_domain_id" = $1 +LIMIT 1; diff --git a/idp/internal/providers/database/queries/account_dynamic_registration_domains.sql b/idp/internal/providers/database/queries/account_dynamic_registration_domains.sql new file mode 100644 index 0000000..60a0436 --- /dev/null +++ b/idp/internal/providers/database/queries/account_dynamic_registration_domains.sql @@ -0,0 +1,97 @@ +-- Copyright (c) 2025 Afonso Barracha +-- +-- This Source Code Form is subject to the terms of the Mozilla Public +-- License, v. 2.0. If a copy of the MPL was not distributed with this +-- file, You can obtain one at https://mozilla.org/MPL/2.0/. + +-- name: CreateAccountDynamicRegistrationDomain :one +INSERT INTO "account_dynamic_registration_domains" ( + "account_id", + "account_public_id", + "domain", + "verification_method" +) VALUES ( + $1, + $2, + $3, + $4 +) RETURNING *; + +-- name: FindAccountDynamicRegistrationDomainByAccountPublicIDAndDomain :one +SELECT * FROM "account_dynamic_registration_domains" WHERE "account_public_id" = $1 AND "domain" = $2 LIMIT 1; + +-- name: VerifyAccountDynamicRegistrationDomain :one +UPDATE "account_dynamic_registration_domains" +SET + "verified_at" = NOW(), + "verification_method" = $2 +WHERE "id" = $1 RETURNING *; + +-- name: FindPaginatedAccountDynamicRegistrationDomainsByAccountPublicIDOrderedByID :many +SELECT * FROM "account_dynamic_registration_domains" +WHERE "account_public_id" = $1 +ORDER BY "id" DESC +LIMIT $2 OFFSET $3; + +-- name: FindPaginatedAccountDynamicRegistrationDomainsByAccountPublicIDOrderedByDomain :many +SELECT * FROM "account_dynamic_registration_domains" +WHERE "account_public_id" = $1 +ORDER BY "domain" ASC +LIMIT $2 OFFSET $3; + +-- name: CountAccountDynamicRegistrationDomainsByAccountPublicID :one +SELECT COUNT(*) FROM "account_dynamic_registration_domains" +WHERE "account_public_id" = $1; + +-- name: FilterAccountDynamicRegistrationDomainsByAccountPublicIDOrderedByID :many +SELECT * FROM "account_dynamic_registration_domains" +WHERE + "account_public_id" = $1 AND + "domain" ILIKE $2 +ORDER BY "id" DESC +LIMIT $3 OFFSET $4; + +-- name: FilterAccountDynamicRegistrationDomainsByAccountPublicIDOrderedByDomain :many +SELECT * FROM "account_dynamic_registration_domains" +WHERE + "account_public_id" = $1 AND + "domain" ILIKE $2 +ORDER BY "domain" ASC +LIMIT $3 OFFSET $4; + +-- name: CountFilteredAccountDynamicRegistrationDomainsByAccountPublicID :one +SELECT COUNT(*) FROM "account_dynamic_registration_domains" +WHERE + "account_public_id" = $1 AND + "domain" ILIKE $2 +LIMIT 1; + +-- name: CountVerifiedAccountDynamicRegistrationDomainsByDomain :one +SELECT COUNT(*) FROM "account_dynamic_registration_domains" +WHERE "domain" = $1 AND "verified_at" IS NOT NULL +LIMIT 1; + +-- name: CountVerifiedAccountDynamicRegistrationDomainsByDomains :one +SELECT COUNT(*) FROM "account_dynamic_registration_domains" +WHERE "domain" IN (sqlc.slice('domains')) AND "verified_at" IS NOT NULL +LIMIT 1; + +-- name: CountVerifiedAccountDynamicRegistrationDomainsByDomainsAndAccountPublicID :one +SELECT COUNT(*) FROM "account_dynamic_registration_domains" +WHERE + "account_public_id" = $1 AND + "domain" IN (sqlc.slice('domains')) AND + "verified_at" IS NOT NULL +LIMIT 1; + +-- name: CountVerifiedAccountDynamicRegistrationDomainsByDomainAndAccountPublicID :one +SELECT COUNT(*) FROM "account_dynamic_registration_domains" +WHERE + "account_public_id" = $1 AND + "domain" = $2 AND + "verified_at" IS NOT NULL +LIMIT 1; + +-- name: DeleteAccountDynamicRegistrationDomain :exec +DELETE FROM "account_dynamic_registration_domains" +WHERE "id" = $1; diff --git a/idp/internal/providers/database/queries/account_hmac_secrets.sql b/idp/internal/providers/database/queries/account_hmac_secrets.sql new file mode 100644 index 0000000..192e8ad --- /dev/null +++ b/idp/internal/providers/database/queries/account_hmac_secrets.sql @@ -0,0 +1,40 @@ +-- Copyright (c) 2025 Afonso Barracha +-- +-- This Source Code Form is subject to the terms of the Mozilla Public +-- License, v. 2.0. If a copy of the MPL was not distributed with this +-- file, You can obtain one at https://mozilla.org/MPL/2.0/. + +-- name: CreateAccountHMACSecret :one +INSERT INTO "account_hmac_secrets" ( + "account_id", + "secret_id", + "secret", + "dek_kid", + "expires_at" +) VALUES ( + $1, + $2, + $3, + $4, + $5 +) RETURNING "id"; + +-- name: UpdateAccountHMACSecret :exec +UPDATE "account_hmac_secrets" SET + "secret" = $2, + "dek_kid" = $3, + "updated_at" = now() +WHERE "id" = $1; + +-- name: FindAccountHMACSecretByAccountIDAndSecretID :one +SELECT * FROM "account_hmac_secrets" +WHERE "account_id" = $1 AND "secret_id" = $2 +LIMIT 1; + +-- name: FindValidHMACSecretByAccountID :one +SELECT * FROM "account_hmac_secrets" +WHERE + "account_id" = $1 AND + "is_revoked" = false AND + "expires_at" > now() +LIMIT 1; \ No newline at end of file diff --git a/idp/internal/providers/database/queries/accounts.sql b/idp/internal/providers/database/queries/accounts.sql index 3c763a2..6bc22ef 100644 --- a/idp/internal/providers/database/queries/accounts.sql +++ b/idp/internal/providers/database/queries/accounts.sql @@ -64,6 +64,13 @@ UPDATE "accounts" SET WHERE "id" = $2 RETURNING *; +-- name: UpdateAccountVersion :one +UPDATE "accounts" SET + "version" = "version" + 1, + "updated_at" = now() +WHERE "id" = $1 +RETURNING *; + -- name: FindAccountByEmail :one SELECT * FROM "accounts" WHERE "email" = $1 LIMIT 1; @@ -92,13 +99,6 @@ UPDATE "accounts" SET WHERE "id" = $1 RETURNING *; --- name: UpdateAccountTwoFactorType :exec -UPDATE "accounts" SET - "two_factor_type" = $1, - "version" = "version" + 1, - "updated_at" = now() -WHERE "id" = $2; - -- name: DeleteAllAccounts :exec DELETE FROM "accounts"; diff --git a/idp/internal/providers/database/queries/apps.sql b/idp/internal/providers/database/queries/apps.sql index 6cc314d..42a8637 100644 --- a/idp/internal/providers/database/queries/apps.sql +++ b/idp/internal/providers/database/queries/apps.sql @@ -92,7 +92,7 @@ SET "name" = $2, "logo_uri" = $5, "tos_uri" = $6, "policy_uri" = $7, - "software_id" = $8, + "auth_providers" = $8, "software_version" = $9, "contacts" = $10, "domain" = $11, @@ -100,7 +100,6 @@ SET "name" = $2, "redirect_uris" = $13, "allow_user_registration" = $14, "response_types" = $15, - "auth_providers" = $16, "version" = "version" + 1, "updated_at" = now() WHERE "id" = $1 @@ -207,5 +206,10 @@ SELECT * FROM "apps" WHERE "client_id" IN (sqlc.slice('client_ids')) AND "account_id" = $1 ORDER BY "name" ASC LIMIT $2; +-- name: CountAppsByClientIDAndAccountPublicID :one +SELECT COUNT(*) FROM "apps" +WHERE "client_id" = $1 AND "account_public_id" = $2 +LIMIT 1; + -- name: DeleteAllApps :exec DELETE FROM "apps"; diff --git a/idp/internal/providers/database/queries/dynamic_registration_domain_codes.sql b/idp/internal/providers/database/queries/dynamic_registration_domain_codes.sql new file mode 100644 index 0000000..9b73f9e --- /dev/null +++ b/idp/internal/providers/database/queries/dynamic_registration_domain_codes.sql @@ -0,0 +1,35 @@ +-- Copyright (c) 2025 Afonso Barracha +-- +-- This Source Code Form is subject to the terms of the Mozilla Public +-- License, v. 2.0. If a copy of the MPL was not distributed with this +-- file, You can obtain one at https://mozilla.org/MPL/2.0/. + +-- name: CreateDynamicRegistrationDomainCode :one +INSERT INTO "dynamic_registration_domain_codes" ( + "account_id", + "verification_host", + "verification_code", + "verification_prefix", + "hmac_secret_id", + "expires_at" +) VALUES ( + $1, + $2, + $3, + $4, + $5, + $6 +) RETURNING "id"; + +-- name: UpdateDynamicRegistrationDomainCode :exec +UPDATE "dynamic_registration_domain_codes" SET + "verification_host" = $2, + "verification_code" = $3, + "verification_prefix" = $4, + "hmac_secret_id" = $5, + "expires_at" = $6 +WHERE "id" = $1; + +-- name: DeleteDynamicRegistrationDomainCode :exec +DELETE FROM "dynamic_registration_domain_codes" +WHERE "id" = $1; diff --git a/idp/internal/providers/database/queries/users.sql b/idp/internal/providers/database/queries/users.sql index 57efc46..6aebab7 100644 --- a/idp/internal/providers/database/queries/users.sql +++ b/idp/internal/providers/database/queries/users.sql @@ -69,14 +69,14 @@ LIMIT 1; -- name: UpdateUser :one UPDATE "users" SET - "email" = $1, - "username" = $2, - "user_data" = $3, - "is_active" = $4, + "email" = $2, + "username" = $3, + "user_data" = $4, "email_verified" = $5, + "activity_status" = $6, "version" = "version" + 1, "updated_at" = now() -WHERE "id" = $6 +WHERE "id" = $1 RETURNING *; -- name: UpdateUserPassword :one diff --git a/idp/internal/providers/database/users.sql.go b/idp/internal/providers/database/users.sql.go index 2e2029c..6ecd08f 100644 --- a/idp/internal/providers/database/users.sql.go +++ b/idp/internal/providers/database/users.sql.go @@ -18,7 +18,7 @@ UPDATE "users" SET "version" = "version" + 1, "updated_at" = now() WHERE "id" = $1 -RETURNING id, public_id, account_id, email, username, password, version, email_verified, is_active, two_factor_type, user_data, created_at, updated_at +RETURNING id, public_id, account_id, email, username, password, version, email_verified, activity_status, user_data, created_at, updated_at ` func (q *Queries) ConfirmUser(ctx context.Context, id int32) (User, error) { @@ -33,8 +33,7 @@ func (q *Queries) ConfirmUser(ctx context.Context, id int32) (User, error) { &i.Password, &i.Version, &i.EmailVerified, - &i.IsActive, - &i.TwoFactorType, + &i.ActivityStatus, &i.UserData, &i.CreatedAt, &i.UpdatedAt, @@ -126,7 +125,7 @@ INSERT INTO "users" ( $4, $5, $6 -) RETURNING id, public_id, account_id, email, username, password, version, email_verified, is_active, two_factor_type, user_data, created_at, updated_at +) RETURNING id, public_id, account_id, email, username, password, version, email_verified, activity_status, user_data, created_at, updated_at ` type CreateUserWithPasswordParams struct { @@ -162,8 +161,7 @@ func (q *Queries) CreateUserWithPassword(ctx context.Context, arg CreateUserWith &i.Password, &i.Version, &i.EmailVerified, - &i.IsActive, - &i.TwoFactorType, + &i.ActivityStatus, &i.UserData, &i.CreatedAt, &i.UpdatedAt, @@ -184,7 +182,7 @@ INSERT INTO "users" ( $3, $4, $5 -) RETURNING id, public_id, account_id, email, username, password, version, email_verified, is_active, two_factor_type, user_data, created_at, updated_at +) RETURNING id, public_id, account_id, email, username, password, version, email_verified, activity_status, user_data, created_at, updated_at ` type CreateUserWithoutPasswordParams struct { @@ -213,8 +211,7 @@ func (q *Queries) CreateUserWithoutPassword(ctx context.Context, arg CreateUserW &i.Password, &i.Version, &i.EmailVerified, - &i.IsActive, - &i.TwoFactorType, + &i.ActivityStatus, &i.UserData, &i.CreatedAt, &i.UpdatedAt, @@ -233,7 +230,7 @@ func (q *Queries) DeleteUser(ctx context.Context, id int32) error { } const filterUsersByEmailOrUsernameAndByAccountIDOrderedByEmail = `-- name: FilterUsersByEmailOrUsernameAndByAccountIDOrderedByEmail :many -SELECT id, public_id, account_id, email, username, password, version, email_verified, is_active, two_factor_type, user_data, created_at, updated_at FROM "users" +SELECT id, public_id, account_id, email, username, password, version, email_verified, activity_status, user_data, created_at, updated_at FROM "users" WHERE "account_id" = $1 AND ("email" ILIKE $2 OR "username" ILIKE $3) ORDER BY "email" ASC OFFSET $4 LIMIT $5 @@ -271,8 +268,7 @@ func (q *Queries) FilterUsersByEmailOrUsernameAndByAccountIDOrderedByEmail(ctx c &i.Password, &i.Version, &i.EmailVerified, - &i.IsActive, - &i.TwoFactorType, + &i.ActivityStatus, &i.UserData, &i.CreatedAt, &i.UpdatedAt, @@ -288,7 +284,7 @@ func (q *Queries) FilterUsersByEmailOrUsernameAndByAccountIDOrderedByEmail(ctx c } const filterUsersByEmailOrUsernameAndByAccountIDOrderedByID = `-- name: FilterUsersByEmailOrUsernameAndByAccountIDOrderedByID :many -SELECT id, public_id, account_id, email, username, password, version, email_verified, is_active, two_factor_type, user_data, created_at, updated_at FROM "users" +SELECT id, public_id, account_id, email, username, password, version, email_verified, activity_status, user_data, created_at, updated_at FROM "users" WHERE "account_id" = $1 AND ("email" ILIKE $2 OR "username" ILIKE $3) ORDER BY "id" DESC OFFSET $4 LIMIT $5 @@ -326,8 +322,7 @@ func (q *Queries) FilterUsersByEmailOrUsernameAndByAccountIDOrderedByID(ctx cont &i.Password, &i.Version, &i.EmailVerified, - &i.IsActive, - &i.TwoFactorType, + &i.ActivityStatus, &i.UserData, &i.CreatedAt, &i.UpdatedAt, @@ -343,7 +338,7 @@ func (q *Queries) FilterUsersByEmailOrUsernameAndByAccountIDOrderedByID(ctx cont } const filterUsersByEmailOrUsernameAndByAccountIDOrderedByUsername = `-- name: FilterUsersByEmailOrUsernameAndByAccountIDOrderedByUsername :many -SELECT id, public_id, account_id, email, username, password, version, email_verified, is_active, two_factor_type, user_data, created_at, updated_at FROM "users" +SELECT id, public_id, account_id, email, username, password, version, email_verified, activity_status, user_data, created_at, updated_at FROM "users" WHERE "account_id" = $1 AND ("email" ILIKE $2 OR "username" ILIKE $3) ORDER BY "username" ASC OFFSET $4 LIMIT $5 @@ -381,8 +376,7 @@ func (q *Queries) FilterUsersByEmailOrUsernameAndByAccountIDOrderedByUsername(ct &i.Password, &i.Version, &i.EmailVerified, - &i.IsActive, - &i.TwoFactorType, + &i.ActivityStatus, &i.UserData, &i.CreatedAt, &i.UpdatedAt, @@ -398,7 +392,7 @@ func (q *Queries) FilterUsersByEmailOrUsernameAndByAccountIDOrderedByUsername(ct } const findPaginatedUsersByAccountIDOrderedByEmail = `-- name: FindPaginatedUsersByAccountIDOrderedByEmail :many -SELECT id, public_id, account_id, email, username, password, version, email_verified, is_active, two_factor_type, user_data, created_at, updated_at FROM "users" +SELECT id, public_id, account_id, email, username, password, version, email_verified, activity_status, user_data, created_at, updated_at FROM "users" WHERE "account_id" = $1 ORDER BY "email" ASC OFFSET $2 LIMIT $3 @@ -428,8 +422,7 @@ func (q *Queries) FindPaginatedUsersByAccountIDOrderedByEmail(ctx context.Contex &i.Password, &i.Version, &i.EmailVerified, - &i.IsActive, - &i.TwoFactorType, + &i.ActivityStatus, &i.UserData, &i.CreatedAt, &i.UpdatedAt, @@ -445,7 +438,7 @@ func (q *Queries) FindPaginatedUsersByAccountIDOrderedByEmail(ctx context.Contex } const findPaginatedUsersByAccountIDOrderedByID = `-- name: FindPaginatedUsersByAccountIDOrderedByID :many -SELECT id, public_id, account_id, email, username, password, version, email_verified, is_active, two_factor_type, user_data, created_at, updated_at FROM "users" +SELECT id, public_id, account_id, email, username, password, version, email_verified, activity_status, user_data, created_at, updated_at FROM "users" WHERE "account_id" = $1 ORDER BY "id" DESC OFFSET $2 LIMIT $3 @@ -475,8 +468,7 @@ func (q *Queries) FindPaginatedUsersByAccountIDOrderedByID(ctx context.Context, &i.Password, &i.Version, &i.EmailVerified, - &i.IsActive, - &i.TwoFactorType, + &i.ActivityStatus, &i.UserData, &i.CreatedAt, &i.UpdatedAt, @@ -492,7 +484,7 @@ func (q *Queries) FindPaginatedUsersByAccountIDOrderedByID(ctx context.Context, } const findPaginatedUsersByAccountIDOrderedByUsername = `-- name: FindPaginatedUsersByAccountIDOrderedByUsername :many -SELECT id, public_id, account_id, email, username, password, version, email_verified, is_active, two_factor_type, user_data, created_at, updated_at FROM "users" +SELECT id, public_id, account_id, email, username, password, version, email_verified, activity_status, user_data, created_at, updated_at FROM "users" WHERE "account_id" = $1 ORDER BY "username" ASC OFFSET $2 LIMIT $3 @@ -522,8 +514,7 @@ func (q *Queries) FindPaginatedUsersByAccountIDOrderedByUsername(ctx context.Con &i.Password, &i.Version, &i.EmailVerified, - &i.IsActive, - &i.TwoFactorType, + &i.ActivityStatus, &i.UserData, &i.CreatedAt, &i.UpdatedAt, @@ -539,7 +530,7 @@ func (q *Queries) FindPaginatedUsersByAccountIDOrderedByUsername(ctx context.Con } const findUserByEmailAndAccountID = `-- name: FindUserByEmailAndAccountID :one -SELECT id, public_id, account_id, email, username, password, version, email_verified, is_active, two_factor_type, user_data, created_at, updated_at FROM "users" +SELECT id, public_id, account_id, email, username, password, version, email_verified, activity_status, user_data, created_at, updated_at FROM "users" WHERE "email" = $1 AND "account_id" = $2 LIMIT 1 ` @@ -561,8 +552,7 @@ func (q *Queries) FindUserByEmailAndAccountID(ctx context.Context, arg FindUserB &i.Password, &i.Version, &i.EmailVerified, - &i.IsActive, - &i.TwoFactorType, + &i.ActivityStatus, &i.UserData, &i.CreatedAt, &i.UpdatedAt, @@ -571,7 +561,7 @@ func (q *Queries) FindUserByEmailAndAccountID(ctx context.Context, arg FindUserB } const findUserByID = `-- name: FindUserByID :one -SELECT id, public_id, account_id, email, username, password, version, email_verified, is_active, two_factor_type, user_data, created_at, updated_at FROM "users" +SELECT id, public_id, account_id, email, username, password, version, email_verified, activity_status, user_data, created_at, updated_at FROM "users" WHERE "id" = $1 LIMIT 1 ` @@ -587,8 +577,7 @@ func (q *Queries) FindUserByID(ctx context.Context, id int32) (User, error) { &i.Password, &i.Version, &i.EmailVerified, - &i.IsActive, - &i.TwoFactorType, + &i.ActivityStatus, &i.UserData, &i.CreatedAt, &i.UpdatedAt, @@ -597,7 +586,7 @@ func (q *Queries) FindUserByID(ctx context.Context, id int32) (User, error) { } const findUserByPublicIDAndVersion = `-- name: FindUserByPublicIDAndVersion :one -SELECT id, public_id, account_id, email, username, password, version, email_verified, is_active, two_factor_type, user_data, created_at, updated_at FROM "users" +SELECT id, public_id, account_id, email, username, password, version, email_verified, activity_status, user_data, created_at, updated_at FROM "users" WHERE "public_id" = $1 AND "version" = $2 LIMIT 1 ` @@ -618,8 +607,7 @@ func (q *Queries) FindUserByPublicIDAndVersion(ctx context.Context, arg FindUser &i.Password, &i.Version, &i.EmailVerified, - &i.IsActive, - &i.TwoFactorType, + &i.ActivityStatus, &i.UserData, &i.CreatedAt, &i.UpdatedAt, @@ -628,7 +616,7 @@ func (q *Queries) FindUserByPublicIDAndVersion(ctx context.Context, arg FindUser } const findUserByUsernameAndAccountID = `-- name: FindUserByUsernameAndAccountID :one -SELECT id, public_id, account_id, email, username, password, version, email_verified, is_active, two_factor_type, user_data, created_at, updated_at FROM "users" +SELECT id, public_id, account_id, email, username, password, version, email_verified, activity_status, user_data, created_at, updated_at FROM "users" WHERE "username" = $1 AND "account_id" = $2 LIMIT 1 ` @@ -650,8 +638,7 @@ func (q *Queries) FindUserByUsernameAndAccountID(ctx context.Context, arg FindUs &i.Password, &i.Version, &i.EmailVerified, - &i.IsActive, - &i.TwoFactorType, + &i.ActivityStatus, &i.UserData, &i.CreatedAt, &i.UpdatedAt, @@ -661,34 +648,34 @@ func (q *Queries) FindUserByUsernameAndAccountID(ctx context.Context, arg FindUs const updateUser = `-- name: UpdateUser :one UPDATE "users" SET - "email" = $1, - "username" = $2, - "user_data" = $3, - "is_active" = $4, + "email" = $2, + "username" = $3, + "user_data" = $4, "email_verified" = $5, + "activity_status" = $6, "version" = "version" + 1, "updated_at" = now() -WHERE "id" = $6 -RETURNING id, public_id, account_id, email, username, password, version, email_verified, is_active, two_factor_type, user_data, created_at, updated_at +WHERE "id" = $1 +RETURNING id, public_id, account_id, email, username, password, version, email_verified, activity_status, user_data, created_at, updated_at ` type UpdateUserParams struct { - Email string - Username string - UserData []byte - IsActive bool - EmailVerified bool - ID int32 + ID int32 + Email string + Username string + UserData []byte + EmailVerified bool + ActivityStatus ActivityStatus } func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) (User, error) { row := q.db.QueryRow(ctx, updateUser, + arg.ID, arg.Email, arg.Username, arg.UserData, - arg.IsActive, arg.EmailVerified, - arg.ID, + arg.ActivityStatus, ) var i User err := row.Scan( @@ -700,8 +687,7 @@ func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) (User, e &i.Password, &i.Version, &i.EmailVerified, - &i.IsActive, - &i.TwoFactorType, + &i.ActivityStatus, &i.UserData, &i.CreatedAt, &i.UpdatedAt, @@ -715,7 +701,7 @@ UPDATE "users" SET "version" = "version" + 1, "updated_at" = now() WHERE "id" = $2 -RETURNING id, public_id, account_id, email, username, password, version, email_verified, is_active, two_factor_type, user_data, created_at, updated_at +RETURNING id, public_id, account_id, email, username, password, version, email_verified, activity_status, user_data, created_at, updated_at ` type UpdateUserPasswordParams struct { @@ -735,8 +721,7 @@ func (q *Queries) UpdateUserPassword(ctx context.Context, arg UpdateUserPassword &i.Password, &i.Version, &i.EmailVerified, - &i.IsActive, - &i.TwoFactorType, + &i.ActivityStatus, &i.UserData, &i.CreatedAt, &i.UpdatedAt, diff --git a/idp/internal/providers/oauth/apple.go b/idp/internal/providers/oauth/apple.go index cb714f1..fb05bba 100644 --- a/idp/internal/providers/oauth/apple.go +++ b/idp/internal/providers/oauth/apple.go @@ -10,7 +10,6 @@ import ( "context" "encoding/json" "errors" - "fmt" "io" "net/http" "slices" @@ -33,16 +32,8 @@ var appleScopes = oauthScopes{ profile: "name", } -func NewAppleUserData(email, firstName, lastName string) UserData { - name := fmt.Sprintf("%s %s", firstName, lastName) - return UserData{ - Name: name, - FirstName: firstName, - LastName: lastName, - Username: utils.Slugify(name), - Email: email, - IsVerified: true, - } +func (p *Providers) IsAppleEnabled() bool { + return p.apple.Enabled } func (p *Providers) GetAppleAuthorizationURL( diff --git a/idp/internal/providers/oauth/facebook.go b/idp/internal/providers/oauth/facebook.go index 46b7244..d613bdd 100644 --- a/idp/internal/providers/oauth/facebook.go +++ b/idp/internal/providers/oauth/facebook.go @@ -119,6 +119,10 @@ var facebookProfileParams = [8]string{ "short_name", } +func (p *Providers) IsFacebookEnabled() bool { + return p.facebook.Enabled +} + func (p *Providers) GetFacebookAuthorizationURL( ctx context.Context, opts AuthorizationURLOptions, diff --git a/idp/internal/providers/oauth/github.go b/idp/internal/providers/oauth/github.go index 5b6d2ae..a881724 100644 --- a/idp/internal/providers/oauth/github.go +++ b/idp/internal/providers/oauth/github.go @@ -140,6 +140,10 @@ func (gu *GitHubUserResponse) ToUserData() UserData { } } +func (p *Providers) IsGitHubEnabled() bool { + return p.gitHub.Enabled +} + func (p *Providers) GetGithubAuthorizationURL( ctx context.Context, opts AuthorizationURLOptions, diff --git a/idp/internal/providers/oauth/google.go b/idp/internal/providers/oauth/google.go index 50c35ee..c60bfda 100644 --- a/idp/internal/providers/oauth/google.go +++ b/idp/internal/providers/oauth/google.go @@ -107,6 +107,10 @@ type GoogleMeResponse struct { Genders []people.Gender `json:"genders,omitempty"` } +func (p *Providers) IsGoogleEnabled() bool { + return p.google.Enabled +} + func (p *Providers) GetGoogleAuthorizationURL( ctx context.Context, opts AuthorizationURLOptions, diff --git a/idp/internal/providers/oauth/microsoft.go b/idp/internal/providers/oauth/microsoft.go index 5305fd4..eb0008a 100644 --- a/idp/internal/providers/oauth/microsoft.go +++ b/idp/internal/providers/oauth/microsoft.go @@ -50,6 +50,10 @@ func (mu *MicrosoftUserResponse) ToUserData() UserData { } } +func (p *Providers) IsMicrosoftEnabled() bool { + return p.microsoft.Enabled +} + func (p *Providers) GetMicrosoftAuthorizationURL( ctx context.Context, opts AuthorizationURLOptions, diff --git a/idp/internal/providers/oauth/oauth.go b/idp/internal/providers/oauth/oauth.go index 1fb6ce5..61742ec 100644 --- a/idp/internal/providers/oauth/oauth.go +++ b/idp/internal/providers/oauth/oauth.go @@ -214,7 +214,7 @@ func NewProviders( }, Enabled: microsoftCfg.Enabled(), }, - logger: log, + logger: log.With(utils.BaseLayer, logLayer), } } diff --git a/idp/internal/providers/tokens/accounts.go b/idp/internal/providers/tokens/accounts.go index 0412b73..050e1e3 100644 --- a/idp/internal/providers/tokens/accounts.go +++ b/idp/internal/providers/tokens/accounts.go @@ -22,16 +22,20 @@ import ( type AccountScope = string const ( - AccountScopeEmail AccountScope = "email" - AccountScopeProfile AccountScope = "profile" - AccountScopeAdmin AccountScope = "account:admin" - AccountScopeUsersRead AccountScope = "account:users:read" - AccountScopeUsersWrite AccountScope = "account:users:write" - AccountScopeAppsRead AccountScope = "account:apps:read" - AccountScopeAppsWrite AccountScope = "account:apps:write" - AccountScopeCredentialsRead AccountScope = "account:credentials:read" - AccountScopeCredentialsWrite AccountScope = "account:credentials:write" - AccountScopeAuthProvidersRead AccountScope = "account:auth_providers:read" + AccountScopeEmail AccountScope = "email" + AccountScopeProfile AccountScope = "profile" + AccountScopeAdmin AccountScope = "account:admin" + AccountScopeUsersRead AccountScope = "account:users:read" + AccountScopeUsersWrite AccountScope = "account:users:write" + AccountScopeAppsRead AccountScope = "account:apps:read" + AccountScopeAppsWrite AccountScope = "account:apps:write" + AccountScopeAppsConfigsRead AccountScope = "account:apps:configs:read" + AccountScopeAppsConfigsWrite AccountScope = "account:apps:configs:write" + AccountScopeCredentialsRead AccountScope = "account:credentials:read" + AccountScopeCredentialsWrite AccountScope = "account:credentials:write" + AccountScopeCredentialsConfigsRead AccountScope = "account:credentials:configs:read" + AccountScopeCredentialsConfigsWrite AccountScope = "account:credentials:configs:write" + AccountScopeAuthProvidersRead AccountScope = "account:auth_providers:read" ) var baseAuthScopes = []AccountScope{AccountScopeEmail, AccountScopeProfile} diff --git a/idp/internal/providers/tokens/dynamic_registration.go b/idp/internal/providers/tokens/dynamic_registration.go new file mode 100644 index 0000000..1a73d72 --- /dev/null +++ b/idp/internal/providers/tokens/dynamic_registration.go @@ -0,0 +1,63 @@ +// Copyright (c) 2025 Afonso Barracha +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package tokens + +import ( + "fmt" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" +) + +type accountCredentialsDynamicRegistrationClaims struct { + AccountClaims + Domain string `json:"domain"` + ClientID string `json:"client_id"` + jwt.RegisteredClaims +} + +type AccountCredentialsDynamicRegistrationTokenOptions struct { + AccountPublicID uuid.UUID + AccountVersion int32 + Domain string + ClientID string +} + +func (t *Tokens) CreateAccountCredentialsDynamicRegistrationToken( + opts AccountCredentialsDynamicRegistrationTokenOptions, +) *jwt.Token { + now := time.Now() + iat := jwt.NewNumericDate(now) + exp := jwt.NewNumericDate(now.Add(time.Second * time.Duration(t.dynamicRegistrationTTL))) + return jwt.NewWithClaims( + jwt.SigningMethodEdDSA, + accountCredentialsDynamicRegistrationClaims{ + AccountClaims: AccountClaims{ + AccountID: opts.AccountPublicID, + AccountVersion: opts.AccountVersion, + }, + Domain: opts.Domain, + ClientID: opts.ClientID, + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: fmt.Sprintf("https://%s", t.backendDomain), + Audience: []string{ + fmt.Sprintf("https://%s", opts.Domain), + }, + Subject: opts.Domain, + IssuedAt: iat, + NotBefore: iat, + ExpiresAt: exp, + ID: uuid.NewString(), + }, + }, + ) +} + +func (t *Tokens) GetDynamicRegistrationTTL() int64 { + return t.dynamicRegistrationTTL +} diff --git a/idp/internal/providers/tokens/tokens.go b/idp/internal/providers/tokens/tokens.go index a12d179..cb24f3f 100644 --- a/idp/internal/providers/tokens/tokens.go +++ b/idp/internal/providers/tokens/tokens.go @@ -44,15 +44,16 @@ const ( ) type Tokens struct { - logger *slog.Logger - backendDomain string - accessTTL int64 - accountCredentialsTTL int64 - appsTTL int64 - refreshTTL int64 - confirmationTTL int64 - resetTTL int64 - twoFATTL int64 + logger *slog.Logger + backendDomain string + accessTTL int64 + accountCredentialsTTL int64 + appsTTL int64 + refreshTTL int64 + confirmationTTL int64 + resetTTL int64 + twoFATTL int64 + dynamicRegistrationTTL int64 } func NewTokens( @@ -65,16 +66,18 @@ func NewTokens( confirmationTTL int64, resetTTL int64, twoFATTL int64, + dynamicRegistrationTTL int64, ) *Tokens { return &Tokens{ - logger: logger.With(utils.BaseLayer, logLayer), - accessTTL: accessTTL, - accountCredentialsTTL: accountCredentialsTTL, - appsTTL: appsTTL, - refreshTTL: refreshTTL, - confirmationTTL: confirmationTTL, - resetTTL: resetTTL, - twoFATTL: twoFATTL, - backendDomain: backendDomain, + logger: logger.With(utils.BaseLayer, logLayer), + accessTTL: accessTTL, + accountCredentialsTTL: accountCredentialsTTL, + appsTTL: appsTTL, + refreshTTL: refreshTTL, + confirmationTTL: confirmationTTL, + resetTTL: resetTTL, + twoFATTL: twoFATTL, + backendDomain: backendDomain, + dynamicRegistrationTTL: dynamicRegistrationTTL, } } diff --git a/idp/internal/providers/tokens/twoFactor.go b/idp/internal/providers/tokens/twoFactor.go index 2d4c7f2..e2a71e7 100644 --- a/idp/internal/providers/tokens/twoFactor.go +++ b/idp/internal/providers/tokens/twoFactor.go @@ -7,27 +7,76 @@ package tokens import ( + "fmt" + "time" + "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" "github.com/tugascript/devlogs/idp/internal/controllers/paths" + "github.com/tugascript/devlogs/idp/internal/utils" +) + +type TwoFAType string + +const ( + TwoFATypeTOTP TwoFAType = "totp" + TwoFATypeEmail TwoFAType = "email" ) +type account2FATokenClaims struct { + AccountClaims + Purpose TokenPurpose `json:"purpose"` + TwoFAType TwoFAType `json:"two_fa_type"` + jwt.RegisteredClaims +} + type Account2FATokenOptions struct { - PublicID uuid.UUID - Version int32 + PublicID uuid.UUID + Version int32 + TwoFAType TwoFAType } func (t *Tokens) Create2FAToken(opts Account2FATokenOptions) *jwt.Token { - return t.createPurposeToken(accountPurposeTokenOptions{ - ttlSec: t.twoFATTL, - accountPublicID: opts.PublicID, - accountVersion: opts.Version, - path: paths.AuthBase + paths.AuthLogin + paths.Auth2FA, - purpose: TokenPurpose2FA, + now := time.Now() + iat := jwt.NewNumericDate(now) + exp := jwt.NewNumericDate(now.Add(time.Second * time.Duration(t.twoFATTL))) + + return jwt.NewWithClaims(jwt.SigningMethodEdDSA, account2FATokenClaims{ + AccountClaims: AccountClaims{ + AccountID: opts.PublicID, + AccountVersion: opts.Version, + }, + RegisteredClaims: jwt.RegisteredClaims{ + Audience: jwt.ClaimStrings{ + buildPathAudience(t.backendDomain, paths.V1+paths.AuthBase+paths.AuthLogin+paths.Auth2FA), + }, + Issuer: fmt.Sprintf("https://%s", t.backendDomain), + Subject: opts.PublicID.String(), + IssuedAt: iat, + NotBefore: iat, + ExpiresAt: exp, + ID: uuid.NewString(), + }, + Purpose: TokenPurpose2FA, + TwoFAType: opts.TwoFAType, }) } +func (t *Tokens) Verify2FAToken(token string, getPublicJWK GetPublicJWK) (AccountClaims, TwoFAType, error) { + claims := new(account2FATokenClaims) + + if _, err := jwt.ParseWithClaims( + token, + claims, + buildVerifyKey(utils.SupportedCryptoSuiteEd25519, getPublicJWK), + ); err != nil { + return AccountClaims{}, "", err + } + + return claims.AccountClaims, claims.TwoFAType, nil +} + func (t *Tokens) Get2FATTL() int64 { return t.twoFATTL } diff --git a/idp/internal/server/routes.go b/idp/internal/server/routes.go index 4cde724..ddc942d 100644 --- a/idp/internal/server/routes.go +++ b/idp/internal/server/routes.go @@ -8,6 +8,7 @@ package server func (s *FiberServer) RegisterFiberRoutes() { s.routes.HealthRoutes(s.App) + s.routes.AccountDynamicRegistrationConfigurationRoutes(s.App) s.routes.OAuthRoutes(s.App) s.routes.AuthRoutes(s.App) s.routes.AccountCredentialsRoutes(s.App) diff --git a/idp/internal/server/routes/account_credentials.go b/idp/internal/server/routes/account_credentials.go index bcc17a8..46bbd23 100644 --- a/idp/internal/server/routes/account_credentials.go +++ b/idp/internal/server/routes/account_credentials.go @@ -14,53 +14,70 @@ import ( ) func (r *Routes) AccountCredentialsRoutes(app *fiber.App) { - router := v1PathRouter(app).Group(paths.AccountsBase+paths.CredentialsBase, r.controllers.AccountAccessClaimsMiddleware) + router := v1PathRouter(app).Group(paths.AccountsBase + paths.CredentialsBase) credentialsWriteScopeMiddleware := r.controllers.ScopeMiddleware(tokens.AccountScopeCredentialsWrite) credentialsReadScopeMiddleware := r.controllers.ScopeMiddleware(tokens.AccountScopeCredentialsRead) - router.Post(paths.Base, credentialsWriteScopeMiddleware, r.controllers.CreateAccountCredentials) - router.Get(paths.Base, credentialsReadScopeMiddleware, r.controllers.ListAccountCredentials) + router.Post( + paths.Base, + r.controllers.AccountAccessClaimsMiddleware, + credentialsWriteScopeMiddleware, + r.controllers.CreateAccountCredentials, + ) + router.Get( + paths.Base, + r.controllers.AccountAccessClaimsMiddleware, + credentialsReadScopeMiddleware, + r.controllers.ListAccountCredentials, + ) router.Get( paths.CredentialsSingle, + r.controllers.AccountAccessClaimsMiddleware, credentialsReadScopeMiddleware, r.controllers.GetSingleAccountCredentials, ) router.Put( paths.CredentialsSingle, + r.controllers.AccountAccessClaimsMiddleware, credentialsWriteScopeMiddleware, r.controllers.UpdateAccountCredentials, ) router.Delete( paths.CredentialsSingle, + r.controllers.AccountAccessClaimsMiddleware, credentialsWriteScopeMiddleware, r.controllers.DeleteAccountCredentials, ) } func (r *Routes) AccountCredentialsSecretsRoutes(app *fiber.App) { - router := v1PathRouter(app).Group(paths.AccountsBase+paths.CredentialsBase, r.controllers.AccountAccessClaimsMiddleware) + router := v1PathRouter(app).Group(paths.AccountsBase + paths.CredentialsBase) credentialsWriteScopeMiddleware := r.controllers.ScopeMiddleware(tokens.AccountScopeCredentialsWrite) credentialsReadScopeMiddleware := r.controllers.ScopeMiddleware(tokens.AccountScopeCredentialsRead) router.Post( paths.CredentialsSecrets, + r.controllers.AccountAccessClaimsMiddleware, credentialsWriteScopeMiddleware, r.controllers.CreateAccountCredentialsSecret, ) router.Get( paths.CredentialsSecrets, + r.controllers.AccountAccessClaimsMiddleware, credentialsReadScopeMiddleware, r.controllers.ListAccountCredentialsSecrets, ) router.Get( paths.CredentialsSecretsSingle, + r.controllers.AccountAccessClaimsMiddleware, credentialsReadScopeMiddleware, r.controllers.GetAccountCredentialsSecret, ) router.Delete( paths.CredentialsSecretsSingle, + r.controllers.AccountAccessClaimsMiddleware, credentialsWriteScopeMiddleware, r.controllers.RevokeAccountCredentialsSecret, ) diff --git a/idp/internal/server/routes/account_dynamic_registration.go b/idp/internal/server/routes/account_dynamic_registration.go new file mode 100644 index 0000000..6ba8980 --- /dev/null +++ b/idp/internal/server/routes/account_dynamic_registration.go @@ -0,0 +1,106 @@ +// Copyright (c) 2025 Afonso Barracha +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package routes + +import ( + "github.com/gofiber/fiber/v2" + + "github.com/tugascript/devlogs/idp/internal/controllers/paths" + "github.com/tugascript/devlogs/idp/internal/providers/tokens" +) + +func (r *Routes) AccountDynamicRegistrationConfigurationRoutes(app *fiber.App) { + router := v1PathRouter(app).Group(paths.AccountsBase + paths.CredentialsBase + paths.DynamicRegistrationBase) + + credentialsConfigsWriteScopeMiddleware := r.controllers.ScopeMiddleware(tokens.AccountScopeCredentialsConfigsWrite) + credentialsConfigsReadScopeMiddleware := r.controllers.ScopeMiddleware(tokens.AccountScopeCredentialsConfigsRead) + + // Dynamic Registration Config + configRouter := router.Group(paths.Config, r.controllers.AccountAccessClaimsMiddleware) + configRouter.Get( + paths.Base, + credentialsConfigsReadScopeMiddleware, + r.controllers.GetAccountDynamicRegistrationConfig, + ) + configRouter.Put( + paths.Base, + credentialsConfigsWriteScopeMiddleware, + r.controllers.UpsertAccountDynamicRegistrationConfig, + ) + configRouter.Delete( + paths.Base, + credentialsConfigsWriteScopeMiddleware, + r.controllers.DeleteAccountDynamicRegistrationConfig, + ) + + // Dynamic Registration Domains + domainsRouter := router.Group(paths.Domains, r.controllers.AccountAccessClaimsMiddleware) + domainsRouter.Post( + paths.Base, + credentialsConfigsWriteScopeMiddleware, + r.controllers.CreateAccountCredentialsRegistrationDomain, + ) + domainsRouter.Get( + paths.Base, + credentialsConfigsReadScopeMiddleware, + r.controllers.ListAccountCredentialsRegistrationDomains, + ) + domainsRouter.Get( + paths.SingleDomain, + credentialsConfigsReadScopeMiddleware, + r.controllers.GetAccountCredentialsRegistrationDomain, + ) + domainsRouter.Delete( + paths.SingleDomain, + credentialsConfigsWriteScopeMiddleware, + r.controllers.DeleteAccountCredentialsRegistrationDomain, + ) + domainsRouter.Post( + paths.VerifyDomain, + credentialsConfigsWriteScopeMiddleware, + r.controllers.VerifyAccountCredentialsRegistrationDomain, + ) + // Dynamic Registration Domains Code + domainsRouter.Get( + paths.DomainCode, + credentialsConfigsReadScopeMiddleware, + r.controllers.GetAccountCredentialsRegistrationDomainCode, + ) + domainsRouter.Put( + paths.DomainCode, + credentialsConfigsWriteScopeMiddleware, + r.controllers.UpsertAccountCredentialsRegistrationDomainCode, + ) + domainsRouter.Delete( + paths.DomainCode, + credentialsConfigsWriteScopeMiddleware, + r.controllers.DeleteAccountCredentialsRegistrationDomainCode, + ) + + // Initial Access Token (IAT) routes + iatRouter := router.Group(paths.InitialAccessToken) + + // Dynamic Registration IAT Code Exchange flow + iatRouter.Get(paths.OAuthAuth, r.controllers.OAuthDynamicRegistrationIATAuth) + iatRouter.Post(paths.OAuthToken, r.controllers.OAuthDynamicRegistrationIATToken) + + // Dynamic Registration IAT Login flow + const loginRoute = paths.InitialAccessTokenSingle + paths.AuthLogin + iatRouter.Get(loginRoute, r.controllers.OAuthDynamicRegistrationIATLoginGet) + iatRouter.Post(loginRoute, r.controllers.OAuthDynamicRegistrationIATLoginPost) + + // Dynamic Registration IAT 2FA flow + const twoFAAuthRoute = loginRoute + paths.Auth2FA + iatRouter.Get(twoFAAuthRoute, r.controllers.OAuthDynamicRegistrationIAT2FAGet) + iatRouter.Post(twoFAAuthRoute, r.controllers.OAuthDynamicRegistrationIAT2FAPost) + + // Dynamic Registration IAT External Auth flow + const extAuthRoute = paths.InitialAccessTokenSingle + paths.OAuthAuth + paths.InitialAccessTokenAuthEXT + iatRouter.Get(extAuthRoute+paths.InitialAccessTokenProvider, r.controllers.OAuthDynamicRegistrationIATExtAuthGet) + iatRouter.Post(extAuthRoute+paths.OAuthAppleCallback, r.controllers.OAuthDynamicRegistrationIATExtAppleCB) + iatRouter.Get(extAuthRoute+paths.OAuthCallback, r.controllers.OAuthDynamicRegistrationIATExtCB) +} diff --git a/idp/internal/server/routes/auth.go b/idp/internal/server/routes/auth.go index 3b78f56..a48d64c 100644 --- a/idp/internal/server/routes/auth.go +++ b/idp/internal/server/routes/auth.go @@ -32,17 +32,6 @@ func (r *Routes) AuthRoutes(app *fiber.App) { r.controllers.TwoFAAccessClaimsMiddleware, r.controllers.RecoverAccount, ) - router.Put( - paths.Auth2FA, - r.controllers.AccountAccessClaimsMiddleware, - r.controllers.AdminScopeMiddleware, - r.controllers.UpdateAccount2FA, - ) - router.Post( - paths.Auth2FA+paths.Confirm, - r.controllers.TwoFAAccessClaimsMiddleware, - r.controllers.ConfirmUpdateAccount2FA, - ) router.Post(paths.AuthRefresh, r.controllers.RefreshAccount) router.Post(paths.AuthLogout, r.controllers.AccountAccessClaimsMiddleware, r.controllers.LogoutAccount) router.Post(paths.AuthForgotPassword, r.controllers.ForgotAccountPassword) @@ -59,4 +48,41 @@ func (r *Routes) AuthRoutes(app *fiber.App) { authProvsReaderMW, r.controllers.GetAccountAuthProvider, ) + + // 2FA routes + router.Post( + paths.Auth2FA, + r.controllers.AccountAccessClaimsMiddleware, + r.controllers.AdminScopeMiddleware, + r.controllers.CreateAccount2FAConfig, + ) + router.Get( + paths.Auth2FA+paths.TwoFADefault, + r.controllers.AccountAccessClaimsMiddleware, + r.controllers.AdminScopeMiddleware, + r.controllers.GetDefaultAccount2FAConfig, + ) + router.Get( + paths.Auth2FA+paths.TwoFASingle, + r.controllers.AccountAccessClaimsMiddleware, + r.controllers.AdminScopeMiddleware, + r.controllers.GetAccount2FAConfig, + ) + router.Patch( + paths.Auth2FA+paths.TwoFASingle, + r.controllers.AccountAccessClaimsMiddleware, + r.controllers.AdminScopeMiddleware, + r.controllers.SetAccount2FAConfigDefault, + ) + router.Delete( + paths.Auth2FA+paths.TwoFASingle, + r.controllers.AccountAccessClaimsMiddleware, + r.controllers.AdminScopeMiddleware, + r.controllers.DeleteAccount2FAConfig, + ) + router.Post( + paths.Auth2FA+paths.TwoFASingle+paths.Confirm, + r.controllers.TwoFAAccessClaimsMiddleware, + r.controllers.ConfirmDeleteAccount2FAConfig, + ) } diff --git a/idp/internal/server/routes/common.go b/idp/internal/server/routes/common.go index e83dacb..1b97e77 100644 --- a/idp/internal/server/routes/common.go +++ b/idp/internal/server/routes/common.go @@ -6,10 +6,12 @@ package routes -import "github.com/gofiber/fiber/v2" +import ( + "github.com/gofiber/fiber/v2" -const V1Path string = "/v1" + "github.com/tugascript/devlogs/idp/internal/controllers/paths" +) func v1PathRouter(app *fiber.App) fiber.Router { - return app.Group(V1Path) + return app.Group(paths.V1) } diff --git a/idp/internal/server/routes/users_auth.go b/idp/internal/server/routes/users_auth.go index 4a9546b..ba425d0 100644 --- a/idp/internal/server/routes/users_auth.go +++ b/idp/internal/server/routes/users_auth.go @@ -18,11 +18,9 @@ func (r *Routes) UsersAuthRoutes(app *fiber.App) { router.Post(paths.AuthRegister, r.controllers.AppAccessClaimsMiddleware, r.controllers.RegisterUser) router.Post(paths.AuthConfirmEmail, r.controllers.AppAccessClaimsMiddleware, r.controllers.ConfirmUser) router.Post(paths.AuthLogin, r.controllers.AppAccessClaimsMiddleware, r.controllers.LoginUser) - router.Post( - paths.AuthLogin+paths.Auth2FA, - r.controllers.User2FAClaimsMiddleware, - r.controllers.TwoFactorLoginUser, - ) + + // TODO: Add 2FA Login + router.Post(paths.AuthRefresh, r.controllers.AppAccessClaimsMiddleware, r.controllers.RefreshUser) router.Post( paths.AuthLogout, diff --git a/idp/internal/server/server.go b/idp/internal/server/server.go index c2f8fd2..b061a7f 100644 --- a/idp/internal/server/server.go +++ b/idp/internal/server/server.go @@ -191,6 +191,7 @@ func New( tokensCfg.ConfirmTTL(), tokensCfg.ResetTTL(), tokensCfg.TwoFATTL(), + tokensCfg.DynamicRegistrationTTL(), ) logger.InfoContext(ctx, "Finished building JWT tokens keys") @@ -283,6 +284,9 @@ func New( cfg.AccountCCExpDays(), cfg.UserCCExpDays(), cfg.AppCCExpDays(), + cfg.HMACSecretExpDays(), + cfg.AccountDomainVerificationHost(), + cfg.AccountDomainVerificationTTL(), ) logger.InfoContext(ctx, "Finished building services") diff --git a/idp/internal/server/validations/scope.go b/idp/internal/server/validations/scope.go index 6627e9d..a67e663 100644 --- a/idp/internal/server/validations/scope.go +++ b/idp/internal/server/validations/scope.go @@ -7,15 +7,16 @@ package validations import ( - "github.com/go-playground/validator/v10" "regexp" + + "github.com/go-playground/validator/v10" ) const singleScopeValidatorTag string = "single_scope" const multipleScopeValidatorTag string = "multiple_scope" -var singleScopeRegex = regexp.MustCompile(`^[a-z\d]+(?:([-_:])[a-z\d]+)*$`) +var singleScopeRegex = regexp.MustCompile(`^[a-z\d]+(?:([-_:\.])[a-z\d]+)*$`) var spacesRegex = regexp.MustCompile(`\s+`) func singleScopeValidator(fl validator.FieldLevel) bool { diff --git a/idp/internal/services/account_2fa_configs.go b/idp/internal/services/account_2fa_configs.go new file mode 100644 index 0000000..ed0f1f0 --- /dev/null +++ b/idp/internal/services/account_2fa_configs.go @@ -0,0 +1,830 @@ +// Copyright (c) 2025 Afonso Barracha +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package services + +import ( + "context" + "fmt" + "slices" + + "github.com/google/uuid" + + "github.com/tugascript/devlogs/idp/internal/exceptions" + "github.com/tugascript/devlogs/idp/internal/providers/cache" + "github.com/tugascript/devlogs/idp/internal/providers/crypto" + "github.com/tugascript/devlogs/idp/internal/providers/database" + "github.com/tugascript/devlogs/idp/internal/providers/mailer" + "github.com/tugascript/devlogs/idp/internal/providers/tokens" + "github.com/tugascript/devlogs/idp/internal/services/dtos" +) + +const ( + account2FAConfigLocation = "account_2fa_configs" + + TwoFactorTypeEmail string = "email" + TwoFactorTypeTotp string = "totp" +) + +type GetDefaultAccount2FAConfigOptions struct { + RequestID string + AccountPublicID uuid.UUID +} + +func (s *Services) GetDefaultAccount2FAConfig( + ctx context.Context, + opts GetDefaultAccount2FAConfigOptions, +) (dtos.Account2FAConfigDTO, *exceptions.ServiceError) { + logger := s.buildLogger(opts.RequestID, account2FAConfigLocation, "GetDefaultAccount2FAConfig").With( + "accountPublicID", opts.AccountPublicID, + ) + logger.InfoContext(ctx, "Getting default account 2FA config...") + + config, err := s.database.FindDefaultAccount2FAConfigByAccountPublicID(ctx, opts.AccountPublicID) + if err != nil { + serviceErr := exceptions.FromDBError(err) + if serviceErr.Code == exceptions.CodeNotFound { + logger.WarnContext(ctx, "Default account 2FA config not found", "error", err) + return dtos.Account2FAConfigDTO{}, serviceErr + } + + logger.ErrorContext(ctx, "Failed to get default account 2FA config", "error", err) + return dtos.Account2FAConfigDTO{}, serviceErr + } + + logger.InfoContext(ctx, "Default account 2FA config found") + return dtos.MapAccount2FAConfigToDTO(&config), nil +} + +type GetAccount2FAConfigOptions struct { + RequestID string + AccountPublicID uuid.UUID + TwoFAType database.TwoFactorType +} + +func (s *Services) GetAccount2FAConfig( + ctx context.Context, + opts GetAccount2FAConfigOptions, +) (dtos.Account2FAConfigDTO, *exceptions.ServiceError) { + logger := s.buildLogger(opts.RequestID, account2FAConfigLocation, "GetAccount2FAConfig").With( + "accountPublicID", opts.AccountPublicID, + "twoFAType", opts.TwoFAType, + ) + logger.InfoContext(ctx, "Getting account 2FA config...") + + config, err := s.database.FindAccount2FAConfigByAccountPublicIDAndType(ctx, database.FindAccount2FAConfigByAccountPublicIDAndTypeParams{ + AccountPublicID: opts.AccountPublicID, + TwoFactorType: opts.TwoFAType, + }) + if err != nil { + serviceErr := exceptions.FromDBError(err) + if serviceErr.Code == exceptions.CodeNotFound { + logger.WarnContext(ctx, "Account 2FA config not found", "error", err) + return dtos.Account2FAConfigDTO{}, serviceErr + } + + logger.ErrorContext(ctx, "Failed to get account 2FA config", "error", err) + return dtos.Account2FAConfigDTO{}, serviceErr + } + + logger.InfoContext(ctx, "Account 2FA config found") + return dtos.MapAccount2FAConfigToDTO(&config), nil +} + +type getDefaultAccount2FAConfigInternalOptions struct { + requestID string + accountPublicID uuid.UUID +} + +func (s *Services) getDefaultAccount2FAConfigInternal( + ctx context.Context, + opts getDefaultAccount2FAConfigInternalOptions, +) (*dtos.Account2FAConfigDTO, *exceptions.ServiceError) { + logger := s.buildLogger(opts.requestID, account2FAConfigLocation, "getDefaultAccount2FAConfigInternal").With( + "accountPublicID", opts.accountPublicID, + ) + logger.InfoContext(ctx, "Getting default account 2FA config...") + + config, err := s.database.FindDefaultAccount2FAConfigByAccountPublicID(ctx, opts.accountPublicID) + if err != nil { + serviceErr := exceptions.FromDBError(err) + if serviceErr.Code == exceptions.CodeNotFound { + logger.InfoContext(ctx, "Default account 2FA config not found", "error", err) + return nil, nil + } + + logger.ErrorContext(ctx, "Failed to get default account 2FA config", "error", err) + return nil, serviceErr + } + + dto := dtos.MapAccount2FAConfigToDTO(&config) + logger.InfoContext(ctx, "Default account 2FA config found") + return &dto, nil +} + +func Map2FAType(twoFAType string) (database.TwoFactorType, *exceptions.ServiceError) { + switch twoFAType { + case TwoFactorTypeEmail: + return database.TwoFactorTypeEmail, nil + case TwoFactorTypeTotp: + return database.TwoFactorTypeTotp, nil + default: + return "", exceptions.NewValidationError("invalid two factor type") + } +} + +type buildStoreAccountTOTPOptions struct { + requestID string + accountID int32 + queries *database.Queries +} + +func (s *Services) buildStoreAccountTOTP( + ctx context.Context, + opts buildStoreAccountTOTPOptions, +) crypto.StoreTOTP { + logger := s.buildLogger(opts.requestID, authLocation, "buildStoreAccountTOTP").With( + "AccountID", opts.accountID, + ) + logger.InfoContext(ctx, "Building store account TOTP function...") + + return func(dekKID, encSecret string, hashedCode []byte, url string) *exceptions.ServiceError { + var serviceErr *exceptions.ServiceError + + qrs := s.mapQueries(opts.queries) + id, err := qrs.CreateTotp(ctx, database.CreateTotpParams{ + DekKid: dekKID, + Url: url, + Secret: encSecret, + RecoveryCodes: hashedCode, + Usage: database.TotpUsageAccount, + AccountID: opts.accountID, + }) + if err != nil { + logger.ErrorContext(ctx, "Failed to create TOTP", "error", err) + serviceErr = exceptions.FromDBError(err) + return serviceErr + } + + if err = qrs.CreateAccountTotp(ctx, database.CreateAccountTotpParams{ + AccountID: opts.accountID, + TotpID: id, + }); err != nil { + logger.ErrorContext(ctx, "Failed to create account recovery keys", "error", err) + serviceErr = exceptions.FromDBError(err) + return serviceErr + } + + return nil + } +} + +type createAccount2FAConfigInternalOptions struct { + requestID string + isDefault bool + accountDTO dtos.AccountDTO + defaultConfig *dtos.Account2FAConfigDTO +} + +func (s *Services) createTOTPAccount2FAConfig( + ctx context.Context, + opts createAccount2FAConfigInternalOptions, +) (dtos.Account2FAConfigDTO, *exceptions.ServiceError) { + logger := s.buildLogger(opts.requestID, account2FAConfigLocation, "createTOTPAccount2FAConfig").With( + "accountID", opts.accountDTO.ID(), + "isDefault", opts.isDefault, + ) + logger.InfoContext(ctx, "Creating account 2FA config...") + + var serviceErr *exceptions.ServiceError + qrs, txn, err := s.database.BeginTx(ctx) + if err != nil { + logger.ErrorContext(ctx, "Failed to start transaction", "error", err) + return dtos.Account2FAConfigDTO{}, exceptions.FromDBError(err) + } + defer func() { + logger.DebugContext(ctx, "Finalizing transaction") + s.database.FinalizeTx(ctx, txn, err, serviceErr) + }() + + twoFAConfig, err := qrs.CreateAccount2FAConfig(ctx, database.CreateAccount2FAConfigParams{ + AccountID: opts.accountDTO.ID(), + AccountPublicID: opts.accountDTO.PublicID, + TwoFactorType: database.TwoFactorTypeTotp, + IsDefault: opts.isDefault || opts.defaultConfig == nil, + }) + if err != nil { + logger.ErrorContext(ctx, "Failed to create account 2FA config", "error", err) + serviceErr = exceptions.FromDBError(err) + return dtos.Account2FAConfigDTO{}, serviceErr + } + + totpKey, err := s.crypto.GenerateTotpKey(ctx, crypto.GenerateTotpKeyOptions{ + RequestID: opts.requestID, + Email: opts.accountDTO.Email, + GetDEKfn: s.BuildGetEncAccountDEKfn(ctx, BuildGetEncAccountDEKOptions{ + RequestID: opts.requestID, + AccountID: opts.accountDTO.ID(), + Queries: qrs, + }), + StoreTOTPfn: s.buildStoreAccountTOTP(ctx, buildStoreAccountTOTPOptions{ + requestID: opts.requestID, + accountID: opts.accountDTO.ID(), + queries: qrs, + }), + }) + if err != nil { + logger.ErrorContext(ctx, "Failed to generate TOTP", "error", err) + serviceErr = exceptions.NewInternalServerError() + return dtos.Account2FAConfigDTO{}, serviceErr + } + + if opts.defaultConfig != nil && opts.isDefault { + if _, err := qrs.UpdateAccount2FAConfig(ctx, database.UpdateAccount2FAConfigParams{ + ID: opts.defaultConfig.ID(), + IsDefault: false, + }); err != nil { + logger.ErrorContext(ctx, "Failed to update default account 2FA config", "error", err) + serviceErr = exceptions.FromDBError(err) + return dtos.Account2FAConfigDTO{}, serviceErr + } + } + + account, err := qrs.UpdateAccountVersion(ctx, opts.accountDTO.ID()) + if err != nil { + logger.ErrorContext(ctx, "Failed to update account version", "error", err) + serviceErr = exceptions.FromDBError(err) + return dtos.Account2FAConfigDTO{}, serviceErr + } + + signedToken, serviceErr := s.crypto.SignToken(ctx, crypto.SignTokenOptions{ + RequestID: opts.requestID, + Token: s.jwt.Create2FAToken(tokens.Account2FATokenOptions{ + PublicID: account.PublicID, + Version: account.Version, + }), + GetJWKfn: s.BuildGetGlobalEncryptedJWKFn(ctx, BuildEncryptedJWKFnOptions{ + RequestID: opts.requestID, + KeyType: database.TokenKeyType2faAuthentication, + TTL: s.jwt.Get2FATTL(), + Queries: qrs, + }), + GetDecryptDEKfn: s.BuildGetGlobalDecDEKFn(ctx, BuildGetGlobalDEKFnOptions{ + RequestID: opts.requestID, + Queries: qrs, + }), + GetEncryptDEKfn: s.BuildGetEncGlobalDEKFn(ctx, BuildGetGlobalDEKFnOptions{ + RequestID: opts.requestID, + Queries: qrs, + }), + StoreFN: s.BuildUpdateJWKDEKFn(ctx, BuildUpdateJWKDEKFnOptions{ + RequestID: opts.requestID, + Queries: qrs, + }), + }) + if serviceErr != nil { + logger.ErrorContext(ctx, "Failed to sign 2FA token", "serviceError", serviceErr) + return dtos.Account2FAConfigDTO{}, serviceErr + } + + logger.InfoContext(ctx, "Account 2FA config created successfully") + return dtos.MapAccount2FAConfigTOTPToDTO( + &twoFAConfig, + signedToken, + totpKey.Img(), + totpKey.Codes(), + s.jwt.Get2FATTL(), + "Please scan QR Code with your authentication app", + ), nil +} + +type createEmailAccount2FAConfigInternalOptions struct { + requestID string + isDefault bool + accountDTO dtos.AccountDTO + defaultConfig *dtos.Account2FAConfigDTO +} + +func (s *Services) createEmailAccount2FAConfig( + ctx context.Context, + opts createEmailAccount2FAConfigInternalOptions, +) (dtos.Account2FAConfigDTO, *exceptions.ServiceError) { + logger := s.buildLogger(opts.requestID, account2FAConfigLocation, "createEmailAccount2FAConfig").With( + "accountID", opts.accountDTO.ID(), + "isDefault", opts.isDefault, + ) + logger.InfoContext(ctx, "Creating account 2FA config...") + + var serviceErr *exceptions.ServiceError + qrs, txn, err := s.database.BeginTx(ctx) + if err != nil { + logger.ErrorContext(ctx, "Failed to start transaction", "error", err) + return dtos.Account2FAConfigDTO{}, exceptions.FromDBError(err) + } + defer func() { + logger.DebugContext(ctx, "Finalizing transaction") + s.database.FinalizeTx(ctx, txn, err, serviceErr) + }() + + twoFAConfig, err := qrs.CreateAccount2FAConfig(ctx, database.CreateAccount2FAConfigParams{ + AccountID: opts.accountDTO.ID(), + AccountPublicID: opts.accountDTO.PublicID, + TwoFactorType: database.TwoFactorTypeEmail, + IsDefault: opts.isDefault || opts.defaultConfig == nil, + }) + if err != nil { + logger.ErrorContext(ctx, "Failed to create account 2FA config", "error", err) + serviceErr = exceptions.FromDBError(err) + return dtos.Account2FAConfigDTO{}, serviceErr + } + + if opts.defaultConfig != nil && opts.isDefault { + if _, err := qrs.UpdateAccount2FAConfig(ctx, database.UpdateAccount2FAConfigParams{ + ID: opts.defaultConfig.ID(), + IsDefault: false, + }); err != nil { + logger.ErrorContext(ctx, "Failed to update default account 2FA config", "error", err) + serviceErr = exceptions.FromDBError(err) + return dtos.Account2FAConfigDTO{}, serviceErr + } + } + + code, err := s.cache.AddTwoFactorCode(ctx, cache.AddTwoFactorCodeOptions{ + RequestID: opts.requestID, + AccountID: opts.accountDTO.ID(), + TTL: s.jwt.Get2FATTL(), + }) + if err != nil { + logger.ErrorContext(ctx, "Failed to generate two factor Code", "error", err) + serviceErr = exceptions.NewInternalServerError() + return dtos.Account2FAConfigDTO{}, serviceErr + } + + if err := s.mail.Publish2FAEmail(ctx, mailer.TwoFactorEmailOptions{ + RequestID: opts.requestID, + Email: opts.accountDTO.Email, + Name: fmt.Sprintf("%s %s", opts.accountDTO.GivenName, opts.accountDTO.FamilyName), + Code: code, + }); err != nil { + logger.ErrorContext(ctx, "Failed to publish two factor email", "error", err) + serviceErr = exceptions.NewInternalServerError() + return dtos.Account2FAConfigDTO{}, serviceErr + } + + account, err := qrs.UpdateAccountVersion(ctx, opts.accountDTO.ID()) + if err != nil { + logger.ErrorContext(ctx, "Failed to update account version", "error", err) + serviceErr = exceptions.FromDBError(err) + return dtos.Account2FAConfigDTO{}, serviceErr + } + + signedToken, serviceErr := s.crypto.SignToken(ctx, crypto.SignTokenOptions{ + RequestID: opts.requestID, + Token: s.jwt.Create2FAToken(tokens.Account2FATokenOptions{ + PublicID: account.PublicID, + Version: account.Version, + }), + GetJWKfn: s.BuildGetGlobalEncryptedJWKFn(ctx, BuildEncryptedJWKFnOptions{ + RequestID: opts.requestID, + KeyType: database.TokenKeyType2faAuthentication, + TTL: s.jwt.Get2FATTL(), + Queries: qrs, + }), + GetDecryptDEKfn: s.BuildGetGlobalDecDEKFn(ctx, BuildGetGlobalDEKFnOptions{ + RequestID: opts.requestID, + Queries: qrs, + }), + GetEncryptDEKfn: s.BuildGetEncGlobalDEKFn(ctx, BuildGetGlobalDEKFnOptions{ + RequestID: opts.requestID, + Queries: qrs, + }), + StoreFN: s.BuildUpdateJWKDEKFn(ctx, BuildUpdateJWKDEKFnOptions{ + RequestID: opts.requestID, + Queries: qrs, + }), + }) + if serviceErr != nil { + logger.ErrorContext(ctx, "Failed to sign 2FA token", "serviceError", serviceErr) + return dtos.Account2FAConfigDTO{}, serviceErr + } + + logger.InfoContext(ctx, "Account 2FA config created successfully") + return dtos.MapAccount2FAConfigCodeToDTO( + &twoFAConfig, + signedToken, + s.jwt.Get2FATTL(), + "Please enter the code sent to your email", + ), nil +} + +type CreateAccount2FAConfigOptions struct { + RequestID string + AccountPublicID uuid.UUID + AccountVersion int32 + TwoFAType string + IsDefault bool +} + +func (s *Services) CreateAccount2FAConfig( + ctx context.Context, + opts CreateAccount2FAConfigOptions, +) (dtos.Account2FAConfigDTO, *exceptions.ServiceError) { + logger := s.buildLogger(opts.RequestID, account2FAConfigLocation, "CreateAccount2FAConfig").With( + "accountPublicID", opts.AccountPublicID, + "accountVersion", opts.AccountVersion, + "twoFAType", opts.TwoFAType, + "isDefault", opts.IsDefault, + ) + logger.InfoContext(ctx, "Creating account 2FA config...") + + twoFAType, serviceErr := Map2FAType(opts.TwoFAType) + if serviceErr != nil { + logger.WarnContext(ctx, "Failed to map two factor type", "serviceError", serviceErr) + return dtos.Account2FAConfigDTO{}, serviceErr + } + + accountDTO, serviceErr := s.GetAccountByPublicIDAndVersion(ctx, GetAccountByPublicIDAndVersionOptions{ + RequestID: opts.RequestID, + PublicID: opts.AccountPublicID, + Version: opts.AccountVersion, + }) + if serviceErr != nil { + logger.WarnContext(ctx, "Failed to get account ID by public ID and version", "serviceError", serviceErr) + return dtos.Account2FAConfigDTO{}, serviceErr + } + + configDTO, serviceErr := s.getDefaultAccount2FAConfigInternal(ctx, getDefaultAccount2FAConfigInternalOptions{ + requestID: opts.RequestID, + accountPublicID: opts.AccountPublicID, + }) + if serviceErr != nil { + logger.ErrorContext(ctx, "Failed to get default account 2FA config", "serviceError", serviceErr) + return dtos.Account2FAConfigDTO{}, serviceErr + } + + switch twoFAType { + case database.TwoFactorTypeTotp: + return s.createTOTPAccount2FAConfig(ctx, createAccount2FAConfigInternalOptions{ + requestID: opts.RequestID, + accountDTO: accountDTO, + isDefault: opts.IsDefault, + defaultConfig: configDTO, + }) + case database.TwoFactorTypeEmail: + return s.createEmailAccount2FAConfig(ctx, createEmailAccount2FAConfigInternalOptions{ + requestID: opts.RequestID, + accountDTO: accountDTO, + isDefault: opts.IsDefault, + defaultConfig: configDTO, + }) + default: + logger.WarnContext(ctx, "Invalid two factor type", "twoFAType", opts.TwoFAType) + return dtos.Account2FAConfigDTO{}, exceptions.NewValidationError("invalid two factor type") + } +} + +type SetAccount2FAConfigDefaultOptions struct { + RequestID string + AccountPublicID uuid.UUID + AccountVersion int32 + TwoFAType string +} + +func (s *Services) SetAccount2FAConfigDefault( + ctx context.Context, + opts SetAccount2FAConfigDefaultOptions, +) (dtos.Account2FAConfigDTO, *exceptions.ServiceError) { + logger := s.buildLogger(opts.RequestID, account2FAConfigLocation, "SetAccount2FAConfigDefault").With( + "accountPublicID", opts.AccountPublicID, + "accountVersion", opts.AccountVersion, + "twoFAType", opts.TwoFAType, + ) + logger.InfoContext(ctx, "Setting account 2FA config default...") + + twoFAType, serviceErr := Map2FAType(opts.TwoFAType) + if serviceErr != nil { + logger.WarnContext(ctx, "Failed to map two factor type", "serviceError", serviceErr) + return dtos.Account2FAConfigDTO{}, serviceErr + } + + if _, serviceErr := s.GetAccountIDByPublicIDAndVersion(ctx, GetAccountIDByPublicIDAndVersionOptions{ + RequestID: opts.RequestID, + PublicID: opts.AccountPublicID, + Version: opts.AccountVersion, + }); serviceErr != nil { + logger.WarnContext(ctx, "Failed to get account ID by public ID and version", "serviceError", serviceErr) + return dtos.Account2FAConfigDTO{}, serviceErr + } + + configDTO, serviceErr := s.GetAccount2FAConfig(ctx, GetAccount2FAConfigOptions{ + RequestID: opts.RequestID, + AccountPublicID: opts.AccountPublicID, + TwoFAType: twoFAType, + }) + if serviceErr != nil { + logger.WarnContext(ctx, "Failed to get account 2FA config", "serviceError", serviceErr) + return dtos.Account2FAConfigDTO{}, serviceErr + } + + if configDTO.IsDefault { + logger.WarnContext(ctx, "Account 2FA config is already default", "twoFAType", opts.TwoFAType) + return dtos.Account2FAConfigDTO{}, exceptions.NewForbiddenError() + } + + defaultConfig, serviceErr := s.getDefaultAccount2FAConfigInternal(ctx, getDefaultAccount2FAConfigInternalOptions{ + requestID: opts.RequestID, + accountPublicID: opts.AccountPublicID, + }) + if serviceErr != nil { + logger.ErrorContext(ctx, "Failed to get default account 2FA config", "serviceError", serviceErr) + return dtos.Account2FAConfigDTO{}, serviceErr + } + + qrs, txn, err := s.database.BeginTx(ctx) + if err != nil { + logger.ErrorContext(ctx, "Failed to start transaction", "error", err) + return dtos.Account2FAConfigDTO{}, exceptions.FromDBError(err) + } + defer func() { + logger.DebugContext(ctx, "Finalizing transaction") + s.database.FinalizeTx(ctx, txn, err, serviceErr) + }() + + config, err := qrs.UpdateAccount2FAConfig(ctx, database.UpdateAccount2FAConfigParams{ + ID: configDTO.ID(), + IsDefault: true, + }) + if err != nil { + logger.ErrorContext(ctx, "Failed to update account 2FA config", "error", err) + return dtos.Account2FAConfigDTO{}, exceptions.FromDBError(err) + } + + if defaultConfig != nil { + if _, err := qrs.UpdateAccount2FAConfig(ctx, database.UpdateAccount2FAConfigParams{ + ID: defaultConfig.ID(), + IsDefault: false, + }); err != nil { + logger.ErrorContext(ctx, "Failed to update default account 2FA config", "error", err) + return dtos.Account2FAConfigDTO{}, exceptions.FromDBError(err) + } + } + + logger.InfoContext(ctx, "Account 2FA config set as default successfully") + return dtos.MapAccount2FAConfigToDTO(&config), nil +} + +type DeleteAccount2FAConfigOptions struct { + RequestID string + AccountPublicID uuid.UUID + AccountVersion int32 + TwoFAType string +} + +func (s *Services) DeleteAccount2FAConfig( + ctx context.Context, + opts DeleteAccount2FAConfigOptions, +) (dtos.Account2FAConfigDTO, *exceptions.ServiceError) { + logger := s.buildLogger(opts.RequestID, account2FAConfigLocation, "DeleteAccount2FAConfig").With( + "accountPublicID", opts.AccountPublicID, + "accountVersion", opts.AccountVersion, + "twoFAType", opts.TwoFAType, + ) + logger.InfoContext(ctx, "Deleting account 2FA config...") + + twoFAType, serviceErr := Map2FAType(opts.TwoFAType) + if serviceErr != nil { + logger.WarnContext(ctx, "Failed to map two factor type", "serviceError", serviceErr) + return dtos.Account2FAConfigDTO{}, serviceErr + } + + accountDTO, serviceErr := s.GetAccountByPublicIDAndVersion(ctx, GetAccountByPublicIDAndVersionOptions{ + RequestID: opts.RequestID, + PublicID: opts.AccountPublicID, + Version: opts.AccountVersion, + }) + if serviceErr != nil { + logger.WarnContext(ctx, "Failed to get account by public ID and version", "serviceError", serviceErr) + return dtos.Account2FAConfigDTO{}, serviceErr + } + + config, err := s.database.FindAccount2FAConfigByAccountPublicIDAndType(ctx, database.FindAccount2FAConfigByAccountPublicIDAndTypeParams{ + AccountPublicID: opts.AccountPublicID, + TwoFactorType: twoFAType, + }) + if err != nil { + serviceErr := exceptions.FromDBError(err) + if serviceErr.Code == exceptions.CodeNotFound { + logger.WarnContext(ctx, "Account 2FA config not found", "error", err) + return dtos.Account2FAConfigDTO{}, serviceErr + } + + logger.ErrorContext(ctx, "Failed to get account 2FA config", "error", err) + return dtos.Account2FAConfigDTO{}, serviceErr + } + + code, err := s.cache.SaveDelete2FAConfigRequest(ctx, cache.SaveDelete2FAConfigRequestOptions{ + RequestID: opts.RequestID, + PrefixType: cache.SensitiveRequestAccountPrefix, + PublicID: opts.AccountPublicID, + TwoFAType: opts.TwoFAType, + TTL: s.jwt.Get2FATTL(), + }) + if err != nil { + logger.ErrorContext(ctx, "Failed to save delete 2FA config request", "error", err) + return dtos.Account2FAConfigDTO{}, exceptions.NewInternalServerError() + } + + if opts.TwoFAType == "email" { + if err := s.mail.Publish2FAEmail(ctx, mailer.TwoFactorEmailOptions{ + RequestID: opts.RequestID, + Email: accountDTO.Email, + Name: fmt.Sprintf("%s %s", accountDTO.GivenName, accountDTO.FamilyName), + Code: code, + }); err != nil { + logger.ErrorContext(ctx, "Failed to publish two factor email", "error", err) + return dtos.Account2FAConfigDTO{}, exceptions.NewInternalServerError() + } + } + + signedToken, serviceErr := s.crypto.SignToken(ctx, crypto.SignTokenOptions{ + RequestID: opts.RequestID, + Token: s.jwt.Create2FAToken(tokens.Account2FATokenOptions{ + PublicID: accountDTO.PublicID, + Version: accountDTO.Version(), + }), + GetJWKfn: s.BuildGetGlobalEncryptedJWKFn(ctx, BuildEncryptedJWKFnOptions{ + RequestID: opts.RequestID, + KeyType: database.TokenKeyType2faAuthentication, + TTL: s.jwt.Get2FATTL(), + }), + GetDecryptDEKfn: s.BuildGetGlobalDecDEKFn(ctx, BuildGetGlobalDEKFnOptions{ + RequestID: opts.RequestID, + }), + GetEncryptDEKfn: s.BuildGetEncGlobalDEKFn(ctx, BuildGetGlobalDEKFnOptions{ + RequestID: opts.RequestID, + }), + StoreFN: s.BuildUpdateJWKDEKFn(ctx, BuildUpdateJWKDEKFnOptions{ + RequestID: opts.RequestID, + }), + }) + if serviceErr != nil { + logger.ErrorContext(ctx, "Failed to sign 2FA token", "serviceError", serviceErr) + return dtos.Account2FAConfigDTO{}, serviceErr + } + + msg := "Please enter the code sent to your email" + if opts.TwoFAType == "totp" { + msg = "Please enter the code from your authentication app" + } + + return dtos.MapAccount2FAConfigCodeToDTO( + &config, + signedToken, + s.jwt.Get2FATTL(), + msg, + ), nil +} + +type ConfirmDeleteAccount2FAConfigOptions struct { + RequestID string + PublicID uuid.UUID + Version int32 + TwoFAType string + Code string +} + +func (s *Services) ConfirmDeleteAccount2FAConfig( + ctx context.Context, + opts ConfirmDeleteAccount2FAConfigOptions, +) (dtos.AuthDTO, *exceptions.ServiceError) { + logger := s.buildLogger(opts.RequestID, account2FAConfigLocation, "ConfirmDeleteAccount2FAConfig").With( + "publicID", opts.PublicID, + "version", opts.Version, + "twoFAType", opts.TwoFAType, + "code", opts.Code, + ) + logger.InfoContext(ctx, "Confirming delete account 2FA config...") + + twoFAType, serviceErr := Map2FAType(opts.TwoFAType) + if serviceErr != nil { + logger.WarnContext(ctx, "Failed to map two factor type", "serviceError", serviceErr) + return dtos.AuthDTO{}, serviceErr + } + + accountDTO, serviceErr := s.GetAccountByPublicIDAndVersion(ctx, GetAccountByPublicIDAndVersionOptions{ + RequestID: opts.RequestID, + PublicID: opts.PublicID, + Version: opts.Version, + }) + if serviceErr != nil { + logger.WarnContext(ctx, "Failed to get account by public ID and version", "serviceError", serviceErr) + return dtos.AuthDTO{}, serviceErr + } + + ok, err := s.cache.VerifyDelete2FAConfigRequest(ctx, cache.VerifyDelete2FAConfigRequestOptions{ + RequestID: opts.RequestID, + PrefixType: cache.SensitiveRequestAccountPrefix, + PublicID: opts.PublicID, + TwoFAType: opts.TwoFAType, + Code: opts.Code, + }) + if err != nil { + logger.ErrorContext(ctx, "Failed to verify delete 2FA config request", "error", err) + return dtos.AuthDTO{}, exceptions.NewInternalServerError() + } + if !ok { + logger.WarnContext(ctx, "Delete 2FA config request does not match") + return dtos.AuthDTO{}, exceptions.NewUnauthorizedError() + } + + configs, err := s.database.FindAccount2FAConfigsByAccountPublicID(ctx, accountDTO.PublicID) + if err != nil { + logger.ErrorContext(ctx, "Failed to get account 2FA configs", "error", err) + return dtos.AuthDTO{}, exceptions.NewInternalServerError() + } + + length := len(configs) + if length == 0 { + logger.WarnContext(ctx, "Account 2FA configs not found") + return dtos.AuthDTO{}, exceptions.NewUnauthorizedError() + } + + qrs, txn, err := s.database.BeginTx(ctx) + if err != nil { + logger.ErrorContext(ctx, "Failed to start transaction", "error", err) + return dtos.AuthDTO{}, exceptions.FromDBError(err) + } + defer func() { + logger.DebugContext(ctx, "Finalizing transaction") + s.database.FinalizeTx(ctx, txn, err, serviceErr) + }() + + if length == 1 { + if err := qrs.DeleteAccount2FAConfig(ctx, configs[0].ID); err != nil { + logger.ErrorContext(ctx, "Failed to delete account 2FA config", "error", err) + serviceErr = exceptions.FromDBError(err) + return dtos.AuthDTO{}, serviceErr + } + + account, err := qrs.UpdateAccountVersion(ctx, accountDTO.ID()) + if err != nil { + logger.ErrorContext(ctx, "Failed to update account version", "error", err) + serviceErr = exceptions.FromDBError(err) + return dtos.AuthDTO{}, serviceErr + } + + accountDTO = dtos.MapAccountToDTO(&account) + return s.GenerateFullAuthDTO( + ctx, + logger, + qrs, + opts.RequestID, + &accountDTO, + []tokens.AccountScope{tokens.AccountScopeAdmin}, + "Account 2FA config deleted successfully", + ) + } + + idx := slices.IndexFunc(configs, func(config database.Account2faConfig) bool { + return config.TwoFactorType == twoFAType + }) + if idx == -1 { + logger.WarnContext(ctx, "Account 2FA config not found") + return dtos.AuthDTO{}, exceptions.NewUnauthorizedError() + } + + config := configs[idx] + if err := qrs.DeleteAccount2FAConfig(ctx, config.ID); err != nil { + logger.ErrorContext(ctx, "Failed to delete account 2FA config", "error", err) + serviceErr = exceptions.FromDBError(err) + return dtos.AuthDTO{}, serviceErr + } + if config.IsDefault { + uIdx := 0 + if idx == 0 { + uIdx = 1 + } + + if _, err := qrs.UpdateAccount2FAConfig(ctx, database.UpdateAccount2FAConfigParams{ + ID: configs[uIdx].ID, + IsDefault: true, + }); err != nil { + logger.ErrorContext(ctx, "Failed to update account 2FA config", "error", err) + serviceErr = exceptions.FromDBError(err) + return dtos.AuthDTO{}, serviceErr + } + } + + return s.GenerateFullAuthDTO( + ctx, + logger, + qrs, + opts.RequestID, + &accountDTO, + []tokens.AccountScope{tokens.AccountScopeAdmin}, + "Account 2FA config deleted successfully", + ) +} diff --git a/idp/internal/services/account_credentials.go b/idp/internal/services/account_credentials.go index afcc61c..baa3c2c 100644 --- a/idp/internal/services/account_credentials.go +++ b/idp/internal/services/account_credentials.go @@ -9,6 +9,7 @@ package services import ( "context" "fmt" + "strings" "time" "github.com/google/uuid" @@ -16,7 +17,6 @@ import ( "github.com/tugascript/devlogs/idp/internal/exceptions" "github.com/tugascript/devlogs/idp/internal/providers/cache" "github.com/tugascript/devlogs/idp/internal/providers/database" - "github.com/tugascript/devlogs/idp/internal/providers/tokens" "github.com/tugascript/devlogs/idp/internal/services/dtos" "github.com/tugascript/devlogs/idp/internal/utils" ) @@ -28,10 +28,89 @@ const ( accountCredentialsKeysCacheKeyPrefix string = "account_credentials_keys" ) +func mapAccountCredentialsTransport( + transport string, + credentialType database.AccountCredentialsType, +) (database.Transport, *exceptions.ServiceError) { + if credentialType == database.AccountCredentialsTypeMcp { + switch transport { + case transportSTDIO: + return database.TransportStdio, nil + case transportStreamableHTTP: + return database.TransportStreamableHttp, nil + default: + return "", exceptions.NewValidationError("invalid transport: " + transport) + } + } + if credentialType == database.AccountCredentialsTypeService || credentialType == database.AccountCredentialsTypeNative { + switch transport { + case transportHTTP: + return database.TransportHttp, nil + case transportHTTPS: + return database.TransportHttps, nil + default: + return "", exceptions.NewValidationError("invalid transport: " + transport) + } + } + + return "", exceptions.NewValidationError("invalid credentials type: " + string(credentialType)) +} + +func mapAccountCredentialsType(credentialsType string) (database.AccountCredentialsType, *exceptions.ServiceError) { + acType := database.AccountCredentialsType(credentialsType) + switch acType { + case database.AccountCredentialsTypeService: + return acType, nil + case database.AccountCredentialsTypeMcp: + return acType, nil + case database.AccountCredentialsTypeNative: + return "", exceptions.NewValidationError("Native credentials are not supported") + default: + return "", exceptions.NewValidationError("invalid credentials type: " + credentialsType) + } +} + +func mapAccountCredentialsTokenEndpointAuthMethod( + authMethod string, + credentialType database.AccountCredentialsType, + transport database.Transport, +) (database.AuthMethod, *exceptions.ServiceError) { + switch credentialType { + case database.AccountCredentialsTypeNative: + if authMethod != "" && authMethod != AuthMethodNone { + return "", exceptions.NewValidationError("auth method is not supported for native credentials") + } + + return database.AuthMethodNone, nil + case database.AccountCredentialsTypeService: + if authMethod == "" || authMethod == AuthMethodNone { + return "", exceptions.NewValidationError("auth method is required for service credentials") + } + + return mapAuthMethod(authMethod) + case database.AccountCredentialsTypeMcp: + if transport == database.TransportStdio { + if authMethod != "" && authMethod != AuthMethodNone { + return "", exceptions.NewValidationError("auth method is not supported for stdio mcp credentials") + } + + return database.AuthMethodNone, nil + } + if transport == database.TransportStreamableHttp { + return mapAuthMethod(authMethod) + } + + return "", exceptions.NewValidationError("invalid transport: " + string(transport)) + default: + return "", exceptions.NewValidationError("invalid credentials type: " + string(credentialType)) + } +} + func mapAccountCredentialsScope(scope string) (database.AccountCredentialsScope, *exceptions.ServiceError) { acScope := database.AccountCredentialsScope(scope) switch acScope { - case database.AccountCredentialsScopeAccountAdmin, database.AccountCredentialsScopeAccountAuthProvidersRead, + case database.AccountCredentialsScopeEmail, database.AccountCredentialsScopeProfile, + database.AccountCredentialsScopeAccountAdmin, database.AccountCredentialsScopeAccountAuthProvidersRead, database.AccountCredentialsScopeAccountUsersRead, database.AccountCredentialsScopeAccountUsersWrite, database.AccountCredentialsScopeAccountAppsRead, database.AccountCredentialsScopeAccountAppsWrite, database.AccountCredentialsScopeAccountCredentialsRead, database.AccountCredentialsScopeAccountCredentialsWrite: @@ -41,14 +120,12 @@ func mapAccountCredentialsScope(scope string) (database.AccountCredentialsScope, return "", exceptions.NewValidationError("invalid scope: " + scope) } -// NOTE: using a map will lead to a null pointer dereference even if the slice is not empty func mapAccountCredentialsScopes(scopes []string) ([]database.AccountCredentialsScope, *exceptions.ServiceError) { scopesSet := utils.SliceToHashSet(scopes) if scopesSet.IsEmpty() { return nil, exceptions.NewValidationError("scopes cannot be empty") } - // return utils.MapSliceWithErr(scopesSet.Items(), mapAccountCredentialsScope) mappedScopes := make([]database.AccountCredentialsScope, 0, scopesSet.Size()) for _, scope := range scopesSet.Items() { mappedScope, serviceErr := mapAccountCredentialsScope(scope) @@ -64,10 +141,21 @@ type CreateAccountCredentialsOptions struct { RequestID string AccountPublicID uuid.UUID AccountVersion int32 - Alias string + CredentialsType string + Name string + Domain string + ClientURI string + RedirectURIs []string + LogoURI string + TOSURI string + PolicyURI string + SoftwareID string + SoftwareVersion string + Contacts []string + CreationMethod database.CreationMethod + Transport string Scopes []string AuthMethod string - Issuers []string Algorithm string } @@ -78,12 +166,28 @@ func (s *Services) CreateAccountCredentials( logger := s.buildLogger(opts.RequestID, accountCredentialsLocation, "CreateAccountCredentials").With( "accountPublicID", opts.AccountPublicID, "scopes", opts.Scopes, - "alias", opts.Alias, + "name", opts.Name, "authMethod", opts.AuthMethod, ) logger.InfoContext(ctx, "Creating account keys...") - authMethod, serviceErr := mapAuthMethod(opts.AuthMethod) + credentialsType, serviceErr := mapAccountCredentialsType(opts.CredentialsType) + if serviceErr != nil { + logger.WarnContext(ctx, "Failed to map credentials type", "serviceError", serviceErr) + return dtos.AccountCredentialsDTO{}, serviceErr + } + + transport, serviceErr := mapAccountCredentialsTransport(opts.Transport, credentialsType) + if serviceErr != nil { + logger.WarnContext(ctx, "Failed to map transport", "serviceError", serviceErr) + return dtos.AccountCredentialsDTO{}, serviceErr + } + + authMethod, serviceErr := mapAccountCredentialsTokenEndpointAuthMethod( + opts.AuthMethod, + credentialsType, + transport, + ) if serviceErr != nil { logger.WarnContext(ctx, "Failed to map auth method", "serviceError", serviceErr) return dtos.AccountCredentialsDTO{}, serviceErr @@ -95,6 +199,12 @@ func (s *Services) CreateAccountCredentials( return dtos.AccountCredentialsDTO{}, serviceErr } + domain, serviceErr := mapDomain(opts.ClientURI, opts.Domain) + if serviceErr != nil { + logger.WarnContext(ctx, "Failed to map domain", "serviceError", serviceErr) + return dtos.AccountCredentialsDTO{}, serviceErr + } + accountID, serviceErr := s.GetAccountIDByPublicIDAndVersion(ctx, GetAccountIDByPublicIDAndVersionOptions{ RequestID: opts.RequestID, PublicID: opts.AccountPublicID, @@ -105,21 +215,55 @@ func (s *Services) CreateAccountCredentials( return dtos.AccountCredentialsDTO{}, serviceErr } - alias := utils.Lowered(opts.Alias) - count, err := s.database.CountAccountCredentialsByAliasAndAccountID( + name := strings.TrimSpace(opts.Name) + count, err := s.database.CountAccountCredentialsByNameAndAccountID( ctx, - database.CountAccountCredentialsByAliasAndAccountIDParams{ + database.CountAccountCredentialsByNameAndAccountIDParams{ AccountID: accountID, - Alias: alias, + Name: name, }, ) if err != nil { - logger.ErrorContext(ctx, "Failed to count account credentials by alias", "error", err) + logger.ErrorContext(ctx, "Failed to count account credentials by name", "error", err) return dtos.AccountCredentialsDTO{}, exceptions.NewInternalServerError() } if count > 0 { - logger.WarnContext(ctx, "Account credentials alias already exists", "alias", alias) - return dtos.AccountCredentialsDTO{}, exceptions.NewConflictError("Account credentials alias already exists") + logger.WarnContext(ctx, "Account credentials name already exists", "name", name) + return dtos.AccountCredentialsDTO{}, exceptions.NewConflictError("Account credentials name already exists") + } + + if authMethod == database.AuthMethodNone { + accountCredentials, err := s.database.CreateAccountCredentials( + ctx, + database.CreateAccountCredentialsParams{ + ClientID: utils.Base62UUID(), + AccountID: accountID, + AccountPublicID: opts.AccountPublicID, + CredentialsType: credentialsType, + Name: name, + Scopes: scopes, + TokenEndpointAuthMethod: authMethod, + Domain: domain, + ClientUri: utils.ProcessURL(opts.ClientURI), + RedirectUris: utils.MapSlice(opts.RedirectURIs, func(uri *string) string { + return utils.ProcessURL(*uri) + }), + LogoUri: mapEmptyURL(opts.LogoURI), + PolicyUri: mapEmptyURL(opts.PolicyURI), + TosUri: mapEmptyURL(opts.TOSURI), + SoftwareID: opts.SoftwareID, + SoftwareVersion: mapEmptyString(opts.SoftwareVersion), + Contacts: opts.Contacts, + CreationMethod: opts.CreationMethod, + Transport: transport, + }, + ) + if err != nil { + logger.ErrorContext(ctx, "Failed to create account credentials", "error", err) + return dtos.AccountCredentialsDTO{}, exceptions.FromDBError(err) + } + + return dtos.MapAccountCredentialsToDTO(&accountCredentials), nil } qrs, txn, err := s.database.BeginTx(ctx) @@ -132,18 +276,31 @@ func (s *Services) CreateAccountCredentials( s.database.FinalizeTx(ctx, txn, err, serviceErr) }() - accountCredentials, err := qrs.CreateAccountCredentials(ctx, database.CreateAccountCredentialsParams{ - ClientID: utils.Base62UUID(), - AccountID: accountID, - AccountPublicID: opts.AccountPublicID, - CredentialsType: database.AccountCredentialsTypeClient, - Scopes: scopes, - TokenEndpointAuthMethod: authMethod, - Alias: alias, - Issuers: utils.MapSlice(opts.Issuers, func(url *string) string { - return utils.ProcessURL(*url) - }), - }) + accountCredentials, err := qrs.CreateAccountCredentials( + ctx, + database.CreateAccountCredentialsParams{ + ClientID: utils.Base62UUID(), + AccountID: accountID, + AccountPublicID: opts.AccountPublicID, + CredentialsType: credentialsType, + Name: name, + Scopes: scopes, + TokenEndpointAuthMethod: authMethod, + Domain: domain, + ClientUri: utils.ProcessURL(opts.ClientURI), + RedirectUris: utils.MapSlice(opts.RedirectURIs, func(uri *string) string { + return utils.ProcessURL(*uri) + }), + LogoUri: mapEmptyURL(opts.LogoURI), + PolicyUri: mapEmptyURL(opts.PolicyURI), + TosUri: mapEmptyURL(opts.TOSURI), + SoftwareID: opts.SoftwareID, + SoftwareVersion: mapEmptyString(opts.SoftwareVersion), + Contacts: opts.Contacts, + CreationMethod: opts.CreationMethod, + Transport: transport, + }, + ) if err != nil { logger.ErrorContext(ctx, "Failed to create account credentials", "error", err) serviceErr = exceptions.FromDBError(err) @@ -367,14 +524,38 @@ func (s *Services) ListAccountCredentialsByAccountPublicID( return utils.MapSlice(accountCredentials, dtos.MapAccountCredentialsToDTO), count, nil } +func mapAccountCredentialsUpdateTransport( + transport string, + currentTransport database.Transport, + credentialsType database.AccountCredentialsType, +) (database.Transport, *exceptions.ServiceError) { + if credentialsType == database.AccountCredentialsTypeMcp { + if transport != "" { + return "", exceptions.NewValidationError("Transport update is not allowed for MCP credentials") + } + + return currentTransport, nil + } + + return mapAccountCredentialsTransport(transport, credentialsType) +} + type UpdateAccountCredentialsScopesOptions struct { RequestID string AccountPublicID uuid.UUID AccountVersion int32 ClientID string - Alias string - Scopes []tokens.AccountScope - Issuers []string + Name string + Domain string + Scopes []string + ClientURI string + RedirectURIs []string + LogoURI string + TOSURI string + PolicyURI string + SoftwareVersion string + Contacts []string + Transport string } func (s *Services) UpdateAccountCredentials( @@ -406,13 +587,13 @@ func (s *Services) UpdateAccountCredentials( return dtos.AccountCredentialsDTO{}, serviceErr } - alias := utils.Lowered(opts.Alias) - if alias != accountCredentialsDTO.Alias { - count, err := s.database.CountAccountCredentialsByAliasAndAccountID( + name := strings.TrimSpace(opts.Name) + if name != accountCredentialsDTO.Name { + count, err := s.database.CountAccountCredentialsByNameAndAccountID( ctx, - database.CountAccountCredentialsByAliasAndAccountIDParams{ + database.CountAccountCredentialsByNameAndAccountIDParams{ AccountID: accountCredentialsDTO.AccountID(), - Alias: alias, + Name: name, }, ) if err != nil { @@ -420,18 +601,39 @@ func (s *Services) UpdateAccountCredentials( return dtos.AccountCredentialsDTO{}, exceptions.NewInternalServerError() } if count > 0 { - logger.WarnContext(ctx, "Account credentials alias already exists", "alias", alias) + logger.WarnContext(ctx, "Account credentials alias already exists", "name", name) return dtos.AccountCredentialsDTO{}, exceptions.NewConflictError("Account credentials alias already exists") } } + transport, serviceErr := mapAccountCredentialsUpdateTransport( + opts.Transport, + accountCredentialsDTO.Transport, + accountCredentialsDTO.Type, + ) + if serviceErr != nil { + return dtos.AccountCredentialsDTO{}, serviceErr + } + + domain, serviceErr := mapDomain(opts.ClientURI, opts.Domain) + if serviceErr != nil { + logger.WarnContext(ctx, "Failed to map domain", "serviceError", serviceErr) + return dtos.AccountCredentialsDTO{}, serviceErr + } + accountCredentials, err := s.database.UpdateAccountCredentials(ctx, database.UpdateAccountCredentialsParams{ - ID: accountCredentialsDTO.ID(), - Scopes: scopes, - Alias: alias, - Issuers: utils.MapSlice(opts.Issuers, func(url *string) string { - return utils.ProcessURL(*url) - }), + ID: accountCredentialsDTO.ID(), + Scopes: scopes, + Name: name, + Domain: domain, + ClientUri: opts.ClientURI, + RedirectUris: opts.RedirectURIs, + LogoUri: mapEmptyURL(opts.LogoURI), + TosUri: mapEmptyURL(opts.TOSURI), + PolicyUri: mapEmptyURL(opts.PolicyURI), + SoftwareVersion: mapEmptyString(opts.SoftwareVersion), + Contacts: opts.Contacts, + Transport: transport, }) if err != nil { logger.ErrorContext(ctx, "Failed to update account keys scopes", "error", err) diff --git a/idp/internal/services/account_credentials_registration_domains.go b/idp/internal/services/account_credentials_registration_domains.go new file mode 100644 index 0000000..1ea7811 --- /dev/null +++ b/idp/internal/services/account_credentials_registration_domains.go @@ -0,0 +1,815 @@ +// Copyright (c) 2025 Afonso Barracha +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package services + +import ( + "context" + "fmt" + "slices" + "time" + + "github.com/google/uuid" + + "github.com/tugascript/devlogs/idp/internal/exceptions" + "github.com/tugascript/devlogs/idp/internal/providers/crypto" + "github.com/tugascript/devlogs/idp/internal/providers/database" + "github.com/tugascript/devlogs/idp/internal/services/dtos" + "github.com/tugascript/devlogs/idp/internal/utils" +) + +const ( + accountCredentialsRegistrationDomainsLocation string = "account_credentials_registration_domains" + + domainCodeByteLength int = 32 +) + +type CreateAccountCredentialsRegistrationDomainOptions struct { + RequestID string + AccountPublicID uuid.UUID + AccountVersion int32 + Domain string +} + +func (s *Services) CreateAccountCredentialsRegistrationDomain( + ctx context.Context, + opts CreateAccountCredentialsRegistrationDomainOptions, +) (dtos.DynamicRegistrationDomainDTO, *exceptions.ServiceError) { + logger := s.buildLogger(opts.RequestID, accountCredentialsRegistrationDomainsLocation, "CreateAccountCredentialsRegistrationDomain").With( + "accountPublicID", opts.AccountPublicID, + "domain", opts.Domain, + ) + logger.InfoContext(ctx, "Creating account credentials registration domain...") + + dynamicRegistrationConfig, serviceErr := s.GetAccountDynamicRegistrationConfig(ctx, GetAccountDynamicRegistrationConfigOptions{ + RequestID: opts.RequestID, + AccountPublicID: opts.AccountPublicID, + }) + if serviceErr != nil { + if serviceErr.Code != exceptions.CodeNotFound { + logger.WarnContext(ctx, "Account dynamic registration config not found", "serviceError", serviceErr) + return dtos.DynamicRegistrationDomainDTO{}, exceptions.NewNotFoundValidationError("Dynamic registration config not found") + } + return dtos.DynamicRegistrationDomainDTO{}, serviceErr + } + if len(dynamicRegistrationConfig.WhitelistedDomains) > 0 && !slices.Contains(dynamicRegistrationConfig.WhitelistedDomains, opts.Domain) { + logger.WarnContext(ctx, "Domain is not whitelisted", "domain", opts.Domain) + return dtos.DynamicRegistrationDomainDTO{}, exceptions.NewForbiddenValidationError("Domain is not whitelisted") + } + + if _, err := s.database.FindAccountDynamicRegistrationDomainByAccountPublicIDAndDomain(ctx, database.FindAccountDynamicRegistrationDomainByAccountPublicIDAndDomainParams{ + AccountPublicID: opts.AccountPublicID, + Domain: opts.Domain, + }); err != nil { + serviceErr := exceptions.FromDBError(err) + if serviceErr.Code != exceptions.CodeNotFound { + logger.WarnContext(ctx, "Failed to find account dynamic registration domain", "error", err) + return dtos.DynamicRegistrationDomainDTO{}, serviceErr + } + } else { + logger.InfoContext(ctx, "Account dynamic registration domain already exists", "domain", opts.Domain) + return dtos.DynamicRegistrationDomainDTO{}, exceptions.NewConflictError("Account credentials registration domain already exists") + } + + accountDTO, serviceErr := s.GetAccountByPublicIDAndVersion(ctx, GetAccountByPublicIDAndVersionOptions{ + RequestID: opts.RequestID, + PublicID: opts.AccountPublicID, + Version: opts.AccountVersion, + }) + if serviceErr != nil { + logger.WarnContext(ctx, "Failed to get account ID", "serviceError", serviceErr) + return dtos.DynamicRegistrationDomainDTO{}, serviceErr + } + + qrs, txn, err := s.database.BeginTx(ctx) + if err != nil { + logger.ErrorContext(ctx, "Failed to start transaction", "error", err) + return dtos.DynamicRegistrationDomainDTO{}, exceptions.FromDBError(err) + } + defer func() { + logger.DebugContext(ctx, "Finalizing transaction") + s.database.FinalizeTx(ctx, txn, err, serviceErr) + }() + + domain, err := qrs.CreateAccountDynamicRegistrationDomain(ctx, database.CreateAccountDynamicRegistrationDomainParams{ + AccountID: accountDTO.ID(), + AccountPublicID: opts.AccountPublicID, + Domain: opts.Domain, + VerificationMethod: database.DomainVerificationMethodDnsTxtRecord, + }) + if err != nil { + logger.ErrorContext(ctx, "Failed to create account dynamic registration domain", "error", err) + serviceErr = exceptions.FromDBError(err) + return dtos.DynamicRegistrationDomainDTO{}, serviceErr + } + + code, err := utils.GenerateBase64Secret(domainCodeByteLength) + if err != nil { + logger.ErrorContext(ctx, "Failed to generate domain code", "error", err) + serviceErr = exceptions.NewInternalServerError() + return dtos.DynamicRegistrationDomainDTO{}, serviceErr + } + + verificationPrefix := fmt.Sprintf("%s-verification", accountDTO.Username) + exp := time.Now().Add(s.accountDomainVerificationTTL) + if serviceErr = s.crypto.HMACSha256Hash(ctx, crypto.HMACSha256HashOptions{ + RequestID: opts.RequestID, + PlainText: code, + GetDecryptDEKfn: s.BuildGetDecAccountDEKFn(ctx, BuildGetDecAccountDEKFnOptions{ + RequestID: opts.RequestID, + AccountID: accountDTO.ID(), + Queries: qrs, + }), + GetEncryptDEKfn: s.BuildGetEncAccountDEKfn(ctx, BuildGetEncAccountDEKOptions{ + RequestID: opts.RequestID, + AccountID: accountDTO.ID(), + Queries: qrs, + }), + GetHMACSecretFN: s.BuildGetHMACSecretFN(ctx, BuildGetHMACSecretFNOptions{ + RequestID: opts.RequestID, + AccountID: accountDTO.ID(), + Queries: qrs, + }), + StoreReEncryptedHMACSecretFN: s.BuildUpdateHMACSecretFN(ctx, BuildUpdateHMACSecretFNOptions{ + RequestID: opts.RequestID, + AccountID: accountDTO.ID(), + Queries: qrs, + }), + StoreHashedDataFN: func(secretID string, hashedData string) *exceptions.ServiceError { + codeID, err := qrs.CreateDynamicRegistrationDomainCode( + ctx, + database.CreateDynamicRegistrationDomainCodeParams{ + AccountID: accountDTO.ID(), + VerificationCode: hashedData, + VerificationPrefix: verificationPrefix, + VerificationHost: s.accountDomainVerificationHost, + HmacSecretID: secretID, + ExpiresAt: exp, + }, + ) + if err != nil { + logger.ErrorContext(ctx, "Failed to create account dynamic registration domain code", "error", err) + return exceptions.FromDBError(err) + } + if err := qrs.CreateAccountDynamicRegistrationDomainCode( + ctx, + database.CreateAccountDynamicRegistrationDomainCodeParams{ + AccountDynamicRegistrationDomainID: domain.ID, + DynamicRegistrationDomainCodeID: codeID, + AccountID: accountDTO.ID(), + }, + ); err != nil { + logger.ErrorContext(ctx, "Failed to create account dynamic registration domain code association", "error", err) + return exceptions.FromDBError(err) + } + return nil + }, + }); serviceErr != nil { + logger.ErrorContext(ctx, "Failed to hash code", "serviceError", serviceErr) + return dtos.DynamicRegistrationDomainDTO{}, serviceErr + } + + logger.InfoContext(ctx, "Created account dynamic registration domain successfully") + return dtos.MapAccountCredentialsRegistrationDomainToDTOWithCode(&domain, s.accountDomainVerificationHost, verificationPrefix, code, exp), nil +} + +type GetAccountCredentialsRegistrationDomainOptions struct { + RequestID string + AccountPublicID uuid.UUID + Domain string +} + +func (s *Services) GetAccountCredentialsRegistrationDomain( + ctx context.Context, + opts GetAccountCredentialsRegistrationDomainOptions, +) (dtos.DynamicRegistrationDomainDTO, *exceptions.ServiceError) { + logger := s.buildLogger(opts.RequestID, accountCredentialsRegistrationDomainsLocation, "GetAccountCredentialsRegistrationDomain").With( + "accountPublicID", opts.AccountPublicID, + "domain", opts.Domain, + ) + logger.InfoContext(ctx, "Getting account credentials registration domain...") + + domainDTO, err := s.database.FindAccountDynamicRegistrationDomainByAccountPublicIDAndDomain(ctx, database.FindAccountDynamicRegistrationDomainByAccountPublicIDAndDomainParams{ + AccountPublicID: opts.AccountPublicID, + Domain: opts.Domain, + }) + if err != nil { + serviceErr := exceptions.FromDBError(err) + if serviceErr.Code != exceptions.CodeNotFound { + logger.ErrorContext(ctx, "Failed to find account dynamic registration domain", "error", err) + return dtos.DynamicRegistrationDomainDTO{}, serviceErr + } + + logger.WarnContext(ctx, "Account dynamic registration domain not found", "domain", opts.Domain) + return dtos.DynamicRegistrationDomainDTO{}, serviceErr + } + + logger.InfoContext(ctx, "Found account dynamic registration domain", "domain", opts.Domain) + return dtos.MapAccountCredentialsRegistrationDomainToDTO(&domainDTO), nil +} + +type ListAccountCredentialsRegistrationDomainsOptions struct { + RequestID string + AccountPublicID uuid.UUID + Offset int32 + Limit int32 + Order string +} + +func (s *Services) ListAccountCredentialsRegistrationDomains( + ctx context.Context, + opts ListAccountCredentialsRegistrationDomainsOptions, +) ([]dtos.DynamicRegistrationDomainDTO, int64, *exceptions.ServiceError) { + logger := s.buildLogger(opts.RequestID, accountCredentialsRegistrationDomainsLocation, "ListAccountCredentialsRegistrationDomains").With( + "accountPublicID", opts.AccountPublicID, + "offset", opts.Offset, + "limit", opts.Limit, + "order", opts.Order, + ) + logger.InfoContext(ctx, "Listing account credentials registration domains...") + + order := utils.Lowered(opts.Order) + var domains []database.AccountDynamicRegistrationDomain + var err error + switch order { + case "date": + domains, err = s.database.FindPaginatedAccountDynamicRegistrationDomainsByAccountPublicIDOrderedByID( + ctx, + database.FindPaginatedAccountDynamicRegistrationDomainsByAccountPublicIDOrderedByIDParams{ + AccountPublicID: opts.AccountPublicID, + Limit: opts.Limit, + Offset: opts.Offset, + }, + ) + case "domain": + domains, err = s.database.FindPaginatedAccountDynamicRegistrationDomainsByAccountPublicIDOrderedByDomain( + ctx, + database.FindPaginatedAccountDynamicRegistrationDomainsByAccountPublicIDOrderedByDomainParams{ + AccountPublicID: opts.AccountPublicID, + Limit: opts.Limit, + Offset: opts.Offset, + }, + ) + default: + logger.WarnContext(ctx, "Invalid order parameter", "order", opts.Order) + return nil, 0, exceptions.NewValidationError("Invalid order parameter") + } + if err != nil { + logger.ErrorContext(ctx, "Failed to find account dynamic registration domains", "error", err) + return nil, 0, exceptions.FromDBError(err) + } + + count, err := s.database.CountAccountDynamicRegistrationDomainsByAccountPublicID(ctx, opts.AccountPublicID) + if err != nil { + logger.ErrorContext(ctx, "Failed to count account dynamic registration domains", "error", err) + return nil, 0, exceptions.FromDBError(err) + } + + logger.InfoContext(ctx, "Listed account dynamic registration domains successfully") + return utils.MapSlice(domains, dtos.MapAccountCredentialsRegistrationDomainToDTO), count, nil +} + +type FilterAccountCredentialsRegistrationDomainsOptions struct { + RequestID string + AccountPublicID uuid.UUID + Search string + Offset int32 + Limit int32 + Order string +} + +func (s *Services) FilterAccountCredentialsRegistrationDomains( + ctx context.Context, + opts FilterAccountCredentialsRegistrationDomainsOptions, +) ([]dtos.DynamicRegistrationDomainDTO, int64, *exceptions.ServiceError) { + logger := s.buildLogger(opts.RequestID, accountCredentialsRegistrationDomainsLocation, "FilterAccountCredentialsRegistrationDomains").With( + "accountPublicID", opts.AccountPublicID, + "search", opts.Search, + "offset", opts.Offset, + "limit", opts.Limit, + "order", opts.Order, + ) + logger.InfoContext(ctx, "Filtering account credentials registration domains...") + + domainSearch := utils.DbSearch(opts.Search) + order := utils.Lowered(opts.Order) + var domains []database.AccountDynamicRegistrationDomain + var err error + + switch order { + case "date": + domains, err = s.database.FilterAccountDynamicRegistrationDomainsByAccountPublicIDOrderedByID( + ctx, + database.FilterAccountDynamicRegistrationDomainsByAccountPublicIDOrderedByIDParams{ + AccountPublicID: opts.AccountPublicID, + Domain: domainSearch, + Limit: opts.Limit, + Offset: opts.Offset, + }, + ) + case "domain": + domains, err = s.database.FilterAccountDynamicRegistrationDomainsByAccountPublicIDOrderedByDomain( + ctx, + database.FilterAccountDynamicRegistrationDomainsByAccountPublicIDOrderedByDomainParams{ + AccountPublicID: opts.AccountPublicID, + Domain: domainSearch, + Limit: opts.Limit, + Offset: opts.Offset, + }, + ) + default: + logger.WarnContext(ctx, "Invalid order parameter", "order", opts.Order) + return nil, 0, exceptions.NewValidationError("Invalid order parameter") + } + if err != nil { + logger.ErrorContext(ctx, "Failed to filter account dynamic registration domains", "error", err) + return nil, 0, exceptions.FromDBError(err) + } + + count, err := s.database.CountFilteredAccountDynamicRegistrationDomainsByAccountPublicID( + ctx, + database.CountFilteredAccountDynamicRegistrationDomainsByAccountPublicIDParams{ + AccountPublicID: opts.AccountPublicID, + Domain: domainSearch, + }, + ) + if err != nil { + logger.ErrorContext(ctx, "Failed to count filtered account dynamic registration domains", "error", err) + return nil, 0, exceptions.FromDBError(err) + } + + logger.InfoContext(ctx, "Filtered account dynamic registration domains successfully") + return utils.MapSlice(domains, dtos.MapAccountCredentialsRegistrationDomainToDTO), count, nil +} + +type DeleteAccountCredentialsRegistrationDomainOptions struct { + RequestID string + AccountPublicID uuid.UUID + AccountVersion int32 + Domain string +} + +func (s *Services) DeleteAccountCredentialsRegistrationDomain( + ctx context.Context, + opts DeleteAccountCredentialsRegistrationDomainOptions, +) *exceptions.ServiceError { + logger := s.buildLogger(opts.RequestID, accountCredentialsRegistrationDomainsLocation, "DeleteAccountCredentialsRegistrationDomain").With( + "accountPublicID", opts.AccountPublicID, + "domain", opts.Domain, + ) + logger.InfoContext(ctx, "Deleting account credentials registration domain...") + + if _, serviceErr := s.GetAccountIDByPublicIDAndVersion(ctx, GetAccountIDByPublicIDAndVersionOptions{ + RequestID: "", + PublicID: uuid.UUID{}, + Version: 0, + }); serviceErr != nil { + logger.WarnContext(ctx, "Failed to get account ID", "serviceError", serviceErr) + return serviceErr + } + + domainDTO, serviceErr := s.GetAccountCredentialsRegistrationDomain(ctx, GetAccountCredentialsRegistrationDomainOptions{ + RequestID: opts.RequestID, + AccountPublicID: opts.AccountPublicID, + Domain: opts.Domain, + }) + if serviceErr != nil { + logger.WarnContext(ctx, "Failed to get account credentials registration domain", "error", serviceErr) + return serviceErr + } + if err := s.database.DeleteAccountDynamicRegistrationDomain(ctx, domainDTO.ID()); err != nil { + logger.ErrorContext(ctx, "Failed to delete account dynamic registration domain", "error", err) + return exceptions.FromDBError(err) + } + + logger.InfoContext(ctx, "Deleted account credentials registration domain") + return nil +} + +type VerifyAccountCredentialsRegistrationDomainOptions struct { + RequestID string + AccountPublicID uuid.UUID + AccountVersion int32 + Domain string + VerificationCode string +} + +func (s *Services) VerifyAccountCredentialsRegistrationDomain( + ctx context.Context, + opts VerifyAccountCredentialsRegistrationDomainOptions, +) (dtos.DynamicRegistrationDomainDTO, *exceptions.ServiceError) { + logger := s.buildLogger(opts.RequestID, accountCredentialsRegistrationDomainsLocation, "VerifyAccountCredentialsRegistrationDomain").With( + "accountPublicID", opts.AccountPublicID, + "domain", opts.Domain, + "verificationCode", opts.VerificationCode, + ) + logger.InfoContext(ctx, "Verifying account credentials registration domain...") + + domainDTO, serviceErr := s.GetAccountCredentialsRegistrationDomain(ctx, GetAccountCredentialsRegistrationDomainOptions{ + RequestID: opts.RequestID, + AccountPublicID: opts.AccountPublicID, + Domain: opts.Domain, + }) + if serviceErr != nil { + return dtos.DynamicRegistrationDomainDTO{}, serviceErr + } + + if domainDTO.Verified { + logger.InfoContext(ctx, "Account credentials registration domain already verified", "domain", opts.Domain) + return dtos.DynamicRegistrationDomainDTO{}, exceptions.NewConflictError("Account credentials registration domain already verified") + } + + if domainDTO.VerificationMethod != database.DomainVerificationMethodDnsTxtRecord { + logger.WarnContext(ctx, "Invalid verification method", "verificationMethod", domainDTO.VerificationMethod) + return dtos.DynamicRegistrationDomainDTO{}, exceptions.NewValidationError("Invalid verification method") + } + + accountID, serviceErr := s.GetAccountIDByPublicIDAndVersion(ctx, GetAccountIDByPublicIDAndVersionOptions{ + RequestID: opts.RequestID, + PublicID: opts.AccountPublicID, + Version: opts.AccountVersion, + }) + if serviceErr != nil { + return dtos.DynamicRegistrationDomainDTO{}, serviceErr + } + + code, err := s.database.FindDynamicRegistrationDomainCodeByAccountDynamicRegistrationDomainID(ctx, domainDTO.ID()) + if err != nil { + serviceErr := exceptions.FromDBError(err) + if serviceErr.Code != exceptions.CodeNotFound { + logger.ErrorContext(ctx, "Failed to find account dynamic registration domain code", "error", err) + return dtos.DynamicRegistrationDomainDTO{}, serviceErr + } + + logger.WarnContext(ctx, "Account dynamic registration domain code not found", "error", err) + return dtos.DynamicRegistrationDomainDTO{}, exceptions.NewNotFoundValidationError("Account dynamic registration domain code not found") + } + + if code.ExpiresAt.Before(time.Now()) { + logger.WarnContext(ctx, "Account dynamic registration domain code expired", "expiresAt", code.ExpiresAt.Unix()) + if err := s.database.DeleteDynamicRegistrationDomainCode(ctx, code.ID); err != nil { + logger.ErrorContext(ctx, "Failed to delete account dynamic registration domain code", "error", err) + return dtos.DynamicRegistrationDomainDTO{}, exceptions.FromDBError(err) + } + + return dtos.DynamicRegistrationDomainDTO{}, exceptions.NewValidationError("Registration domain code expired, generate a new one") + } + + if serviceErr := s.crypto.HMACSha256CompareHash(ctx, crypto.HMACSha256CompareHashOptions{ + RequestID: opts.RequestID, + PlainText: code.VerificationCode, + HashedSecretFN: func() (string, string, *exceptions.ServiceError) { + return code.HmacSecretID, code.VerificationCode, nil + }, + GetHMACSecretByIDFN: s.BuildGetHMACSecretByIDFN(ctx, BuildGetHMACSecretByIDFNOptions{ + RequestID: opts.RequestID, + AccountID: accountID, + }), + GetDecryptDEKfn: s.BuildGetDecAccountDEKFn(ctx, BuildGetDecAccountDEKFnOptions{ + RequestID: opts.RequestID, + AccountID: accountID, + }), + GetEncryptDEKfn: s.BuildGetEncAccountDEKfn(ctx, BuildGetEncAccountDEKOptions{ + RequestID: opts.RequestID, + AccountID: accountID, + }), + StoreReEncryptedHMACSecretFN: s.BuildUpdateHMACSecretFN(ctx, BuildUpdateHMACSecretFNOptions{ + RequestID: opts.RequestID, + AccountID: accountID, + }), + }); serviceErr != nil { + logger.WarnContext(ctx, "Failed to verify account credentials registration domain", "serviceError", serviceErr) + return dtos.DynamicRegistrationDomainDTO{}, serviceErr + } + + if serviceErr := s.verifyTXTRecord(ctx, verifyTXTRecordOptions{ + requestID: opts.RequestID, + host: code.VerificationHost, + domain: opts.Domain, + prefix: code.VerificationPrefix, + code: opts.VerificationCode, + }); serviceErr != nil { + logger.WarnContext(ctx, "Failed to verify TXT record", "serviceError", serviceErr) + return dtos.DynamicRegistrationDomainDTO{}, serviceErr + } + + qrs, txn, err := s.database.BeginTx(ctx) + if err != nil { + logger.ErrorContext(ctx, "Failed to start transaction", "error", err) + serviceErr = exceptions.FromDBError(err) + return dtos.DynamicRegistrationDomainDTO{}, serviceErr + } + defer func() { + logger.DebugContext(ctx, "Finalizing transaction") + s.database.FinalizeTx(ctx, txn, err, serviceErr) + }() + + domain, err := qrs.VerifyAccountDynamicRegistrationDomain( + ctx, + database.VerifyAccountDynamicRegistrationDomainParams{ + ID: domainDTO.ID(), + VerificationMethod: database.DomainVerificationMethodDnsTxtRecord, + }, + ) + if err != nil { + logger.ErrorContext(ctx, "Failed to verify account dynamic registration domain", "error", err) + serviceErr = exceptions.FromDBError(err) + return dtos.DynamicRegistrationDomainDTO{}, serviceErr + } + if err = qrs.DeleteDynamicRegistrationDomainCode(ctx, code.ID); err != nil { + logger.ErrorContext(ctx, "Failed to delete account dynamic registration domain code", "error", err) + serviceErr = exceptions.FromDBError(err) + return dtos.DynamicRegistrationDomainDTO{}, serviceErr + } + + logger.InfoContext(ctx, "Verified account credentials registration domain successfully", "domain", opts.Domain) + return dtos.MapAccountCredentialsRegistrationDomainToDTO(&domain), nil +} + +type GetAccountCredentialsRegistrationDomainCodeOptions struct { + RequestID string + AccountPublicID uuid.UUID + Domain string +} + +func (s *Services) GetAccountCredentialsRegistrationDomainCode( + ctx context.Context, + opts GetAccountCredentialsRegistrationDomainCodeOptions, +) (dtos.DynamicRegistrationDomainCodeDTO, *exceptions.ServiceError) { + logger := s.buildLogger(opts.RequestID, accountCredentialsRegistrationDomainsLocation, "GetAccountCredentialsRegistrationDomainCode").With( + "accountPublicID", opts.AccountPublicID, + "domain", opts.Domain, + ) + logger.InfoContext(ctx, "Getting account credentials registration domain code...") + + domainDTO, serviceErr := s.GetAccountCredentialsRegistrationDomain(ctx, GetAccountCredentialsRegistrationDomainOptions(opts)) + if serviceErr != nil { + return dtos.DynamicRegistrationDomainCodeDTO{}, serviceErr + } + + if domainDTO.VerificationMethod != database.DomainVerificationMethodDnsTxtRecord { + logger.WarnContext(ctx, "Invalid verification method", "verificationMethod", domainDTO.VerificationMethod) + return dtos.DynamicRegistrationDomainCodeDTO{}, exceptions.NewValidationError("Invalid verification method") + } + if domainDTO.Verified { + logger.InfoContext(ctx, "Verification code not available for verified domain") + return dtos.DynamicRegistrationDomainCodeDTO{}, exceptions.NewConflictError("Verification code not available for verified domain") + } + + code, err := s.database.FindDynamicRegistrationDomainCodeByAccountDynamicRegistrationDomainID(ctx, domainDTO.ID()) + if err != nil { + serviceErr := exceptions.FromDBError(err) + if serviceErr.Code != exceptions.CodeNotFound { + logger.ErrorContext(ctx, "Failed to find account dynamic registration domain code", "error", err) + return dtos.DynamicRegistrationDomainCodeDTO{}, serviceErr + } + + logger.WarnContext(ctx, "Account dynamic registration domain code not found", "error", err) + return dtos.DynamicRegistrationDomainCodeDTO{}, exceptions.NewNotFoundValidationError("Account dynamic registration domain code not found") + } + + logger.InfoContext(ctx, "Found account dynamic registration domain code") + return dtos.MapDynamicRegistrationDomainCodeToDTO(&code), nil +} + +type SaveAccountCredentialsRegistrationDomainCodeOptions struct { + RequestID string + AccountPublicID uuid.UUID + AccountVersion int32 + Domain string +} + +func (s *Services) SaveAccountCredentialsRegistrationDomainCode( + ctx context.Context, + opts SaveAccountCredentialsRegistrationDomainCodeOptions, +) (dtos.DynamicRegistrationDomainCodeDTO, *exceptions.ServiceError) { + logger := s.buildLogger(opts.RequestID, accountCredentialsRegistrationDomainsLocation, "SaveAccountCredentialsRegistrationDomainCode").With( + "accountPublicID", opts.AccountPublicID, + "domain", opts.Domain, + ) + logger.InfoContext(ctx, "Saving account credentials registration domain code...") + + domainDTO, serviceErr := s.GetAccountCredentialsRegistrationDomain(ctx, GetAccountCredentialsRegistrationDomainOptions{ + RequestID: opts.RequestID, + AccountPublicID: opts.AccountPublicID, + Domain: opts.Domain, + }) + if serviceErr != nil { + if serviceErr.Code != exceptions.CodeNotFound { + logger.ErrorContext(ctx, "Failed to find account dynamic registration domain code", "serviceError", serviceErr) + return dtos.DynamicRegistrationDomainCodeDTO{}, serviceErr + } + + logger.WarnContext(ctx, "Account dynamic registration domain not found") + return dtos.DynamicRegistrationDomainCodeDTO{}, exceptions.NewNotFoundValidationError("Account dynamic registration domain code not found") + } + + if domainDTO.VerificationMethod != database.DomainVerificationMethodDnsTxtRecord { + logger.WarnContext(ctx, "Invalid verification method", "verificationMethod", domainDTO.VerificationMethod) + return dtos.DynamicRegistrationDomainCodeDTO{}, exceptions.NewValidationError("Invalid verification method") + } + if domainDTO.Verified { + logger.InfoContext(ctx, "Verification code not available for verified domain") + return dtos.DynamicRegistrationDomainCodeDTO{}, exceptions.NewConflictError("Verification code not available for verified domain") + } + + accountDTO, serviceErr := s.GetAccountByPublicIDAndVersion(ctx, GetAccountByPublicIDAndVersionOptions{ + RequestID: opts.RequestID, + PublicID: opts.AccountPublicID, + Version: opts.AccountVersion, + }) + if serviceErr != nil { + return dtos.DynamicRegistrationDomainCodeDTO{}, serviceErr + } + + verificationCode, err := utils.GenerateBase64Secret(domainCodeByteLength) + if err != nil { + logger.ErrorContext(ctx, "Failed to generate domain code", "error", err) + return dtos.DynamicRegistrationDomainCodeDTO{}, exceptions.NewInternalServerError() + } + + verificationPrefix := fmt.Sprintf("%s-verification", accountDTO.Username) + exp := time.Now().Add(s.accountDomainVerificationTTL) + code, err := s.database.FindDynamicRegistrationDomainCodeByAccountDynamicRegistrationDomainID(ctx, domainDTO.ID()) + if err != nil { + serviceErr := exceptions.FromDBError(err) + if serviceErr.Code != exceptions.CodeNotFound { + logger.ErrorContext(ctx, "Failed to find account dynamic registration domain code", "error", err) + return dtos.DynamicRegistrationDomainCodeDTO{}, serviceErr + } + + if serviceErr := s.crypto.HMACSha256Hash(ctx, crypto.HMACSha256HashOptions{ + RequestID: opts.RequestID, + PlainText: verificationCode, + GetDecryptDEKfn: s.BuildGetDecAccountDEKFn(ctx, BuildGetDecAccountDEKFnOptions{ + RequestID: opts.RequestID, + AccountID: accountDTO.ID(), + }), + GetEncryptDEKfn: s.BuildGetEncAccountDEKfn(ctx, BuildGetEncAccountDEKOptions{ + RequestID: opts.RequestID, + AccountID: accountDTO.ID(), + }), + GetHMACSecretFN: s.BuildGetHMACSecretFN(ctx, BuildGetHMACSecretFNOptions{ + RequestID: opts.RequestID, + AccountID: accountDTO.ID(), + }), + StoreReEncryptedHMACSecretFN: s.BuildUpdateHMACSecretFN(ctx, BuildUpdateHMACSecretFNOptions{ + RequestID: opts.RequestID, + AccountID: accountDTO.ID(), + }), + StoreHashedDataFN: func(secretID string, hashedData string) *exceptions.ServiceError { + var serviceErr *exceptions.ServiceError + qrs, txn, err := s.database.BeginTx(ctx) + if err != nil { + logger.ErrorContext(ctx, "Failed to start transaction", "error", err) + serviceErr = exceptions.FromDBError(err) + return serviceErr + } + defer func() { + logger.DebugContext(ctx, "Finalizing transaction") + s.database.FinalizeTx(ctx, txn, err, serviceErr) + }() + + codeID, err := qrs.CreateDynamicRegistrationDomainCode( + ctx, + database.CreateDynamicRegistrationDomainCodeParams{ + AccountID: accountDTO.ID(), + VerificationCode: hashedData, + VerificationPrefix: verificationPrefix, + VerificationHost: s.accountDomainVerificationHost, + HmacSecretID: secretID, + ExpiresAt: exp, + }, + ) + if err != nil { + logger.ErrorContext(ctx, "Failed to create account dynamic registration domain code", "error", err) + serviceErr = exceptions.FromDBError(err) + return serviceErr + } + if err := qrs.CreateAccountDynamicRegistrationDomainCode( + ctx, + database.CreateAccountDynamicRegistrationDomainCodeParams{ + AccountDynamicRegistrationDomainID: domainDTO.ID(), + DynamicRegistrationDomainCodeID: codeID, + AccountID: accountDTO.ID(), + }, + ); err != nil { + logger.ErrorContext(ctx, "Failed to create account dynamic registration domain code association", "error", err) + serviceErr = exceptions.FromDBError(err) + return serviceErr + } + return nil + }, + }); serviceErr != nil { + logger.ErrorContext(ctx, "Failed to hash code", "serviceError", serviceErr) + return dtos.DynamicRegistrationDomainCodeDTO{}, serviceErr + } + + return dtos.CreateDynamicRegistrationDomainCodeDTO( + s.accountDomainVerificationHost, + verificationPrefix, + verificationCode, + exp, + ), nil + } + + if serviceErr := s.crypto.HMACSha256Hash(ctx, crypto.HMACSha256HashOptions{ + RequestID: opts.RequestID, + PlainText: verificationCode, + GetDecryptDEKfn: s.BuildGetDecAccountDEKFn(ctx, BuildGetDecAccountDEKFnOptions{ + RequestID: opts.RequestID, + AccountID: accountDTO.ID(), + }), + GetEncryptDEKfn: s.BuildGetEncAccountDEKfn(ctx, BuildGetEncAccountDEKOptions{ + RequestID: opts.RequestID, + AccountID: accountDTO.ID(), + }), + GetHMACSecretFN: s.BuildGetHMACSecretFN(ctx, BuildGetHMACSecretFNOptions{ + RequestID: opts.RequestID, + AccountID: accountDTO.ID(), + }), + StoreReEncryptedHMACSecretFN: s.BuildUpdateHMACSecretFN(ctx, BuildUpdateHMACSecretFNOptions{ + RequestID: opts.RequestID, + AccountID: accountDTO.ID(), + }), + StoreHashedDataFN: func(secretID string, hashedData string) *exceptions.ServiceError { + if err := s.database.UpdateDynamicRegistrationDomainCode( + ctx, + database.UpdateDynamicRegistrationDomainCodeParams{ + ID: code.ID, + VerificationCode: hashedData, + VerificationPrefix: verificationPrefix, + VerificationHost: s.accountDomainVerificationHost, + HmacSecretID: secretID, + ExpiresAt: exp, + }, + ); err != nil { + logger.ErrorContext(ctx, "Failed to create account dynamic registration domain code", "error", err) + serviceErr = exceptions.FromDBError(err) + return serviceErr + } + return nil + }, + }); serviceErr != nil { + logger.ErrorContext(ctx, "Failed to hash code", "serviceError", serviceErr) + return dtos.DynamicRegistrationDomainCodeDTO{}, serviceErr + } + + logger.InfoContext(ctx, "Saved account dynamic registration domain code successfully") + return dtos.CreateDynamicRegistrationDomainCodeDTO( + s.accountDomainVerificationHost, + verificationPrefix, + verificationCode, + exp, + ), nil +} + +type DeleteAccountCredentialsRegistrationDomainCodeOptions struct { + RequestID string + AccountPublicID uuid.UUID + AccountVersion int32 + Domain string +} + +func (s *Services) DeleteAccountCredentialsRegistrationDomainCode( + ctx context.Context, + opts DeleteAccountCredentialsRegistrationDomainCodeOptions, +) *exceptions.ServiceError { + logger := s.buildLogger(opts.RequestID, accountCredentialsRegistrationDomainsLocation, "DeleteAccountCredentialsRegistrationDomainCode").With( + "accountPublicID", opts.AccountPublicID, + "domain", opts.Domain, + ) + logger.InfoContext(ctx, "Deleting account credentials registration domain...") + + domainCodeDTO, serviceErr := s.GetAccountCredentialsRegistrationDomainCode( + ctx, + GetAccountCredentialsRegistrationDomainCodeOptions{ + RequestID: opts.RequestID, + AccountPublicID: opts.AccountPublicID, + Domain: opts.Domain, + }, + ) + if serviceErr != nil { + logger.WarnContext(ctx, "Failed to get account credentials registration domain code", "serviceError", serviceErr) + return serviceErr + } + if _, serviceErr := s.GetAccountIDByPublicIDAndVersion(ctx, GetAccountIDByPublicIDAndVersionOptions{ + RequestID: opts.RequestID, + PublicID: opts.AccountPublicID, + Version: opts.AccountVersion, + }); serviceErr != nil { + logger.WarnContext(ctx, "Failed to get account ID", "serviceError", serviceErr) + return serviceErr + } + + if err := s.database.DeleteDynamicRegistrationDomainCode(ctx, domainCodeDTO.ID()); err != nil { + logger.ErrorContext(ctx, "Failed to delete account dynamic registration domain", "error", err) + return exceptions.FromDBError(err) + } + + logger.InfoContext(ctx, "Deleted account credentials registration domain successfully") + return nil +} diff --git a/idp/internal/services/account_credentials_registration_iat.go b/idp/internal/services/account_credentials_registration_iat.go new file mode 100644 index 0000000..b6d4021 --- /dev/null +++ b/idp/internal/services/account_credentials_registration_iat.go @@ -0,0 +1,93 @@ +// Copyright (c) 2025 Afonso Barracha +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package services + +import ( + "context" + + "github.com/google/uuid" + + "github.com/tugascript/devlogs/idp/internal/exceptions" + "github.com/tugascript/devlogs/idp/internal/providers/crypto" + "github.com/tugascript/devlogs/idp/internal/providers/database" + "github.com/tugascript/devlogs/idp/internal/providers/tokens" + "github.com/tugascript/devlogs/idp/internal/utils" +) + +const accountCredentialsRegistrationIATLocation = "account_credentials_registration_iat" + +type CreateAccountCredentialsRegistrationIATOptions struct { + RequestID string + AccountPublicID uuid.UUID + AccountVersion int32 + Domain string +} + +func (s *Services) CreateAccountCredentialsRegistrationIAT( + ctx context.Context, + opts CreateAccountCredentialsRegistrationIATOptions, +) (string, *exceptions.ServiceError) { + logger := s.buildLogger(opts.RequestID, accountCredentialsRegistrationIATLocation, "CreateAccountCredentialsRegistrationIAT").With( + "accountPublicId", opts.AccountPublicID, + "domain", opts.Domain, + ) + logger.InfoContext(ctx, "Creating account credentials registration IAT...") + + domainDTO, serviceErr := s.GetAccountCredentialsRegistrationDomain(ctx, GetAccountCredentialsRegistrationDomainOptions{ + RequestID: opts.RequestID, + AccountPublicID: opts.AccountPublicID, + Domain: opts.Domain, + }) + if serviceErr != nil { + logger.ErrorContext(ctx, "Failed to get account credentials registration domain", "serviceError", serviceErr) + return "", serviceErr + } + if !domainDTO.Verified { + logger.ErrorContext(ctx, "Account credentials registration domain is not verified") + return "", exceptions.NewValidationError("account credentials registration domain is not verified") + } + + if _, serviceErr := s.GetAccountIDByPublicIDAndVersion(ctx, GetAccountIDByPublicIDAndVersionOptions{ + RequestID: opts.RequestID, + PublicID: opts.AccountPublicID, + Version: opts.AccountVersion, + }); serviceErr != nil { + logger.ErrorContext(ctx, "Failed to get account", "serviceError", serviceErr) + return "", serviceErr + } + + signedToken, serviceErr := s.crypto.SignToken(ctx, crypto.SignTokenOptions{ + RequestID: opts.RequestID, + Token: s.jwt.CreateAccountCredentialsDynamicRegistrationToken(tokens.AccountCredentialsDynamicRegistrationTokenOptions{ + AccountPublicID: opts.AccountPublicID, + AccountVersion: opts.AccountVersion, + Domain: opts.Domain, + ClientID: utils.Base62UUID(), + }), + GetJWKfn: s.BuildGetGlobalEncryptedJWKFn(ctx, BuildEncryptedJWKFnOptions{ + RequestID: opts.RequestID, + KeyType: database.TokenKeyTypeDynamicRegistration, + TTL: s.jwt.GetDynamicRegistrationTTL(), + }), + GetDecryptDEKfn: s.BuildGetGlobalDecDEKFn(ctx, BuildGetGlobalDEKFnOptions{ + RequestID: opts.RequestID, + }), + GetEncryptDEKfn: s.BuildGetEncGlobalDEKFn(ctx, BuildGetGlobalDEKFnOptions{ + RequestID: opts.RequestID, + }), + StoreFN: s.BuildUpdateJWKDEKFn(ctx, BuildUpdateJWKDEKFnOptions{ + RequestID: opts.RequestID, + }), + }) + if serviceErr != nil { + logger.ErrorContext(ctx, "Failed to sign account credentials registration IAT", "serviceError", serviceErr) + return "", serviceErr + } + + logger.InfoContext(ctx, "Created account credentials registration IAT successfully") + return signedToken, nil +} diff --git a/idp/internal/services/account_dynamic_registration_configs.go b/idp/internal/services/account_dynamic_registration_configs.go new file mode 100644 index 0000000..3724bd7 --- /dev/null +++ b/idp/internal/services/account_dynamic_registration_configs.go @@ -0,0 +1,278 @@ +// Copyright (c) 2025 Afonso Barracha +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package services + +import ( + "context" + + "github.com/google/uuid" + + "github.com/tugascript/devlogs/idp/internal/exceptions" + "github.com/tugascript/devlogs/idp/internal/providers/database" + "github.com/tugascript/devlogs/idp/internal/services/dtos" + "github.com/tugascript/devlogs/idp/internal/utils" +) + +const ( + accountDynamicRegistrationConfigsLocation string = "account_dynamic_registration_configs" + + softwareStatementVerificationMethodJwksUri string = "jwks_uri" + softwareStatementVerificationMethodManual string = "manual" + + initialAccessTokenGenerationMethodAuthorizationCode string = "authorization_code" + initialAccessTokenGenerationMethodManual string = "manual" +) + +func mapAccountCredentialsTypes(credentialsTypes []string) ([]database.AccountCredentialsType, *exceptions.ServiceError) { + accountCredentialsTypes := make([]database.AccountCredentialsType, 0, len(credentialsTypes)) + for _, credentialsType := range credentialsTypes { + accountCredentialsType, serviceErr := mapAccountCredentialsType(credentialsType) + if serviceErr != nil { + return nil, serviceErr + } + accountCredentialsTypes = append(accountCredentialsTypes, accountCredentialsType) + } + return accountCredentialsTypes, nil +} + +func mapSoftwareStatementVerificationMethod( + softwareStatementVerificationMethod string, +) (database.SoftwareStatementVerificationMethod, *exceptions.ServiceError) { + switch softwareStatementVerificationMethod { + case softwareStatementVerificationMethodJwksUri: + return database.SoftwareStatementVerificationMethodJwksUri, nil + case softwareStatementVerificationMethodManual: + return database.SoftwareStatementVerificationMethodManual, nil + default: + return "", exceptions.NewValidationError("Invalid software statement verification method: " + softwareStatementVerificationMethod) + } +} + +func mapSoftwareStatementVerificationMethods( + ssvms []string, +) ([]database.SoftwareStatementVerificationMethod, *exceptions.ServiceError) { + softwareStatementVerificationMethods := make([]database.SoftwareStatementVerificationMethod, 0, len(ssvms)) + for _, ssvm := range ssvms { + softwareStatementVerificationMethod, serviceErr := mapSoftwareStatementVerificationMethod(ssvm) + if serviceErr != nil { + return nil, serviceErr + } + softwareStatementVerificationMethods = append(softwareStatementVerificationMethods, softwareStatementVerificationMethod) + } + return softwareStatementVerificationMethods, nil +} + +func mapInitialAccessTokenGenerationMethod( + initialAccessTokenGenerationMethod string, +) (database.InitialAccessTokenGenerationMethod, *exceptions.ServiceError) { + switch initialAccessTokenGenerationMethod { + case initialAccessTokenGenerationMethodAuthorizationCode: + return database.InitialAccessTokenGenerationMethodAuthorizationCode, nil + case initialAccessTokenGenerationMethodManual: + return database.InitialAccessTokenGenerationMethodManual, nil + default: + return "", exceptions.NewValidationError("Invalid initial access token generation method: " + initialAccessTokenGenerationMethod) + } +} + +func mapInitialAccessTokenGenerationMethods( + iatgms []string, +) ([]database.InitialAccessTokenGenerationMethod, *exceptions.ServiceError) { + initialAccessTokenGenerationMethods := make([]database.InitialAccessTokenGenerationMethod, 0, len(iatgms)) + for _, iatgm := range iatgms { + initialAccessTokenGenerationMethod, serviceErr := mapInitialAccessTokenGenerationMethod(iatgm) + if serviceErr != nil { + return nil, serviceErr + } + initialAccessTokenGenerationMethods = append(initialAccessTokenGenerationMethods, initialAccessTokenGenerationMethod) + } + return initialAccessTokenGenerationMethods, nil +} + +type SaveAccountDynamicRegistrationConfigOptions struct { + RequestID string + AccountPublicID uuid.UUID + AccountVersion int32 + AccountCredentialsTypes []string + WhitelistedDomains []string + RequireSoftwareStatementCredentialTypes []string + SoftwareStatementVerificationMethods []string + RequireInitialAccessTokenCredentialTypes []string + InitialAccessTokenGenerationMethods []string +} + +func (s *Services) SaveAccountDynamicRegistrationConfig( + ctx context.Context, + opts SaveAccountDynamicRegistrationConfigOptions, +) (dtos.AccountDynamicRegistrationConfigDTO, bool, *exceptions.ServiceError) { + logger := s.buildLogger(opts.RequestID, accountDynamicRegistrationConfigsLocation, "CreateAccountDynamicRegistrationConfig").With( + "accountPublicID", opts.AccountPublicID, + "accountVersion", opts.AccountVersion, + ) + logger.InfoContext(ctx, "Creating account dynamic registration config...") + + credentialsTypes, serviceErr := mapAccountCredentialsTypes(opts.AccountCredentialsTypes) + if serviceErr != nil { + logger.WarnContext(ctx, "Failed to map credentials types", "serviceError", serviceErr) + return dtos.AccountDynamicRegistrationConfigDTO{}, false, serviceErr + } + + requireSoftwareStatementCredentialTypes, serviceErr := mapAccountCredentialsTypes(opts.RequireSoftwareStatementCredentialTypes) + if serviceErr != nil { + logger.WarnContext(ctx, "Failed to map require software statement credential types", "serviceError", serviceErr) + return dtos.AccountDynamicRegistrationConfigDTO{}, false, serviceErr + } + + requireInitialAccessTokenCredentialTypes, serviceErr := mapAccountCredentialsTypes(opts.RequireInitialAccessTokenCredentialTypes) + if serviceErr != nil { + logger.WarnContext(ctx, "Failed to map require initial access token credential types", "serviceError", serviceErr) + return dtos.AccountDynamicRegistrationConfigDTO{}, false, serviceErr + } + + softwareStatementVerificationMethods, serviceErr := mapSoftwareStatementVerificationMethods(opts.SoftwareStatementVerificationMethods) + if serviceErr != nil { + logger.WarnContext(ctx, "Failed to map software statement verification methods", "serviceError", serviceErr) + return dtos.AccountDynamicRegistrationConfigDTO{}, false, serviceErr + } + + initialAccessTokenGenerationMethods, serviceErr := mapInitialAccessTokenGenerationMethods(opts.InitialAccessTokenGenerationMethods) + if serviceErr != nil { + logger.WarnContext(ctx, "Failed to map initial access token generation methods", "serviceError", serviceErr) + return dtos.AccountDynamicRegistrationConfigDTO{}, false, serviceErr + } + + accountID, serviceErr := s.GetAccountIDByPublicIDAndVersion(ctx, GetAccountIDByPublicIDAndVersionOptions{ + RequestID: opts.RequestID, + PublicID: opts.AccountPublicID, + Version: opts.AccountVersion, + }) + if serviceErr != nil { + logger.WarnContext(ctx, "Failed to get account", "serviceError", serviceErr) + return dtos.AccountDynamicRegistrationConfigDTO{}, false, serviceErr + } + + accountDynamicRegistrationConfig, err := s.database.FindAccountDynamicRegistrationConfigByAccountID(ctx, accountID) + if err != nil { + serviceErr := exceptions.FromDBError(err) + if serviceErr.Code != exceptions.CodeNotFound { + logger.ErrorContext(ctx, "Failed to find account dynamic registration config", "error", err) + return dtos.AccountDynamicRegistrationConfigDTO{}, false, serviceErr + } + + logger.InfoContext(ctx, "Account dynamic registration config not found, creating new one...") + accountDynamicRegistrationConfig, err = s.database.CreateAccountDynamicRegistrationConfig( + ctx, + database.CreateAccountDynamicRegistrationConfigParams{ + AccountID: accountID, + AccountPublicID: opts.AccountPublicID, + AccountCredentialsTypes: credentialsTypes, + WhitelistedDomains: utils.ToEmptySlice(opts.WhitelistedDomains), + RequireSoftwareStatementCredentialTypes: requireSoftwareStatementCredentialTypes, + SoftwareStatementVerificationMethods: softwareStatementVerificationMethods, + RequireInitialAccessTokenCredentialTypes: requireInitialAccessTokenCredentialTypes, + InitialAccessTokenGenerationMethods: initialAccessTokenGenerationMethods, + }, + ) + if err != nil { + logger.ErrorContext(ctx, "Failed to create account dynamic registration config", "error", err) + return dtos.AccountDynamicRegistrationConfigDTO{}, false, exceptions.FromDBError(err) + } + + return dtos.MapAccountDynamicRegistrationConfigToDTO(&accountDynamicRegistrationConfig), true, nil + + } + + accountDynamicRegistrationConfig, err = s.database.UpdateAccountDynamicRegistrationConfig(ctx, database.UpdateAccountDynamicRegistrationConfigParams{ + ID: accountDynamicRegistrationConfig.ID, + AccountCredentialsTypes: credentialsTypes, + WhitelistedDomains: utils.ToEmptySlice(opts.WhitelistedDomains), + RequireSoftwareStatementCredentialTypes: requireSoftwareStatementCredentialTypes, + SoftwareStatementVerificationMethods: softwareStatementVerificationMethods, + RequireInitialAccessTokenCredentialTypes: requireInitialAccessTokenCredentialTypes, + InitialAccessTokenGenerationMethods: initialAccessTokenGenerationMethods, + }) + if err != nil { + logger.ErrorContext(ctx, "Failed to update account dynamic registration config", "error", err) + return dtos.AccountDynamicRegistrationConfigDTO{}, false, exceptions.FromDBError(err) + } + + return dtos.MapAccountDynamicRegistrationConfigToDTO(&accountDynamicRegistrationConfig), false, nil +} + +type GetAccountDynamicRegistrationConfigOptions struct { + RequestID string + AccountPublicID uuid.UUID +} + +func (s *Services) GetAccountDynamicRegistrationConfig( + ctx context.Context, + opts GetAccountDynamicRegistrationConfigOptions, +) (dtos.AccountDynamicRegistrationConfigDTO, *exceptions.ServiceError) { + logger := s.buildLogger(opts.RequestID, accountDynamicRegistrationConfigsLocation, "GetAccountDynamicRegistrationConfig").With( + "accountPublicID", opts.AccountPublicID, + ) + logger.InfoContext(ctx, "Retrieving account dynamic registration config...") + + accountDynamicRegistrationConfig, err := s.database.FindAccountDynamicRegistrationConfigByAccountPublicID(ctx, opts.AccountPublicID) + if err != nil { + serviceErr := exceptions.FromDBError(err) + if serviceErr.Code != exceptions.CodeNotFound { + logger.ErrorContext(ctx, "Failed to find account dynamic registration config", "error", err) + return dtos.AccountDynamicRegistrationConfigDTO{}, serviceErr + } + + logger.InfoContext(ctx, "Account dynamic registration config not found", "error", err) + return dtos.AccountDynamicRegistrationConfigDTO{}, nil + } + + return dtos.MapAccountDynamicRegistrationConfigToDTO(&accountDynamicRegistrationConfig), nil +} + +type DeleteAccountDynamicRegistrationConfigOptions struct { + RequestID string + AccountPublicID uuid.UUID + AccountVersion int32 +} + +func (s *Services) DeleteAccountDynamicRegistrationConfig( + ctx context.Context, + opts DeleteAccountDynamicRegistrationConfigOptions, +) *exceptions.ServiceError { + logger := s.buildLogger(opts.RequestID, accountDynamicRegistrationConfigsLocation, "DeleteAccountDynamicRegistrationConfig").With( + "accountPublicID", opts.AccountPublicID, + "accountVersion", opts.AccountVersion, + ) + logger.InfoContext(ctx, "Deleting account dynamic registration config...") + + dynamicRegistratioDTO, serviceErr := s.GetAccountDynamicRegistrationConfig( + ctx, + GetAccountDynamicRegistrationConfigOptions{ + RequestID: opts.RequestID, + AccountPublicID: opts.AccountPublicID, + }, + ) + if serviceErr != nil { + logger.ErrorContext(ctx, "Failed to get account dynamic registration config", "serviceError", serviceErr) + return serviceErr + } + + if _, serviceErr := s.GetAccountIDByPublicIDAndVersion(ctx, GetAccountIDByPublicIDAndVersionOptions{ + RequestID: opts.RequestID, + PublicID: opts.AccountPublicID, + Version: opts.AccountVersion, + }); serviceErr != nil { + logger.ErrorContext(ctx, "Failed to get account ID by public ID and version", "serviceError", serviceErr) + return serviceErr + } + + if err := s.database.DeleteAccountDynamicRegistrationConfig(ctx, dynamicRegistratioDTO.ID()); err != nil { + logger.ErrorContext(ctx, "Failed to delete account dynamic registration config", "error", err) + return exceptions.FromDBError(err) + } + + return nil +} diff --git a/idp/internal/services/account_dynamic_registration_tokens.go b/idp/internal/services/account_dynamic_registration_tokens.go new file mode 100644 index 0000000..1df7cfd --- /dev/null +++ b/idp/internal/services/account_dynamic_registration_tokens.go @@ -0,0 +1,7 @@ +// Copyright (c) 2025 Afonso Barracha +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package services diff --git a/idp/internal/services/account_hmac_secrets.go b/idp/internal/services/account_hmac_secrets.go new file mode 100644 index 0000000..50e9f24 --- /dev/null +++ b/idp/internal/services/account_hmac_secrets.go @@ -0,0 +1,169 @@ +// Copyright (c) 2025 Afonso Barracha +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package services + +import ( + "context" + "time" + + "github.com/tugascript/devlogs/idp/internal/exceptions" + "github.com/tugascript/devlogs/idp/internal/providers/crypto" + "github.com/tugascript/devlogs/idp/internal/providers/database" +) + +const accountHMACSecretsLocation = "account_hmac_secrets" + +type buildStoreAccountHMACSecretOptions struct { + requestID string + accountID int32 + data map[string]string + queries *database.Queries +} + +func (s *Services) buildStoreAccountHMACSecretFn( + ctx context.Context, + opts buildStoreAccountHMACSecretOptions, +) crypto.StoreHMACSecret { + logger := s.buildLogger(opts.requestID, accountHMACSecretsLocation, "buildStoreAccountHMACSecretFn") + logger.InfoContext(ctx, "Building store function for account HMAC secret...") + + return func(dekID string, secretID string, encryptedSecret string) (int32, *exceptions.ServiceError) { + id, err := s.mapQueries(opts.queries).CreateAccountHMACSecret(ctx, database.CreateAccountHMACSecretParams{ + AccountID: opts.accountID, + SecretID: secretID, + Secret: encryptedSecret, + DekKid: dekID, + ExpiresAt: time.Now().Add(s.hmacSecretExpDays), + }) + if err != nil { + logger.ErrorContext(ctx, "Failed to create account HMAC secret", "error", err) + return 0, exceptions.FromDBError(err) + } + + opts.data["secretID"] = secretID + opts.data["encryptedSecret"] = encryptedSecret + logger.InfoContext(ctx, "Created account HMAC secret", "id", id) + return id, nil + } +} + +type BuildGetHMACSecretFNOptions struct { + RequestID string + AccountID int32 + Queries *database.Queries +} + +func (s *Services) BuildGetHMACSecretFN( + ctx context.Context, + opts BuildGetHMACSecretFNOptions, +) crypto.GetHMACSecretFN { + logger := s.buildLogger(opts.RequestID, accountHMACSecretsLocation, "BuildGetHMACSecretFN") + logger.InfoContext(ctx, "Building get HMAC secret function...") + + return func() (string, crypto.DEKCiphertext, *exceptions.ServiceError) { + logger.InfoContext(ctx, "Getting HMAC secret...") + + secret, err := s.mapQueries(opts.Queries).FindValidHMACSecretByAccountID(ctx, opts.AccountID) + if err != nil { + serviceErr := exceptions.FromDBError(err) + if serviceErr.Code != exceptions.CodeNotFound { + logger.ErrorContext(ctx, "Failed to find account HMAC secret", "error", err) + return "", "", serviceErr + } + + data := make(map[string]string) + if _, serviceErr := s.crypto.GenerateHMACSecret(ctx, crypto.GenerateHMACSecretOptions{ + RequestID: opts.RequestID, + StoreFN: s.buildStoreAccountHMACSecretFn(ctx, buildStoreAccountHMACSecretOptions{ + requestID: opts.RequestID, + accountID: opts.AccountID, + queries: opts.Queries, + data: data, + }), + }); serviceErr != nil { + logger.ErrorContext(ctx, "Failed to generate account HMAC secret", "serviceError", serviceErr) + return "", "", serviceErr + } + + return data["secretID"], data["encryptedSecret"], nil + } + + return secret.SecretID, secret.Secret, nil + } +} + +type BuildUpdateHMACSecretFNOptions struct { + RequestID string + AccountID int32 + Queries *database.Queries +} + +func (s *Services) BuildUpdateHMACSecretFN( + ctx context.Context, + opts BuildUpdateHMACSecretFNOptions, +) crypto.StoreReEncryptedData { + logger := s.buildLogger(opts.RequestID, accountHMACSecretsLocation, "BuildUpdateHMACSecretFN") + logger.InfoContext(ctx, "Building update HMAC secret function...") + + return func(secretID crypto.EntityID, dekID crypto.DEKID, encPrivKey crypto.DEKCiphertext) *exceptions.ServiceError { + logger.InfoContext(ctx, "Updating HMAC secret...") + + qrs := s.mapQueries(opts.Queries) + secret, err := qrs.FindAccountHMACSecretByAccountIDAndSecretID(ctx, database.FindAccountHMACSecretByAccountIDAndSecretIDParams{ + AccountID: opts.AccountID, + SecretID: secretID, + }) + if err != nil { + logger.ErrorContext(ctx, "Failed to find account HMAC secret", "error", err) + return exceptions.FromDBError(err) + } + + if err := qrs.UpdateAccountHMACSecret(ctx, database.UpdateAccountHMACSecretParams{ + ID: secret.ID, + Secret: encPrivKey, + DekKid: dekID, + }); err != nil { + logger.ErrorContext(ctx, "Failed to update account HMAC secret", "error", err) + return exceptions.FromDBError(err) + } + + logger.InfoContext(ctx, "Updated HMAC secret successfully") + return nil + } +} + +type BuildGetHMACSecretByIDFNOptions struct { + RequestID string + AccountID int32 + Queries *database.Queries +} + +func (s *Services) BuildGetHMACSecretByIDFN( + ctx context.Context, + opts BuildGetHMACSecretByIDFNOptions, +) crypto.GetHMACSecretByIDfn { + logger := s.buildLogger(opts.RequestID, accountHMACSecretsLocation, "BuildGetHMACSecretByIDFN") + logger.InfoContext(ctx, "Building get HMAC secret by ID function...") + + return func(secretID crypto.SecretID) (crypto.DEKCiphertext, *exceptions.ServiceError) { + logger.InfoContext(ctx, "Getting HMAC secret by ID...", "secretID", secretID) + + secret, err := s.mapQueries(opts.Queries).FindAccountHMACSecretByAccountIDAndSecretID( + ctx, + database.FindAccountHMACSecretByAccountIDAndSecretIDParams{ + AccountID: opts.AccountID, + SecretID: secretID, + }, + ) + if err != nil { + logger.ErrorContext(ctx, "Failed to find account HMAC secret", "error", err) + return "", exceptions.FromDBError(err) + } + + return secret.Secret, nil + } +} diff --git a/idp/internal/services/accounts.go b/idp/internal/services/accounts.go index 9b1dc9a..90d3aeb 100644 --- a/idp/internal/services/accounts.go +++ b/idp/internal/services/accounts.go @@ -399,59 +399,69 @@ func (s *Services) UpdateAccountEmail( return dtos.AuthDTO{}, exceptions.NewConflictError("Email already in use") } - if accountDTO.TwoFactorType != database.TwoFactorTypeNone { - logger.InfoContext(ctx, "Account has 2FA enabled", "twoFactorType", accountDTO.TwoFactorType) - - err = s.cache.SaveUpdateEmailRequest(ctx, cache.SaveUpdateEmailRequestOptions{ - RequestID: opts.RequestID, - PrefixType: cache.SensitiveRequestAccountPrefix, - PublicID: accountDTO.PublicID, - Email: newEmail, - DurationSeconds: s.jwt.Get2FATTL(), - }) + default2FAConfig, serviceErr := s.getDefaultAccount2FAConfigInternal(ctx, getDefaultAccount2FAConfigInternalOptions{ + requestID: opts.RequestID, + accountPublicID: accountDTO.PublicID, + }) + if serviceErr != nil { + logger.ErrorContext(ctx, "Failed to get default 2FA config", "serviceError", serviceErr) + return dtos.AuthDTO{}, serviceErr + } + if default2FAConfig == nil { + account, err := s.updateAccountEmailInDB(ctx, logger, accountDTO.ID(), accountDTO.Email, newEmail) if err != nil { - logger.ErrorContext(ctx, "Failed to cache email update request", "error", err) - return dtos.AuthDTO{}, exceptions.NewInternalServerError() + logger.ErrorContext(ctx, "Failed to update account email", "error", err) + serviceErr = exceptions.FromDBError(err) + return dtos.AuthDTO{}, serviceErr } - authDTO, serviceErr := s.generate2FAAuth( + logger.InfoContext(ctx, "Updated account email successfully") + accountDTO = dtos.MapAccountToDTO(&account) + return s.GenerateFullAuthDTO( ctx, logger, + s.database.Queries, opts.RequestID, &accountDTO, - "Please provide two factor code to confirm email update", + []tokens.AccountScope{tokens.AccountScopeAdmin}, + "Email updated successfully", ) - if serviceErr != nil { - return dtos.AuthDTO{}, serviceErr - } - - logger.InfoContext(ctx, "Email update request cached successfully") - return authDTO, serviceErr } - account, err := s.updateAccountEmailInDB(ctx, logger, accountDTO.ID(), accountDTO.Email, newEmail) + logger.InfoContext(ctx, "Account has 2FA enabled", "twoFactorType", default2FAConfig.TwoFactorType) + err = s.cache.SaveUpdateEmailRequest(ctx, cache.SaveUpdateEmailRequestOptions{ + RequestID: opts.RequestID, + PrefixType: cache.SensitiveRequestAccountPrefix, + PublicID: accountDTO.PublicID, + Email: newEmail, + DurationSeconds: s.jwt.Get2FATTL(), + }) if err != nil { - logger.ErrorContext(ctx, "Failed to update account email", "error", err) - serviceErr = exceptions.FromDBError(err) - return dtos.AuthDTO{}, serviceErr + logger.ErrorContext(ctx, "Failed to cache email update request", "error", err) + return dtos.AuthDTO{}, exceptions.NewInternalServerError() } - logger.InfoContext(ctx, "Updated account email successfully") - accountDTO = dtos.MapAccountToDTO(&account) - return s.GenerateFullAuthDTO( + authDTO, serviceErr := s.generate2FAAuth( ctx, logger, opts.RequestID, &accountDTO, - []tokens.AccountScope{tokens.AccountScopeAdmin}, - "Email updated successfully", + default2FAConfig.TwoFactorType, + "Please provide two factor code to confirm email update", ) + if serviceErr != nil { + return dtos.AuthDTO{}, serviceErr + } + + logger.InfoContext(ctx, "Email update request cached successfully") + return authDTO, serviceErr } type ConfirmUpdateAccountEmailOptions struct { RequestID string PublicID uuid.UUID Version int32 + TwoFAType tokens.TwoFAType Code string } @@ -488,13 +498,15 @@ func (s *Services) ConfirmUpdateAccountEmail( return dtos.AuthDTO{}, serviceErr } - if serviceErr := s.verifyAccountTwoFactor( - ctx, - logger, - opts.RequestID, - &accountDTO, - opts.Code, - ); serviceErr != nil { + if serviceErr := s.verifyAccount2FAInternal(ctx, verifyAccount2FAInternalOptions{ + requestID: opts.RequestID, + accountID: accountDTO.ID(), + accountPublicID: opts.PublicID, + accountVersion: opts.Version, + twoFAType: opts.TwoFAType, + code: opts.Code, + }); serviceErr != nil { + logger.ErrorContext(ctx, "Failed to verify account two factor", "serviceError", serviceErr) return dtos.AuthDTO{}, serviceErr } @@ -509,6 +521,7 @@ func (s *Services) ConfirmUpdateAccountEmail( return s.GenerateFullAuthDTO( ctx, logger, + s.database.Queries, opts.RequestID, &accountDTO, []tokens.AccountScope{tokens.AccountScopeAdmin}, @@ -592,17 +605,24 @@ func (s *Services) UpdateAccountPassword( return dtos.AuthDTO{}, exceptions.NewValidationError("Invalid password") } - if accountDTO.TwoFactorType != database.TwoFactorTypeNone { - logger.InfoContext(ctx, "Account has 2FA enabled", "twoFactorType", accountDTO.TwoFactorType) + default2FAConfig, serviceErr := s.getDefaultAccount2FAConfigInternal(ctx, getDefaultAccount2FAConfigInternalOptions{ + requestID: opts.RequestID, + accountPublicID: accountDTO.PublicID, + }) + if serviceErr != nil { + logger.ErrorContext(ctx, "Failed to get default 2FA config", "serviceError", serviceErr) + return dtos.AuthDTO{}, serviceErr + } - err = s.cache.SaveUpdatePasswordRequest(ctx, cache.SaveUpdatePasswordRequestOptions{ + if default2FAConfig != nil { + logger.InfoContext(ctx, "Account has 2FA enabled", "twoFactorType", default2FAConfig.TwoFactorType) + if err := s.cache.SaveUpdatePasswordRequest(ctx, cache.SaveUpdatePasswordRequestOptions{ RequestID: opts.RequestID, PrefixType: cache.SensitiveRequestAccountPrefix, PublicID: accountDTO.PublicID, NewPassword: opts.NewPassword, DurationSeconds: s.jwt.Get2FATTL(), - }) - if err != nil { + }); err != nil { logger.ErrorContext(ctx, "Failed to cache password update request", "error", err) return dtos.AuthDTO{}, exceptions.NewInternalServerError() } @@ -612,6 +632,7 @@ func (s *Services) UpdateAccountPassword( logger, opts.RequestID, &accountDTO, + default2FAConfig.TwoFactorType, "Please provide two factor code to confirm password update", ) if serviceErr != nil { @@ -638,6 +659,7 @@ func (s *Services) UpdateAccountPassword( return s.GenerateFullAuthDTO( ctx, logger, + s.database.Queries, opts.RequestID, &accountDTO, []tokens.AccountScope{tokens.AccountScopeAdmin}, @@ -649,6 +671,7 @@ type ConfirmUpdateAccountPasswordOptions struct { RequestID string PublicID uuid.UUID Version int32 + TwoFAType tokens.TwoFAType Code string } @@ -685,13 +708,15 @@ func (s *Services) ConfirmUpdateAccountPassword( return dtos.AuthDTO{}, exceptions.NewUnauthorizedError() } - if serviceErr := s.verifyAccountTwoFactor( - ctx, - logger, - opts.RequestID, - &accountDTO, - opts.Code, - ); serviceErr != nil { + if serviceErr := s.verifyAccount2FAInternal(ctx, verifyAccount2FAInternalOptions{ + requestID: opts.RequestID, + accountID: accountDTO.ID(), + accountPublicID: opts.PublicID, + accountVersion: opts.Version, + twoFAType: opts.TwoFAType, + code: opts.Code, + }); serviceErr != nil { + logger.ErrorContext(ctx, "Failed to verify account two factor", "serviceError", serviceErr) return dtos.AuthDTO{}, serviceErr } @@ -706,6 +731,7 @@ func (s *Services) ConfirmUpdateAccountPassword( return s.GenerateFullAuthDTO( ctx, logger, + s.database.Queries, opts.RequestID, &accountDTO, []tokens.AccountScope{tokens.AccountScopeAdmin}, @@ -801,6 +827,7 @@ func (s *Services) CreateAccountPassword( return s.GenerateFullAuthDTO( ctx, logger, + s.database.Queries, opts.RequestID, &accountDTO, []tokens.AccountScope{tokens.AccountScopeAdmin}, @@ -920,8 +947,16 @@ func (s *Services) UpdateAccountUsername( return dtos.AuthDTO{}, exceptions.NewConflictError("Username already in use") } - if accountDTO.TwoFactorType != database.TwoFactorTypeNone { - logger.InfoContext(ctx, "Account has 2FA enabled", "twoFactorType", accountDTO.TwoFactorType) + default2FAConfig, serviceErr := s.getDefaultAccount2FAConfigInternal(ctx, getDefaultAccount2FAConfigInternalOptions{ + requestID: opts.RequestID, + accountPublicID: accountDTO.PublicID, + }) + if serviceErr != nil { + logger.ErrorContext(ctx, "Failed to get default 2FA config", "serviceError", serviceErr) + return dtos.AuthDTO{}, serviceErr + } + if default2FAConfig != nil { + logger.InfoContext(ctx, "Account has 2FA enabled", "twoFactorType", default2FAConfig.TwoFactorType) if err := s.cache.SaveUpdateUsernameRequest(ctx, cache.SaveUpdateUsernameRequestOptions{ RequestID: opts.RequestID, @@ -939,6 +974,7 @@ func (s *Services) UpdateAccountUsername( logger, opts.RequestID, &accountDTO, + default2FAConfig.TwoFactorType, "Please provide two factor code to confirm username update", ) if serviceErr != nil { @@ -963,6 +999,7 @@ func (s *Services) UpdateAccountUsername( return s.GenerateFullAuthDTO( ctx, logger, + s.database.Queries, opts.RequestID, &accountDTO, []tokens.AccountScope{tokens.AccountScopeAdmin}, @@ -974,6 +1011,7 @@ type ConfirmUpdateAccountUsernameOptions struct { RequestID string PublicID uuid.UUID Version int32 + TwoFAType tokens.TwoFAType Code string } @@ -1010,13 +1048,15 @@ func (s *Services) ConfirmUpdateAccountUsername( return dtos.AuthDTO{}, serviceErr } - if serviceErr := s.verifyAccountTwoFactor( - ctx, - logger, - opts.RequestID, - &accountDTO, - opts.Code, - ); serviceErr != nil { + if serviceErr := s.verifyAccount2FAInternal(ctx, verifyAccount2FAInternalOptions{ + requestID: opts.RequestID, + accountID: accountDTO.ID(), + accountPublicID: opts.PublicID, + accountVersion: opts.Version, + twoFAType: opts.TwoFAType, + code: opts.Code, + }); serviceErr != nil { + logger.ErrorContext(ctx, "Failed to verify account two factor", "serviceError", serviceErr) return dtos.AuthDTO{}, serviceErr } @@ -1034,6 +1074,7 @@ func (s *Services) ConfirmUpdateAccountUsername( return s.GenerateFullAuthDTO( ctx, logger, + s.database.Queries, opts.RequestID, &accountDTO, []tokens.AccountScope{tokens.AccountScopeAdmin}, @@ -1093,9 +1134,17 @@ func (s *Services) DeleteAccount( } } - if accountDTO.TwoFactorType != database.TwoFactorTypeNone { - logger.InfoContext(ctx, "Account has 2FA enabled", "twoFactorType", accountDTO.TwoFactorType) + default2FAConfig, serviceErr := s.getDefaultAccount2FAConfigInternal(ctx, getDefaultAccount2FAConfigInternalOptions{ + requestID: opts.RequestID, + accountPublicID: accountDTO.PublicID, + }) + if serviceErr != nil { + logger.ErrorContext(ctx, "Failed to get default 2FA config", "serviceError", serviceErr) + return false, dtos.AuthDTO{}, serviceErr + } + if default2FAConfig != nil { + logger.InfoContext(ctx, "Account has 2FA enabled", "twoFactorType", default2FAConfig.TwoFactorType) if err := s.cache.SaveDeleteAccountRequest(ctx, cache.SaveDeleteAccountRequestOptions{ RequestID: opts.RequestID, PrefixType: cache.SensitiveRequestAccountPrefix, @@ -1111,6 +1160,7 @@ func (s *Services) DeleteAccount( logger, opts.RequestID, &accountDTO, + default2FAConfig.TwoFactorType, "Please provide two factor code to confirm account deletion", ) if serviceErr != nil { @@ -1134,6 +1184,7 @@ type ConfirmDeleteAccountOptions struct { RequestID string PublicID uuid.UUID Version int32 + TwoFAType tokens.TwoFAType Code string } @@ -1170,13 +1221,14 @@ func (s *Services) ConfirmDeleteAccount( return serviceErr } - if serviceErr := s.verifyAccountTwoFactor( - ctx, - logger, - opts.RequestID, - &accountDTO, - opts.Code, - ); serviceErr != nil { + if serviceErr := s.verifyAccount2FAInternal(ctx, verifyAccount2FAInternalOptions{ + requestID: opts.RequestID, + accountID: accountDTO.ID(), + accountPublicID: opts.PublicID, + accountVersion: opts.Version, + twoFAType: opts.TwoFAType, + code: opts.Code, + }); serviceErr != nil { return serviceErr } diff --git a/idp/internal/services/apps.go b/idp/internal/services/apps.go index bcfa925..5841c37 100644 --- a/idp/internal/services/apps.go +++ b/idp/internal/services/apps.go @@ -8,7 +8,6 @@ package services import ( "context" - "net/url" "strings" "time" @@ -30,6 +29,7 @@ const ( transportSTDIO string = "stdio" transportStreamableHTTP string = "streamable_http" transportHTTP string = "http" + transportHTTPS string = "https" ) var authCodeAppGrantTypes = []database.GrantType{database.GrantTypeAuthorizationCode, database.GrantTypeRefreshToken} @@ -621,27 +621,6 @@ func (s *Services) checkForDuplicateApps( return nil } -func mapDomain(clientURI string, domain string) (string, *exceptions.ServiceError) { - trimmed := strings.TrimSpace(domain) - if trimmed != "" { - return trimmed, nil - } - - parsed, err := url.Parse(strings.TrimSpace(clientURI)) - if err != nil || parsed == nil { - return "", exceptions.NewValidationError("Invalid client URI") - } - if parsed.Scheme != "http" && parsed.Scheme != "https" { - return "", exceptions.NewValidationError("Invalid client URI") - } - - host := parsed.Hostname() - if strings.TrimSpace(host) == "" { - return "", exceptions.NewValidationError("Invalid client URI") - } - return host, nil -} - type createAppOptions struct { requestID string accountID int32 @@ -2032,7 +2011,6 @@ type updateAppOptions struct { logoURI string tosURI string policyURI string - softwareID string softwareVersion string contacts []string redirectURIs []string @@ -2086,7 +2064,6 @@ func (s *Services) updateApp( LogoUri: mapEmptyURL(opts.logoURI), TosUri: mapEmptyURL(opts.tosURI), PolicyUri: mapEmptyURL(opts.policyURI), - SoftwareID: opts.softwareID, SoftwareVersion: softwareVersion, Domain: derivedDomain, Transport: opts.transport, @@ -2154,7 +2131,6 @@ func (s *Services) updateSingleApp( LogoUri: mapEmptyURL(opts.logoURI), TosUri: mapEmptyURL(opts.tosURI), PolicyUri: mapEmptyURL(opts.policyURI), - SoftwareID: opts.softwareID, SoftwareVersion: softwareVersion, Domain: derivedDomain, Transport: opts.transport, @@ -2280,7 +2256,6 @@ func (s *Services) UpdateWebSPANativeApp( logoURI: opts.LogoURI, tosURI: opts.TOSURI, policyURI: opts.PolicyURI, - softwareID: opts.SoftwareID, softwareVersion: opts.SoftwareVersion, contacts: opts.Contacts, redirectURIs: opts.RedirectURIs, @@ -2359,7 +2334,6 @@ func (s *Services) UpdateBackendApp( logoURI: opts.LogoURI, tosURI: opts.TOSURI, policyURI: opts.PolicyURI, - softwareID: opts.SoftwareID, softwareVersion: opts.SoftwareVersion, contacts: opts.Contacts, redirectURIs: make([]string, 0), @@ -2492,7 +2466,6 @@ func (s *Services) UpdateDeviceApp( logoURI: opts.LogoURI, tosURI: opts.TOSURI, policyURI: opts.PolicyURI, - softwareID: opts.SoftwareID, softwareVersion: opts.SoftwareVersion, contacts: opts.Contacts, redirectURIs: make([]string, 0), @@ -2610,7 +2583,6 @@ func (s *Services) UpdateServiceApp( logoURI: opts.LogoURI, tosURI: opts.TOSURI, policyURI: opts.PolicyURI, - softwareID: opts.SoftwareID, softwareVersion: opts.SoftwareVersion, contacts: opts.Contacts, redirectURIs: make([]string, 0), @@ -2710,7 +2682,6 @@ func (s *Services) UpdateMCPApp( logoURI: opts.LogoURI, tosURI: opts.TOSURI, policyURI: opts.PolicyURI, - softwareID: opts.SoftwareID, softwareVersion: opts.SoftwareVersion, contacts: opts.Contacts, redirectURIs: utils.ToEmptySlice(opts.RedirectURIs), diff --git a/idp/internal/services/auth.go b/idp/internal/services/auth.go index fd113d3..93d2efd 100644 --- a/idp/internal/services/auth.go +++ b/idp/internal/services/auth.go @@ -104,22 +104,42 @@ func (s *Services) ProcessAccountAuthHeader( func (s *Services) Process2FAAuthHeader( ctx context.Context, opts ProcessAuthHeaderOptions, -) (tokens.AccountClaims, *exceptions.ServiceError) { - return s.processPurposeAuthHeader( - ctx, - processPurposeAuthHeaderOptions{ - requestID: opts.RequestID, - authHeader: opts.AuthHeader, - tokenPurpose: tokens.TokenPurpose2FA, - tokenKeyType: database.TokenKeyType2faAuthentication, - }, +) (tokens.AccountClaims, tokens.TwoFAType, *exceptions.ServiceError) { + logger := s.buildLogger(opts.RequestID, authLocation, "Process2FAAuthHeader") + logger.InfoContext(ctx, "Processing purpose auth header...") + + token, serviceErr := extractAuthHeaderToken(opts.AuthHeader) + if serviceErr != nil { + return tokens.AccountClaims{}, "", serviceErr + } + + accountClaims, twoFAType, err := s.jwt.Verify2FAToken( + token, + s.BuildGetGlobalPublicKeyFn(ctx, BuildGetGlobalVerifyKeyFnOptions{ + RequestID: opts.RequestID, + KeyType: database.TokenKeyType2faAuthentication, + }), ) + if err != nil { + logger.ErrorContext(ctx, "Failed to verify purpose token", "error", err) + return tokens.AccountClaims{}, "", exceptions.NewUnauthorizedError() + } + + return accountClaims, twoFAType, nil } func (s *Services) GetRefreshTTL() int64 { return s.jwt.GetRefreshTTL() } +func (s *Services) Get2FATTL() int64 { + return s.jwt.Get2FATTL() +} + +func (s *Services) GetOAuthCodeTTL() int64 { + return s.cache.OAuthCodeTTL() +} + func (s *Services) sendConfirmationEmail( ctx context.Context, logger *slog.Logger, @@ -127,7 +147,7 @@ func (s *Services) sendConfirmationEmail( accountDTO *dtos.AccountDTO, ) *exceptions.ServiceError { logger.InfoContext(ctx, "Sending confirmation email...") - signedToken, err := s.crypto.SignToken(ctx, crypto.SignTokenOptions{ + signedToken, serviceErr := s.crypto.SignToken(ctx, crypto.SignTokenOptions{ RequestID: requestID, Token: s.jwt.CreateConfirmationToken(tokens.AccountConfirmationTokenOptions{ PublicID: accountDTO.PublicID, @@ -138,12 +158,18 @@ func (s *Services) sendConfirmationEmail( KeyType: database.TokenKeyTypeEmailVerification, TTL: s.jwt.GetConfirmationTTL(), }), - GetDecryptDEKfn: s.BuildGetGlobalDecDEKFn(ctx, requestID), - GetEncryptDEKfn: s.BuildGetEncGlobalDEKFn(ctx, requestID), - StoreFN: s.BuildUpdateJWKDEKFn(ctx, requestID), + GetDecryptDEKfn: s.BuildGetGlobalDecDEKFn(ctx, BuildGetGlobalDEKFnOptions{ + RequestID: requestID, + }), + GetEncryptDEKfn: s.BuildGetEncGlobalDEKFn(ctx, BuildGetGlobalDEKFnOptions{ + RequestID: requestID, + }), + StoreFN: s.BuildUpdateJWKDEKFn(ctx, BuildUpdateJWKDEKFnOptions{ + RequestID: requestID, + }), }) - if err != nil { - logger.ErrorContext(ctx, "Failed to sign confirmation token", "error", err) + if serviceErr != nil { + logger.ErrorContext(ctx, "Failed to sign confirmation token", "serviceError", serviceErr) return exceptions.NewInternalServerError() } @@ -209,6 +235,7 @@ func (s *Services) RegisterAccount( func (s *Services) GenerateFullAuthDTO( ctx context.Context, logger *slog.Logger, + qrs *database.Queries, requestID string, accountDTO *dtos.AccountDTO, scopes []tokens.AccountScope, @@ -232,10 +259,20 @@ func (s *Services) GenerateFullAuthDTO( RequestID: requestID, KeyType: database.TokenKeyTypeAccess, TTL: s.jwt.GetAccessTTL(), + Queries: qrs, + }), + GetDecryptDEKfn: s.BuildGetGlobalDecDEKFn(ctx, BuildGetGlobalDEKFnOptions{ + RequestID: requestID, + Queries: qrs, + }), + GetEncryptDEKfn: s.BuildGetEncGlobalDEKFn(ctx, BuildGetGlobalDEKFnOptions{ + RequestID: requestID, + Queries: qrs, + }), + StoreFN: s.BuildUpdateJWKDEKFn(ctx, BuildUpdateJWKDEKFnOptions{ + RequestID: requestID, + Queries: qrs, }), - GetDecryptDEKfn: s.BuildGetGlobalDecDEKFn(ctx, requestID), - GetEncryptDEKfn: s.BuildGetEncGlobalDEKFn(ctx, requestID), - StoreFN: s.BuildUpdateJWKDEKFn(ctx, requestID), }) if serviceErr != nil { logger.ErrorContext(ctx, "Failed to sign access token", "serviceError", serviceErr) @@ -259,10 +296,20 @@ func (s *Services) GenerateFullAuthDTO( RequestID: requestID, KeyType: database.TokenKeyTypeRefresh, TTL: s.jwt.GetRefreshTTL(), + Queries: qrs, + }), + GetDecryptDEKfn: s.BuildGetGlobalDecDEKFn(ctx, BuildGetGlobalDEKFnOptions{ + RequestID: requestID, + Queries: qrs, + }), + GetEncryptDEKfn: s.BuildGetEncGlobalDEKFn(ctx, BuildGetGlobalDEKFnOptions{ + RequestID: requestID, + Queries: qrs, + }), + StoreFN: s.BuildUpdateJWKDEKFn(ctx, BuildUpdateJWKDEKFnOptions{ + RequestID: requestID, + Queries: qrs, }), - GetDecryptDEKfn: s.BuildGetGlobalDecDEKFn(ctx, requestID), - GetEncryptDEKfn: s.BuildGetEncGlobalDEKFn(ctx, requestID), - StoreFN: s.BuildUpdateJWKDEKFn(ctx, requestID), }) if serviceErr != nil { logger.ErrorContext(ctx, "Failed to sign refresh token", "serviceError", serviceErr) @@ -324,6 +371,7 @@ func (s *Services) ConfirmAccount( return s.GenerateFullAuthDTO( ctx, logger, + s.database.Queries, opts.RequestID, &accountDTO, []tokens.AccountScope{tokens.AccountScopeAdmin}, @@ -336,6 +384,7 @@ func (s *Services) generate2FAAuth( logger *slog.Logger, requestID string, accountDTO *dtos.AccountDTO, + twoFAType database.TwoFactorType, msg string, ) (dtos.AuthDTO, *exceptions.ServiceError) { twoFAToken, err := s.crypto.SignToken(ctx, crypto.SignTokenOptions{ @@ -349,16 +398,22 @@ func (s *Services) generate2FAAuth( KeyType: database.TokenKeyType2faAuthentication, TTL: s.jwt.Get2FATTL(), }), - GetDecryptDEKfn: s.BuildGetGlobalDecDEKFn(ctx, requestID), - GetEncryptDEKfn: s.BuildGetEncGlobalDEKFn(ctx, requestID), - StoreFN: s.BuildUpdateJWKDEKFn(ctx, requestID), + GetDecryptDEKfn: s.BuildGetGlobalDecDEKFn(ctx, BuildGetGlobalDEKFnOptions{ + RequestID: requestID, + }), + GetEncryptDEKfn: s.BuildGetEncGlobalDEKFn(ctx, BuildGetGlobalDEKFnOptions{ + RequestID: requestID, + }), + StoreFN: s.BuildUpdateJWKDEKFn(ctx, BuildUpdateJWKDEKFnOptions{ + RequestID: requestID, + }), }) if err != nil { logger.ErrorContext(ctx, "Failed to sign 2FA token", "error", err) return dtos.AuthDTO{}, exceptions.NewInternalServerError() } - if accountDTO.TwoFactorType == database.TwoFactorTypeEmail { + if twoFAType == database.TwoFactorTypeEmail { code, err := s.cache.AddTwoFactorCode(ctx, cache.AddTwoFactorCodeOptions{ RequestID: requestID, AccountID: accountDTO.ID(), @@ -412,6 +467,22 @@ func (s *Services) LoginAccount( logger.WarnContext(ctx, "Account was not found", "error", serviceErr) return dtos.AuthDTO{}, exceptions.NewUnauthorizedError() } + if _, err := s.database.FindAccountAuthProviderByAccountPublicIdAndProvider( + ctx, + database.FindAccountAuthProviderByAccountPublicIdAndProviderParams{ + AccountPublicID: accountDTO.PublicID, + Provider: database.AuthProviderLocal, + }, + ); err != nil { + serviceErr := exceptions.FromDBError(err) + if serviceErr.Code != exceptions.CodeNotFound { + logger.ErrorContext(ctx, "Failed to find account auth provider", "error", err) + return dtos.AuthDTO{}, serviceErr + } + + logger.WarnContext(ctx, "Account auth provider not found", "error", err) + return dtos.AuthDTO{}, exceptions.NewUnauthorizedError() + } passwordVerified, err := utils.Argon2CompareHash(opts.Password, accountDTO.Password()) if err != nil { @@ -431,13 +502,21 @@ func (s *Services) LoginAccount( } } - switch accountDTO.TwoFactorType { - case database.TwoFactorTypeEmail, database.TwoFactorTypeTotp: + default2FaConfig, serviceErr := s.getDefaultAccount2FAConfigInternal(ctx, getDefaultAccount2FAConfigInternalOptions{ + requestID: opts.RequestID, + accountPublicID: accountDTO.PublicID, + }) + if serviceErr != nil { + logger.ErrorContext(ctx, "Failed to get default account 2FA config", "serviceError", serviceErr) + return dtos.AuthDTO{}, serviceErr + } + if default2FaConfig != nil { authDTO, serviceErr := s.generate2FAAuth( ctx, logger, opts.RequestID, &accountDTO, + default2FaConfig.TwoFactorType, "Please provide two factor code", ) if serviceErr != nil { @@ -449,6 +528,7 @@ func (s *Services) LoginAccount( return s.GenerateFullAuthDTO( ctx, logger, + s.database.Queries, opts.RequestID, &accountDTO, []tokens.AccountScope{tokens.AccountScopeAdmin}, @@ -559,25 +639,65 @@ func (s *Services) VerifyAccountTotp( return verified, nil } -func (s *Services) verifyAccountTwoFactor( +func mapTokens2FAType(twoFAType tokens.TwoFAType) (database.TwoFactorType, *exceptions.ServiceError) { + switch twoFAType { + case tokens.TwoFATypeTOTP: + return database.TwoFactorTypeTotp, nil + case tokens.TwoFATypeEmail: + return database.TwoFactorTypeEmail, nil + default: + return "", exceptions.NewUnauthorizedError() + } +} + +type verifyAccount2FAInternalOptions struct { + requestID string + accountID int32 + accountPublicID uuid.UUID + accountVersion int32 + twoFAType tokens.TwoFAType + code string +} + +func (s *Services) verifyAccount2FAInternal( ctx context.Context, - logger *slog.Logger, - requestID string, - accountDTO *dtos.AccountDTO, - code string, + opts verifyAccount2FAInternalOptions, ) *exceptions.ServiceError { - switch accountDTO.TwoFactorType { - case database.TwoFactorTypeNone: - logger.WarnContext(ctx, "User has two factor inactive") - return exceptions.NewForbiddenError() + logger := s.buildLogger(opts.requestID, authLocation, "verifyDefaultAccount2FA").With( + "accountPublicId", opts.accountPublicID, + ) + logger.InfoContext(ctx, "Verifying account two factor...") + + twoFAType, serviceErr := mapTokens2FAType(opts.twoFAType) + if serviceErr != nil { + logger.WarnContext(ctx, "Failed to map two factor type", "serviceError", serviceErr) + return serviceErr + } + + configDTO, serviceErr := s.GetAccount2FAConfig(ctx, GetAccount2FAConfigOptions{ + RequestID: opts.requestID, + AccountPublicID: opts.accountPublicID, + TwoFAType: twoFAType, + }) + if serviceErr != nil { + if serviceErr.Code == exceptions.CodeNotFound { + logger.WarnContext(ctx, "Account 2FA config not found", "serviceError", serviceErr) + return exceptions.NewForbiddenError() + } + + logger.ErrorContext(ctx, "Failed to get account 2FA config", "serviceError", serviceErr) + return serviceErr + } + + switch configDTO.TwoFactorType { case database.TwoFactorTypeTotp: ok, serviceErr := s.VerifyAccountTotp(ctx, VerifyAccountTotpOptions{ - RequestID: requestID, - ID: accountDTO.ID(), - Code: code, + RequestID: opts.requestID, + ID: opts.accountID, + Code: opts.code, }) if serviceErr != nil { - logger.ErrorContext(ctx, "Failed to verify TOTP Code", "error", serviceErr) + logger.ErrorContext(ctx, "Failed to verify TOTP Code", "serviceError", serviceErr) return serviceErr } if !ok { @@ -586,9 +706,9 @@ func (s *Services) verifyAccountTwoFactor( } case database.TwoFactorTypeEmail: ok, err := s.cache.VerifyTwoFactorCode(ctx, cache.VerifyTwoFactorCodeOptions{ - RequestID: requestID, - AccountID: accountDTO.ID(), - Code: code, + RequestID: opts.requestID, + AccountID: opts.accountID, + Code: opts.code, }) if err != nil { logger.ErrorContext(ctx, "Error verifying Code", "error", err) @@ -598,61 +718,59 @@ func (s *Services) verifyAccountTwoFactor( logger.WarnContext(ctx, "Failed to verify Code") return exceptions.NewUnauthorizedError() } + default: + logger.WarnContext(ctx, "Invalid two factor type", "twoFactorType", configDTO.TwoFactorType) + return exceptions.NewUnauthorizedError() } + logger.InfoContext(ctx, "Account two factor verified successfully") return nil } -type TwoFactorLoginAccountOptions struct { - RequestID string - PublicID uuid.UUID - Version int32 - Code string +type VerifyAccount2FAOptions struct { + RequestID string + AccountPublicID uuid.UUID + AccountVersion int32 + TwoFAType tokens.TwoFAType + Code string } -func (s *Services) TwoFactorLoginAccount( +func (s *Services) VerifyAccount2FA( ctx context.Context, - opts TwoFactorLoginAccountOptions, + opts VerifyAccount2FAOptions, ) (dtos.AuthDTO, *exceptions.ServiceError) { - logger := s.buildLogger(opts.RequestID, authLocation, "TwoFactorLoginAccount") - logger.InfoContext(ctx, "2FA logging in account...") + logger := s.buildLogger(opts.RequestID, authLocation, "VerifyAccount2FA").With( + "accountPublicId", opts.AccountPublicID, + "twoFactorType", opts.TwoFAType, + ) + logger.InfoContext(ctx, "Verifying account two factor...") - accountDTO, serviceErr := s.GetAccountByPublicID(ctx, GetAccountByPublicIDOptions{ + accountDTO, serviceErr := s.GetAccountByPublicIDAndVersion(ctx, GetAccountByPublicIDAndVersionOptions{ RequestID: opts.RequestID, - PublicID: opts.PublicID, + PublicID: opts.AccountPublicID, + Version: opts.AccountVersion, }) if serviceErr != nil { - if serviceErr.Code != exceptions.CodeNotFound { - return dtos.AuthDTO{}, serviceErr - } - - logger.WarnContext(ctx, "Account was not found", "error", serviceErr) - return dtos.AuthDTO{}, exceptions.NewUnauthorizedError() - } - - logger = logger.With("accountId", accountDTO.ID()) - accountVersion := accountDTO.Version() - if accountVersion != opts.Version { - logger.WarnContext(ctx, "Account versions do not match", - "accessTokenVersion", opts.Version, - "accountVersion", accountVersion, - ) - return dtos.AuthDTO{}, exceptions.NewUnauthorizedError() + logger.ErrorContext(ctx, "Failed to get account ID by public ID and version", "serviceError", serviceErr) + return dtos.AuthDTO{}, serviceErr } - if serviceErr := s.verifyAccountTwoFactor( - ctx, - logger, - opts.RequestID, - &accountDTO, - opts.Code, - ); serviceErr != nil { + if serviceErr := s.verifyAccount2FAInternal(ctx, verifyAccount2FAInternalOptions{ + requestID: opts.RequestID, + accountID: accountDTO.ID(), + accountPublicID: opts.AccountPublicID, + accountVersion: opts.AccountVersion, + twoFAType: opts.TwoFAType, + code: opts.Code, + }); serviceErr != nil { + logger.ErrorContext(ctx, "Failed to verify account two factor", "serviceError", serviceErr) return dtos.AuthDTO{}, serviceErr } return s.GenerateFullAuthDTO( ctx, logger, + s.database.Queries, opts.RequestID, &accountDTO, []tokens.AccountScope{tokens.AccountScopeAdmin}, @@ -764,29 +882,16 @@ func (s *Services) RefreshTokenAccount( return dtos.AuthDTO{}, exceptions.NewUnauthorizedError() } - accountDTO, serviceErr := s.GetAccountByPublicID(ctx, GetAccountByPublicIDOptions{ + accountDTO, serviceErr := s.GetAccountByPublicIDAndVersion(ctx, GetAccountByPublicIDAndVersionOptions{ RequestID: opts.RequestID, PublicID: data.AccountClaims.AccountID, + Version: data.AccountClaims.AccountVersion, }) if serviceErr != nil { - if serviceErr.Code != exceptions.CodeNotFound { - logger.WarnContext(ctx, "Account not found", "error", serviceErr) - return dtos.AuthDTO{}, exceptions.NewUnauthorizedError() - } - - logger.ErrorContext(ctx, "Failed to get account", "error", serviceErr) + logger.WarnContext(ctx, "Failed to get account by public ID and version", "serviceErr", serviceErr) return dtos.AuthDTO{}, serviceErr } - accountVersion := accountDTO.Version() - if accountVersion != data.AccountClaims.AccountVersion { - logger.WarnContext(ctx, "Account versions do not match", - "claimsVersion", data.AccountClaims.AccountVersion, - "accountVersion", accountVersion, - ) - return dtos.AuthDTO{}, exceptions.NewUnauthorizedError() - } - if err := s.database.RevokeToken(ctx, database.RevokeTokenParams{ TokenID: data.TokenID, AccountID: accountDTO.ID(), @@ -802,6 +907,7 @@ func (s *Services) RefreshTokenAccount( return s.GenerateFullAuthDTO( ctx, logger, + s.database.Queries, opts.RequestID, &accountDTO, data.Scopes, @@ -843,9 +949,15 @@ func (s *Services) ForgotAccountPassword( KeyType: database.TokenKeyTypePasswordReset, TTL: s.jwt.GetResetTTL(), }), - GetDecryptDEKfn: s.BuildGetGlobalDecDEKFn(ctx, opts.RequestID), - GetEncryptDEKfn: s.BuildGetEncGlobalDEKFn(ctx, opts.RequestID), - StoreFN: s.BuildUpdateJWKDEKFn(ctx, opts.RequestID), + GetDecryptDEKfn: s.BuildGetGlobalDecDEKFn(ctx, BuildGetGlobalDEKFnOptions{ + RequestID: opts.RequestID, + }), + GetEncryptDEKfn: s.BuildGetEncGlobalDEKFn(ctx, BuildGetGlobalDEKFnOptions{ + RequestID: opts.RequestID, + }), + StoreFN: s.BuildUpdateJWKDEKFn(ctx, BuildUpdateJWKDEKFnOptions{ + RequestID: opts.RequestID, + }), }) if err != nil { logger.ErrorContext(ctx, "Failed to generate rest token", "error", err) @@ -1032,8 +1144,12 @@ func (s *Services) RecoverAccount( logger.ErrorContext(ctx, "Failed to get account by public ID and version", "error", serviceErr) return dtos.AuthDTO{}, serviceErr } - if accountDTO.TwoFactorType != database.TwoFactorTypeTotp { - logger.WarnContext(ctx, "Account does not have TOTP enabled") + if _, serviceErr := s.GetAccount2FAConfig(ctx, GetAccount2FAConfigOptions{ + RequestID: opts.RequestID, + AccountPublicID: accountDTO.PublicID, + TwoFAType: database.TwoFactorTypeTotp, + }); serviceErr != nil { + logger.WarnContext(ctx, "Account does not have TOTP enabled", "serviceError", serviceErr) return dtos.AuthDTO{}, exceptions.NewUnauthorizedError() } @@ -1072,9 +1188,15 @@ func (s *Services) RecoverAccount( KeyType: database.TokenKeyType2faAuthentication, TTL: s.jwt.Get2FATTL(), }), - GetDecryptDEKfn: s.BuildGetGlobalDecDEKFn(ctx, opts.RequestID), - GetEncryptDEKfn: s.BuildGetEncGlobalDEKFn(ctx, opts.RequestID), - StoreFN: s.BuildUpdateJWKDEKFn(ctx, opts.RequestID), + GetDecryptDEKfn: s.BuildGetGlobalDecDEKFn(ctx, BuildGetGlobalDEKFnOptions{ + RequestID: opts.RequestID, + }), + GetEncryptDEKfn: s.BuildGetEncGlobalDEKFn(ctx, BuildGetGlobalDEKFnOptions{ + RequestID: opts.RequestID, + }), + StoreFN: s.BuildUpdateJWKDEKFn(ctx, BuildUpdateJWKDEKFnOptions{ + RequestID: opts.RequestID, + }), }) if serviceErr != nil { logger.ErrorContext(ctx, "Failed to sign 2FA token", "serviceError", serviceErr) @@ -1157,479 +1279,3 @@ func (s *Services) GetAccountAuthProvider( logger.InfoContext(ctx, "Retrieved account auth provider successfully") return dtos.MapAccountAuthProviderToDTO(&authProvider), nil } - -type buildStoreAccountTOTPOptions struct { - requestID string - accountID int32 -} - -func (s *Services) buildStoreAccountTOTP( - ctx context.Context, - opts buildStoreAccountTOTPOptions, -) crypto.StoreTOTP { - logger := s.buildLogger(opts.requestID, authLocation, "buildStoreAccountTOTP").With( - "AccountID", opts.accountID, - ) - logger.InfoContext(ctx, "Building store account TOTP function...") - - return func(dekKID, encSecret string, hashedCode []byte, url string) *exceptions.ServiceError { - var serviceErr *exceptions.ServiceError - qrs, txn, err := s.database.BeginTx(ctx) - if err != nil { - logger.ErrorContext(ctx, "Failed to start transaction", "error", err) - return exceptions.FromDBError(err) - } - defer func() { - logger.DebugContext(ctx, "Finalizing transaction") - s.database.FinalizeTx(ctx, txn, err, serviceErr) - }() - - id, err := qrs.CreateTotp(ctx, database.CreateTotpParams{ - DekKid: dekKID, - Url: url, - Secret: encSecret, - RecoveryCodes: hashedCode, - Usage: database.TotpUsageAccount, - AccountID: opts.accountID, - }) - if err != nil { - logger.ErrorContext(ctx, "Failed to create TOTP", "error", err) - serviceErr = exceptions.FromDBError(err) - return serviceErr - } - - if err = qrs.CreateAccountTotp(ctx, database.CreateAccountTotpParams{ - AccountID: opts.accountID, - TotpID: id, - }); err != nil { - logger.ErrorContext(ctx, "Failed to create account recovery keys", "error", err) - serviceErr = exceptions.FromDBError(err) - return serviceErr - } - - if err = qrs.UpdateAccountTwoFactorType(ctx, database.UpdateAccountTwoFactorTypeParams{ - TwoFactorType: database.TwoFactorTypeTotp, - ID: opts.accountID, - }); err != nil { - logger.ErrorContext(ctx, "Failed to update account 2FA", "error", err) - serviceErr = exceptions.FromDBError(err) - return serviceErr - } - - return nil - } -} - -type updateAccount2FAOptions struct { - requestID string - id int32 - email string - prev2FAType database.TwoFactorType -} - -func (s *Services) updateAccountTOTP2FA( - ctx context.Context, - opts updateAccount2FAOptions, -) (dtos.AuthDTO, *exceptions.ServiceError) { - logger := s.buildLogger(opts.requestID, authLocation, "updateAccountTOTP2FA").With( - "id", opts.id, - ) - logger.InfoContext(ctx, "Update account TOTP 2FA...") - - totpKey, err := s.crypto.GenerateTotpKey(ctx, crypto.GenerateTotpKeyOptions{ - RequestID: opts.requestID, - Email: opts.email, - GetDEKfn: s.BuildGetEncAccountDEKfn(ctx, BuildGetEncAccountDEKOptions{ - RequestID: opts.requestID, - AccountID: opts.id, - }), - StoreTOTPfn: s.buildStoreAccountTOTP(ctx, buildStoreAccountTOTPOptions{ - requestID: opts.requestID, - accountID: opts.id, - }), - }) - if err != nil { - logger.ErrorContext(ctx, "Failed to generate TOTP", "error", err) - return dtos.AuthDTO{}, exceptions.NewInternalServerError() - } - - accountDTO, serviceErr := s.GetAccountByID(ctx, GetAccountByIDOptions{ - RequestID: opts.requestID, - ID: opts.id, - }) - if serviceErr != nil { - logger.ErrorContext(ctx, "Failed to get account by ID", "error", serviceErr) - return dtos.AuthDTO{}, serviceErr - } - - signedToken, serviceErr := s.crypto.SignToken(ctx, crypto.SignTokenOptions{ - RequestID: opts.requestID, - Token: s.jwt.Create2FAToken(tokens.Account2FATokenOptions{ - PublicID: accountDTO.PublicID, - Version: accountDTO.Version(), - }), - GetJWKfn: s.BuildGetGlobalEncryptedJWKFn(ctx, BuildEncryptedJWKFnOptions{ - RequestID: opts.requestID, - KeyType: database.TokenKeyType2faAuthentication, - TTL: s.jwt.Get2FATTL(), - }), - GetDecryptDEKfn: s.BuildGetGlobalDecDEKFn(ctx, opts.requestID), - GetEncryptDEKfn: s.BuildGetEncGlobalDEKFn(ctx, opts.requestID), - StoreFN: s.BuildUpdateJWKDEKFn(ctx, opts.requestID), - }) - if serviceErr != nil { - logger.ErrorContext(ctx, "Failed to sign 2FA token", "serviceError", serviceErr) - return dtos.AuthDTO{}, serviceErr - } - - return dtos.NewAuthDTOWithData( - signedToken, - "Please scan QR Code with your authentication app", - map[string]string{ - "image": totpKey.Img(), - "recovery_keys": totpKey.Codes(), - }, - s.jwt.Get2FATTL(), - ), nil -} - -func (s *Services) updateAccountEmail2FA( - ctx context.Context, - opts updateAccount2FAOptions, -) (dtos.AuthDTO, *exceptions.ServiceError) { - logger := s.buildLogger(opts.requestID, authLocation, "updateAccountEmail2FA").With( - "id", opts.id, - ) - logger.InfoContext(ctx, "Update account email 2FA...") - - code, err := s.cache.AddTwoFactorCode(ctx, cache.AddTwoFactorCodeOptions{ - RequestID: opts.requestID, - AccountID: opts.id, - }) - if err != nil { - logger.ErrorContext(ctx, "Failed to generate two factor Code", "error", err) - return dtos.AuthDTO{}, exceptions.NewInternalServerError() - } - - if opts.prev2FAType == database.TwoFactorTypeTotp { - var serviceErr *exceptions.ServiceError - qrs, txn, err := s.database.BeginTx(ctx) - if err != nil { - logger.ErrorContext(ctx, "Failed to start transaction", "error", err) - return dtos.AuthDTO{}, exceptions.FromDBError(err) - } - defer func() { - logger.DebugContext(ctx, "Finalizing transaction") - s.database.FinalizeTx(ctx, txn, err, serviceErr) - }() - - if err = qrs.UpdateAccountTwoFactorType(ctx, database.UpdateAccountTwoFactorTypeParams{ - TwoFactorType: database.TwoFactorTypeEmail, - ID: opts.id, - }); err != nil { - logger.ErrorContext(ctx, "Failed to enable 2FA email", "error", err) - serviceErr = exceptions.FromDBError(err) - return dtos.AuthDTO{}, serviceErr - } - - if err := qrs.DeleteAccountRecoveryKeys(ctx, opts.id); err != nil { - logger.ErrorContext(ctx, "Failed to delete recovery keys", "error", err) - serviceErr = exceptions.FromDBError(err) - return dtos.AuthDTO{}, serviceErr - } - } else { - if err = s.database.UpdateAccountTwoFactorType(ctx, database.UpdateAccountTwoFactorTypeParams{ - TwoFactorType: database.TwoFactorTypeEmail, - ID: opts.id, - }); err != nil { - logger.ErrorContext(ctx, "Failed to enable 2FA email", "error", err) - return dtos.AuthDTO{}, exceptions.FromDBError(err) - } - } - - accountDTO, serviceErr := s.GetAccountByID(ctx, GetAccountByIDOptions{ - RequestID: opts.requestID, - ID: opts.id, - }) - if serviceErr != nil { - logger.ErrorContext(ctx, "Failed to get account by ID", "serviceError", serviceErr) - return dtos.AuthDTO{}, serviceErr - } - - if err := s.mail.Publish2FAEmail(ctx, mailer.TwoFactorEmailOptions{ - RequestID: opts.requestID, - Email: accountDTO.Email, - Name: fmt.Sprintf("%s %s", accountDTO.GivenName, accountDTO.FamilyName), - Code: code, - }); err != nil { - logger.ErrorContext(ctx, "Failed to publish two factor email", "error", err) - return dtos.AuthDTO{}, exceptions.NewInternalServerError() - } - - signedToken, serviceErr := s.crypto.SignToken(ctx, crypto.SignTokenOptions{ - RequestID: opts.requestID, - Token: s.jwt.Create2FAToken(tokens.Account2FATokenOptions{ - PublicID: accountDTO.PublicID, - Version: accountDTO.Version(), - }), - GetJWKfn: s.BuildGetGlobalEncryptedJWKFn(ctx, BuildEncryptedJWKFnOptions{ - RequestID: opts.requestID, - KeyType: database.TokenKeyType2faAuthentication, - TTL: s.jwt.Get2FATTL(), - }), - GetDecryptDEKfn: s.BuildGetGlobalDecDEKFn(ctx, opts.requestID), - GetEncryptDEKfn: s.BuildGetEncGlobalDEKFn(ctx, opts.requestID), - StoreFN: s.BuildUpdateJWKDEKFn(ctx, opts.requestID), - }) - if serviceErr != nil { - logger.ErrorContext(ctx, "Failed to sign 2FA token", "serviceError", serviceErr) - return dtos.AuthDTO{}, exceptions.NewInternalServerError() - } - - return dtos.NewTempAuthDTO(signedToken, "Please provide email two factor code", s.jwt.Get2FATTL()), nil -} - -func (s *Services) disableAccount2FA( - ctx context.Context, - opts updateAccount2FAOptions, -) (dtos.AuthDTO, *exceptions.ServiceError) { - logger := s.buildLogger(opts.requestID, authLocation, "disableAccount2FA").With( - "id", opts.id, - ) - logger.InfoContext(ctx, "Update account TOTP 2FA...") - - if opts.prev2FAType == database.TwoFactorTypeTotp { - var serviceErr *exceptions.ServiceError - qrs, txn, err := s.database.BeginTx(ctx) - if err != nil { - logger.ErrorContext(ctx, "Failed to start transaction", "error", err) - return dtos.AuthDTO{}, exceptions.FromDBError(err) - } - defer func() { - logger.DebugContext(ctx, "Finalizing transaction") - s.database.FinalizeTx(ctx, txn, err, serviceErr) - }() - - if err = qrs.UpdateAccountTwoFactorType(ctx, database.UpdateAccountTwoFactorTypeParams{ - TwoFactorType: database.TwoFactorTypeNone, - ID: opts.id, - }); err != nil { - logger.ErrorContext(ctx, "Failed to disable 2FA", "error", err) - serviceErr = exceptions.FromDBError(err) - return dtos.AuthDTO{}, serviceErr - } - - if err := qrs.DeleteAccountRecoveryKeys(ctx, opts.id); err != nil { - logger.ErrorContext(ctx, "Failed to delete recovery keys", "error", err) - serviceErr = exceptions.FromDBError(err) - return dtos.AuthDTO{}, serviceErr - } - } else { - if err := s.database.UpdateAccountTwoFactorType(ctx, database.UpdateAccountTwoFactorTypeParams{ - TwoFactorType: database.TwoFactorTypeNone, - ID: opts.id, - }); err != nil { - logger.ErrorContext(ctx, "Failed to disable 2FA", "error", err) - return dtos.AuthDTO{}, exceptions.FromDBError(err) - } - } - - accountDTO, serviceErr := s.GetAccountByID(ctx, GetAccountByIDOptions{ - RequestID: opts.requestID, - ID: opts.id, - }) - if serviceErr != nil { - logger.ErrorContext(ctx, "Failed to get account by ID", "serviceError", serviceErr) - return dtos.AuthDTO{}, serviceErr - } - - return s.GenerateFullAuthDTO( - ctx, - logger, - opts.requestID, - &accountDTO, - []tokens.AccountScope{tokens.AccountScopeAdmin}, - "Successfully disabled oauth", - ) -} - -type UpdateAccount2FAOptions struct { - RequestID string - PublicID uuid.UUID - Version int32 - TwoFactorType string - Password string -} - -func (s *Services) UpdateAccount2FA( - ctx context.Context, - opts UpdateAccount2FAOptions, -) (dtos.AuthDTO, *exceptions.ServiceError) { - logger := s.buildLogger(opts.RequestID, authLocation, "UpdateAccount2FA").With( - "publicID", opts.PublicID, - "twoFactorType", opts.TwoFactorType, - ) - logger.InfoContext(ctx, "Updating account 2FA...") - - twoFactorType, serviceErr := mapTwoFactorType(opts.TwoFactorType) - if serviceErr != nil { - logger.WarnContext(ctx, "Invalid two factor type", "serviceError", serviceErr) - return dtos.AuthDTO{}, serviceErr - } - - accountDTO, serviceErr := s.GetAccountByPublicIDAndVersion(ctx, GetAccountByPublicIDAndVersionOptions{ - RequestID: opts.RequestID, - PublicID: opts.PublicID, - Version: opts.Version, - }) - if serviceErr != nil { - return dtos.AuthDTO{}, serviceErr - } - - count, err := s.database.CountAccountAuthProvidersByEmailAndProvider( - ctx, - database.CountAccountAuthProvidersByEmailAndProviderParams{ - Email: accountDTO.Email, - Provider: database.AuthProviderLocal, - }, - ) - if err != nil { - logger.ErrorContext(ctx, "Failed to count auth providers", "error", err) - return dtos.AuthDTO{}, exceptions.NewInternalServerError() - } - if count > 0 { - if opts.Password == "" { - logger.WarnContext(ctx, "Password is required for email auth Provider") - return dtos.AuthDTO{}, exceptions.NewValidationError("password is required") - } - - ok, err := utils.Argon2CompareHash(opts.Password, accountDTO.Password()) - if err != nil { - logger.ErrorContext(ctx, "Failed to compare password hashes", "error", err) - return dtos.AuthDTO{}, exceptions.NewInternalServerError() - } - if !ok { - logger.WarnContext(ctx, "Passwords do not match") - return dtos.AuthDTO{}, exceptions.NewValidationError("Invalid password") - } - } - - if accountDTO.TwoFactorType == twoFactorType { - logger.WarnContext(ctx, "Account already uses given 2FA type", "twoFactorType", twoFactorType) - return dtos.AuthDTO{}, exceptions.NewValidationError("Account already uses given 2FA type") - } - - updateOpts := updateAccount2FAOptions{ - requestID: opts.RequestID, - id: accountDTO.ID(), - email: accountDTO.Email, - prev2FAType: accountDTO.TwoFactorType, - } - if accountDTO.TwoFactorType == database.TwoFactorTypeNone { - switch twoFactorType { - case database.TwoFactorTypeTotp: - logger.InfoContext(ctx, "Enabling TOTP 2FA") - return s.updateAccountTOTP2FA(ctx, updateOpts) - case database.TwoFactorTypeEmail: - logger.InfoContext(ctx, "Enabling email 2FA") - return s.updateAccountEmail2FA(ctx, updateOpts) - default: - logger.WarnContext(ctx, "Unknown two factor type, it must be 'totp' or 'email'") - return dtos.AuthDTO{}, exceptions.NewForbiddenError() - } - } - - if err := s.cache.SaveTwoFactorUpdateRequest(ctx, cache.SaveTwoFactorUpdateRequestOptions{ - RequestID: opts.RequestID, - PrefixType: cache.SensitiveRequestAccountPrefix, - PublicID: accountDTO.PublicID, - TwoFactorType: database.TwoFactorType(opts.TwoFactorType), - DurationSeconds: s.jwt.Get2FATTL(), - }); err != nil { - logger.ErrorContext(ctx, "Failed to save two-factor update request", "error", err) - return dtos.AuthDTO{}, exceptions.NewInternalServerError() - } - - authDTO, serviceErr := s.generate2FAAuth( - ctx, - logger, - opts.RequestID, - &accountDTO, - "Please provide two factor code to confirm two factor update", - ) - if serviceErr != nil { - return dtos.AuthDTO{}, serviceErr - } - - return authDTO, nil -} - -type ConfirmUpdateAccount2FAUpdateOptions struct { - RequestID string - PublicID uuid.UUID - Version int32 - Code string -} - -func (s *Services) ConfirmUpdateAccount2FAUpdate( - ctx context.Context, - opts ConfirmUpdateAccount2FAUpdateOptions, -) (dtos.AuthDTO, *exceptions.ServiceError) { - logger := s.buildLogger(opts.RequestID, authLocation, "ConfirmUpdateAccount2FAUpdate").With( - "publicID", opts.PublicID, - ) - logger.InfoContext(ctx, "Confirming account 2FA update...") - - twoFactorType, err := s.cache.GetTwoFactorUpdateRequest(ctx, cache.GetTwoFactorUpdateRequestOptions{ - RequestID: opts.RequestID, - PrefixType: cache.SensitiveRequestAccountPrefix, - PublicID: opts.PublicID, - }) - if err != nil { - logger.ErrorContext(ctx, "Failed to get two-factor update request", "error", err) - return dtos.AuthDTO{}, exceptions.NewInternalServerError() - } - if twoFactorType == "" { - logger.WarnContext(ctx, "Two-factor update request not found") - return dtos.AuthDTO{}, exceptions.NewUnauthorizedError() - } - - accountDTO, serviceErr := s.GetAccountByPublicIDAndVersion(ctx, GetAccountByPublicIDAndVersionOptions{ - RequestID: opts.RequestID, - PublicID: opts.PublicID, - Version: opts.Version, - }) - if serviceErr != nil { - return dtos.AuthDTO{}, serviceErr - } - - if serviceErr := s.verifyAccountTwoFactor( - ctx, - logger, - opts.RequestID, - &accountDTO, - opts.Code, - ); serviceErr != nil { - return dtos.AuthDTO{}, serviceErr - } - - updateOpts := updateAccount2FAOptions{ - requestID: opts.RequestID, - id: accountDTO.ID(), - email: accountDTO.Email, - prev2FAType: accountDTO.TwoFactorType, - } - switch twoFactorType { - case database.TwoFactorTypeTotp: - logger.InfoContext(ctx, "Enabling TOTP 2FA") - return s.updateAccountTOTP2FA(ctx, updateOpts) - case database.TwoFactorTypeEmail: - logger.InfoContext(ctx, "Enabling email 2FA") - return s.updateAccountEmail2FA(ctx, updateOpts) - case database.TwoFactorTypeNone: - logger.InfoContext(ctx, "Disabling 2FA") - return s.disableAccount2FA(ctx, updateOpts) - default: - return dtos.AuthDTO{}, exceptions.NewForbiddenError() - } -} diff --git a/idp/internal/services/deks.go b/idp/internal/services/deks.go index 44ac62a..a5411d3 100644 --- a/idp/internal/services/deks.go +++ b/idp/internal/services/deks.go @@ -12,6 +12,7 @@ import ( "time" "github.com/google/uuid" + "github.com/jackc/pgx/v5" "github.com/tugascript/devlogs/idp/internal/exceptions" "github.com/tugascript/devlogs/idp/internal/providers/cache" @@ -52,11 +53,13 @@ func (s *Services) buildStoreGlobalDEKfn( ctx context.Context, requestID string, data map[string]string, + queries *database.Queries, ) crypto.StoreDEK { logger := s.buildLogger(requestID, deksLocation, "storeGlobalDEK") logger.InfoContext(ctx, "Building store function for global DEK...") return func(dekID string, encryptedDEK string, kekID uuid.UUID) (int32, *exceptions.ServiceError) { - dekEnt, err := s.database.CreateDataEncryptionKey(ctx, database.CreateDataEncryptionKeyParams{ + qrs := s.mapQueries(queries) + dekEnt, err := qrs.CreateDataEncryptionKey(ctx, database.CreateDataEncryptionKeyParams{ Kid: dekID, KekKid: kekID, Usage: database.DekUsageGlobal, @@ -86,15 +89,20 @@ func (s *Services) buildStoreGlobalDEKfn( } } +type BuildGetGlobalDEKFnOptions struct { + RequestID string + Queries *database.Queries +} + func (s *Services) BuildGetEncGlobalDEKFn( ctx context.Context, - requestID string, + opts BuildGetGlobalDEKFnOptions, ) crypto.GetDEKtoEncrypt { - logger := s.buildLogger(requestID, deksLocation, "BuildGetEncGlobalDEKFn") + logger := s.buildLogger(opts.RequestID, deksLocation, "BuildGetEncGlobalDEKFn") logger.InfoContext(ctx, "Build GetDEKtoEncrypt function...") return func() (crypto.DEKID, crypto.EncryptedDEK, uuid.UUID, *exceptions.ServiceError) { kid, dek, kekKID, ok, err := s.cache.GetEncDEK(ctx, cache.GetEncDEKOptions{ - RequestID: requestID, + RequestID: opts.RequestID, Suffix: "global", }) if err != nil { @@ -106,7 +114,8 @@ func (s *Services) BuildGetEncGlobalDEKFn( return kid, dek, kekKID, nil } - dekEnt, err := s.database.FindValidGlobalDataEncryptionKey(ctx, time.Now().Add(-2*time.Hour)) + qrs := s.mapQueries(opts.Queries) + dekEnt, err := qrs.FindValidGlobalDataEncryptionKey(ctx, time.Now().Add(-2*time.Hour)) if err != nil { serviceErr := exceptions.FromDBError(err) if serviceErr.Code != exceptions.CodeNotFound { @@ -114,7 +123,7 @@ func (s *Services) BuildGetEncGlobalDEKFn( return "", "", uuid.Nil, serviceErr } - kekKID, serviceErr := s.GetOrCreateGlobalKEK(ctx, requestID) + kekKID, serviceErr := s.GetOrCreateGlobalKEK(ctx, opts.RequestID) if serviceErr != nil { logger.ErrorContext(ctx, "Failed to get or create global KEK", "error", serviceErr) return "", "", uuid.Nil, serviceErr @@ -122,9 +131,9 @@ func (s *Services) BuildGetEncGlobalDEKFn( data := make(map[string]string) if serviceErr := s.createDEK(ctx, createDEKOptions{ - requestID: requestID, + requestID: opts.RequestID, kekKID: kekKID, - storeFN: s.buildStoreGlobalDEKfn(ctx, requestID, data), + storeFN: s.buildStoreGlobalDEKfn(ctx, opts.RequestID, data, qrs), }); serviceErr != nil { logger.ErrorContext(ctx, "Failed to create global DEK", "serviceError", serviceErr) return "", "", uuid.Nil, serviceErr @@ -152,14 +161,14 @@ func (s *Services) BuildGetEncGlobalDEKFn( func (s *Services) BuildGetGlobalDecDEKFn( ctx context.Context, - requestID string, + opts BuildGetGlobalDEKFnOptions, ) crypto.GetDEKtoDecrypt { - logger := s.buildLogger(requestID, deksLocation, "BuildGetGlobalDecDEKFn") + logger := s.buildLogger(opts.RequestID, deksLocation, "BuildGetGlobalDecDEKFn") logger.InfoContext(ctx, "Building GetDEKtoDecrypt function for global DEK...") return func(kid string) (crypto.EncryptedDEK, crypto.KEKID, crypto.IsExpiredDEK, *exceptions.ServiceError) { dek, kekKID, expiresAt, ok, err := s.cache.GetDecDEK(ctx, cache.GetDecDEKOptions{ - RequestID: requestID, + RequestID: opts.RequestID, KID: kid, Prefix: "global", }) @@ -174,14 +183,15 @@ func (s *Services) BuildGetGlobalDecDEKFn( return dek, kekKID, now.After(expiresAt), nil } - dekEnt, err := s.database.FindDataEncryptionKeyByKID(ctx, kid) + qrs := s.mapQueries(opts.Queries) + dekEnt, err := qrs.FindDataEncryptionKeyByKID(ctx, kid) if err != nil { logger.ErrorContext(ctx, "Failed to get DEK", "error", err) return "", uuid.Nil, false, exceptions.FromDBError(err) } if err := s.cache.SaveDecDEK(ctx, cache.SaveDecDEKOptions{ - RequestID: requestID, + RequestID: opts.RequestID, DEK: dekEnt.Dek, KID: dekEnt.Kid, KEKid: dekEnt.KekKid, @@ -201,6 +211,7 @@ type buildStoreAccountDEKOptions struct { requestID string accountID int32 data map[string]string + queries *database.Queries } func (s *Services) buildStoreAccountDEKfn( @@ -211,16 +222,23 @@ func (s *Services) buildStoreAccountDEKfn( logger.InfoContext(ctx, "Building store function for account DEK...") return func(dekID string, encryptedDEK string, kekID uuid.UUID) (int32, *exceptions.ServiceError) { + var qrs *database.Queries + var txn pgx.Tx + var err error var serviceErr *exceptions.ServiceError - qrs, txn, err := s.database.BeginTx(ctx) - if err != nil { - logger.ErrorContext(ctx, "Failed to start transaction", "error", err) - return 0, exceptions.FromDBError(err) + if opts.queries != nil { + qrs = opts.queries + } else { + qrs, txn, err = s.database.BeginTx(ctx) + if err != nil { + logger.ErrorContext(ctx, "Failed to start transaction", "error", err) + return 0, exceptions.FromDBError(err) + } + defer func() { + logger.DebugContext(ctx, "Finalizing transaction") + s.database.FinalizeTx(ctx, txn, err, serviceErr) + }() } - defer func() { - logger.DebugContext(ctx, "Finalizing transaction") - s.database.FinalizeTx(ctx, txn, err, serviceErr) - }() dekEnt, err := qrs.CreateDataEncryptionKey(ctx, database.CreateDataEncryptionKeyParams{ Kid: dekID, @@ -266,6 +284,7 @@ func (s *Services) buildStoreAccountDEKfn( type BuildGetEncAccountDEKOptions struct { RequestID string AccountID int32 + Queries *database.Queries } func (s *Services) BuildGetEncAccountDEKfn( @@ -294,7 +313,8 @@ func (s *Services) BuildGetEncAccountDEKfn( } logger.InfoContext(ctx, "DEK not found in cache, checking database...") - dekEnt, err := s.database.FindAccountDataEncryptionKeyByAccountID( + qrs := s.mapQueries(opts.Queries) + dekEnt, err := qrs.FindAccountDataEncryptionKeyByAccountID( ctx, database.FindAccountDataEncryptionKeyByAccountIDParams{ AccountID: opts.AccountID, @@ -325,6 +345,7 @@ func (s *Services) BuildGetEncAccountDEKfn( requestID: opts.RequestID, accountID: opts.AccountID, data: data, + queries: qrs, }), }, ); serviceErr != nil { @@ -366,6 +387,7 @@ func (s *Services) BuildGetEncAccountDEKfn( type BuildGetDecAccountDEKFnOptions struct { RequestID string AccountID int32 + Queries *database.Queries } func (s *Services) BuildGetDecAccountDEKFn( @@ -396,8 +418,9 @@ func (s *Services) BuildGetDecAccountDEKFn( return dek, kekKID, now.After(expiresAt), nil } + qrs := s.mapQueries(opts.Queries) logger.InfoContext(ctx, "DEK not found in cache, checking database...") - dekEnt, err := s.database.FindAccountDataEncryptionKeyByAccountIDAndKID( + dekEnt, err := qrs.FindAccountDataEncryptionKeyByAccountIDAndKID( ctx, database.FindAccountDataEncryptionKeyByAccountIDAndKIDParams{ AccountID: opts.AccountID, diff --git a/idp/internal/services/dtos/account.go b/idp/internal/services/dtos/account.go index 3ebd99b..f2d0dcc 100644 --- a/idp/internal/services/dtos/account.go +++ b/idp/internal/services/dtos/account.go @@ -13,12 +13,11 @@ import ( ) type AccountDTO struct { - PublicID uuid.UUID `json:"id"` - GivenName string `json:"given_name"` - FamilyName string `json:"family_name"` - Email string `json:"email"` - Username string `json:"username"` - TwoFactorType database.TwoFactorType `json:"two_factor_type"` + PublicID uuid.UUID `json:"id"` + GivenName string `json:"given_name"` + FamilyName string `json:"family_name"` + Email string `json:"email"` + Username string `json:"username"` id int32 version int32 @@ -50,7 +49,6 @@ func MapAccountToDTO(account *database.Account) AccountDTO { GivenName: account.GivenName, FamilyName: account.FamilyName, Email: account.Email, - TwoFactorType: account.TwoFactorType, Username: account.Username, emailVerified: account.EmailVerified, password: account.Password.String, diff --git a/idp/internal/services/dtos/account_2fa_config.go b/idp/internal/services/dtos/account_2fa_config.go new file mode 100644 index 0000000..15ae54f --- /dev/null +++ b/idp/internal/services/dtos/account_2fa_config.go @@ -0,0 +1,94 @@ +// Copyright (c) 2025 Afonso Barracha +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package dtos + +import ( + "github.com/tugascript/devlogs/idp/internal/providers/database" +) + +type Account2FATOTPConfigDTO struct { + AccessToken string `json:"access_token"` + Image string `json:"image"` + RecoveryKeys string `json:"recovery_keys"` + ExpiresIn int64 `json:"expires_in"` + Message string `json:"message"` +} + +type Account2FACodeConfigDTO struct { + AccessToken string `json:"access_token"` + ExpiresIn int64 `json:"expires_in"` + Message string `json:"message"` +} + +type Account2FAConfigDTO struct { + id int32 + + TwoFactorType database.TwoFactorType `json:"two_factor_type"` + IsDefault bool `json:"is_default"` + CreatedAt int64 `json:"created_at"` + + // TOTP 2FA + *Account2FATOTPConfigDTO + + // Email & TOTP Deletion 2FA + *Account2FACodeConfigDTO +} + +func (a *Account2FAConfigDTO) ID() int32 { + return a.id +} + +func MapAccount2FAConfigToDTO(account2FAConfig *database.Account2faConfig) Account2FAConfigDTO { + return Account2FAConfigDTO{ + id: account2FAConfig.ID, + TwoFactorType: account2FAConfig.TwoFactorType, + IsDefault: account2FAConfig.IsDefault, + CreatedAt: account2FAConfig.CreatedAt.Unix(), + } +} + +func MapAccount2FAConfigTOTPToDTO( + account2FAConfig *database.Account2faConfig, + accessToken string, + image string, + recoveryKeys string, + expiresIn int64, + message string, +) Account2FAConfigDTO { + return Account2FAConfigDTO{ + id: account2FAConfig.ID, + TwoFactorType: account2FAConfig.TwoFactorType, + IsDefault: account2FAConfig.IsDefault, + CreatedAt: account2FAConfig.CreatedAt.Unix(), + Account2FATOTPConfigDTO: &Account2FATOTPConfigDTO{ + AccessToken: accessToken, + Image: image, + RecoveryKeys: recoveryKeys, + ExpiresIn: expiresIn, + Message: message, + }, + } +} + +func MapAccount2FAConfigCodeToDTO( + account2FAConfig *database.Account2faConfig, + accessToken string, + expiresIn int64, + message string, +) Account2FAConfigDTO { + return Account2FAConfigDTO{ + id: account2FAConfig.ID, + TwoFactorType: account2FAConfig.TwoFactorType, + IsDefault: account2FAConfig.IsDefault, + CreatedAt: account2FAConfig.CreatedAt.Unix(), + Account2FACodeConfigDTO: &Account2FACodeConfigDTO{ + AccessToken: accessToken, + ExpiresIn: expiresIn, + Message: message, + }, + } +} diff --git a/idp/internal/services/dtos/account_credentials.go b/idp/internal/services/dtos/account_credentials.go index e2a1ce1..20d5ede 100644 --- a/idp/internal/services/dtos/account_credentials.go +++ b/idp/internal/services/dtos/account_credentials.go @@ -17,10 +17,21 @@ import ( type AccountCredentialsDTO struct { ClientID string `json:"client_id"` - Alias string `json:"alias"` + Type database.AccountCredentialsType `json:"type"` + Name string `json:"name"` + Domain string `json:"domain"` Scopes []database.AccountCredentialsScope `json:"scopes"` TokenEndpointAuthMethod database.AuthMethod `json:"token_endpoint_auth_method"` - Issuers []string `json:"issuers,omitempty"` + Transport database.Transport `json:"transport"` + CreationMethod database.CreationMethod `json:"creation_method"` + ClientURI string `json:"client_uri"` + RedirectURIs []string `json:"redirect_uris"` + LogoURI string `json:"logo_uri,omitempty"` + TOSURI string `json:"tos_uri,omitempty"` + PolicyURI string `json:"policy_uri,omitempty"` + SoftwareID string `json:"software_id"` + SoftwareVersion string `json:"software_version,omitempty"` + Contacts []string `json:"contacts,omitempty"` ClientSecretID string `json:"client_secret_id,omitempty"` ClientSecret string `json:"client_secret,omitempty"` ClientSecretJWK utils.JWK `json:"client_secret_jwk,omitempty"` @@ -65,12 +76,32 @@ func (ak *AccountCredentialsDTO) UnmarshalJSON(data []byte) error { func MapAccountCredentialsToDTO( accountCredential *database.AccountCredential, ) AccountCredentialsDTO { + var redirectURIs []string + if len(accountCredential.RedirectUris) > 0 { + redirectURIs = accountCredential.RedirectUris + } + + var contacts []string + if len(accountCredential.Contacts) > 0 { + contacts = accountCredential.Contacts + } + return AccountCredentialsDTO{ id: accountCredential.ID, ClientID: accountCredential.ClientID, - Alias: accountCredential.Alias, - Scopes: accountCredential.Scopes, - Issuers: accountCredential.Issuers, + Type: accountCredential.CredentialsType, + Name: accountCredential.Name, + Domain: accountCredential.Domain, + ClientURI: accountCredential.ClientUri, + RedirectURIs: redirectURIs, + LogoURI: accountCredential.LogoUri.String, + TOSURI: accountCredential.TosUri.String, + PolicyURI: accountCredential.PolicyUri.String, + SoftwareID: accountCredential.SoftwareID, + SoftwareVersion: accountCredential.SoftwareVersion.String, + Contacts: contacts, + CreationMethod: accountCredential.CreationMethod, + Transport: accountCredential.Transport, TokenEndpointAuthMethod: accountCredential.TokenEndpointAuthMethod, accountId: accountCredential.AccountID, } @@ -81,17 +112,33 @@ func MapAccountCredentialsToDTOWithJWK( jwk utils.JWK, exp time.Time, ) AccountCredentialsDTO { + var contacts []string + if len(accountKeys.Contacts) > 0 { + contacts = accountKeys.Contacts + } + return AccountCredentialsDTO{ id: accountKeys.ID, - Alias: accountKeys.Alias, + Type: accountKeys.CredentialsType, + Name: accountKeys.Name, + Domain: accountKeys.Domain, + ClientURI: accountKeys.ClientUri, + RedirectURIs: accountKeys.RedirectUris, + LogoURI: accountKeys.LogoUri.String, + TOSURI: accountKeys.TosUri.String, + PolicyURI: accountKeys.PolicyUri.String, + SoftwareID: accountKeys.SoftwareID, + SoftwareVersion: accountKeys.SoftwareVersion.String, + Contacts: contacts, + CreationMethod: accountKeys.CreationMethod, + Transport: accountKeys.Transport, + TokenEndpointAuthMethod: accountKeys.TokenEndpointAuthMethod, + accountId: accountKeys.AccountID, ClientID: accountKeys.ClientID, ClientSecretID: jwk.GetKeyID(), ClientSecretJWK: jwk, ClientSecretExp: exp.Unix(), Scopes: accountKeys.Scopes, - Issuers: accountKeys.Issuers, - TokenEndpointAuthMethod: accountKeys.TokenEndpointAuthMethod, - accountId: accountKeys.AccountID, } } @@ -101,16 +148,32 @@ func MapAccountCredentialsToDTOWithSecret( secret string, exp time.Time, ) AccountCredentialsDTO { + var contacts []string + if len(accountKeys.Contacts) > 0 { + contacts = accountKeys.Contacts + } + return AccountCredentialsDTO{ id: accountKeys.ID, - Alias: accountKeys.Alias, + Type: accountKeys.CredentialsType, + Name: accountKeys.Name, + Domain: accountKeys.Domain, + ClientURI: accountKeys.ClientUri, + RedirectURIs: accountKeys.RedirectUris, + LogoURI: accountKeys.LogoUri.String, + TOSURI: accountKeys.TosUri.String, + PolicyURI: accountKeys.PolicyUri.String, + SoftwareID: accountKeys.SoftwareID, + SoftwareVersion: accountKeys.SoftwareVersion.String, + Contacts: contacts, + CreationMethod: accountKeys.CreationMethod, + Transport: accountKeys.Transport, + TokenEndpointAuthMethod: accountKeys.TokenEndpointAuthMethod, + accountId: accountKeys.AccountID, ClientID: accountKeys.ClientID, ClientSecretID: secretID, ClientSecret: fmt.Sprintf("%s.%s", secretID, secret), ClientSecretExp: exp.Unix(), Scopes: accountKeys.Scopes, - Issuers: accountKeys.Issuers, - TokenEndpointAuthMethod: accountKeys.TokenEndpointAuthMethod, - accountId: accountKeys.AccountID, } } diff --git a/idp/internal/services/dtos/account_dynamic_registration_config.go b/idp/internal/services/dtos/account_dynamic_registration_config.go new file mode 100644 index 0000000..964ece5 --- /dev/null +++ b/idp/internal/services/dtos/account_dynamic_registration_config.go @@ -0,0 +1,38 @@ +// Copyright (c) 2025 Afonso Barracha +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package dtos + +import "github.com/tugascript/devlogs/idp/internal/providers/database" + +type AccountDynamicRegistrationConfigDTO struct { + id int32 + + CredentialsTypes []database.AccountCredentialsType `json:"credentials_types"` + WhitelistedDomains []string `json:"whitelisted_domains"` + RequireSoftwareStatementCredentialTypes []database.AccountCredentialsType `json:"require_software_statement_credential_types"` + SoftwareStatementVerificationMethods []database.SoftwareStatementVerificationMethod `json:"software_statement_verification_methods"` + RequireInitialAccessTokenCredentialTypes []database.AccountCredentialsType `json:"require_initial_access_token_credential_types"` + InitialAccessTokenGenerationMethods []database.InitialAccessTokenGenerationMethod `json:"initial_access_token_generation_methods"` +} + +func (a *AccountDynamicRegistrationConfigDTO) ID() int32 { + return a.id +} + +func MapAccountDynamicRegistrationConfigToDTO( + config *database.AccountDynamicRegistrationConfig, +) AccountDynamicRegistrationConfigDTO { + return AccountDynamicRegistrationConfigDTO{ + id: config.ID, + CredentialsTypes: config.AccountCredentialsTypes, + WhitelistedDomains: config.WhitelistedDomains, + RequireSoftwareStatementCredentialTypes: config.RequireSoftwareStatementCredentialTypes, + SoftwareStatementVerificationMethods: config.SoftwareStatementVerificationMethods, + RequireInitialAccessTokenCredentialTypes: config.RequireInitialAccessTokenCredentialTypes, + InitialAccessTokenGenerationMethods: config.InitialAccessTokenGenerationMethods, + } +} diff --git a/idp/internal/services/dtos/auth_provider.go b/idp/internal/services/dtos/auth_provider.go index fd768d7..64b34b3 100644 --- a/idp/internal/services/dtos/auth_provider.go +++ b/idp/internal/services/dtos/auth_provider.go @@ -13,8 +13,8 @@ import ( ) type AuthProviderDTO struct { - Provider string `json:"provider"` - RegisteredAt string `json:"registered_at"` + Provider database.AuthProvider `json:"provider"` + RegisteredAt string `json:"registered_at"` id int32 } @@ -26,7 +26,7 @@ func (a *AuthProviderDTO) ID() int32 { func MapAccountAuthProviderToDTO(provider *database.AccountAuthProvider) AuthProviderDTO { return AuthProviderDTO{ id: provider.ID, - Provider: string(provider.Provider), + Provider: provider.Provider, RegisteredAt: provider.CreatedAt.Format(time.RFC3339), } } diff --git a/idp/internal/services/dtos/dynamic_registration_domain.go b/idp/internal/services/dtos/dynamic_registration_domain.go new file mode 100644 index 0000000..d15f07b --- /dev/null +++ b/idp/internal/services/dtos/dynamic_registration_domain.go @@ -0,0 +1,70 @@ +// Copyright (c) 2025 Afonso Barracha +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package dtos + +import ( + "fmt" + "time" + + "github.com/tugascript/devlogs/idp/internal/providers/database" +) + +type DynamicRegistrationDomainDTO struct { + id int32 + + Domain string `json:"domain"` + Verified bool `json:"verified"` + VerificationMethod database.DomainVerificationMethod `json:"verification_method"` + VerifiedAt int64 `json:"verified_at,omitempty"` + + VerificationHost string `json:"verification_host,omitempty"` + VerificationPrefix string `json:"verification_prefix,omitempty"` + VerificationCode string `json:"verification_code,omitempty"` + VerificationValue string `json:"verification_value,omitempty"` + VerificationCodeExpiresAt int64 `json:"verification_code_expires_at,omitempty"` +} + +func (a *DynamicRegistrationDomainDTO) ID() int32 { + return a.id +} + +func MapAccountCredentialsRegistrationDomainToDTOWithCode( + domain *database.AccountDynamicRegistrationDomain, + verificationHost string, + verificationPrefix string, + verificationCode string, + expiresAt time.Time, +) DynamicRegistrationDomainDTO { + return DynamicRegistrationDomainDTO{ + id: domain.ID, + Domain: domain.Domain, + VerificationMethod: domain.VerificationMethod, + VerificationHost: verificationHost, + VerificationPrefix: verificationPrefix, + VerificationCode: verificationCode, + VerificationValue: fmt.Sprintf("%s=%s", verificationPrefix, verificationCode), + VerificationCodeExpiresAt: expiresAt.Unix(), + Verified: false, + } +} + +func MapAccountCredentialsRegistrationDomainToDTO( + domain *database.AccountDynamicRegistrationDomain, +) DynamicRegistrationDomainDTO { + verifiedAt := int64(0) + if domain.VerifiedAt.Valid { + verifiedAt = domain.VerifiedAt.Time.Unix() + } + + return DynamicRegistrationDomainDTO{ + id: domain.ID, + Domain: domain.Domain, + Verified: verifiedAt > 0, + VerifiedAt: verifiedAt, + VerificationMethod: domain.VerificationMethod, + } +} diff --git a/idp/internal/services/dtos/dynamic_registration_domain_code.go b/idp/internal/services/dtos/dynamic_registration_domain_code.go new file mode 100644 index 0000000..7fd4ce6 --- /dev/null +++ b/idp/internal/services/dtos/dynamic_registration_domain_code.go @@ -0,0 +1,55 @@ +// Copyright (c) 2025 Afonso Barracha +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package dtos + +import ( + "fmt" + "time" + + "github.com/tugascript/devlogs/idp/internal/providers/database" +) + +type DynamicRegistrationDomainCodeDTO struct { + id int32 + + VerificationHost string `json:"verification_host"` + VerificationPrefix string `json:"verification_prefix"` + VerificationCode string `json:"verification_code,omitempty"` + VerificationValue string `json:"verification_value,omitempty"` + VerificationCodeExpiresAt int64 `json:"verification_code_expires_at"` +} + +func (a *DynamicRegistrationDomainCodeDTO) ID() int32 { + return a.id +} + +func MapDynamicRegistrationDomainCodeToDTO( + domainCode *database.DynamicRegistrationDomainCode, +) DynamicRegistrationDomainCodeDTO { + return DynamicRegistrationDomainCodeDTO{ + id: domainCode.ID, + VerificationHost: domainCode.VerificationHost, + VerificationPrefix: domainCode.VerificationPrefix, + VerificationCode: domainCode.VerificationCode, + VerificationCodeExpiresAt: domainCode.ExpiresAt.Unix(), + } +} + +func CreateDynamicRegistrationDomainCodeDTO( + verificationHost string, + verificationPrefix string, + verificationCode string, + expiresAt time.Time, +) DynamicRegistrationDomainCodeDTO { + return DynamicRegistrationDomainCodeDTO{ + VerificationHost: verificationHost, + VerificationPrefix: verificationPrefix, + VerificationCode: verificationCode, + VerificationValue: fmt.Sprintf("%s=%s", verificationPrefix, verificationCode), + VerificationCodeExpiresAt: expiresAt.Unix(), + } +} diff --git a/idp/internal/services/dtos/initial_access_token.go b/idp/internal/services/dtos/initial_access_token.go new file mode 100644 index 0000000..56e91a3 --- /dev/null +++ b/idp/internal/services/dtos/initial_access_token.go @@ -0,0 +1,7 @@ +// Copyright (c) 2025 Afonso Barracha +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package dtos diff --git a/idp/internal/services/dtos/user.go b/idp/internal/services/dtos/user.go index d8b69d6..22ce422 100644 --- a/idp/internal/services/dtos/user.go +++ b/idp/internal/services/dtos/user.go @@ -16,10 +16,9 @@ import ( ) type UserDTO struct { - PublicID uuid.UUID `json:"id"` - Email string `json:"email"` - Username string `json:"username"` - TwoFactorType database.TwoFactorType `json:"two_factor_type"` + PublicID uuid.UUID `json:"id"` + Email string `json:"email"` + Username string `json:"username"` DataDTO id int32 @@ -55,7 +54,6 @@ func MapUserToDTO(user *database.User) (UserDTO, *exceptions.ServiceError) { PublicID: user.PublicID, Email: user.Email, Username: user.Username, - TwoFactorType: user.TwoFactorType, DataDTO: data, version: user.Version, emailVerified: user.EmailVerified, diff --git a/idp/internal/services/helpers.go b/idp/internal/services/helpers.go index ed8b61f..1f82d3e 100644 --- a/idp/internal/services/helpers.go +++ b/idp/internal/services/helpers.go @@ -7,7 +7,11 @@ package services import ( + "context" + "fmt" "log/slog" + "net" + "net/url" "strings" "github.com/jackc/pgx/v5/pgtype" @@ -18,6 +22,8 @@ import ( ) const ( + helpersLocation string = "helpers" + AuthMethodPrivateKeyJwt string = "private_key_jwt" AuthMethodClientSecretBasic string = "client_secret_basic" AuthMethodClientSecretPost string = "client_secret_post" @@ -55,6 +61,13 @@ func (s *Services) buildLogger(requestID, location, function string) *slog.Logge }) } +func (s *Services) mapQueries(qrs *database.Queries) *database.Queries { + if qrs != nil { + return qrs + } + return s.database.Queries +} + func extractAuthHeaderToken(ah string) (string, *exceptions.ServiceError) { if ah == "" { return "", exceptions.NewUnauthorizedError() @@ -81,7 +94,7 @@ func mapAuthMethod(authMethod string) (database.AuthMethod, *exceptions.ServiceE return database.AuthMethodClientSecretPost, nil case AuthMethodClientSecretJWT: return database.AuthMethodClientSecretJwt, nil - case AuthMethodNone: + case AuthMethodNone, "": return database.AuthMethodNone, nil default: return "", exceptions.NewValidationError("invalid auth method") @@ -164,18 +177,25 @@ func mapScope(scope string) (database.Scopes, *exceptions.ServiceError) { } } -func mapTwoFactorType(twoFactorType string) (database.TwoFactorType, *exceptions.ServiceError) { - if len(twoFactorType) < 4 { - return "", exceptions.NewValidationError("invalid two factor type") +func mapDomain(baseURI string, domain string) (string, *exceptions.ServiceError) { + trimmed := strings.TrimSpace(domain) + if trimmed != "" { + return trimmed, nil } - dbTwoFactorType := database.TwoFactorType(twoFactorType) - switch dbTwoFactorType { - case database.TwoFactorTypeNone, database.TwoFactorTypeEmail, database.TwoFactorTypeTotp: - return dbTwoFactorType, nil - default: - return "", exceptions.NewValidationError("invalid two factor type") + parsed, err := url.Parse(strings.TrimSpace(baseURI)) + if err != nil || parsed == nil { + return "", exceptions.NewValidationError("Invalid client URI") + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return "", exceptions.NewValidationError("Invalid client URI") + } + + host := parsed.Hostname() + if strings.TrimSpace(host) == "" { + return "", exceptions.NewValidationError("Invalid client URI") } + return host, nil } func mapCCSecretStorageMode(authMethod string) database.SecretStorageMode { @@ -188,13 +208,13 @@ func mapCCSecretStorageMode(authMethod string) database.SecretStorageMode { func hashChallenge(challenge, challengeMethod string) (string, *exceptions.ServiceError) { if challengeMethod == "" { - return utils.Sha256HashBase64([]byte(challenge)), nil + return utils.Sha256HashBase64(challenge), nil } switch utils.Lowered(challengeMethod) { case ChallengeMethodS256: return challenge, nil case ChallengeMethodPlain: - return utils.Sha256HashBase64([]byte(challenge)), nil + return utils.Sha256HashBase64(challenge), nil default: return "", exceptions.NewValidationError("Invalid challenge method: " + challengeMethod) } @@ -215,3 +235,39 @@ func mapEmptyString(str string) pgtype.Text { return pgtype.Text{String: strings.TrimSpace(str), Valid: true} } + +type verifyTXTRecordOptions struct { + requestID string + host string + domain string + prefix string + code string +} + +func (s *Services) verifyTXTRecord( + ctx context.Context, + opts verifyTXTRecordOptions, +) *exceptions.ServiceError { + logger := s.buildLogger(opts.requestID, helpersLocation, "verifyTXTRecord").With( + "host", opts.host, + "domain", opts.domain, + "prefix", opts.prefix, + ) + logger.InfoContext(ctx, "Verifying TXT record...") + + records, err := net.LookupTXT(fmt.Sprintf("%s.%s", opts.host, opts.domain)) + if err != nil { + logger.ErrorContext(ctx, "Failed to lookup TXT record", "error", err) + return exceptions.NewValidationError("TXT record not found") + } + + hashSet := utils.SliceToHashSet(records) + value := fmt.Sprintf("%s=%s", opts.prefix, opts.code) + if !hashSet.Contains(value) { + logger.InfoContext(ctx, "TXT code not found in records") + return exceptions.NewValidationError("TXT code not found in records") + } + + logger.InfoContext(ctx, "TXT code found in records") + return nil +} diff --git a/idp/internal/services/jwks.go b/idp/internal/services/jwks.go index c45c489..0973312 100644 --- a/idp/internal/services/jwks.go +++ b/idp/internal/services/jwks.go @@ -91,6 +91,7 @@ type buildStoreGlobalJWKfnOptions struct { requestID string keyType database.TokenKeyType data map[string]string + queries *database.Queries } func (s *Services) buildStoreGlobalJWKfn( @@ -113,7 +114,8 @@ func (s *Services) buildStoreGlobalJWKfn( } logger.InfoContext(ctx, "Storing global JWK", "kid", kid, "cryptoSuite", dbCryptoSuite) - id, err := s.database.CreateTokenSigningKey(ctx, database.CreateTokenSigningKeyParams{ + qrs := s.mapQueries(opts.queries) + id, err := qrs.CreateTokenSigningKey(ctx, database.CreateTokenSigningKeyParams{ Kid: kid, KeyType: opts.keyType, PublicKey: pubKeyBytes, @@ -151,6 +153,7 @@ type BuildEncryptedJWKFnOptions struct { RequestID string KeyType database.TokenKeyType TTL int64 + Queries *database.Queries } func (s *Services) BuildGetGlobalEncryptedJWKFn( @@ -183,7 +186,8 @@ func (s *Services) BuildGetGlobalEncryptedJWKFn( } logger.InfoContext(ctx, "JWK not found in cache, checking database...") - jwkEnt, err := s.database.FindGlobalTokenSigningKey(ctx, database.FindGlobalTokenSigningKeyParams{ + qrs := s.mapQueries(opts.Queries) + jwkEnt, err := qrs.FindGlobalTokenSigningKey(ctx, database.FindGlobalTokenSigningKeyParams{ KeyType: opts.KeyType, ExpiresAt: time.Now().Add(-1 * (time.Hour + time.Duration(opts.TTL)*time.Second)), }) @@ -200,11 +204,15 @@ func (s *Services) BuildGetGlobalEncryptedJWKFn( requestID: opts.RequestID, keyType: opts.KeyType, cryptoSuite: cryptoSuite, - getDEKfn: s.BuildGetEncGlobalDEKFn(ctx, opts.RequestID), + getDEKfn: s.BuildGetEncGlobalDEKFn(ctx, BuildGetGlobalDEKFnOptions{ + RequestID: opts.RequestID, + Queries: qrs, + }), storeFN: s.buildStoreGlobalJWKfn(ctx, buildStoreGlobalJWKfnOptions{ requestID: opts.RequestID, keyType: opts.KeyType, data: data, + queries: qrs, }), }); serviceErr != nil { logger.ErrorContext(ctx, "Failed to create JWK", "serviceError", serviceErr) @@ -730,22 +738,28 @@ func (s *Services) GetAndCacheAccountDistributedJWK( return etag, jwks, nil } +type BuildUpdateJWKDEKFnOptions struct { + RequestID string + Queries *database.Queries +} + func (s *Services) BuildUpdateJWKDEKFn( ctx context.Context, - requestID string, + opts BuildUpdateJWKDEKFnOptions, ) crypto.StoreReEncryptedData { - logger := s.buildLogger(requestID, jwkLocation, "BuildUpdateJWKDEKFn") + logger := s.buildLogger(opts.RequestID, jwkLocation, "BuildUpdateJWKDEKFn") logger.InfoContext(ctx, "Building update JWK DEK function...") return func(kid crypto.EntityID, dekID crypto.DEKID, encPrivKey crypto.DEKCiphertext) *exceptions.ServiceError { logger.InfoContext(ctx, "Updating JWK DEK...") - jwkEnt, err := s.database.FindTokenSigningKeyByKID(ctx, kid) + qrs := s.mapQueries(opts.Queries) + jwkEnt, err := qrs.FindTokenSigningKeyByKID(ctx, kid) if err != nil { logger.ErrorContext(ctx, "Failed to get JWK from database", "error", err) return exceptions.FromDBError(err) } - if err := s.database.UpdateTokenSigningKeyDEKAndPrivateKey( + if err := qrs.UpdateTokenSigningKeyDEKAndPrivateKey( ctx, database.UpdateTokenSigningKeyDEKAndPrivateKeyParams{ ID: jwkEnt.ID, diff --git a/idp/internal/services/keks.go b/idp/internal/services/keks.go index 87665ef..c8a6470 100644 --- a/idp/internal/services/keks.go +++ b/idp/internal/services/keks.go @@ -12,6 +12,7 @@ import ( "time" "github.com/google/uuid" + "github.com/jackc/pgx/v5" "github.com/tugascript/devlogs/idp/internal/exceptions" "github.com/tugascript/devlogs/idp/internal/providers/cache" @@ -152,6 +153,7 @@ func (s *Services) GetOrCreateGlobalKEK( type createAndCacheAccountKEKOptions struct { requestID string accountID int32 + queries *database.Queries } func (s *Services) createAndCacheAccountKEK( @@ -161,16 +163,23 @@ func (s *Services) createAndCacheAccountKEK( logger := s.buildLogger(opts.requestID, keksLocation, "createAndCacheAccountKEK") logger.InfoContext(ctx, "Creating and caching account KEK...") + var qrs *database.Queries + var txn pgx.Tx + var err error var serviceErr *exceptions.ServiceError - qrs, txn, err := s.database.BeginTx(ctx) - if err != nil { - logger.ErrorContext(ctx, "Failed to start transaction", "error", err) - return uuid.Nil, exceptions.FromDBError(err) + if opts.queries != nil { + qrs = opts.queries + } else { + qrs, txn, err = s.database.BeginTx(ctx) + if err != nil { + logger.ErrorContext(ctx, "Failed to start transaction", "error", err) + return uuid.Nil, exceptions.FromDBError(err) + } + defer func() { + logger.DebugContext(ctx, "Finalizing transaction") + s.database.FinalizeTx(ctx, txn, err, serviceErr) + }() } - defer func() { - logger.DebugContext(ctx, "Finalizing transaction") - s.database.FinalizeTx(ctx, txn, err, serviceErr) - }() dbID, keyID, err := s.crypto.GenerateKEK(ctx, crypto.GenerateKEKOptions{ RequestID: opts.requestID, @@ -213,6 +222,7 @@ func (s *Services) createAndCacheAccountKEK( type getAndCacheAccountKEKOptions struct { requestID string accountID int32 + queries *database.Queries } func (s *Services) getAndCacheAccountKEK( @@ -236,7 +246,8 @@ func (s *Services) getAndCacheAccountKEK( return kek, nil } - kekEntity, err := s.database.FindAccountKeyEncryptionKeyByAccountID(ctx, opts.accountID) + qrs := s.mapQueries(opts.queries) + kekEntity, err := qrs.FindAccountKeyEncryptionKeyByAccountID(ctx, opts.accountID) if err != nil { serviceErr := exceptions.FromDBError(err) if serviceErr.Code != exceptions.CodeNotFound { @@ -267,7 +278,7 @@ func (s *Services) getAndCacheAccountKEK( RequestID: opts.requestID, KEKid: kekEntity.Kid, StoreFN: func(_ uuid.UUID) (int32, error) { - return s.database.RotateKeyEncryptionKey(ctx, database.RotateKeyEncryptionKeyParams{ + return qrs.RotateKeyEncryptionKey(ctx, database.RotateKeyEncryptionKeyParams{ ID: kekEntity.ID, NextRotationAt: time.Now().Add(s.kekExpDays), }) @@ -292,6 +303,7 @@ func (s *Services) getAndCacheAccountKEK( type GetOrCreateAccountKEKOptions struct { RequestID string AccountID int32 + Queries *database.Queries } func (s *Services) GetOrCreateAccountKEK( @@ -304,6 +316,7 @@ func (s *Services) GetOrCreateAccountKEK( kek, serviceErr := s.getAndCacheAccountKEK(ctx, getAndCacheAccountKEKOptions{ requestID: opts.RequestID, accountID: opts.AccountID, + queries: opts.Queries, }) if serviceErr != nil { if serviceErr.Code == exceptions.CodeNotFound { @@ -311,6 +324,7 @@ func (s *Services) GetOrCreateAccountKEK( return s.createAndCacheAccountKEK(ctx, createAndCacheAccountKEKOptions{ requestID: opts.RequestID, accountID: opts.AccountID, + queries: opts.Queries, }) } diff --git a/idp/internal/services/oauth.go b/idp/internal/services/oauth.go index 7193e0e..fa25070 100644 --- a/idp/internal/services/oauth.go +++ b/idp/internal/services/oauth.go @@ -11,7 +11,6 @@ import ( "fmt" "log/slog" "net/url" - "slices" "strings" "time" @@ -335,31 +334,6 @@ func (s *Services) ExtLoginAccount( return queryParams.Encode(), nil } -type verifyOAuthChallengeOptions struct { - requestID string - challenge string - challengeVerifier string -} - -func (s *Services) verifyOAuthChallenge( - ctx context.Context, - opts verifyOAuthChallengeOptions, -) *exceptions.ServiceError { - logger := s.buildLogger(opts.requestID, oauthLocation, "verifyOAuthChallenge") - - hashedVerifier := utils.Sha256HashBase64([]byte(opts.challengeVerifier)) - if !utils.CompareSha256([]byte(hashedVerifier), []byte(opts.challenge)) { - logger.WarnContext(ctx, "OAuth code challenge verification failed", - "challenge", opts.challenge, - "challengeVerifier", opts.challengeVerifier, - ) - return exceptions.NewUnauthorizedError() - } - - logger.InfoContext(ctx, "OAuth code challenge verified successfully") - return nil -} - type AppleLoginAccountOptions struct { RequestID string FirstName string @@ -470,13 +444,14 @@ func (s *Services) OAuthLoginAccount( return dtos.AuthDTO{}, exceptions.NewUnauthorizedError() } - if serviceErr := s.verifyOAuthChallenge(ctx, verifyOAuthChallengeOptions{ - requestID: opts.RequestID, - challenge: oauthData.Challenge, - challengeVerifier: opts.ChallengeVerifier, - }); serviceErr != nil { - logger.WarnContext(ctx, "Failed to verify OAuth challenge", "serviceError", serviceErr) - return dtos.AuthDTO{}, serviceErr + ok, err = utils.CompareShaBase64(oauthData.Challenge, opts.ChallengeVerifier) + if err != nil { + logger.ErrorContext(ctx, "Failed to compare challenge", "error", err) + return dtos.AuthDTO{}, exceptions.NewInternalServerError() + } + if !ok { + logger.WarnContext(ctx, "OAuth Code challenge verification failed") + return dtos.AuthDTO{}, exceptions.NewUnauthorizedError() } accountDTO, serviceErr := s.saveExtAccount(ctx, logger, saveExtAccount{ @@ -494,6 +469,7 @@ func (s *Services) OAuthLoginAccount( return s.GenerateFullAuthDTO( ctx, logger, + s.database.Queries, opts.RequestID, &accountDTO, []tokens.AccountScope{tokens.AccountScopeAdmin}, @@ -624,7 +600,7 @@ func (s *Services) validateAccountJWTClaims( } if opts.claims.Issuer == "" || !utils.IsValidURL(opts.claims.Issuer) || - !slices.Contains(accountClientsDTO.Issuers, utils.ProcessURL(opts.claims.Issuer)) { + fmt.Sprintf("https://%s", accountClientsDTO.Domain) != opts.claims.Issuer { logger.WarnContext(ctx, "JWT Bearer token issuer is not allowed", "issuer", opts.claims.Issuer) return dtos.AccountCredentialsDTO{}, exceptions.NewForbiddenError() } @@ -742,9 +718,15 @@ func (s *Services) generateClientCredentialsAuthentication( KeyType: database.TokenKeyTypeClientCredentials, TTL: s.jwt.GetAccountCredentialsTTL(), }), - GetDecryptDEKfn: s.BuildGetGlobalDecDEKFn(ctx, opts.requestID), - GetEncryptDEKfn: s.BuildGetEncGlobalDEKFn(ctx, opts.requestID), - StoreFN: s.BuildUpdateJWKDEKFn(ctx, opts.requestID), + GetDecryptDEKfn: s.BuildGetGlobalDecDEKFn(ctx, BuildGetGlobalDEKFnOptions{ + RequestID: opts.requestID, + }), + GetEncryptDEKfn: s.BuildGetEncGlobalDEKFn(ctx, BuildGetGlobalDEKFnOptions{ + RequestID: opts.requestID, + }), + StoreFN: s.BuildUpdateJWKDEKFn(ctx, BuildUpdateJWKDEKFnOptions{ + RequestID: opts.requestID, + }), }) if serviceErr != nil { logger.ErrorContext(ctx, "Failed to sign access token", "serviceError", serviceErr) diff --git a/idp/internal/services/oauth_dynamic_registration.go b/idp/internal/services/oauth_dynamic_registration.go new file mode 100644 index 0000000..0996752 --- /dev/null +++ b/idp/internal/services/oauth_dynamic_registration.go @@ -0,0 +1,1566 @@ +// Copyright (c) 2025 Afonso Barracha +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package services + +import ( + "context" + "net/url" + "slices" + + "github.com/google/uuid" + "golang.org/x/net/publicsuffix" + + "github.com/tugascript/devlogs/idp/internal/controllers/paths" + "github.com/tugascript/devlogs/idp/internal/exceptions" + "github.com/tugascript/devlogs/idp/internal/providers/cache" + "github.com/tugascript/devlogs/idp/internal/providers/crypto" + "github.com/tugascript/devlogs/idp/internal/providers/database" + "github.com/tugascript/devlogs/idp/internal/providers/mailer" + "github.com/tugascript/devlogs/idp/internal/providers/oauth" + "github.com/tugascript/devlogs/idp/internal/providers/tokens" + "github.com/tugascript/devlogs/idp/internal/services/dtos" + "github.com/tugascript/devlogs/idp/internal/services/templates" + "github.com/tugascript/devlogs/idp/internal/utils" +) + +const oauthDynamicRegistrationLocation string = "oauth_dynamic_registration" + +const ( + oauthDynamicRegistrationIATPath string = paths.V1 + paths.AccountsBase + paths.CredentialsBase + paths.DynamicRegistrationBase + paths.InitialAccessToken + oauthDynamicRegistrationIATAuthPath string = oauthDynamicRegistrationIATPath + paths.OAuthAuth +) + +type buildOAuthDynamicRegistrationIATLoginURLOptions struct { + accClientID string + domain string + state string + challenge string + challengeMethod string + redirectURI string +} + +func buildOAuthDynamicRegistrationIATLoginURL(opts buildOAuthDynamicRegistrationIATLoginURLOptions) string { + queryParams := make(url.Values) + queryParams.Add("client_id", opts.domain) + queryParams.Add("response_type", "code") + queryParams.Add("state", opts.state) + queryParams.Add("code_challenge", opts.challenge) + if opts.challengeMethod != "" { + queryParams.Add("code_challenge_method", opts.challengeMethod) + } + return oauthDynamicRegistrationIATPath + "/" + opts.accClientID + paths.OAuthAuth + paths.AuthLogin + "?" + queryParams.Encode() +} + +type buildOAuthDynamicRegistrationIATCallbackURLOptions struct { + redirectURI string + code string + state string + backendDomain string +} + +func buildOAuthDynamicRegistrationIATCallbackURL(opts buildOAuthDynamicRegistrationIATCallbackURLOptions) string { + queryParams := make(url.Values) + queryParams.Add("code", opts.code) + queryParams.Add("state", opts.state) + queryParams.Add("iss", "https://"+opts.backendDomain) + return opts.redirectURI + "?" + queryParams.Encode() +} + +type generateOAuthDynamicRegistrationIATCallbackOptions struct { + requestID string + clientID string + accountPublicID uuid.UUID + accountVersion int32 + challenge string + domain string + redirectURI string + state string + backendDomain string +} + +func (s *Services) generateOAuthDynamicRegistrationIATCallback( + ctx context.Context, + opts generateOAuthDynamicRegistrationIATCallbackOptions, +) (string, *exceptions.ServiceError) { + logger := s.buildLogger( + opts.requestID, + oauthDynamicRegistrationLocation, + "generateOAuthDynamicRegistrationIATCallback", + ).With( + "clientId", opts.clientID, + "accountPublicId", opts.accountPublicID, + ) + logger.InfoContext(ctx, "Generating OAuth dynamic registration IAT callback...") + + code, err := s.cache.GenerateAccountCredentialsRegistrationIATCode( + ctx, + cache.GenerateAccountCredentialsRegistrationIATCodeOptions{ + RequestID: opts.requestID, + ClientID: opts.clientID, + AccountPublicID: opts.accountPublicID, + AccountVersion: opts.accountVersion, + Challenge: opts.challenge, + Domain: opts.domain, + }, + ) + if err != nil { + logger.ErrorContext(ctx, "Failed to generate account credentials registration IAT code", "error", err) + return "", exceptions.NewInternalServerError() + } + + return buildOAuthDynamicRegistrationIATCallbackURL(buildOAuthDynamicRegistrationIATCallbackURLOptions{ + redirectURI: opts.redirectURI, + code: code, + state: opts.state, + backendDomain: opts.backendDomain, + }), nil +} + +type oauthDynamicRegistrationIATAuthOptions struct { + requestID string + challenge string + challengeMethod string + domain string + redirectURI string + state string +} + +func (s *Services) oauthDynamicRegistrationIATAuth( + ctx context.Context, + opts oauthDynamicRegistrationIATAuthOptions, +) (string, *exceptions.ServiceError) { + logger := s.buildLogger( + opts.requestID, + oauthDynamicRegistrationLocation, + "oauthDynamicRegistrationIATAuth", + ).With( + "domain", opts.domain, + "redirectUri", opts.redirectURI, + ) + logger.InfoContext(ctx, "Handling OAuth dynamic registration IAT auth...") + + tldOneDomain, err := publicsuffix.EffectiveTLDPlusOne(opts.domain) + if err != nil { + logger.WarnContext(ctx, "Invalid domain", "error", err) + return "", exceptions.NewValidationError("invalid client_id") + } + + var count int64 + if tldOneDomain != opts.domain { + count, err = s.database.CountVerifiedAccountDynamicRegistrationDomainsByDomains( + ctx, + []string{opts.domain, tldOneDomain}, + ) + if err != nil { + logger.ErrorContext(ctx, "Failed to count account dynamic registration domains by domains", "error", err) + return "", exceptions.NewInternalServerError() + } + if count == 0 { + logger.WarnContext(ctx, "Domain not registered for dynamic registration") + return "", exceptions.NewForbiddenError() + } + } else { + count, err = s.database.CountVerifiedAccountDynamicRegistrationDomainsByDomain(ctx, opts.domain) + } + if err != nil { + logger.ErrorContext(ctx, "Failed to count account dynamic registration domains by domains", "error", err) + return "", exceptions.NewInternalServerError() + } + if count == 0 { + logger.WarnContext(ctx, "Domain not registered for dynamic registration") + return "", exceptions.NewForbiddenError() + } + + hashedChallenge, serviceErr := hashChallenge(opts.challenge, opts.challengeMethod) + if serviceErr != nil { + logger.ErrorContext(ctx, "Invalid code challenge", "serviceError", serviceErr) + return "", serviceErr + } + + clientID, err := s.cache.SaveAccountCredentialsDynamicRegistrationIATAuth( + ctx, + cache.SaveAccountCredentialsDynamicRegistrationIATAuthOptions{ + Domain: opts.domain, + RequestID: opts.requestID, + State: opts.state, + RedirectURI: opts.redirectURI, + Challenge: hashedChallenge, + }, + ) + if err != nil { + logger.ErrorContext(ctx, "Failed to save account credentials dynamic registration IAT auth", "error", err) + return "", exceptions.NewInternalServerError() + } + + return buildOAuthDynamicRegistrationIATLoginURL(buildOAuthDynamicRegistrationIATLoginURLOptions{ + accClientID: clientID, + domain: opts.domain, + state: opts.state, + challenge: opts.challenge, + challengeMethod: opts.challengeMethod, + redirectURI: opts.redirectURI, + }), nil +} + +type refreshTokenOAuthDynamicRegistrationIATLoginOptions struct { + requestID string + refreshToken string + challenge string + challengeMethod string + domain string + redirectURI string + state string + backendDomain string +} + +func (s *Services) refreshTokenOAuthDynamicRegistrationIATLogin( + ctx context.Context, + opts refreshTokenOAuthDynamicRegistrationIATLoginOptions, +) (string, *exceptions.ServiceError) { + logger := s.buildLogger( + opts.requestID, + oauthDynamicRegistrationLocation, + "refreshTokenOAuthDynamicRegistrationIATLogin", + ).With( + "domain", opts.domain, + "redirectUri", opts.redirectURI, + ) + logger.InfoContext(ctx, "Refreshing OAuth dynamic registration IAT callback...") + + data, err := s.jwt.VerifyRefreshToken( + opts.refreshToken, + s.BuildGetGlobalPublicKeyFn(ctx, BuildGetGlobalVerifyKeyFnOptions{ + RequestID: opts.requestID, + KeyType: database.TokenKeyTypeRefresh, + }), + ) + if err != nil { + logger.WarnContext(ctx, "Invalid refresh token", "error", err) + return buildOAuthDynamicRegistrationIATLoginURL(buildOAuthDynamicRegistrationIATLoginURLOptions{ + domain: opts.domain, + state: opts.state, + challenge: opts.challenge, + challengeMethod: opts.challengeMethod, + redirectURI: opts.redirectURI, + }), nil + } + + if !slices.ContainsFunc(data.Scopes, func(s string) bool { + return s == tokens.AccountScopeAdmin || s == tokens.AccountScopeCredentialsWrite + }) { + logger.WarnContext(ctx, "Refresh token missing offline_access scope") + return buildOAuthDynamicRegistrationIATLoginURL(buildOAuthDynamicRegistrationIATLoginURLOptions{ + domain: opts.domain, + state: opts.state, + challenge: opts.challenge, + challengeMethod: opts.challengeMethod, + redirectURI: opts.redirectURI, + }), nil + } + + blt, err := s.database.GetRevokedToken(ctx, data.TokenID) + if err != nil { + if exceptions.FromDBError(err).Code != exceptions.CodeNotFound { + logger.ErrorContext(ctx, "Failed to get blacklisted token", "error", err) + return "", exceptions.NewInternalServerError() + } + } else { + logger.WarnContext(ctx, "Token is revoked", "revokedAt", blt.CreatedAt) + return buildOAuthDynamicRegistrationIATLoginURL(buildOAuthDynamicRegistrationIATLoginURLOptions{ + domain: opts.domain, + state: opts.state, + challenge: opts.challenge, + challengeMethod: opts.challengeMethod, + redirectURI: opts.redirectURI, + }), nil + } + + accountDTO, serviceErr := s.GetAccountByPublicIDAndVersion(ctx, GetAccountByPublicIDAndVersionOptions{ + RequestID: opts.requestID, + PublicID: data.AccountClaims.AccountID, + Version: data.AccountClaims.AccountVersion, + }) + if serviceErr != nil { + if serviceErr.Code != exceptions.CodeNotFound && serviceErr.Code != exceptions.CodeUnauthorized { + logger.ErrorContext(ctx, "Failed to get account by public ID and version", "serviceError", serviceErr) + return "", serviceErr + } + + logger.WarnContext(ctx, "Account not found or version mismatch", "serviceError", serviceErr) + return s.oauthDynamicRegistrationIATAuth(ctx, oauthDynamicRegistrationIATAuthOptions{ + requestID: opts.requestID, + challenge: opts.challenge, + challengeMethod: opts.challengeMethod, + domain: opts.domain, + redirectURI: opts.redirectURI, + state: opts.state, + }) + } + + hashedChallenge, serviceErr := hashChallenge(opts.challenge, opts.challengeMethod) + if serviceErr != nil { + logger.ErrorContext(ctx, "Invalid code challenge", "serviceError", serviceErr) + return "", serviceErr + } + + cbURL, serviceErr := s.generateOAuthDynamicRegistrationIATCallback( + ctx, + generateOAuthDynamicRegistrationIATCallbackOptions{ + requestID: opts.requestID, + clientID: utils.Base62UUID(), + accountPublicID: accountDTO.PublicID, + accountVersion: accountDTO.Version(), + challenge: hashedChallenge, + domain: opts.domain, + redirectURI: opts.redirectURI, + state: opts.state, + backendDomain: opts.backendDomain, + }, + ) + if serviceErr != nil { + logger.ErrorContext(ctx, "Failed to generate OAuth dynamic registration IAT callback", "serviceErr", serviceErr) + return "", serviceErr + } + + return cbURL, nil +} + +type InitiateOAuthDynamicRegistrationIATAuthOptions struct { + RequestID string + Domain string + State string + SessionKey string + RefreshToken string + Challenge string + ChallengeMethod string + RedirectURI string + BackendDomain string +} + +func (s *Services) InitiateOAuthDynamicRegistrationIATAuth( + ctx context.Context, + opts InitiateOAuthDynamicRegistrationIATAuthOptions, +) (string, *exceptions.ServiceError) { + logger := s.buildLogger( + opts.RequestID, + oauthDynamicRegistrationLocation, + "InitiateOAuthDynamicRegistrationIATAuth", + ).With( + "redirectUri", opts.RedirectURI, + ) + logger.InfoContext(ctx, "Starting OAuth dynamic registration IAT authorization...") + + if opts.SessionKey == "" { + if opts.RefreshToken == "" { + logger.InfoContext(ctx, "No session key or refresh token provided, redirecting to login") + return s.oauthDynamicRegistrationIATAuth(ctx, oauthDynamicRegistrationIATAuthOptions{ + requestID: opts.RequestID, + challenge: opts.Challenge, + challengeMethod: opts.ChallengeMethod, + domain: opts.Domain, + redirectURI: opts.RedirectURI, + state: opts.State, + }) + } + + logger.InfoContext(ctx, "No session key provided, attempting to refresh with refresh token") + return s.refreshTokenOAuthDynamicRegistrationIATLogin( + ctx, + refreshTokenOAuthDynamicRegistrationIATLoginOptions{ + requestID: opts.RequestID, + refreshToken: opts.RefreshToken, + challenge: opts.Challenge, + challengeMethod: opts.ChallengeMethod, + domain: opts.Domain, + redirectURI: opts.RedirectURI, + state: opts.State, + backendDomain: opts.BackendDomain, + }, + ) + } + + data, credsClientID, verified, found, err := s.cache.VerifyAccountCredentialsRegistrationSessionKey( + ctx, + cache.VerifyAccountCredentialsRegistrationSessionKeyOptions{ + RequestID: opts.RequestID, + SessionKey: opts.SessionKey, + Domain: opts.Domain, + }, + ) + if err != nil { + logger.ErrorContext(ctx, "Failed to verify account credentials registration session key", "error", err) + return "", exceptions.NewInternalServerError() + } + if !found { + logger.WarnContext(ctx, "Account credentials registration session key not found") + return "", exceptions.NewUnauthorizedError() + } + + if !verified { + logger.WarnContext(ctx, "Account credentials registration session key is not verified") + return "", exceptions.NewUnauthorizedError() + } + + if err := s.cache.DeleteAccountCredentialsRegistrationSessionKey( + ctx, + cache.DeleteAccountCredentialsRegistrationSessionKeyOptions{ + RequestID: opts.RequestID, + ClientID: credsClientID, + }, + ); err != nil { + logger.ErrorContext(ctx, "Failed to delete account credentials registration session key", "error", err) + return "", exceptions.NewInternalServerError() + } + + accountDTO, serviceErr := s.GetAccountByPublicIDAndVersion(ctx, GetAccountByPublicIDAndVersionOptions{ + RequestID: opts.RequestID, + PublicID: data.AccountPublicID, + Version: data.AccountVersion, + }) + if serviceErr != nil { + logger.WarnContext(ctx, "Failed to get account by public ID and version", "serviceError", serviceErr) + return "", serviceErr + } + + hashedChallenge, serviceErr := hashChallenge(opts.Challenge, opts.ChallengeMethod) + if serviceErr != nil { + logger.ErrorContext(ctx, "Invalid code challenge", "serviceError", serviceErr) + return "", serviceErr + } + + logger.InfoContext(ctx, "Successfully verified account credentials registration IAT session key, creating code...") + cbURL, serviceErr := s.generateOAuthDynamicRegistrationIATCallback( + ctx, + generateOAuthDynamicRegistrationIATCallbackOptions{ + requestID: opts.RequestID, + clientID: credsClientID, + accountPublicID: accountDTO.PublicID, + accountVersion: accountDTO.Version(), + challenge: hashedChallenge, + domain: opts.Domain, + redirectURI: opts.RedirectURI, + state: opts.State, + backendDomain: opts.BackendDomain, + }, + ) + if serviceErr != nil { + logger.ErrorContext(ctx, "Failed to generate OAuth dynamic registration IAT callback", "serviceErr", serviceErr) + return "", serviceErr + } + + return cbURL, nil +} + +type OAuthDynamicRegistrationIATAuthRenderOptions struct { + RequestID string + ACCClientID string + State string + Domain string + CodeChallenge string + CodeChallengeMethod string + RedirectURI string +} + +func (s *Services) OAuthDynamicRegistrationIATAuthRender( + ctx context.Context, + opts OAuthDynamicRegistrationIATAuthRenderOptions, +) (string, *exceptions.ServiceError) { + logger := s.buildLogger( + opts.RequestID, + oauthDynamicRegistrationLocation, + "OAuthDynamicRegistrationIATAuthRender", + ).With( + "redirectUri", opts.RedirectURI, + ) + logger.InfoContext(ctx, "Starting OAuth dynamic registration IAT authorization html render...") + + data, found, err := s.cache.GetAccountCredentialsDynamicRegistrationAuthIAT( + ctx, + cache.GetAccountCredentialsDynamicRegistrationIATAuthOptions{ + RequestID: opts.RequestID, + ClientID: opts.ACCClientID, + }, + ) + if err != nil { + logger.ErrorContext(ctx, "Failed to get account credentials dynamic registration IAT", "error", err) + return "", exceptions.NewInternalServerError() + } + if !found { + logger.WarnContext(ctx, "Account credentials dynamic registration IAT not found") + return "", exceptions.NewForbiddenError() + } + + if data.Domain != opts.Domain { + logger.WarnContext(ctx, "OAuth Domain does not match", "dataDomain", data.Domain) + return "", exceptions.NewUnauthorizedError() + } + if data.State != opts.State { + logger.WarnContext(ctx, "OAuth State does not match") + return "", exceptions.NewUnauthorizedError() + } + if data.RedirectURI != opts.RedirectURI { + logger.WarnContext(ctx, "OAuth Redirect URI does not match") + return "", exceptions.NewUnauthorizedError() + } + + csrfToken, err := s.cache.SaveAccountCredentialsDynamicRegistrationIATLoginCSRF( + ctx, + cache.SaveAccountCredentialsDynamicRegistrationIATLoginCSRFOptions{ + RequestID: opts.RequestID, + ClientID: opts.ACCClientID, + Domain: opts.Domain, + }, + ) + if err != nil { + logger.ErrorContext(ctx, "Failed to save account credentials dynamic registration IAT auth CSRF token", "error", err) + return "", exceptions.NewInternalServerError() + } + + loginHTML, err := templates.BuildAccountDynamicRegistrationIATAuthTemplate( + templates.AccountDynamicRegistrationIATAuthOptions{ + ACCClientID: opts.ACCClientID, + CSRFToken: csrfToken, + Domain: opts.Domain, + State: opts.State, + CodeChallenge: opts.CodeChallenge, + CodeChallengeMethod: opts.CodeChallengeMethod, + RedirectURI: opts.RedirectURI, + AppleEnabled: s.oauthProviders.IsAppleEnabled(), + FacebookEnabled: s.oauthProviders.IsFacebookEnabled(), + GitHubEnabled: s.oauthProviders.IsGitHubEnabled(), + GoogleEnabled: s.oauthProviders.IsGoogleEnabled(), + MicrosoftEnabled: s.oauthProviders.IsMicrosoftEnabled(), + }, + ) + if err != nil { + logger.ErrorContext(ctx, "Failed to build account dynamic registration IAT auth template", "error", err) + return "", exceptions.NewInternalServerError() + } + + return loginHTML, nil +} + +type OAuthDynamicRegistrationIATAuthReRenderOptions struct { + RequestID string + Errors []string + CSRFToken string + ACCClientID string + State string + Domain string + CodeChallenge string + CodeChallengeMethod string + RedirectURI string +} + +func (s *Services) OAuthDynamicRegistrationIATAuthReRender( + ctx context.Context, + opts OAuthDynamicRegistrationIATAuthReRenderOptions, +) (string, *exceptions.ServiceError) { + logger := s.buildLogger( + opts.RequestID, + oauthDynamicRegistrationLocation, + "OAuthDynamicRegistrationIATAuthReRender", + ).With( + "clientId", opts.ACCClientID, + "redirectUri", opts.RedirectURI, + ) + logger.InfoContext(ctx, "Re-rendering OAuth dynamic registration IAT authorization html...") + + loginHTML, err := templates.BuildAccountDynamicRegistrationIATAuthTemplate( + templates.AccountDynamicRegistrationIATAuthOptions{ + Errors: opts.Errors, + ACCClientID: opts.ACCClientID, + CSRFToken: opts.CSRFToken, + Domain: opts.Domain, + State: opts.State, + CodeChallenge: opts.CodeChallenge, + CodeChallengeMethod: opts.CodeChallengeMethod, + RedirectURI: opts.RedirectURI, + AppleEnabled: s.oauthProviders.IsAppleEnabled(), + FacebookEnabled: s.oauthProviders.IsFacebookEnabled(), + GitHubEnabled: s.oauthProviders.IsGitHubEnabled(), + GoogleEnabled: s.oauthProviders.IsGoogleEnabled(), + MicrosoftEnabled: s.oauthProviders.IsMicrosoftEnabled(), + }, + ) + if err != nil { + logger.ErrorContext(ctx, "Failed to build account dynamic registration IAT auth template", "error", err) + return "", exceptions.NewInternalServerError() + } + + return loginHTML, nil +} + +type OAuthDynamicRegistrationIATLoginOptions struct { + RequestID string + ACCClientID string + Domain string + CSRFToken string + CodeChallenge string + CodeChallengeMethod string + State string + RedirectURI string + Email string + Password string + BackendDomain string +} + +func (s *Services) OAuthDynamicRegistrationIATLogin( + ctx context.Context, + opts OAuthDynamicRegistrationIATLoginOptions, +) (string, string, bool, *exceptions.ServiceError) { + logger := s.buildLogger(opts.RequestID, oauthDynamicRegistrationLocation, "OAuthDynamicRegistrationIATLoginPost").With( + "clientId", opts.ACCClientID, + "domain", opts.Domain, + ) + logger.InfoContext(ctx, "Logging in with OAuth dynamic registration IAT...") + + validCSRF, err := s.cache.VerifyAccountCredentialsDynamicRegistrationIATLoginCSRF( + ctx, + cache.VerifyAccountCredentialsDynamicRegistrationIATLoginCSRFOptions{ + RequestID: opts.RequestID, + ClientID: opts.ACCClientID, + Domain: opts.Domain, + CSRFToken: opts.CSRFToken, + }, + ) + if err != nil { + logger.ErrorContext(ctx, "Failed to verify account credentials dynamic registration IAT auth CSRF token", "error", err) + return "", "", false, exceptions.NewInternalServerError() + } + if !validCSRF { + logger.WarnContext(ctx, "Invalid CSRF token") + return "", "", false, exceptions.NewForbiddenError() + } + + data, found, err := s.cache.GetAccountCredentialsDynamicRegistrationAuthIAT(ctx, cache.GetAccountCredentialsDynamicRegistrationIATAuthOptions{ + RequestID: opts.RequestID, + ClientID: opts.ACCClientID, + }) + if err != nil { + logger.ErrorContext(ctx, "Failed to get account credentials dynamic registration IAT", "error", err) + return "", "", false, exceptions.NewInternalServerError() + } + if !found { + logger.ErrorContext(ctx, "Account credentials dynamic registration IAT not found") + return "", "", false, exceptions.NewNotFoundError() + } + + if data.Domain != opts.Domain { + logger.WarnContext(ctx, "OAuth Domain does not match", "dataDomain", data.Domain) + return "", "", false, exceptions.NewUnauthorizedError() + } + if data.State != opts.State { + logger.WarnContext(ctx, "OAuth State does not match") + return "", "", false, exceptions.NewUnauthorizedError() + } + if data.RedirectURI != opts.RedirectURI { + logger.WarnContext(ctx, "OAuth Redirect URI does not match") + return "", "", false, exceptions.NewUnauthorizedError() + } + + accountDTO, serviceErr := s.GetAccountByEmail(ctx, GetAccountByEmailOptions{ + RequestID: opts.RequestID, + Email: opts.Email, + }) + if serviceErr != nil { + if serviceErr.Code != exceptions.CodeNotFound { + return "", "", false, serviceErr + } + + logger.WarnContext(ctx, "Account was not found", "error", serviceErr) + return "", "", false, exceptions.NewUnauthorizedError() + } + if _, err := s.database.FindAccountAuthProviderByAccountPublicIdAndProvider( + ctx, + database.FindAccountAuthProviderByAccountPublicIdAndProviderParams{ + AccountPublicID: accountDTO.PublicID, + Provider: database.AuthProviderLocal, + }, + ); err != nil { + serviceErr := exceptions.FromDBError(err) + if serviceErr.Code != exceptions.CodeNotFound { + logger.ErrorContext(ctx, "Failed to find account auth provider", "error", err) + return "", "", false, serviceErr + } + + logger.WarnContext(ctx, "Account auth provider not found", "error", err) + return "", "", false, exceptions.NewUnauthorizedError() + } + + passwordVerified, err := utils.Argon2CompareHash(opts.Password, accountDTO.Password()) + if err != nil { + logger.ErrorContext(ctx, "Failed to verify password", "error", err) + return "", "", false, exceptions.NewInternalServerError() + } + if !passwordVerified { + logger.WarnContext(ctx, "Passwords do not match") + return "", "", false, exceptions.NewUnauthorizedError() + } + if !accountDTO.EmailVerified() { + logger.InfoContext(ctx, "Account is not confirmed") + return "", "", false, exceptions.NewForbiddenError() + } + + default2FAConfig, serviceErr := s.getDefaultAccount2FAConfigInternal(ctx, getDefaultAccount2FAConfigInternalOptions{ + requestID: opts.RequestID, + accountPublicID: accountDTO.PublicID, + }) + if serviceErr != nil { + logger.ErrorContext(ctx, "Failed to get default 2FA config", "serviceError", serviceErr) + return "", "", false, serviceErr + } + if default2FAConfig != nil { + logger.InfoContext(ctx, "Two-Factor is enabled, proceeding to 2FA step") + sessionID, err := s.cache.SaveAccountCredentialsDynamicRegistrationIAT2FA( + ctx, + cache.SaveAccountCredentialsDynamicRegistrationIAT2FAOptions{ + RequestID: opts.RequestID, + AccountPublicID: accountDTO.PublicID, + AccountVersion: accountDTO.Version(), + RedirectURI: opts.RedirectURI, + Domain: data.Domain, + ClientID: opts.ACCClientID, + State: data.State, + TwoFAType: string(default2FAConfig.TwoFactorType), + TwoFATTL: s.jwt.Get2FATTL(), + }, + ) + if err != nil { + logger.ErrorContext(ctx, "Failed to save account credentials dynamic registration IAT 2FA", "error", err) + return "", "", false, exceptions.NewInternalServerError() + } + + if default2FAConfig.TwoFactorType == database.TwoFactorTypeEmail { + code, err := s.cache.AddTwoFactorCode(ctx, cache.AddTwoFactorCodeOptions{ + RequestID: opts.RequestID, + AccountID: accountDTO.ID(), + TTL: s.jwt.Get2FATTL(), + }) + if err != nil { + logger.ErrorContext(ctx, "Failed to add two factor code", "error", err) + return "", "", false, exceptions.NewInternalServerError() + } + + if err := s.mail.Publish2FAEmail(ctx, mailer.TwoFactorEmailOptions{ + RequestID: opts.RequestID, + Email: accountDTO.Email, + Name: accountDTO.GivenName, + Code: code, + }); err != nil { + logger.ErrorContext(ctx, "Failed to send two factor code email", "error", err) + return "", "", false, exceptions.NewInternalServerError() + } + + logger.InfoContext(ctx, "Sent two factor code email successfully") + } + + if err := s.cache.DeleteAccountCredentialsDynamicRegistrationIATAuth( + ctx, + cache.DeleteAccountCredentialsDynamicRegistrationIATAuthOptions{ + RequestID: opts.RequestID, + ClientID: opts.ACCClientID, + }, + ); err != nil { + logger.ErrorContext(ctx, "Failed to delete account credentials dynamic registration IAT auth", "error", err) + return "", "", false, exceptions.NewInternalServerError() + } + + queryParams := make(url.Values) + queryParams.Add("client_id", data.Domain) + queryParams.Add("redirect_uri", opts.RedirectURI) + queryParams.Add("state", data.State) + queryParams.Add("code_challenge", opts.CodeChallenge) + if opts.CodeChallengeMethod != "" { + queryParams.Add("code_challenge_method", opts.CodeChallengeMethod) + } + return oauthDynamicRegistrationIATPath + "/" + opts.ACCClientID + paths.OAuthAuth + + paths.AuthLogin + paths.Auth2FA + queryParams.Encode(), sessionID, false, nil + } + + domainDTO, serviceErr := s.GetAccountCredentialsRegistrationDomain(ctx, GetAccountCredentialsRegistrationDomainOptions{ + RequestID: opts.RequestID, + AccountPublicID: accountDTO.PublicID, + Domain: data.Domain, + }) + if serviceErr != nil { + if serviceErr.Code != exceptions.CodeNotFound { + logger.ErrorContext(ctx, "Failed to get account credentials registration domain", "serviceError", serviceErr) + return "", "", false, serviceErr + } + + if err := s.cache.DeleteAccountCredentialsDynamicRegistrationIATAuth( + ctx, + cache.DeleteAccountCredentialsDynamicRegistrationIATAuthOptions{ + RequestID: opts.RequestID, + ClientID: opts.ACCClientID, + }, + ); err != nil { + logger.ErrorContext(ctx, "Failed to delete account credentials dynamic registration IAT auth", "error", err) + return "", "", false, exceptions.NewInternalServerError() + } + + logger.WarnContext(ctx, "Account credentials registration domain not found") + return "", "", false, exceptions.NewForbiddenError() + } + if !domainDTO.Verified { + logger.ErrorContext(ctx, "Account credentials registration domain is not validCSRF") + return "", "", false, exceptions.NewForbiddenError() + } + + sessionKey, err := s.cache.CreateAccountCredentialsRegistrationSessionKey( + ctx, + cache.CreateAccountCredentialsRegistrationSessionKeyOptions{ + RequestID: opts.RequestID, + ClientID: opts.ACCClientID, + Domain: opts.Domain, + AccountPublicID: accountDTO.PublicID, + AccountVersion: accountDTO.Version(), + }, + ) + if err != nil { + logger.ErrorContext(ctx, "Failed to create account credentials registration session", "error", err) + return "", "", false, exceptions.NewInternalServerError() + } + + return oauthDynamicRegistrationIATAuthPath, sessionKey, true, nil +} + +type OAuthDynamicRegistrationIAT2FARenderOptions struct { + RequestID string + Domain string + ACCClientID string + SessionID string + Challenge string + ChallengeMethod string + State string + RedirectURI string +} + +func (s *Services) OAuthDynamicRegistrationIAT2FARender( + ctx context.Context, + opts OAuthDynamicRegistrationIAT2FARenderOptions, +) (string, *exceptions.ServiceError) { + logger := s.buildLogger(opts.RequestID, oauthDynamicRegistrationLocation, "OAuthDynamicRegistrationIAT2FARender").With( + "clientId", opts.ACCClientID, + ) + logger.InfoContext(ctx, "Handling OAuth dynamic registration IAT 2FA...") + + data, found, err := s.cache.GetAccountCredentialsDynamicRegistrationIAT2FA(ctx, cache.GetAccountCredentialsDynamicRegistrationIAT2FAOptions{ + RequestID: opts.RequestID, + SessionID: opts.SessionID, + }) + if err != nil { + logger.ErrorContext(ctx, "Failed to get account credentials dynamic registration IAT 2FA", "error", err) + return "", exceptions.NewInternalServerError() + } + if !found { + logger.WarnContext(ctx, "Failed to get account credentials dynamic registration IAT 2FA") + return "", exceptions.NewUnauthorizedError() + } + + if opts.ACCClientID != data.ClientID { + logger.WarnContext(ctx, "Client IDs do not match", "sessionClientId", data.ClientID) + return "", exceptions.NewUnauthorizedError() + } + if data.Domain != opts.Domain { + logger.WarnContext(ctx, "OAuth Domain does not match", "dataDomain", data.Domain) + return "", exceptions.NewUnauthorizedError() + } + if data.State != opts.State { + logger.WarnContext(ctx, "OAuth State does not match") + return "", exceptions.NewUnauthorizedError() + } + if data.RedirectURI != opts.RedirectURI { + logger.WarnContext(ctx, "OAuth Redirect URI does not match") + return "", exceptions.NewUnauthorizedError() + } + + csrfToken, err := s.cache.SaveAccountCredentialsDynamicRegistrationIAT2FACSRFToken( + ctx, + cache.SaveAccountCredentialsDynamicRegistrationIAT2FACSRFTokenOptions{ + RequestID: opts.RequestID, + SessionID: opts.SessionID, + TwoFATTL: s.jwt.Get2FATTL(), + }, + ) + if err != nil { + logger.ErrorContext(ctx, "Failed to save account credentials dynamic registration IAT 2FA CSRF token", "error", err) + return "", exceptions.NewInternalServerError() + } + + twoFAhtml, err := templates.BuildAccountDynamicRegistrationIAT2FATemplate( + templates.AccountDynamicRegistrationIAT2FAOptions{ + ACCClientID: opts.ACCClientID, + Domain: opts.Domain, + SessionID: opts.SessionID, + CSRFToken: csrfToken, + State: data.State, + CodeChallenge: opts.Challenge, + CodeChallengeMethod: opts.ChallengeMethod, + RedirectURI: data.RedirectURI, + }, + ) + if err != nil { + logger.ErrorContext(ctx, "Failed to build account dynamic registration IAT 2FA template", "error", err) + return "", exceptions.NewInternalServerError() + } + + return twoFAhtml, nil +} + +type OAuthDynamicRegistrationIAT2FAReRenderOptions struct { + RequestID string + Domain string + ACCClientID string + SessionID string + Errors []string + CSRFToken string + Challenge string + ChallengeMethod string + State string + RedirectURI string +} + +func (s *Services) OAuthDynamicRegistrationIAT2FAReRender( + ctx context.Context, + opts OAuthDynamicRegistrationIAT2FAReRenderOptions, +) (string, *exceptions.ServiceError) { + logger := s.buildLogger(opts.RequestID, oauthDynamicRegistrationLocation, "OAuthDynamicRegistrationIAT2FAReRender").With( + "clientId", opts.ACCClientID, + ) + logger.InfoContext(ctx, "Re-rendering OAuth dynamic registration IAT 2FA...") + + twoFAhtml, err := templates.BuildAccountDynamicRegistrationIAT2FATemplate( + templates.AccountDynamicRegistrationIAT2FAOptions{ + Errors: opts.Errors, + ACCClientID: opts.ACCClientID, + Domain: opts.Domain, + SessionID: opts.SessionID, + CSRFToken: opts.CSRFToken, + State: opts.State, + CodeChallenge: opts.Challenge, + CodeChallengeMethod: opts.ChallengeMethod, + RedirectURI: opts.RedirectURI, + }, + ) + if err != nil { + logger.ErrorContext(ctx, "Failed to build account dynamic registration IAT 2FA template", "error", err) + return "", exceptions.NewInternalServerError() + } + + return twoFAhtml, nil +} + +func map2FATypeTokens(twoFAType string) (tokens.TwoFAType, *exceptions.ServiceError) { + switch twoFAType { + case TwoFactorTypeEmail: + return tokens.TwoFATypeEmail, nil + case TwoFactorTypeTotp: + return tokens.TwoFATypeTOTP, nil + default: + return "", exceptions.NewValidationError("invalid two factor type") + } +} + +type OAuthDynamicRegistrationIATVerify2FACodeOptions struct { + RequestID string + ACCClientID string + Domain string + SessionID string + CSRFToken string + Code string + BackendDomain string +} + +func (s *Services) OAuthDynamicRegistrationIATVerify2FACode( + ctx context.Context, + opts OAuthDynamicRegistrationIATVerify2FACodeOptions, +) (string, string, *exceptions.ServiceError) { + logger := s.buildLogger(opts.RequestID, oauthDynamicRegistrationLocation, "OAuthDynamicRegistrationIATVerify2FACode").With( + "clientId", opts.ACCClientID, + "sessionId", opts.SessionID, + ) + logger.InfoContext(ctx, "Verifying OAuth dynamic registration IAT 2FA...") + + verifiedCSRF, err := s.cache.VerifyAccountCredentialsDynamicRegistrationIAT2FACSRFToken( + ctx, + cache.VerifyAccountCredentialsDynamicRegistrationIAT2FACSRFTokenOptions{ + RequestID: opts.RequestID, + SessionID: opts.SessionID, + CSRFToken: opts.CSRFToken, + }, + ) + if err != nil { + logger.ErrorContext(ctx, "Failed to verify account credentials dynamic registration IAT 2FA CSRF token", "error", err) + return "", "", exceptions.NewInternalServerError() + } + if !verifiedCSRF { + logger.WarnContext(ctx, "Invalid CSRF token") + return "", "", exceptions.NewForbiddenError() + } + + data, found, err := s.cache.GetAccountCredentialsDynamicRegistrationIAT2FA(ctx, cache.GetAccountCredentialsDynamicRegistrationIAT2FAOptions{ + RequestID: opts.RequestID, + SessionID: opts.SessionID, + }) + if err != nil { + logger.ErrorContext(ctx, "Failed to get account credentials dynamic registration IAT 2FA", "error", err) + return "", "", exceptions.NewInternalServerError() + } + if !found { + logger.WarnContext(ctx, "Failed to get account credentials dynamic registration IAT 2FA") + return "", "", exceptions.NewUnauthorizedError() + } + if opts.ACCClientID != data.ClientID { + logger.WarnContext(ctx, "Client IDs do not match", "sessionClientId", data.ClientID) + return "", "", exceptions.NewUnauthorizedError() + } + twoFAType, serviceErr := map2FATypeTokens(data.TwoFAType) + if serviceErr != nil { + logger.WarnContext(ctx, "Failed to map two factor type", "serviceError", serviceErr) + return "", "", serviceErr + } + + accountDTO, serviceErr := s.GetAccountByPublicIDAndVersion(ctx, GetAccountByPublicIDAndVersionOptions{ + RequestID: opts.RequestID, + PublicID: data.AccountPublicID, + Version: data.AccountVersion, + }) + if serviceErr != nil { + logger.WarnContext(ctx, "Failed to get account by public ID and version", "serviceError", serviceErr) + return "", "", serviceErr + } + if serviceErr := s.verifyAccount2FAInternal(ctx, verifyAccount2FAInternalOptions{ + requestID: opts.RequestID, + accountID: accountDTO.ID(), + accountPublicID: accountDTO.PublicID, + accountVersion: accountDTO.Version(), + twoFAType: twoFAType, + code: opts.Code, + }); serviceErr != nil { + logger.WarnContext(ctx, "Failed to verify account two factor", "serviceError", serviceErr) + return "", "", serviceErr + } + + domainDTO, serviceErr := s.GetAccountCredentialsRegistrationDomain(ctx, GetAccountCredentialsRegistrationDomainOptions{ + RequestID: opts.RequestID, + AccountPublicID: accountDTO.PublicID, + Domain: data.Domain, + }) + if serviceErr != nil { + if serviceErr.Code != exceptions.CodeNotFound { + logger.ErrorContext(ctx, "Failed to get account credentials registration domain", "serviceError", serviceErr) + return "", "", serviceErr + } + + if err := s.cache.DeleteAccountCredentialsDynamicRegistrationIATAuth( + ctx, + cache.DeleteAccountCredentialsDynamicRegistrationIATAuthOptions{ + RequestID: opts.RequestID, + ClientID: opts.ACCClientID, + }, + ); err != nil { + logger.ErrorContext(ctx, "Failed to delete account credentials dynamic registration IAT auth", "error", err) + return "", "", exceptions.NewInternalServerError() + } + + logger.WarnContext(ctx, "Account credentials registration domain not found") + return "", "", exceptions.NewForbiddenError() + } + if !domainDTO.Verified { + logger.ErrorContext(ctx, "Account credentials registration domain is not verified") + return "", "", exceptions.NewForbiddenError() + } + + sessionKey, err := s.cache.CreateAccountCredentialsRegistrationSessionKey( + ctx, + cache.CreateAccountCredentialsRegistrationSessionKeyOptions{ + RequestID: opts.RequestID, + ClientID: opts.ACCClientID, + Domain: opts.Domain, + AccountPublicID: accountDTO.PublicID, + AccountVersion: accountDTO.Version(), + }, + ) + if err != nil { + logger.ErrorContext(ctx, "Failed to create account credentials registration session", "error", err) + return "", "", exceptions.NewInternalServerError() + } + + return oauthDynamicRegistrationIATAuthPath, sessionKey, nil +} + +// TODO: add external callbacks + +type VerifyOAuthDynamicRegistrationIATCodeOptions struct { + RequestID string + Code string + CodeVerifier string + Domain string +} + +func (s *Services) VerifyOAuthDynamicRegistrationIATCode( + ctx context.Context, + opts VerifyOAuthDynamicRegistrationIATCodeOptions, +) (dtos.AuthDTO, *exceptions.ServiceError) { + logger := s.buildLogger(opts.RequestID, accountCredentialsRegistrationIATLocation, "VerifyOAuthDynamicRegistrationIATCode") + logger.InfoContext(ctx, "Verifying account credentials registration IAT code...") + + data, found, err := s.cache.VerifyAccountCredentialsRegistrationIATCode(ctx, cache.VerifyAccountCredentialsRegistrationIATCodeOptions{ + RequestID: opts.RequestID, + Code: opts.Code, + }) + if err != nil { + logger.ErrorContext(ctx, "Failed to verify account credentials registration IAT code", "error", err) + return dtos.AuthDTO{}, exceptions.NewInternalServerError() + } + if !found { + logger.DebugContext(ctx, "Account credentials registration IAT code not found or invalid") + return dtos.AuthDTO{}, exceptions.NewUnauthorizedError() + } + + if data.Domain != opts.Domain { + logger.WarnContext(ctx, "OAuth Domain does not match", "dataDomain", data.Domain) + return dtos.AuthDTO{}, exceptions.NewUnauthorizedError() + } + + ok, err := utils.CompareShaBase64(data.Challenge, opts.CodeVerifier) + if err != nil { + logger.ErrorContext(ctx, "Failed to compare challenge", "error", err) + return dtos.AuthDTO{}, exceptions.NewInternalServerError() + } + if !ok { + logger.WarnContext(ctx, "OAuth Code challenge verification failed") + return dtos.AuthDTO{}, exceptions.NewUnauthorizedError() + } + + accountDTO, serviceErr := s.GetAccountByPublicIDAndVersion(ctx, GetAccountByPublicIDAndVersionOptions{ + RequestID: opts.RequestID, + PublicID: data.AccountPublicID, + Version: data.AccountVersion, + }) + if serviceErr != nil { + logger.ErrorContext(ctx, "Failed to get account", "serviceError", serviceErr) + return dtos.AuthDTO{}, serviceErr + } + + tldOneDomain, err := publicsuffix.EffectiveTLDPlusOne(opts.Domain) + if err != nil { + logger.WarnContext(ctx, "Invalid domain", "error", err) + return dtos.AuthDTO{}, exceptions.NewValidationError("invalid client_id") + } + + var count int64 + if tldOneDomain != data.Domain { + count, err = s.database.CountVerifiedAccountDynamicRegistrationDomainsByDomainsAndAccountPublicID( + ctx, + database.CountVerifiedAccountDynamicRegistrationDomainsByDomainsAndAccountPublicIDParams{ + AccountPublicID: accountDTO.PublicID, + Domains: []string{data.Domain, tldOneDomain}, + }, + ) + } else { + count, err = s.database.CountVerifiedAccountDynamicRegistrationDomainsByDomainAndAccountPublicID( + ctx, + database.CountVerifiedAccountDynamicRegistrationDomainsByDomainAndAccountPublicIDParams{ + AccountPublicID: accountDTO.PublicID, + Domain: data.Domain, + }, + ) + } + if err != nil { + logger.ErrorContext(ctx, "Failed to count verified account dynamic registration domains by domains and account public ID", "error", err) + return dtos.AuthDTO{}, exceptions.NewInternalServerError() + } + if count == 0 { + logger.WarnContext(ctx, "Account does not have any verified dynamic registration domains matching the OAuth Domain") + return dtos.AuthDTO{}, exceptions.NewForbiddenError() + } + + tokenTTL := s.jwt.GetDynamicRegistrationTTL() + signedToken, serviceErr := s.crypto.SignToken(ctx, crypto.SignTokenOptions{ + RequestID: opts.RequestID, + Token: s.jwt.CreateAccountCredentialsDynamicRegistrationToken(tokens.AccountCredentialsDynamicRegistrationTokenOptions{ + AccountPublicID: accountDTO.PublicID, + AccountVersion: accountDTO.Version(), + Domain: data.Domain, + ClientID: data.ClientID, + }), + GetJWKfn: s.BuildGetGlobalEncryptedJWKFn(ctx, BuildEncryptedJWKFnOptions{ + RequestID: opts.RequestID, + KeyType: database.TokenKeyTypeDynamicRegistration, + TTL: tokenTTL, + }), + GetDecryptDEKfn: s.BuildGetGlobalDecDEKFn(ctx, BuildGetGlobalDEKFnOptions{ + RequestID: opts.RequestID, + }), + GetEncryptDEKfn: s.BuildGetEncGlobalDEKFn(ctx, BuildGetGlobalDEKFnOptions{ + RequestID: opts.RequestID, + }), + StoreFN: s.BuildUpdateJWKDEKFn(ctx, BuildUpdateJWKDEKFnOptions{ + RequestID: opts.RequestID, + }), + }) + if serviceErr != nil { + logger.ErrorContext(ctx, "Failed to sign account credentials registration IAT", "serviceError", serviceErr) + return dtos.AuthDTO{}, serviceErr + } + + logger.InfoContext(ctx, "Verified account credentials registration IAT code successfully") + return dtos.NewAuthDTO(signedToken, tokenTTL), nil +} + +type OAuthDynamicRegistrationIATExtGetOptions struct { + RequestID string + ACCClientID string + Domain string + Provider string + CallbackURL string + RedirectURI string + State string + BackendDomain string +} + +func (s *Services) OAuthDynamicRegistrationIATExtGet( + ctx context.Context, + opts OAuthDynamicRegistrationIATExtGetOptions, +) (string, *exceptions.ServiceError) { + logger := s.buildLogger(opts.RequestID, oauthLocation, "OAuthDynamicRegistrationIATExtGet").With( + "Provider", opts.Provider, + ) + logger.InfoContext(ctx, "External logging in account...") + + authUrlOpts := oauth.AuthorizationURLOptions{ + RequestID: opts.RequestID, + Scopes: make([]oauth.Scope, 0), + RedirectURL: opts.CallbackURL, + } + var oauthUrl, state string + var serviceErr *exceptions.ServiceError + switch opts.Provider { + case AuthProviderApple: + oauthUrl, state, serviceErr = s.oauthProviders.GetAppleAuthorizationURL(ctx, authUrlOpts) + case AuthProviderFacebook: + oauthUrl, state, serviceErr = s.oauthProviders.GetFacebookAuthorizationURL(ctx, authUrlOpts) + case AuthProviderGitHub: + oauthUrl, state, serviceErr = s.oauthProviders.GetGithubAuthorizationURL(ctx, authUrlOpts) + case AuthProviderGoogle: + oauthUrl, state, serviceErr = s.oauthProviders.GetGoogleAuthorizationURL(ctx, authUrlOpts) + case AuthProviderMicrosoft: + oauthUrl, state, serviceErr = s.oauthProviders.GetMicrosoftAuthorizationURL(ctx, authUrlOpts) + default: + logger.ErrorContext(ctx, "Provider must be 'apple', 'facebook', 'github', 'google' and 'microsoft'") + return "", exceptions.NewInternalServerError() + } + if serviceErr != nil { + logger.ErrorContext(ctx, "Failed to get authorization url or State", "error", serviceErr) + return "", serviceErr + } + + data, found, err := s.cache.GetAccountCredentialsDynamicRegistrationAuthIAT(ctx, cache.GetAccountCredentialsDynamicRegistrationIATAuthOptions{ + RequestID: opts.RequestID, + ClientID: opts.ACCClientID, + }) + if err != nil { + logger.ErrorContext(ctx, "Failed to get account credentials dynamic registration IAT", "error", err) + return "", exceptions.NewInternalServerError() + } + if !found { + logger.ErrorContext(ctx, "Account credentials dynamic registration IAT not found") + return "", exceptions.NewNotFoundError() + } + + if data.Domain != opts.Domain { + logger.WarnContext(ctx, "OAuth Domain does not match", "dataDomain", data.Domain) + return "", exceptions.NewUnauthorizedError() + } + if data.State != opts.State { + logger.WarnContext(ctx, "OAuth State does not match") + return "", exceptions.NewUnauthorizedError() + } + if data.RedirectURI != opts.RedirectURI { + logger.WarnContext(ctx, "OAuth Redirect URI does not match") + return "", exceptions.NewUnauthorizedError() + } + + if err := s.cache.SaveAccountCredentialsDynamicRegistrationIATExtAuth(ctx, cache.SaveAccountCredentialsDynamicRegistrationIATExtAuthOptions{ + RequestID: opts.RequestID, + ClientID: opts.ACCClientID, + Domain: opts.Domain, + Provider: opts.Provider, + State: state, + RequestState: opts.State, + }); err != nil { + logger.ErrorContext(ctx, "Failed to save account credentials dynamic registration IAT external auth", "error", err) + return "", exceptions.NewInternalServerError() + } + + logger.InfoContext(ctx, "Saved account credentials dynamic registration IAT external auth successfully") + return oauthUrl, nil +} + +type OAuthDynamicRegistrationIATExtCBOptions struct { + RequestID string + ACCClientID string + Provider string + State string + Code string + RedirectURL string + BackendDomain string +} + +func (s *Services) OAuthDynamicRegistrationIATExtCB( + ctx context.Context, + opts OAuthDynamicRegistrationIATExtCBOptions, +) (string, *exceptions.ServiceError) { + logger := s.buildLogger(opts.RequestID, oauthLocation, "OAuthDynamicRegistrationIATExtCB") + logger.InfoContext(ctx, "External callback for account...") + + data, found, err := s.cache.GetAccountCredentialsDynamicRegistrationIATExtAuth(ctx, cache.GetAccountCredentialsDynamicRegistrationIATExtAuthOptions{ + RequestID: opts.RequestID, + Provider: opts.Provider, + State: opts.State, + }) + if err != nil { + logger.ErrorContext(ctx, "Failed to get account credentials dynamic registration IAT external auth", "error", err) + return "", exceptions.NewInternalServerError() + } + if !found { + logger.ErrorContext(ctx, "Account credentials dynamic registration IAT external auth not found") + return "", exceptions.NewNotFoundError() + } + + if data.ClientID != opts.ACCClientID { + logger.WarnContext(ctx, "Client IDs do not match", "dataClientId", data.ClientID) + return "", exceptions.NewUnauthorizedError() + } + + accessTokenOpts := oauth.AccessTokenOptions{ + RequestID: opts.RequestID, + Code: opts.Code, + Scopes: oauthScopes, + RedirectURL: opts.RedirectURL, + } + var token string + var serviceErr *exceptions.ServiceError + switch opts.Provider { + case AuthProviderFacebook: + token, serviceErr = s.oauthProviders.GetFacebookAccessToken(ctx, accessTokenOpts) + case AuthProviderGitHub: + token, serviceErr = s.oauthProviders.GetGithubAccessToken(ctx, accessTokenOpts) + case AuthProviderGoogle: + token, serviceErr = s.oauthProviders.GetGoogleAccessToken(ctx, accessTokenOpts) + case AuthProviderMicrosoft: + token, serviceErr = s.oauthProviders.GetMicrosoftAccessToken(ctx, accessTokenOpts) + default: + logger.ErrorContext(ctx, "Provider must be 'facebook', 'github', 'google' and 'microsoft'") + return "", exceptions.NewInternalServerError() + } + if serviceErr != nil { + logger.WarnContext(ctx, "Failed to get oauth access token", "error", serviceErr) + return "", serviceErr + } + + authData, found, err := s.cache.GetAccountCredentialsDynamicRegistrationAuthIAT(ctx, cache.GetAccountCredentialsDynamicRegistrationIATAuthOptions{ + RequestID: opts.RequestID, + ClientID: opts.ACCClientID, + }) + if err != nil { + logger.ErrorContext(ctx, "Failed to get account credentials dynamic registration IAT", "error", err) + return "", exceptions.NewInternalServerError() + } + if !found { + logger.ErrorContext(ctx, "Account credentials dynamic registration IAT not found") + return "", exceptions.NewNotFoundError() + } + + if authData.Domain != data.Domain { + logger.WarnContext(ctx, "OAuth Domain does not match", "dataDomain", authData.Domain) + return "", exceptions.NewUnauthorizedError() + } + if authData.State != data.RequestState { + logger.WarnContext(ctx, "OAuth State does not match") + return "", exceptions.NewUnauthorizedError() + } + + userData, serviceErr := s.extOAuthUser(ctx, logger, extOAuthUserOptions{ + requestID: opts.RequestID, + provider: opts.Provider, + token: token, + }) + if serviceErr != nil { + return "", serviceErr + } + + accountDTO, serviceErr := s.GetAccountByEmail(ctx, GetAccountByEmailOptions{ + RequestID: opts.RequestID, + Email: userData.Email, + }) + if serviceErr != nil { + return "", serviceErr + } + if _, serviceErr := s.GetAccountAuthProvider(ctx, GetAccountAuthProviderOptions{ + RequestID: opts.RequestID, + PublicID: accountDTO.PublicID, + Provider: opts.Provider, + }); serviceErr != nil { + if serviceErr.Code != exceptions.CodeNotFound { + logger.ErrorContext(ctx, "Failed to get account auth provider", "serviceError", serviceErr) + return "", serviceErr + } + + logger.WarnContext(ctx, "Account auth provider not found", "serviceError", serviceErr) + return "", exceptions.NewUnauthorizedError() + } + + cbURL, serviceErr := s.generateOAuthDynamicRegistrationIATCallback( + ctx, + generateOAuthDynamicRegistrationIATCallbackOptions{ + requestID: opts.RequestID, + clientID: opts.ACCClientID, + accountPublicID: accountDTO.PublicID, + accountVersion: accountDTO.Version(), + challenge: authData.Challenge, + domain: authData.Domain, + redirectURI: authData.RedirectURI, + state: authData.State, + backendDomain: opts.BackendDomain, + }, + ) + if serviceErr != nil { + logger.ErrorContext(ctx, "Failed to generate OAuth dynamic registration IAT callback", "serviceError", serviceErr) + return "", serviceErr + } + + return cbURL, nil +} + +type OAuthDynamicRegistrationIATExtAppleCBOptions struct { + RequestID string + ACCClientID string + Email string + Code string + State string + RedirectURL string + BackendDomain string +} + +func (s *Services) OAuthDynamicRegistrationIATExtAppleCB( + ctx context.Context, + opts OAuthDynamicRegistrationIATExtAppleCBOptions, +) (string, *exceptions.ServiceError) { + logger := s.buildLogger(opts.RequestID, oauthLocation, "OAuthDynamicRegistrationIATExtAppleCB") + logger.InfoContext(ctx, "External callback for account...") + + data, found, err := s.cache.GetAccountCredentialsDynamicRegistrationIATExtAuth(ctx, cache.GetAccountCredentialsDynamicRegistrationIATExtAuthOptions{ + RequestID: opts.RequestID, + Provider: AuthProviderApple, + State: opts.State, + }) + if err != nil { + logger.ErrorContext(ctx, "Failed to get account credentials dynamic registration IAT external auth", "error", err) + return "", exceptions.NewInternalServerError() + } + if !found { + logger.ErrorContext(ctx, "Account credentials dynamic registration IAT external auth not found") + return "", exceptions.NewNotFoundError() + } + + if data.ClientID != opts.ACCClientID { + logger.WarnContext(ctx, "Client IDs do not match", "dataClientId", data.ClientID) + return "", exceptions.NewUnauthorizedError() + } + + idToken, serviceErr := s.oauthProviders.GetAppleIDToken(ctx, oauth.AccessTokenOptions{ + RequestID: opts.RequestID, + Code: opts.Code, + Scopes: oauthScopes, + }) + if serviceErr != nil { + logger.WarnContext(ctx, "Failed to get apple AccountID token", "error", serviceErr) + return "", serviceErr + } + + authData, found, err := s.cache.GetAccountCredentialsDynamicRegistrationAuthIAT(ctx, cache.GetAccountCredentialsDynamicRegistrationIATAuthOptions{ + RequestID: opts.RequestID, + ClientID: opts.ACCClientID, + }) + if err != nil { + logger.ErrorContext(ctx, "Failed to get account credentials dynamic registration IAT", "error", err) + return "", exceptions.NewInternalServerError() + } + if !found { + logger.ErrorContext(ctx, "Account credentials dynamic registration IAT not found") + return "", exceptions.NewNotFoundError() + } + + if authData.Domain != data.Domain { + logger.WarnContext(ctx, "OAuth Domain does not match", "dataDomain", authData.Domain) + return "", exceptions.NewUnauthorizedError() + } + if authData.State != data.RequestState { + logger.WarnContext(ctx, "OAuth State does not match") + return "", exceptions.NewUnauthorizedError() + } + + ok, serviceErr := s.oauthProviders.ValidateAppleIDToken(ctx, oauth.ValidateAppleIDTokenOptions{ + RequestID: opts.RequestID, + Token: idToken, + Email: opts.Email, + }) + if serviceErr != nil { + logger.ErrorContext(ctx, "Failed to validate apple AccountID token", "error", serviceErr) + return "", serviceErr + } + if !ok { + logger.WarnContext(ctx, "Apple account is not verified") + return "", exceptions.NewUnauthorizedError() + } + + accountDTO, serviceErr := s.GetAccountByEmail(ctx, GetAccountByEmailOptions{ + RequestID: opts.RequestID, + Email: opts.Email, + }) + if serviceErr != nil { + return "", serviceErr + } + if _, serviceErr := s.GetAccountAuthProvider(ctx, GetAccountAuthProviderOptions{ + RequestID: opts.RequestID, + PublicID: accountDTO.PublicID, + Provider: AuthProviderApple, + }); serviceErr != nil { + if serviceErr.Code != exceptions.CodeNotFound { + logger.ErrorContext(ctx, "Failed to get account auth provider", "serviceError", serviceErr) + return "", serviceErr + } + + logger.WarnContext(ctx, "Account auth provider not found", "serviceError", serviceErr) + return "", exceptions.NewUnauthorizedError() + } + + cbURL, serviceErr := s.generateOAuthDynamicRegistrationIATCallback( + ctx, + generateOAuthDynamicRegistrationIATCallbackOptions{ + requestID: opts.RequestID, + clientID: opts.ACCClientID, + accountPublicID: accountDTO.PublicID, + accountVersion: accountDTO.Version(), + challenge: authData.Challenge, + domain: authData.Domain, + redirectURI: authData.RedirectURI, + state: authData.State, + backendDomain: opts.BackendDomain, + }, + ) + if serviceErr != nil { + logger.ErrorContext(ctx, "Failed to generate OAuth dynamic registration IAT callback", "serviceError", serviceErr) + return "", serviceErr + } + + return cbURL, nil +} diff --git a/idp/internal/services/services.go b/idp/internal/services/services.go index a70ac1b..d955264 100644 --- a/idp/internal/services/services.go +++ b/idp/internal/services/services.go @@ -20,19 +20,22 @@ import ( ) type Services struct { - logger *slog.Logger - database *database.Database - cache *cache.Cache - mail *mailer.EmailPublisher - jwt *tokens.Tokens - crypto *crypto.Crypto - oauthProviders *oauth.Providers - kekExpDays time.Duration - dekExpDays time.Duration - jwkExpDays time.Duration - accountCCExpDays time.Duration - appCCExpDays time.Duration - userCCExpDays time.Duration + logger *slog.Logger + database *database.Database + cache *cache.Cache + mail *mailer.EmailPublisher + jwt *tokens.Tokens + crypto *crypto.Crypto + oauthProviders *oauth.Providers + kekExpDays time.Duration + dekExpDays time.Duration + jwkExpDays time.Duration + accountCCExpDays time.Duration + appCCExpDays time.Duration + userCCExpDays time.Duration + hmacSecretExpDays time.Duration + accountDomainVerificationHost string + accountDomainVerificationTTL time.Duration } func NewServices( @@ -49,20 +52,26 @@ func NewServices( accountCCExpDays int64, appCCExpDays int64, userCCExpDays int64, + hmacSecretExpDays int64, + accountDomainVerificationHost string, + accountDomainVerificationTTL int64, ) *Services { return &Services{ - logger: logger.With(utils.BaseLayer, utils.ServicesLogLayer), - database: database, - cache: cache, - mail: mail, - jwt: jwt, - crypto: encrypt, - oauthProviders: oauthProv, - kekExpDays: utils.ToDaysDuration(kekExpDays), - dekExpDays: utils.ToDaysDuration(dekExpDays), - jwkExpDays: utils.ToDaysDuration(jwkExpDays), - accountCCExpDays: utils.ToDaysDuration(accountCCExpDays), - appCCExpDays: utils.ToDaysDuration(appCCExpDays), - userCCExpDays: utils.ToDaysDuration(userCCExpDays), + logger: logger.With(utils.BaseLayer, utils.ServicesLogLayer), + database: database, + cache: cache, + mail: mail, + jwt: jwt, + crypto: encrypt, + oauthProviders: oauthProv, + kekExpDays: utils.ToDaysDuration(kekExpDays), + dekExpDays: utils.ToDaysDuration(dekExpDays), + jwkExpDays: utils.ToDaysDuration(jwkExpDays), + accountCCExpDays: utils.ToDaysDuration(accountCCExpDays), + appCCExpDays: utils.ToDaysDuration(appCCExpDays), + userCCExpDays: utils.ToDaysDuration(userCCExpDays), + hmacSecretExpDays: utils.ToDaysDuration(hmacSecretExpDays), + accountDomainVerificationHost: accountDomainVerificationHost, + accountDomainVerificationTTL: utils.ToSecondsDuration(accountDomainVerificationTTL), } } diff --git a/idp/internal/services/templates/account_dynamic_registration.go b/idp/internal/services/templates/account_dynamic_registration.go new file mode 100644 index 0000000..0eab5dc --- /dev/null +++ b/idp/internal/services/templates/account_dynamic_registration.go @@ -0,0 +1,739 @@ +// Copyright (c) 2025 Afonso Barracha +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package templates + +import ( + "bytes" + "fmt" + "html/template" + "net/url" + "strings" + + "github.com/tugascript/devlogs/idp/internal/controllers/paths" +) + +const accountDynamicRegistrationBaseTemplate = ` + + + + + + + + {{.Title}} + + + +
+
+
+ +
+

{{.Header}}

+
+ %s +
+ + + +` + +func buildEntryAccountDynamicRegistrationTemplate(body string) string { + return fmt.Sprintf(accountDynamicRegistrationBaseTemplate, body) +} + +const baseAccountLoginTitle = "Account Login" + +const formErrors = ` +
+ %s +
+` + +func buildFormErrors(errors []string) string { + return fmt.Sprintf(formErrors, strings.Join(errors, "\n")) +} + +const loginForm = ` +
+ + + + + + + + + + +
+` + +const divider = ` +
+ OR +
+` + +const appleLoginButton = ` + +` + +const facebookLoginButton = ` + +` + +const githubLoginButton = ` + +` + +const googleLoginButton = ` + +` + +const microsoftLoginButton = ` + +` + +const accountDynamicRegistrationLoginTemplateName = "login" + +type accountDynamicRegistrationLoginTemplateData struct { + Title string + Header string + ClientID string + LoginURL string + AppleLoginURL string + FacebookLoginURL string + GithubLoginURL string + GoogleLoginURL string + MicrosoftLoginURL string + RedirectURI string + CodeChallenge string + CodeChallengeMethod string + State string + CSRFToken string +} + +type AccountDynamicRegistrationIATAuthOptions struct { + ACCClientID string + Domain string + CSRFToken string + State string + CodeChallenge string + CodeChallengeMethod string + RedirectURI string + Errors []string + AppleEnabled bool + FacebookEnabled bool + GitHubEnabled bool + GoogleEnabled bool + MicrosoftEnabled bool +} + +func BuildAccountDynamicRegistrationIATAuthTemplate(opts AccountDynamicRegistrationIATAuthOptions) (string, error) { + baseTemplateBody := "" + if len(opts.Errors) > 0 { + baseTemplateBody += buildFormErrors(opts.Errors) + } + + baseURL := paths.V1 + paths.AccountsBase + paths.CredentialsBase + paths.DynamicRegistrationBase + + paths.InitialAccessToken + "/" + opts.ACCClientID + paths.OAuthAuth + data := accountDynamicRegistrationLoginTemplateData{ + Title: baseAccountLoginTitle, + Header: "OAuth Dynamic Client Registration Initial Access Token Login", + LoginURL: baseURL + paths.AuthLogin, + RedirectURI: opts.RedirectURI, + ClientID: opts.Domain, + CodeChallenge: opts.CodeChallenge, + CodeChallengeMethod: opts.CodeChallengeMethod, + State: opts.State, + CSRFToken: opts.CSRFToken, + } + baseTemplateBody += loginForm + + if opts.AppleEnabled || opts.FacebookEnabled || opts.GitHubEnabled || opts.GoogleEnabled || opts.MicrosoftEnabled { + baseTemplateBody += divider + extAuthURL := baseURL + paths.InitialAccessTokenAuthEXT + + // Common URL parameters for all OAuth providers + urlParams := make(url.Values) + urlParams.Add("client_id", opts.Domain) + urlParams.Add("response_type", "code") + urlParams.Add("state", opts.State) + urlParams.Add("code_challenge", opts.CodeChallenge) + if opts.CodeChallengeMethod != "" { + urlParams.Add("code_challenge_method", opts.CodeChallengeMethod) + } + urlParams.Add("redirect_uri", opts.RedirectURI) + + if opts.AppleEnabled { + data.AppleLoginURL = extAuthURL + "/apple" + "?" + urlParams.Encode() + baseTemplateBody += appleLoginButton + } + if opts.FacebookEnabled { + data.FacebookLoginURL = extAuthURL + "/facebook" + "?" + urlParams.Encode() + baseTemplateBody += facebookLoginButton + } + if opts.GitHubEnabled { + data.GithubLoginURL = extAuthURL + "/github" + "?" + urlParams.Encode() + baseTemplateBody += githubLoginButton + } + if opts.GoogleEnabled { + data.GoogleLoginURL = extAuthURL + "/google" + "?" + urlParams.Encode() + baseTemplateBody += googleLoginButton + } + if opts.MicrosoftEnabled { + data.MicrosoftLoginURL = extAuthURL + "/microsoft" + "?" + urlParams.Encode() + baseTemplateBody += microsoftLoginButton + } + } + + loginTemplate := buildEntryAccountDynamicRegistrationTemplate(baseTemplateBody) + t, err := template.New(accountDynamicRegistrationLoginTemplateName).Parse(loginTemplate) + if err != nil { + return "", nil + } + var loginTemplateContent bytes.Buffer + if err := t.Execute(&loginTemplateContent, data); err != nil { + return "", err + } + + return loginTemplateContent.String(), nil +} + +const twoFaTemplate = ` + + + + + + Title + + +
+
+
+ +
+

Two-Factor Authentication

+
+ %s +
+ + + + + + + + + + +
+
+ + +` + +type accountDynamicRegistrationIAT2FAData struct { + TwoFAURL string + ClientID string + SessionID string + CSRFToken string + State string + CodeChallenge string + CodeChallengeMethod string + RedirectURI string +} + +type AccountDynamicRegistrationIAT2FAOptions struct { + Errors []string + ACCClientID string + Domain string + SessionID string + CSRFToken string + State string + CodeChallenge string + CodeChallengeMethod string + RedirectURI string +} + +func BuildAccountDynamicRegistrationIAT2FATemplate(opts AccountDynamicRegistrationIAT2FAOptions) (string, error) { + errDiv := "" + if len(opts.Errors) > 0 { + errDiv = buildFormErrors(opts.Errors) + } + + data := accountDynamicRegistrationIAT2FAData{ + TwoFAURL: paths.V1 + paths.AccountsBase + paths.CredentialsBase + paths.DynamicRegistrationBase + + paths.InitialAccessToken + "/" + opts.ACCClientID + paths.OAuthAuth + paths.AuthLogin + paths.Auth2FA, + ClientID: opts.Domain, + RedirectURI: opts.RedirectURI, + CodeChallenge: opts.CodeChallenge, + CodeChallengeMethod: opts.CodeChallengeMethod, + State: opts.State, + CSRFToken: opts.CSRFToken, + SessionID: opts.SessionID, + } + + t, err := template.New("two_fa").Parse(fmt.Sprintf(twoFaTemplate, errDiv)) + if err != nil { + return "", nil + } + var twoFATemplateContent bytes.Buffer + if err := t.Execute(&twoFATemplateContent, data); err != nil { + return "", err + } + + return twoFATemplateContent.String(), nil +} diff --git a/idp/internal/services/templates/error.go b/idp/internal/services/templates/error.go new file mode 100644 index 0000000..69bf978 --- /dev/null +++ b/idp/internal/services/templates/error.go @@ -0,0 +1,308 @@ +// Copyright (c) 2025 Afonso Barracha +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +package templates + +import ( + "bytes" + "fmt" + "html/template" + "strings" + + "github.com/tugascript/devlogs/idp/internal/utils" +) + +const errorTemplate = ` + + + + + + {{.Status}} {{.ErrorCode}} - Dev Logs + + +
+
+
+ +
+

{{.Status}} {{.ErrorCode}}

+
+ +
+

{{.MessageTitle}}

+ %s +
+
+ + +` + +const InternalServerErrorTemplate = ` + + + + + + 500 Internal Server Error - Dev Logs + + +
+
+
+ +
+

500 Internal Server Error

+
+ +
+

Internal Server Error

+

Something Error

+
+
+ + +` + +const errorTemplateName = "error" + +type errorTemplateData struct { + Status int + ErrorCode string + MessageTitle string +} + +type ErrorTemplateOptions struct { + Status int + ErrorCode string + MessageTitle string + Messages []string +} + +func BuildErrorTemplate(options ErrorTemplateOptions) (string, error) { + messageParagraphs := utils.MapSlice(options.Messages, func(msg *string) string { + return fmt.Sprintf("

%s

", *msg) + }) + errTemplate := fmt.Sprintf(errorTemplate, strings.Join(messageParagraphs, "\n")) + + data := errorTemplateData{ + Status: options.Status, + ErrorCode: options.ErrorCode, + MessageTitle: options.MessageTitle, + } + + t, err := template.New(errorTemplateName).Parse(errTemplate) + if err != nil { + return "", err + } + + var errorContent bytes.Buffer + if err := t.Execute(&errorContent, data); err != nil { + return "", err + } + + return errorContent.String(), nil +} diff --git a/idp/internal/services/templates/error.html b/idp/internal/services/templates/error.html new file mode 100644 index 0000000..0e83969 --- /dev/null +++ b/idp/internal/services/templates/error.html @@ -0,0 +1,123 @@ + + + + + + 500 InternalServerError - Dev Logs + + +
+
+
+ +
+

500 Internal Server Error

+
+ +
+

Internal Server Error

+

Something Error

+
+
+ + \ No newline at end of file diff --git a/idp/internal/services/templates/login.html b/idp/internal/services/templates/login.html new file mode 100644 index 0000000..b4ca881 --- /dev/null +++ b/idp/internal/services/templates/login.html @@ -0,0 +1,450 @@ + + + + + + + + Login - DevLogs + + + +
+
+
+ +
+

Welcome back {{.Name}}

+
+ +
+

Invalid credentials

+
+ +
+ + + + + + + + + +
+ +
+ OR +
+ +
+ + + + + + + + + +
+
+ + + + + \ No newline at end of file diff --git a/idp/internal/services/templates/two_factor.html b/idp/internal/services/templates/two_factor.html new file mode 100644 index 0000000..b0dd761 --- /dev/null +++ b/idp/internal/services/templates/two_factor.html @@ -0,0 +1,160 @@ + + + + + + {{.Title}} + + +
+
+
+ +
+

Confirm {{.Name}}

+
+ +
+ + + + + + + + +
+
+ + \ No newline at end of file diff --git a/idp/internal/services/users.go b/idp/internal/services/users.go index fc07ea3..23f6786 100644 --- a/idp/internal/services/users.go +++ b/idp/internal/services/users.go @@ -512,7 +512,6 @@ func (s *Services) UpdateUser( Email: email, Username: username, UserData: data, - IsActive: opts.IsActive, EmailVerified: opts.EmailVerified, }) if err != nil { diff --git a/idp/internal/services/users_auth.go b/idp/internal/services/users_auth.go index 81a6675..e15a36a 100644 --- a/idp/internal/services/users_auth.go +++ b/idp/internal/services/users_auth.go @@ -201,7 +201,9 @@ func (s *Services) sendUserConfirmationEmail( RequestID: opts.requestID, AccountID: opts.accountID, }), - StoreFN: s.BuildUpdateJWKDEKFn(ctx, opts.requestID), + StoreFN: s.BuildUpdateJWKDEKFn(ctx, BuildUpdateJWKDEKFnOptions{ + RequestID: opts.requestID, + }), }) if serviceErr != nil { logger.ErrorContext(ctx, "Failed to sign user token", "serviceError", serviceErr) @@ -334,7 +336,9 @@ func (s *Services) generateFullUserAuthDTO( RequestID: requestID, AccountID: accountID, }), - StoreFN: s.BuildUpdateJWKDEKFn(ctx, requestID), + StoreFN: s.BuildUpdateJWKDEKFn(ctx, BuildUpdateJWKDEKFnOptions{ + RequestID: requestID, + }), }) if serviceErr != nil { logger.ErrorContext(ctx, "Failed to sign access token", "serviceError", serviceErr) @@ -373,7 +377,9 @@ func (s *Services) generateFullUserAuthDTO( RequestID: requestID, AccountID: accountID, }), - StoreFN: s.BuildUpdateJWKDEKFn(ctx, requestID), + StoreFN: s.BuildUpdateJWKDEKFn(ctx, BuildUpdateJWKDEKFnOptions{ + RequestID: requestID, + }), }) if serviceErr != nil { logger.ErrorContext(ctx, "Failed to sign access token", "serviceError", serviceErr) @@ -633,75 +639,7 @@ func (s *Services) LoginUser( } } - switch userDTO.TwoFactorType { - case database.TwoFactorTypeEmail, database.TwoFactorTypeTotp: - logger.WarnContext(ctx, "User has two-factor authentication enabled") - twoFAToken, err := s.jwt.CreateUserPurposeToken(tokens.UserPurposeTokenOptions{ - TokenType: tokens.PurposeTokenTypeTwoFA, - AccountUsername: opts.AccountUsername, - UserPublicID: userDTO.PublicID, - UserVersion: userDTO.Version(), - AppClientID: appDTO.ClientID, - AppVersion: appDTO.Version(), - Path: paths.AppsBase + paths.UsersBase + paths.AuthLogin + paths.Auth2FA, - }) - if err != nil { - logger.ErrorContext(ctx, "Failed to create two-factor authentication token", "error", err) - return dtos.AuthDTO{}, exceptions.NewInternalServerError() - } - - signedTwoFAToken, serviceErr := s.crypto.SignToken(ctx, crypto.SignTokenOptions{ - RequestID: opts.RequestID, - Token: twoFAToken, - GetJWKfn: s.BuildGetEncryptedAccountJWKFn(ctx, BuildGetEncryptedAccountJWKFnOptions{ - RequestID: opts.RequestID, - KeyType: database.TokenKeyType2faAuthentication, - AccountID: opts.AccountID, - }), - GetDecryptDEKfn: s.BuildGetDecAccountDEKFn(ctx, BuildGetDecAccountDEKFnOptions{ - RequestID: opts.RequestID, - AccountID: opts.AccountID, - }), - GetEncryptDEKfn: s.BuildGetEncAccountDEKfn(ctx, BuildGetEncAccountDEKOptions{ - RequestID: opts.RequestID, - AccountID: opts.AccountID, - }), - StoreFN: s.BuildUpdateJWKDEKFn(ctx, opts.RequestID), - }) - if serviceErr != nil { - logger.ErrorContext(ctx, "Failed to sign two-factor authentication token", "serviceError", serviceErr) - return dtos.AuthDTO{}, serviceErr - } - - if userDTO.TwoFactorType == database.TwoFactorTypeEmail { - code, err := s.cache.AddTwoFactorCode(ctx, cache.AddTwoFactorCodeOptions{ - RequestID: opts.RequestID, - AccountID: opts.AccountID, - UserID: userDTO.ID(), - TTL: s.jwt.Get2FATTL(), - }) - if err != nil { - logger.ErrorContext(ctx, "Failed to add two-factor Code", "error", err) - return dtos.AuthDTO{}, exceptions.NewInternalServerError() - } - - if err := s.mail.PublishUser2FAEmail(ctx, mailer.User2FAEmailOptions{ - RequestID: opts.RequestID, - AppName: appDTO.Name, - Email: userDTO.Email, - Code: code, - }); err != nil { - logger.ErrorContext(ctx, "Failed to publish 2FA email", "error", err) - return dtos.AuthDTO{}, exceptions.NewInternalServerError() - } - } - - return dtos.NewTempAuthDTO( - signedTwoFAToken, - "Please provide two factor Code", - s.jwt.Get2FATTL(), - ), nil - } + // TODO: add two factor login return s.generateFullUserAuthDTO( ctx, @@ -716,17 +654,17 @@ func (s *Services) LoginUser( ) } -type verifyUserTotpOptions struct { +type VerifyUserTOTPOptions struct { requestID string userID int32 code string } -func (s *Services) verifyUserTotp( +func (s *Services) VerifyUserTOTP( ctx context.Context, - opts verifyUserTotpOptions, + opts VerifyUserTOTPOptions, ) (bool, *exceptions.ServiceError) { - logger := s.buildLogger(opts.requestID, usersAuthLocation, "verifyUserTotp").With( + logger := s.buildLogger(opts.requestID, usersAuthLocation, "VerifyUserTOTP").With( "userId", opts.userID, ) logger.InfoContext(ctx, "Verifying user TOTP...") @@ -765,18 +703,18 @@ func (s *Services) verifyUserTotp( return true, nil } -type verifierUserEmailCodeOptions struct { +type VerifyUserEmailCodeOptions struct { requestID string accountID int32 userID int32 code string } -func (s *Services) verifyUserEmailCode( +func (s *Services) VerifyUserEmailCode( ctx context.Context, - opts verifierUserEmailCodeOptions, + opts VerifyUserEmailCodeOptions, ) (bool, *exceptions.ServiceError) { - logger := s.buildLogger(opts.requestID, usersAuthLocation, "verifyUserEmailCode").With( + logger := s.buildLogger(opts.requestID, usersAuthLocation, "VerifyUserEmailCode").With( "accountId", opts.accountID, "userId", opts.userID, ) @@ -802,106 +740,6 @@ func (s *Services) verifyUserEmailCode( return true, nil } -type TwoFactorLoginUserOptions struct { - RequestID string - AccountID int32 - AccountUsername string - AppClientID string - AppVersion int32 - UserPublicID uuid.UUID - UserVersion int32 - Code string -} - -func (s *Services) TwoFactorLoginUser( - ctx context.Context, - opts TwoFactorLoginUserOptions, -) (dtos.AuthDTO, *exceptions.ServiceError) { - logger := s.buildLogger(opts.RequestID, usersAuthLocation, "TwoFactorLoginUser").With( - "accountId", opts.AccountID, - "appClientId", opts.AppClientID, - "userPublicId", opts.UserPublicID, - ) - logger.InfoContext(ctx, "Two-factor login for user...") - - appDTO, serviceErr := s.GetAppByClientIDVersionAndAccountID(ctx, GetAppByClientIDVersionAndAccountIDOptions{ - RequestID: opts.RequestID, - ClientID: opts.AppClientID, - Version: opts.AppVersion, - AccountID: opts.AccountID, - }) - if serviceErr != nil { - logger.ErrorContext(ctx, "Failed to get app by ID", "error", serviceErr) - return dtos.AuthDTO{}, serviceErr - } - - userDTO, serviceErr := s.GetUserByPublicIDAndVersion(ctx, GetUserByPublicIDAndVersionOptions{ - RequestID: opts.RequestID, - AccountID: opts.AccountID, - PublicID: opts.UserPublicID, - Version: opts.UserVersion, - }) - if serviceErr != nil { - logger.ErrorContext(ctx, "Failed to get user by ID", "error", serviceErr) - return dtos.AuthDTO{}, serviceErr - } - - if _, err := s.database.FindAppProfileByAppIDAndUserID(ctx, database.FindAppProfileByAppIDAndUserIDParams{ - AppID: appDTO.ID(), - UserID: userDTO.ID(), - }); err != nil { - serviceErr := exceptions.FromDBError(err) - if serviceErr.Code == exceptions.CodeNotFound { - logger.WarnContext(ctx, "App profile not found") - return dtos.AuthDTO{}, exceptions.NewUnauthorizedError() - } - - logger.ErrorContext(ctx, "Failed to get app profile", "error", serviceErr) - return dtos.AuthDTO{}, serviceErr - } - - // Verify the two-factor Code based on the user's two-factor type - var verified bool - switch userDTO.TwoFactorType { - case database.TwoFactorTypeTotp: - verified, serviceErr = s.verifyUserTotp(ctx, verifyUserTotpOptions{ - requestID: opts.RequestID, - userID: userDTO.ID(), - code: opts.Code, - }) - case database.TwoFactorTypeEmail: - verified, serviceErr = s.verifyUserEmailCode(ctx, verifierUserEmailCodeOptions{ - requestID: opts.RequestID, - accountID: opts.AccountID, - userID: userDTO.ID(), - code: opts.Code, - }) - default: - logger.WarnContext(ctx, "Invalid two-factor type", "twoFactorType", userDTO.TwoFactorType) - return dtos.AuthDTO{}, exceptions.NewUnauthorizedError() - } - - if serviceErr != nil { - return dtos.AuthDTO{}, serviceErr - } - if !verified { - logger.WarnContext(ctx, "Two-factor Code verification failed") - return dtos.AuthDTO{}, exceptions.NewUnauthorizedError() - } - - return s.generateFullUserAuthDTO( - ctx, - logger, - opts.RequestID, - opts.AccountID, - &userDTO, - &appDTO, - appDTO.DefaultScopes, - opts.AccountUsername, - "User two-factor login successful", - ) -} - type LogoutUserOptions struct { RequestID string AccountID int32 @@ -1196,7 +1034,9 @@ func (s *Services) ForgotUserPassword( RequestID: opts.RequestID, AccountID: opts.AccountID, }), - StoreFN: s.BuildUpdateJWKDEKFn(ctx, opts.RequestID), + StoreFN: s.BuildUpdateJWKDEKFn(ctx, BuildUpdateJWKDEKFnOptions{ + RequestID: opts.RequestID, + }), }) if serviceErr != nil { logger.ErrorContext(ctx, "Failed to sign reset token", "serviceError", serviceErr) diff --git a/idp/internal/utils/hasher.go b/idp/internal/utils/hasher.go index 269c9c8..3b32b90 100644 --- a/idp/internal/utils/hasher.go +++ b/idp/internal/utils/hasher.go @@ -88,13 +88,13 @@ func Argon2CompareHash(str, hash string) (bool, error) { return bytes.Equal(decodedHash, comparisonHash), nil } -func Sha256HashHex(bytes []byte) string { - hash := sha256.Sum256(bytes) +func Sha256HashHex(str string) string { + hash := sha256.Sum256([]byte(str)) return hex.EncodeToString(hash[:]) } -func Sha256HashBase64(bytes []byte) string { - hash := sha256.Sum256(bytes) +func Sha256HashBase64(str string) string { + hash := sha256.Sum256([]byte(str)) return base64.RawURLEncoding.EncodeToString(hash[:]) } @@ -105,6 +105,27 @@ func CompareSha256(a, b []byte) bool { return subtle.ConstantTimeCompare(a, b) == 1 } +func CompareShaHex(plainText, shaHex string) (bool, error) { + hash := sha256.Sum256([]byte(plainText)) + shaBytes, err := hex.DecodeString(shaHex) + if err != nil { + return false, fmt.Errorf("failed to decode first hex string: %w", err) + } + + return CompareSha256(hash[:], shaBytes), nil +} + +func CompareShaBase64(plainText, shaBase64 string) (bool, error) { + hash := sha256.Sum256([]byte(plainText)) + shaBytes, err := base64.RawURLEncoding.DecodeString(shaBase64) + if err != nil { + return false, fmt.Errorf("failed to decode first base64 string: %w", err) + } + + return CompareSha256(hash[:], shaBytes), nil +} + func GenerateETag(bytes []byte) string { - return `"` + Sha256HashHex(bytes) + `"` + hash := sha256.Sum256(bytes) + return `"` + hex.EncodeToString(hash[:]) + `"` } diff --git a/idp/internal/utils/secrets.go b/idp/internal/utils/secrets.go index 89a5d60..558b959 100644 --- a/idp/internal/utils/secrets.go +++ b/idp/internal/utils/secrets.go @@ -32,6 +32,15 @@ func GenerateBase64Secret(byteLen int) (string, error) { return base64.RawURLEncoding.EncodeToString(randomBytes), nil } +func GenerateBase62Secret(byteLen int) (string, error) { + randomBytes, err := GenerateRandomBytes(byteLen) + if err != nil { + return "", err + } + + return Base62Encode(randomBytes), nil +} + func DecodeBase64Secret(secret string) ([]byte, error) { decoded, err := base64.RawURLEncoding.DecodeString(secret) if err != nil { diff --git a/idp/tests/account_credentials_test.go b/idp/tests/account_credentials_test.go index 0e50956..654ab98 100644 --- a/idp/tests/account_credentials_test.go +++ b/idp/tests/account_credentials_test.go @@ -8,11 +8,8 @@ package tests import ( "context" - rand2 "math/rand/v2" "net/http" - "strings" "testing" - "time" "github.com/google/uuid" @@ -31,7 +28,7 @@ func accountCredentialsCleanUp(t *testing.T) func() { db := GetTestDatabase(t) if err := db.DeleteAllAccountCredentials(context.Background()); err != nil { - t.Fatal("Failed to delete all accounts", err) + t.Fatal("Failed to delete all account credentials", err) } if err := db.DeleteAllCredentialsKeys(context.Background()); err != nil { t.Fatal("Failed to delete all credentials keys", err) @@ -50,14 +47,19 @@ func TestCreateAccountCredentials(t *testing.T) { testCases := []TestRequestCase[bodies.CreateAccountCredentialsBody]{ { - Name: "Should return 201 CREATED with secret and client_secret_jwt", + Name: "Should create service credentials with client_secret_jwt", ReqFn: func(t *testing.T) (bodies.CreateAccountCredentialsBody, string) { account := CreateTestAccount(t, GenerateFakeAccountData(t, services.AuthProviderGoogle)) accessToken, _ := GenerateTestAccountAuthTokens(t, &account) return bodies.CreateAccountCredentialsBody{ - Scopes: []string{"account:admin"}, - Alias: "admin", - AuthMethod: "client_secret_jwt", + Type: "service", + Name: "admin-service", + Scopes: []string{"account:admin"}, + TokenEndpointAuthMethod: "client_secret_jwt", + Transport: "https", + ClientURI: "https://admin.example.com", + SoftwareID: "admin-service", + SoftwareVersion: "1.0.0", }, accessToken }, ExpStatus: http.StatusCreated, @@ -69,20 +71,25 @@ func TestCreateAccountCredentials(t *testing.T) { AssertNotEmpty(t, resBody.ClientSecretExp) AssertEmpty(t, resBody.ClientSecretJWK) AssertEqual(t, resBody.TokenEndpointAuthMethod, database.AuthMethodClientSecretJwt) - AssertEqual(t, len(resBody.Issuers), 0) + AssertEqual(t, resBody.Type, database.AccountCredentialsTypeService) + AssertEqual(t, resBody.Transport, database.TransportHttps) }, }, { - Name: "Should return 201 CREATED with secret and private key JWT with ES256 algorithm", + Name: "Should create service credentials with private_key_jwt and ES256", ReqFn: func(t *testing.T) (bodies.CreateAccountCredentialsBody, string) { account := CreateTestAccount(t, GenerateFakeAccountData(t, services.AuthProviderGoogle)) accessToken, _ := GenerateTestAccountAuthTokens(t, &account) return bodies.CreateAccountCredentialsBody{ - Scopes: []string{"account:credentials:read", "account:credentials:write"}, - Alias: "super-key", - AuthMethod: "private_key_jwt", - Issuers: []string{"https://issuer.example.com"}, - Algorithm: "ES256", + Type: "service", + Name: "super-service", + Scopes: []string{"account:credentials:read", "account:credentials:write"}, + TokenEndpointAuthMethod: "private_key_jwt", + Transport: "https", + ClientURI: "https://super.example.com", + SoftwareID: "super-service", + SoftwareVersion: "2.0.0", + Algorithm: "ES256", }, accessToken }, ExpStatus: http.StatusCreated, @@ -94,19 +101,24 @@ func TestCreateAccountCredentials(t *testing.T) { AssertNotEmpty(t, resBody.ClientSecretExp) AssertNotEmpty(t, resBody.ClientSecretJWK) AssertEqual(t, resBody.TokenEndpointAuthMethod, database.AuthMethodPrivateKeyJwt) + AssertEqual(t, resBody.Type, database.AccountCredentialsTypeService) }, }, { - Name: "Should return 201 CREATED with secret and private key JWT with EdDSA algorithm", + Name: "Should create service credentials with private_key_jwt and EdDSA", ReqFn: func(t *testing.T) (bodies.CreateAccountCredentialsBody, string) { account := CreateTestAccount(t, GenerateFakeAccountData(t, services.AuthProviderGoogle)) accessToken, _ := GenerateTestAccountAuthTokens(t, &account) return bodies.CreateAccountCredentialsBody{ - Scopes: []string{"account:credentials:read", "account:credentials:write"}, - Alias: "super-key", - AuthMethod: "private_key_jwt", - Issuers: []string{"https://issuer.example.com"}, - Algorithm: "EdDSA", + Type: "service", + Name: "eddsa-service", + Scopes: []string{"account:credentials:read", "account:credentials:write"}, + TokenEndpointAuthMethod: "private_key_jwt", + Transport: "https", + ClientURI: "https://eddsa.example.com", + SoftwareID: "eddsa-service", + SoftwareVersion: "1.0.0", + Algorithm: "EdDSA", }, accessToken }, ExpStatus: http.StatusCreated, @@ -118,18 +130,23 @@ func TestCreateAccountCredentials(t *testing.T) { AssertNotEmpty(t, resBody.ClientSecretExp) AssertNotEmpty(t, resBody.ClientSecretJWK) AssertEqual(t, resBody.TokenEndpointAuthMethod, database.AuthMethodPrivateKeyJwt) + AssertEqual(t, resBody.Type, database.AccountCredentialsTypeService) }, }, { - Name: "Should return 201 CREATED with secret and private key JWT with default algorithm", + Name: "Should create service credentials with client_secret_post", ReqFn: func(t *testing.T) (bodies.CreateAccountCredentialsBody, string) { account := CreateTestAccount(t, GenerateFakeAccountData(t, services.AuthProviderGoogle)) accessToken, _ := GenerateTestAccountAuthTokens(t, &account) return bodies.CreateAccountCredentialsBody{ - Scopes: []string{"account:credentials:read", "account:credentials:write"}, - Alias: "super-key", - AuthMethod: "private_key_jwt", - Issuers: []string{"https://issuer.example.com"}, + Type: "service", + Name: "app-service", + Scopes: []string{"account:apps:read", "account:apps:write"}, + TokenEndpointAuthMethod: "client_secret_post", + Transport: "https", + ClientURI: "https://app.example.com", + SoftwareID: "app-service", + SoftwareVersion: "1.0.0", }, accessToken }, ExpStatus: http.StatusCreated, @@ -137,21 +154,27 @@ func TestCreateAccountCredentials(t *testing.T) { resBody := AssertTestResponseBody(t, res, dtos.AccountCredentialsDTO{}) AssertNotEmpty(t, resBody.ClientID) AssertNotEmpty(t, resBody.ClientSecretID) - AssertEmpty(t, resBody.ClientSecret) + AssertNotEmpty(t, resBody.ClientSecret) AssertNotEmpty(t, resBody.ClientSecretExp) - AssertNotEmpty(t, resBody.ClientSecretJWK) - AssertEqual(t, resBody.TokenEndpointAuthMethod, database.AuthMethodPrivateKeyJwt) + AssertEmpty(t, resBody.ClientSecretJWK) + AssertEqual(t, resBody.TokenEndpointAuthMethod, database.AuthMethodClientSecretPost) + AssertEqual(t, resBody.Type, database.AccountCredentialsTypeService) }, }, { - Name: "Should return 201 CREATED with secret and client secret post", + Name: "Should create service credentials with client_secret_basic", ReqFn: func(t *testing.T) (bodies.CreateAccountCredentialsBody, string) { account := CreateTestAccount(t, GenerateFakeAccountData(t, services.AuthProviderGoogle)) - accessToken, _ := GenerateTestAccountAuthTokens(t, &account) + accessToken := GenerateScopedAccountAccessToken(t, &account, []string{tokens.AccountScopeCredentialsWrite}) return bodies.CreateAccountCredentialsBody{ - Scopes: []string{"account:apps:read", "account:apps:write"}, - Alias: "app-keys", - AuthMethod: "client_secret_post", + Type: "service", + Name: "user-service", + Scopes: []string{"account:users:read", "account:users:write"}, + TokenEndpointAuthMethod: "client_secret_basic", + Transport: "https", + ClientURI: "https://user.example.com", + SoftwareID: "user-service", + SoftwareVersion: "1.0.0", }, accessToken }, ExpStatus: http.StatusCreated, @@ -162,18 +185,24 @@ func TestCreateAccountCredentials(t *testing.T) { AssertNotEmpty(t, resBody.ClientSecret) AssertNotEmpty(t, resBody.ClientSecretExp) AssertEmpty(t, resBody.ClientSecretJWK) - AssertEqual(t, resBody.TokenEndpointAuthMethod, database.AuthMethodClientSecretPost) + AssertEqual(t, resBody.TokenEndpointAuthMethod, database.AuthMethodClientSecretBasic) + AssertEqual(t, resBody.Type, database.AccountCredentialsTypeService) }, }, { - Name: "Should return 201 CREATED with secret and client secret basic", + Name: "Should create MCP credentials with streamable_http transport", ReqFn: func(t *testing.T) (bodies.CreateAccountCredentialsBody, string) { account := CreateTestAccount(t, GenerateFakeAccountData(t, services.AuthProviderGoogle)) - accessToken := GenerateScopedAccountAccessToken(t, &account, []string{tokens.AccountScopeCredentialsWrite}) + accessToken, _ := GenerateTestAccountAuthTokens(t, &account) return bodies.CreateAccountCredentialsBody{ - Scopes: []string{"account:users:read", "account:users:write"}, - Alias: "user-keys", - AuthMethod: "client_secret_basic", + Type: "mcp", + Name: "mcp-client", + Scopes: []string{"account:admin"}, + TokenEndpointAuthMethod: "client_secret_basic", + Transport: "streamable_http", + ClientURI: "https://mcp.example.com", + SoftwareID: "mcp-client", + SoftwareVersion: "1.0.0", }, accessToken }, ExpStatus: http.StatusCreated, @@ -185,86 +214,136 @@ func TestCreateAccountCredentials(t *testing.T) { AssertNotEmpty(t, resBody.ClientSecretExp) AssertEmpty(t, resBody.ClientSecretJWK) AssertEqual(t, resBody.TokenEndpointAuthMethod, database.AuthMethodClientSecretBasic) + AssertEqual(t, resBody.Type, database.AccountCredentialsTypeMcp) + AssertEqual(t, resBody.Transport, database.TransportStreamableHttp) + }, + }, + { + Name: "Should create MCP credentials with stdio transport", + ReqFn: func(t *testing.T) (bodies.CreateAccountCredentialsBody, string) { + account := CreateTestAccount(t, GenerateFakeAccountData(t, services.AuthProviderGoogle)) + accessToken, _ := GenerateTestAccountAuthTokens(t, &account) + return bodies.CreateAccountCredentialsBody{ + Type: "mcp", + Name: "mcp-stdio", + Scopes: []string{"account:admin"}, + TokenEndpointAuthMethod: "private_key_jwt", + Transport: "stdio", + ClientURI: "https://mcp-stdio.example.com", + SoftwareID: "mcp-stdio", + SoftwareVersion: "1.0.0", + Algorithm: "ES256", + }, accessToken + }, + ExpStatus: http.StatusCreated, + AssertFn: func(t *testing.T, _ bodies.CreateAccountCredentialsBody, res *http.Response) { + resBody := AssertTestResponseBody(t, res, dtos.AccountCredentialsDTO{}) + AssertNotEmpty(t, resBody.ClientID) + AssertNotEmpty(t, resBody.ClientSecretID) + AssertEmpty(t, resBody.ClientSecret) + AssertNotEmpty(t, resBody.ClientSecretExp) + AssertNotEmpty(t, resBody.ClientSecretJWK) + AssertEqual(t, resBody.TokenEndpointAuthMethod, database.AuthMethodPrivateKeyJwt) + AssertEqual(t, resBody.Type, database.AccountCredentialsTypeMcp) + AssertEqual(t, resBody.Transport, database.TransportStdio) }, }, { - Name: "Should return 400 BAD REQUEST with auth method of private_key_jwt but no issuers", + Name: "Should reject native credentials creation", ReqFn: func(t *testing.T) (bodies.CreateAccountCredentialsBody, string) { account := CreateTestAccount(t, GenerateFakeAccountData(t, services.AuthProviderGoogle)) accessToken, _ := GenerateTestAccountAuthTokens(t, &account) return bodies.CreateAccountCredentialsBody{ - Scopes: []string{"account:credentials:read", "account:credentials:write"}, - Alias: "super-key", - AuthMethod: "private_key_jwt", + Type: "native", + Name: "native-client", + Scopes: []string{"account:admin"}, + TokenEndpointAuthMethod: "client_secret_basic", + Transport: "https", + ClientURI: "https://native.example.com", + SoftwareID: "native-client", + SoftwareVersion: "1.0.0", }, accessToken }, ExpStatus: http.StatusBadRequest, AssertFn: func(t *testing.T, _ bodies.CreateAccountCredentialsBody, res *http.Response) { - resBody := AssertTestResponseBody(t, res, exceptions.ValidationErrorResponse{}) - AssertEqual(t, len(resBody.Fields), 1) - AssertEqual(t, resBody.Fields[0].Param, "issuers") + resBody := AssertTestResponseBody(t, res, exceptions.ErrorResponse{}) + AssertEqual(t, resBody.Message, "Native credentials are not supported") }, }, { - Name: "Should return 400 BAD REQUEST with bad values", + Name: "Should return 400 BAD REQUEST with invalid data", ReqFn: func(t *testing.T) (bodies.CreateAccountCredentialsBody, string) { account := CreateTestAccount(t, GenerateFakeAccountData(t, services.AuthProviderGoogle)) accessToken, _ := GenerateTestAccountAuthTokens(t, &account) return bodies.CreateAccountCredentialsBody{ - Scopes: []string{"invalid:scope", "account:users:readsd"}, - Alias: "invalid asdfasd ### scope", - AuthMethod: "client_secret_not_valid", - Issuers: []string{"https://issuer.example.com"}, + Type: "service", + Name: "", + Scopes: []string{"invalid:scope", "account:users:readsd"}, + TokenEndpointAuthMethod: "invalid_auth_method", + Transport: "invalid_transport", + ClientURI: "not-a-uri", + SoftwareID: "", + SoftwareVersion: "", }, accessToken }, ExpStatus: http.StatusBadRequest, AssertFn: func(t *testing.T, _ bodies.CreateAccountCredentialsBody, res *http.Response) { resBody := AssertTestResponseBody(t, res, exceptions.ValidationErrorResponse{}) - AssertEqual(t, len(resBody.Fields), 4) - AssertEqual(t, resBody.Fields[0].Param, "scopes[0]") - AssertEqual(t, resBody.Fields[1].Param, "scopes[1]") - AssertEqual(t, resBody.Fields[2].Param, "alias") - AssertEqual(t, resBody.Fields[3].Param, "auth_method") + AssertEqual(t, len(resBody.Fields) >= 5, true) }, }, { - Name: "Should return 409 CONFLICT with existing alias", + Name: "Should return 409 CONFLICT with existing name", ReqFn: func(t *testing.T) (bodies.CreateAccountCredentialsBody, string) { account := CreateTestAccount(t, GenerateFakeAccountData(t, services.AuthProviderGoogle)) accessToken, _ := GenerateTestAccountAuthTokens(t, &account) + // Create initial credentials if _, err := GetTestServices(t).CreateAccountCredentials(context.Background(), services.CreateAccountCredentialsOptions{ RequestID: uuid.NewString(), AccountPublicID: account.PublicID, AccountVersion: account.Version(), - Alias: "existing-alias", - Scopes: []string{"account:users:read", "account:users:write"}, - AuthMethod: "private_key_jwt", + CredentialsType: "service", + Name: "existing-name", + Scopes: []string{"account:admin"}, + AuthMethod: "client_secret_basic", + Transport: "https", + ClientURI: "https://existing.example.com", + SoftwareID: "existing-service", + SoftwareVersion: "1.0.0", }); err != nil { t.Fatal("Failed to create initial account credentials", err) } return bodies.CreateAccountCredentialsBody{ - Scopes: []string{"account:admin"}, - Alias: "existing-alias", - AuthMethod: "client_secret_basic", - Issuers: []string{"https://issuer.example.com"}, + Type: "service", + Name: "existing-name", + Scopes: []string{"account:admin"}, + TokenEndpointAuthMethod: "client_secret_basic", + Transport: "https", + ClientURI: "https://new.example.com", + SoftwareID: "new-service", + SoftwareVersion: "1.0.0", }, accessToken }, ExpStatus: http.StatusConflict, AssertFn: func(t *testing.T, _ bodies.CreateAccountCredentialsBody, res *http.Response) { resBody := AssertTestResponseBody(t, res, exceptions.ErrorResponse{}) - AssertEqual(t, resBody.Message, "Account credentials alias already exists") + AssertEqual(t, resBody.Message, "Account credentials name already exists") }, }, { Name: "Should return 401 UNAUTHORIZED without access token", ReqFn: func(t *testing.T) (bodies.CreateAccountCredentialsBody, string) { return bodies.CreateAccountCredentialsBody{ - Scopes: []string{"account:credentials:write", "account:auth_providers:read"}, - Alias: "user-keys", - AuthMethod: "client_secret_basic", - Issuers: []string{"https://issuer.example.com"}, + Type: "service", + Name: "unauthorized-service", + Scopes: []string{"account:credentials:write", "account:auth_providers:read"}, + TokenEndpointAuthMethod: "client_secret_basic", + Transport: "https", + ClientURI: "https://unauthorized.example.com", + SoftwareID: "unauthorized-service", + SoftwareVersion: "1.0.0", }, "" }, ExpStatus: http.StatusUnauthorized, @@ -276,10 +355,14 @@ func TestCreateAccountCredentials(t *testing.T) { account := CreateTestAccount(t, GenerateFakeAccountData(t, services.AuthProviderGoogle)) accessToken := GenerateScopedAccountAccessToken(t, &account, []string{tokens.AccountScopeCredentialsRead}) return bodies.CreateAccountCredentialsBody{ - Scopes: []string{"account:apps:read", "account:apps:write"}, - Alias: "app-keys", - AuthMethod: "client_secret_post", - Issuers: []string{"https://issuer.example.com"}, + Type: "service", + Name: "forbidden-service", + Scopes: []string{"account:apps:read", "account:apps:write"}, + TokenEndpointAuthMethod: "client_secret_post", + Transport: "https", + ClientURI: "https://forbidden.example.com", + SoftwareID: "forbidden-service", + SoftwareVersion: "1.0.0", }, accessToken }, ExpStatus: http.StatusForbidden, @@ -296,173 +379,11 @@ func TestCreateAccountCredentials(t *testing.T) { t.Cleanup(accountCredentialsCleanUp(t)) } -func TestListAccountCredentials(t *testing.T) { - const accountCredentialsPath = v1Path + paths.AccountsBase + paths.CredentialsBase - - listAccountBeforeEach := func(t *testing.T, n int) string { - account := CreateTestAccount(t, GenerateFakeAccountData(t, services.AuthProviderGoogle)) - accessToken := GenerateScopedAccountAccessToken(t, &account, []string{tokens.AccountScopeCredentialsRead, tokens.AccountScopeCredentialsWrite}) - - authMethodsList := []string{ - "client_secret_basic", - "client_secret_post", - "client_secret_jwt", - "private_key_jwt", - } - scopesList := [][]string{ - {"account:admin"}, - {"account:credentials:read", "account:credentials:write"}, - {"account:apps:read", "account:apps:write"}, - {"account:users:read", "account:users:write"}, - } - issuersList := [][]string{ - {"https://issuer1.example.com"}, - {"https://issuer2.example.com"}, - {"https://issuer3.example.com"}, - } - - for i := 0; i < n; i++ { - authMethods := authMethodsList[rand2.IntN(len(authMethodsList))] - scopes := scopesList[rand2.IntN(len(scopesList))] - issuers := issuersList[rand2.IntN(len(issuersList))] - alias := "cred-" + uuid.NewString() - - _, err := GetTestServices(t).CreateAccountCredentials(context.Background(), services.CreateAccountCredentialsOptions{ - RequestID: uuid.NewString(), - AccountPublicID: account.PublicID, - AccountVersion: account.Version(), - Alias: alias, - Scopes: scopes, - AuthMethod: authMethods, - Issuers: issuers, - }) - if err != nil { - t.Fatalf("Failed to create account credentials: %v", err) - } - } - - return accessToken - } - - testCases := []TestRequestCase[any]{ - { - Name: "Should return 200 OK without any account credentials", - ReqFn: func(t *testing.T) (any, string) { - account := CreateTestAccount(t, GenerateFakeAccountData(t, services.AuthProviderGoogle)) - accessToken, _ := GenerateTestAccountAuthTokens(t, &account) - return nil, accessToken - }, - ExpStatus: http.StatusOK, - AssertFn: func(t *testing.T, _ any, res *http.Response) { - resBody := AssertTestResponseBody(t, res, dtos.PaginationDTO[dtos.AccountCredentialsDTO]{}) - AssertEqual(t, len(resBody.Items), 0) - AssertEmpty(t, resBody.Next) - AssertEmpty(t, resBody.Previous) - AssertEqual(t, resBody.Total, 0) - }, - Path: accountCredentialsPath, - }, - { - Name: "Should return 200 OK with paginated account credentials", - ReqFn: func(t *testing.T) (any, string) { - accessToken := listAccountBeforeEach(t, 30) - return nil, accessToken - }, - ExpStatus: http.StatusOK, - AssertFn: func(t *testing.T, _ any, res *http.Response) { - resBody := AssertTestResponseBody(t, res, dtos.PaginationDTO[dtos.AccountCredentialsDTO]{}) - AssertEqual(t, len(resBody.Items), 20) - AssertEqual(t, resBody.Total, 30) - AssertEqual( - t, - strings.Split(resBody.Next, GetTestConfig(t).BackendDomain())[1], - "/v1/accounts/credentials?offset=20&limit=20", - ) - AssertEmpty(t, resBody.Previous) - }, - Path: accountCredentialsPath, - }, - { - Name: "Should return 200 OK with paginated account credentials and previous link", - ReqFn: func(t *testing.T) (any, string) { - accessToken := listAccountBeforeEach(t, 12) - return nil, accessToken - }, - ExpStatus: http.StatusOK, - AssertFn: func(t *testing.T, _ any, res *http.Response) { - resBody := AssertTestResponseBody(t, res, dtos.PaginationDTO[dtos.AccountCredentialsDTO]{}) - AssertEqual(t, len(resBody.Items), 2) - AssertEqual(t, resBody.Total, 12) - AssertEmpty(t, resBody.Next) - AssertEqual( - t, - strings.Split(resBody.Previous, GetTestConfig(t).BackendDomain())[1], - "/v1/accounts/credentials?offset=0&limit=20", - ) - }, - Path: accountCredentialsPath + "?offset=10&limit=20", - }, - { - Name: "Should return 200 OK with paginated account with next and previous link", - ReqFn: func(t *testing.T) (any, string) { - accessToken := listAccountBeforeEach(t, 20) - return nil, accessToken - }, - ExpStatus: http.StatusOK, - AssertFn: func(t *testing.T, _ any, res *http.Response) { - resBody := AssertTestResponseBody(t, res, dtos.PaginationDTO[dtos.AccountCredentialsDTO]{}) - backendDomain := GetTestConfig(t).BackendDomain() - AssertEqual(t, len(resBody.Items), 5) - AssertEqual(t, resBody.Total, 20) - AssertEqual( - t, - strings.Split(resBody.Next, backendDomain)[1], - "/v1/accounts/credentials?offset=15&limit=5", - ) - AssertEqual( - t, - strings.Split(resBody.Previous, backendDomain)[1], - "/v1/accounts/credentials?offset=5&limit=5", - ) - }, - Path: accountCredentialsPath + "?offset=10&limit=5", - }, - { - Name: "Should return 401 UNAUTHORIZED without access token", - ReqFn: func(t *testing.T) (any, string) { - return nil, "" - }, - ExpStatus: http.StatusUnauthorized, - AssertFn: AssertUnauthorizedError[any], - Path: accountCredentialsPath, - }, - { - Name: "Should return 403 FORBIDDEN without account:credentials:read scope", - ReqFn: func(t *testing.T) (any, string) { - account := CreateTestAccount(t, GenerateFakeAccountData(t, services.AuthProviderGoogle)) - accessToken := GenerateScopedAccountAccessToken(t, &account, []string{tokens.AccountScopeCredentialsWrite}) - return nil, accessToken - }, - ExpStatus: http.StatusForbidden, - AssertFn: AssertForbiddenError[any], - Path: accountCredentialsPath, - }, - } - - for _, tc := range testCases { - t.Run(tc.Name, func(t *testing.T) { - PerformTestRequestCase(t, http.MethodGet, tc.Path, tc) - }) - } - - t.Cleanup(accountCredentialsCleanUp(t)) -} - -func TestGetAccountCredentials(t *testing.T) { +func TestUpdateAccountCredentials(t *testing.T) { const accountCredentialsPath = v1Path + paths.AccountsBase + paths.CredentialsBase var clientID string - getAccountCredentialBeforeEach := func(t *testing.T) string { + updateAccountCredentialBeforeEach := func(t *testing.T) string { account := CreateTestAccount(t, GenerateFakeAccountData(t, services.AuthProviderGoogle)) accessToken := GenerateScopedAccountAccessToken(t, &account, []string{tokens.AccountScopeCredentialsRead, tokens.AccountScopeCredentialsWrite}) @@ -470,141 +391,87 @@ func TestGetAccountCredentials(t *testing.T) { RequestID: uuid.NewString(), AccountPublicID: account.PublicID, AccountVersion: account.Version(), - Alias: "get-cred", + CredentialsType: "service", + Name: "update-cred", Scopes: []string{"account:admin"}, AuthMethod: "client_secret_basic", - Issuers: []string{"https://issuer.example.com"}, + Transport: "https", + ClientURI: "https://update.example.com", + SoftwareID: "update-service", + SoftwareVersion: "1.0.0", }) if err != nil { - t.Fatalf("Failed to create account credentials: %v", err) + t.Fatalf("Failed to create initial account credentials: %v", err) } clientID = cred.ClientID return accessToken } - testCases := []TestRequestCase[any]{ + testCases := []TestRequestCase[bodies.UpdateAccountCredentialsBody]{ { - Name: "Should return 200 OK with account credential", - ReqFn: func(t *testing.T) (any, string) { - accessToken := getAccountCredentialBeforeEach(t) - return nil, accessToken + Name: "Should update service credentials name and scopes", + ReqFn: func(t *testing.T) (bodies.UpdateAccountCredentialsBody, string) { + accessToken := updateAccountCredentialBeforeEach(t) + return bodies.UpdateAccountCredentialsBody{ + Name: "updated-service-name", + Scopes: []string{"account:users:read"}, + Transport: "https", + ClientURI: "https://updated.example.com", + SoftwareVersion: "2.0.0", + }, accessToken }, ExpStatus: http.StatusOK, - AssertFn: func(t *testing.T, _ any, res *http.Response) { + AssertFn: func(t *testing.T, _ bodies.UpdateAccountCredentialsBody, res *http.Response) { resBody := AssertTestResponseBody(t, res, dtos.AccountCredentialsDTO{}) - AssertNotEmpty(t, resBody.ClientID) - AssertNotEmpty(t, resBody.Alias) - AssertEqual(t, resBody.TokenEndpointAuthMethod, database.AuthMethodClientSecretBasic) - AssertEmpty(t, resBody.ClientSecret) - AssertEmpty(t, resBody.ClientSecretJWK) - }, - PathFn: func() string { - return accountCredentialsPath + "/" + clientID - }, - }, - { - Name: "Should return 404 NOT FOUND for non-existent credential", - ReqFn: func(t *testing.T) (any, string) { - account := CreateTestAccount(t, GenerateFakeAccountData(t, services.AuthProviderGoogle)) - accessToken := GenerateScopedAccountAccessToken(t, &account, []string{tokens.AccountScopeCredentialsRead}) - return nil, accessToken - }, - ExpStatus: http.StatusNotFound, - AssertFn: AssertNotFoundError[any], - PathFn: func() string { - return accountCredentialsPath + "/" + utils.Base62UUID() - }, - }, - { - Name: "Should return 401 UNAUTHORIZED without access token", - ReqFn: func(t *testing.T) (any, string) { - getAccountCredentialBeforeEach(t) - return nil, "" + AssertEqual(t, resBody.Name, "updated-service-name") + AssertEqual(t, len(resBody.Scopes), 1) + AssertEqual(t, resBody.Scopes[0], "account:users:read") + AssertEqual(t, resBody.SoftwareVersion, "2.0.0") }, - ExpStatus: http.StatusUnauthorized, - AssertFn: AssertUnauthorizedError[any], PathFn: func() string { return accountCredentialsPath + "/" + clientID }, }, { - Name: "Should return 403 FORBIDDEN without account:credentials:read scope", - ReqFn: func(t *testing.T) (any, string) { + Name: "Should update MCP credentials scopes and software version", + ReqFn: func(t *testing.T) (bodies.UpdateAccountCredentialsBody, string) { account := CreateTestAccount(t, GenerateFakeAccountData(t, services.AuthProviderGoogle)) - accessToken := GenerateScopedAccountAccessToken(t, &account, []string{tokens.AccountScopeCredentialsWrite}) + accessToken := GenerateScopedAccountAccessToken(t, &account, []string{tokens.AccountScopeCredentialsRead, tokens.AccountScopeCredentialsWrite}) + + // Create MCP credentials first cred, err := GetTestServices(t).CreateAccountCredentials(context.Background(), services.CreateAccountCredentialsOptions{ RequestID: uuid.NewString(), AccountPublicID: account.PublicID, AccountVersion: account.Version(), - Alias: "forbidden-cred", + CredentialsType: "mcp", + Name: "mcp-update", Scopes: []string{"account:admin"}, AuthMethod: "client_secret_basic", + Transport: "streamable_http", + ClientURI: "https://mcp-update.example.com", + SoftwareID: "mcp-update", + SoftwareVersion: "1.0.0", }) if err != nil { - t.Fatalf("Failed to create account credentials: %v", err) + t.Fatalf("Failed to create MCP credentials: %v", err) } clientID = cred.ClientID - return nil, accessToken - }, - ExpStatus: http.StatusForbidden, - AssertFn: AssertForbiddenError[any], - PathFn: func() string { - return accountCredentialsPath + "/" + clientID - }, - }, - } - - for _, tc := range testCases { - t.Run(tc.Name, func(t *testing.T) { - PerformTestRequestCaseWithPathFn(t, http.MethodGet, tc) - }) - } - - t.Cleanup(accountCredentialsCleanUp(t)) -} - -func TestUpdateAccountCredentials(t *testing.T) { - const accountCredentialsPath = v1Path + paths.AccountsBase + paths.CredentialsBase - - var clientID string - updateAccountCredentialBeforeEach := func(t *testing.T) string { - account := CreateTestAccount(t, GenerateFakeAccountData(t, services.AuthProviderGoogle)) - accessToken := GenerateScopedAccountAccessToken(t, &account, []string{tokens.AccountScopeCredentialsRead, tokens.AccountScopeCredentialsWrite}) - - cred, err := GetTestServices(t).CreateAccountCredentials(context.Background(), services.CreateAccountCredentialsOptions{ - RequestID: uuid.NewString(), - AccountPublicID: account.PublicID, - AccountVersion: account.Version(), - Alias: "update-cred", - Scopes: []string{"account:admin"}, - AuthMethod: "client_secret_basic", - Issuers: []string{"https://issuer.example.com"}, - }) - if err != nil { - t.Fatalf("Failed to create account credentials: %v", err) - } - clientID = cred.ClientID - return accessToken - } - testCases := []TestRequestCase[bodies.UpdateAccountCredentialsBody]{ - { - Name: "Should return 200 OK and update alias and scopes", - ReqFn: func(t *testing.T) (bodies.UpdateAccountCredentialsBody, string) { - accessToken := updateAccountCredentialBeforeEach(t) return bodies.UpdateAccountCredentialsBody{ - Alias: "updated-alias", - Scopes: []string{"account:users:read"}, - Issuers: []string{"https://issuer-updated.example.com"}, + Name: "updated-mcp-name", + Scopes: []string{"account:users:read", "account:apps:read"}, + ClientURI: "https://updated-mcp.example.com", + SoftwareVersion: "2.0.0", }, accessToken }, ExpStatus: http.StatusOK, AssertFn: func(t *testing.T, _ bodies.UpdateAccountCredentialsBody, res *http.Response) { resBody := AssertTestResponseBody(t, res, dtos.AccountCredentialsDTO{}) - AssertEqual(t, resBody.Alias, "updated-alias") - AssertEqual(t, len(resBody.Scopes), 1) + AssertEqual(t, resBody.Name, "updated-mcp-name") + AssertEqual(t, len(resBody.Scopes), 2) AssertEqual(t, resBody.Scopes[0], "account:users:read") - AssertEqual(t, resBody.Issuers[0], "https://issuer-updated.example.com") + AssertEqual(t, resBody.Scopes[1], "account:apps:read") + AssertEqual(t, resBody.SoftwareVersion, "2.0.0") }, PathFn: func() string { return accountCredentialsPath + "/" + clientID @@ -615,63 +482,76 @@ func TestUpdateAccountCredentials(t *testing.T) { ReqFn: func(t *testing.T) (bodies.UpdateAccountCredentialsBody, string) { accessToken := updateAccountCredentialBeforeEach(t) return bodies.UpdateAccountCredentialsBody{ - Alias: "invalid alias ###", - Scopes: []string{"account:users:read", "invalid:scope"}, - Issuers: []string{"https://issuer-updated.example.com"}, + Name: "", + Scopes: []string{"account:users:read", "invalid:scope"}, + Transport: "invalid_transport", + ClientURI: "not-a-uri", + SoftwareVersion: "", }, accessToken }, ExpStatus: http.StatusBadRequest, AssertFn: func(t *testing.T, _ bodies.UpdateAccountCredentialsBody, res *http.Response) { resBody := AssertTestResponseBody(t, res, exceptions.ValidationErrorResponse{}) - AssertEqual(t, len(resBody.Fields), 2) - AssertEqual(t, resBody.Fields[0].Param, "scopes[1]") - AssertEqual(t, resBody.Fields[1].Param, "alias") + AssertEqual(t, len(resBody.Fields) >= 3, true) }, PathFn: func() string { return accountCredentialsPath + "/" + clientID }, }, { - Name: "Should return 409 conflict and update alias and scopes", + Name: "Should return 409 CONFLICT with existing name", ReqFn: func(t *testing.T) (bodies.UpdateAccountCredentialsBody, string) { - account := CreateTestAccount(t, GenerateFakeAccountData(t, services.AuthProviderFacebook)) + account := CreateTestAccount(t, GenerateFakeAccountData(t, services.AuthProviderGoogle)) accessToken := GenerateScopedAccountAccessToken(t, &account, []string{tokens.AccountScopeCredentialsWrite}) - testS := GetTestServices(t) - if _, err := testS.CreateAccountCredentials(context.Background(), services.CreateAccountCredentialsOptions{ + + // Create first credentials + if _, err := GetTestServices(t).CreateAccountCredentials(context.Background(), services.CreateAccountCredentialsOptions{ RequestID: uuid.NewString(), AccountPublicID: account.PublicID, AccountVersion: account.Version(), - Alias: "existing-alias", - Scopes: []string{"account:users:read"}, + CredentialsType: "service", + Name: "existing-name", + Scopes: []string{"account:admin"}, AuthMethod: "client_secret_basic", - Issuers: []string{"updated.example.com"}, + Transport: "https", + ClientURI: "https://existing.example.com", + SoftwareID: "existing-service", + SoftwareVersion: "1.0.0", }); err != nil { - t.Fatalf("Failed to create initial account credentials: %v", err) + t.Fatalf("Failed to create first credentials: %v", err) } - clientCreds, err := testS.CreateAccountCredentials(context.Background(), services.CreateAccountCredentialsOptions{ + // Create second credentials to update + cred, err := GetTestServices(t).CreateAccountCredentials(context.Background(), services.CreateAccountCredentialsOptions{ RequestID: uuid.NewString(), AccountPublicID: account.PublicID, AccountVersion: account.Version(), - Alias: "other-alias", - Scopes: []string{"account:users:read"}, - Issuers: []string{"https://updated.example.com"}, + CredentialsType: "service", + Name: "other-name", + Scopes: []string{"account:admin"}, AuthMethod: "client_secret_basic", + Transport: "https", + ClientURI: "https://other.example.com", + SoftwareID: "other-service", + SoftwareVersion: "1.0.0", }) if err != nil { - t.Fatalf("Failed to create initial account credentials: %v", err) + t.Fatalf("Failed to create second credentials: %v", err) } - clientID = clientCreds.ClientID + clientID = cred.ClientID + return bodies.UpdateAccountCredentialsBody{ - Alias: "existing-alias", - Scopes: []string{"account:users:read"}, - Issuers: []string{"https://issuer-updated.example.com"}, + Name: "existing-name", + Scopes: []string{"account:users:read"}, + Transport: "https", + ClientURI: "https://updated.example.com", + SoftwareVersion: "2.0.0", }, accessToken }, ExpStatus: http.StatusConflict, AssertFn: func(t *testing.T, _ bodies.UpdateAccountCredentialsBody, res *http.Response) { resBody := AssertTestResponseBody(t, res, exceptions.ErrorResponse{}) - AssertEqual(t, resBody.Message, "Account credentials alias already exists") + AssertEqual(t, resBody.Message, "Account credentials name already exists") }, PathFn: func() string { return accountCredentialsPath + "/" + clientID @@ -683,9 +563,11 @@ func TestUpdateAccountCredentials(t *testing.T) { account := CreateTestAccount(t, GenerateFakeAccountData(t, services.AuthProviderGoogle)) accessToken := GenerateScopedAccountAccessToken(t, &account, []string{tokens.AccountScopeCredentialsWrite}) return bodies.UpdateAccountCredentialsBody{ - Alias: "new-alias", - Scopes: []string{"account:users:read"}, - Issuers: []string{"https://issuer-updated.example.com"}, + Name: "new-name", + Scopes: []string{"account:users:read"}, + Transport: "https", + ClientURI: "https://new.example.com", + SoftwareVersion: "1.0.0", }, accessToken }, ExpStatus: http.StatusNotFound, @@ -699,9 +581,11 @@ func TestUpdateAccountCredentials(t *testing.T) { ReqFn: func(t *testing.T) (bodies.UpdateAccountCredentialsBody, string) { updateAccountCredentialBeforeEach(t) return bodies.UpdateAccountCredentialsBody{ - Alias: "updated-alias", - Scopes: []string{"account:users:read"}, - Issuers: []string{"https://issuer-updated.example.com"}, + Name: "updated-name", + Scopes: []string{"account:users:read"}, + Transport: "https", + ClientURI: "https://updated.example.com", + SoftwareVersion: "2.0.0", }, "" }, ExpStatus: http.StatusUnauthorized, @@ -715,23 +599,31 @@ func TestUpdateAccountCredentials(t *testing.T) { ReqFn: func(t *testing.T) (bodies.UpdateAccountCredentialsBody, string) { account := CreateTestAccount(t, GenerateFakeAccountData(t, services.AuthProviderGoogle)) accessToken := GenerateScopedAccountAccessToken(t, &account, []string{tokens.AccountScopeCredentialsRead}) + cred, err := GetTestServices(t).CreateAccountCredentials(context.Background(), services.CreateAccountCredentialsOptions{ RequestID: uuid.NewString(), AccountPublicID: account.PublicID, AccountVersion: account.Version(), - Alias: "forbidden-cred", + CredentialsType: "service", + Name: "forbidden-update", Scopes: []string{"account:admin"}, AuthMethod: "client_secret_basic", - Issuers: []string{"https://issuer.example.com"}, + Transport: "https", + ClientURI: "https://forbidden.example.com", + SoftwareID: "forbidden-service", + SoftwareVersion: "1.0.0", }) if err != nil { - t.Fatalf("Failed to create account credentials: %v", err) + t.Fatalf("Failed to create credentials: %v", err) } clientID = cred.ClientID + return bodies.UpdateAccountCredentialsBody{ - Alias: "updated-alias", - Scopes: []string{"account:users:read"}, - Issuers: []string{"https://issuer-updated.example.com"}, + Name: "updated-name", + Scopes: []string{"account:users:read"}, + Transport: "https", + ClientURI: "https://updated.example.com", + SoftwareVersion: "2.0.0", }, accessToken }, ExpStatus: http.StatusForbidden, @@ -751,11 +643,114 @@ func TestUpdateAccountCredentials(t *testing.T) { t.Cleanup(accountCredentialsCleanUp(t)) } -func TestDeleteAccountCredentials(t *testing.T) { +func TestListAccountCredentials(t *testing.T) { + const accountCredentialsPath = v1Path + paths.AccountsBase + paths.CredentialsBase + + listAccountBeforeEach := func(t *testing.T, n int) string { + account := CreateTestAccount(t, GenerateFakeAccountData(t, services.AuthProviderGoogle)) + accessToken := GenerateScopedAccountAccessToken(t, &account, []string{tokens.AccountScopeCredentialsRead, tokens.AccountScopeCredentialsWrite}) + + types := []string{"service", "mcp"} + authMethods := []string{"client_secret_basic", "client_secret_post", "client_secret_jwt", "private_key_jwt"} + transports := []string{"https", "streamable_http", "stdio"} + + for i := 0; i < n; i++ { + credType := types[i%len(types)] + authMethod := authMethods[i%len(authMethods)] + transport := transports[i%len(transports)] + name := "cred-" + uuid.NewString() + + _, err := GetTestServices(t).CreateAccountCredentials(context.Background(), services.CreateAccountCredentialsOptions{ + RequestID: uuid.NewString(), + AccountPublicID: account.PublicID, + AccountVersion: account.Version(), + CredentialsType: credType, + Name: name, + Scopes: []string{"account:admin"}, + AuthMethod: authMethod, + Transport: transport, + ClientURI: "https://" + name + ".example.com", + SoftwareID: name + "-service", + SoftwareVersion: "1.0.0", + }) + if err != nil { + t.Fatalf("Failed to create account credentials: %v", err) + } + } + + return accessToken + } + + testCases := []TestRequestCase[any]{ + { + Name: "Should return 200 OK without any account credentials", + ReqFn: func(t *testing.T) (any, string) { + account := CreateTestAccount(t, GenerateFakeAccountData(t, services.AuthProviderGoogle)) + accessToken, _ := GenerateTestAccountAuthTokens(t, &account) + return nil, accessToken + }, + ExpStatus: http.StatusOK, + AssertFn: func(t *testing.T, _ any, res *http.Response) { + resBody := AssertTestResponseBody(t, res, dtos.PaginationDTO[dtos.AccountCredentialsDTO]{}) + AssertEqual(t, len(resBody.Items), 0) + AssertEmpty(t, resBody.Next) + AssertEmpty(t, resBody.Previous) + AssertEqual(t, resBody.Total, 0) + }, + Path: accountCredentialsPath, + }, + { + Name: "Should return 200 OK with paginated account credentials", + ReqFn: func(t *testing.T) (any, string) { + accessToken := listAccountBeforeEach(t, 30) + return nil, accessToken + }, + ExpStatus: http.StatusOK, + AssertFn: func(t *testing.T, _ any, res *http.Response) { + resBody := AssertTestResponseBody(t, res, dtos.PaginationDTO[dtos.AccountCredentialsDTO]{}) + AssertEqual(t, len(resBody.Items), 20) + AssertEqual(t, resBody.Total, 30) + AssertNotEmpty(t, resBody.Next) + AssertEmpty(t, resBody.Previous) + }, + Path: accountCredentialsPath, + }, + { + Name: "Should return 401 UNAUTHORIZED without access token", + ReqFn: func(t *testing.T) (any, string) { + return nil, "" + }, + ExpStatus: http.StatusUnauthorized, + AssertFn: AssertUnauthorizedError[any], + Path: accountCredentialsPath, + }, + { + Name: "Should return 403 FORBIDDEN without account:credentials:read scope", + ReqFn: func(t *testing.T) (any, string) { + account := CreateTestAccount(t, GenerateFakeAccountData(t, services.AuthProviderGoogle)) + accessToken := GenerateScopedAccountAccessToken(t, &account, []string{tokens.AccountScopeCredentialsWrite}) + return nil, accessToken + }, + ExpStatus: http.StatusForbidden, + AssertFn: AssertForbiddenError[any], + Path: accountCredentialsPath, + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + PerformTestRequestCase(t, http.MethodGet, tc.Path, tc) + }) + } + + t.Cleanup(accountCredentialsCleanUp(t)) +} + +func TestGetSingleAccountCredentials(t *testing.T) { const accountCredentialsPath = v1Path + paths.AccountsBase + paths.CredentialsBase var clientID string - deleteAccountCredentialBeforeEach := func(t *testing.T) string { + getAccountCredentialBeforeEach := func(t *testing.T) string { account := CreateTestAccount(t, GenerateFakeAccountData(t, services.AuthProviderGoogle)) accessToken := GenerateScopedAccountAccessToken(t, &account, []string{tokens.AccountScopeCredentialsRead, tokens.AccountScopeCredentialsWrite}) @@ -763,10 +758,14 @@ func TestDeleteAccountCredentials(t *testing.T) { RequestID: uuid.NewString(), AccountPublicID: account.PublicID, AccountVersion: account.Version(), - Alias: "delete-cred", + CredentialsType: "service", + Name: "get-cred", Scopes: []string{"account:admin"}, AuthMethod: "client_secret_basic", - Issuers: []string{"https://issuer.example.com"}, + Transport: "https", + ClientURI: "https://get.example.com", + SoftwareID: "get-service", + SoftwareVersion: "1.0.0", }) if err != nil { t.Fatalf("Failed to create account credentials: %v", err) @@ -777,14 +776,19 @@ func TestDeleteAccountCredentials(t *testing.T) { testCases := []TestRequestCase[any]{ { - Name: "Should return 204 NO CONTENT on successful delete", + Name: "Should return 200 OK with account credential", ReqFn: func(t *testing.T) (any, string) { - accessToken := deleteAccountCredentialBeforeEach(t) + accessToken := getAccountCredentialBeforeEach(t) return nil, accessToken }, - ExpStatus: http.StatusNoContent, + ExpStatus: http.StatusOK, AssertFn: func(t *testing.T, _ any, res *http.Response) { - AssertEqual(t, res.StatusCode, http.StatusNoContent) + resBody := AssertTestResponseBody(t, res, dtos.AccountCredentialsDTO{}) + AssertNotEmpty(t, resBody.ClientID) + AssertNotEmpty(t, resBody.Name) + AssertEqual(t, resBody.TokenEndpointAuthMethod, database.AuthMethodClientSecretBasic) + AssertEmpty(t, resBody.ClientSecret) + AssertEmpty(t, resBody.ClientSecretJWK) }, PathFn: func() string { return accountCredentialsPath + "/" + clientID @@ -794,7 +798,7 @@ func TestDeleteAccountCredentials(t *testing.T) { Name: "Should return 404 NOT FOUND for non-existent credential", ReqFn: func(t *testing.T) (any, string) { account := CreateTestAccount(t, GenerateFakeAccountData(t, services.AuthProviderGoogle)) - accessToken := GenerateScopedAccountAccessToken(t, &account, []string{tokens.AccountScopeCredentialsWrite}) + accessToken := GenerateScopedAccountAccessToken(t, &account, []string{tokens.AccountScopeCredentialsRead}) return nil, accessToken }, ExpStatus: http.StatusNotFound, @@ -806,7 +810,7 @@ func TestDeleteAccountCredentials(t *testing.T) { { Name: "Should return 401 UNAUTHORIZED without access token", ReqFn: func(t *testing.T) (any, string) { - deleteAccountCredentialBeforeEach(t) + getAccountCredentialBeforeEach(t) return nil, "" }, ExpStatus: http.StatusUnauthorized, @@ -816,17 +820,23 @@ func TestDeleteAccountCredentials(t *testing.T) { }, }, { - Name: "Should return 403 FORBIDDEN without account:credentials:write scope", + Name: "Should return 403 FORBIDDEN without account:credentials:read scope", ReqFn: func(t *testing.T) (any, string) { account := CreateTestAccount(t, GenerateFakeAccountData(t, services.AuthProviderGoogle)) - accessToken := GenerateScopedAccountAccessToken(t, &account, []string{tokens.AccountScopeCredentialsRead}) + accessToken := GenerateScopedAccountAccessToken(t, &account, []string{tokens.AccountScopeCredentialsWrite}) + cred, err := GetTestServices(t).CreateAccountCredentials(context.Background(), services.CreateAccountCredentialsOptions{ RequestID: uuid.NewString(), AccountPublicID: account.PublicID, AccountVersion: account.Version(), - Alias: "forbidden-cred", + CredentialsType: "service", + Name: "forbidden-cred", Scopes: []string{"account:admin"}, AuthMethod: "client_secret_basic", + Transport: "https", + ClientURI: "https://forbidden.example.com", + SoftwareID: "forbidden-service", + SoftwareVersion: "1.0.0", }) if err != nil { t.Fatalf("Failed to create account credentials: %v", err) @@ -844,155 +854,111 @@ func TestDeleteAccountCredentials(t *testing.T) { for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { - PerformTestRequestCaseWithPathFn(t, http.MethodDelete, tc) + PerformTestRequestCaseWithPathFn(t, http.MethodGet, tc) }) } t.Cleanup(accountCredentialsCleanUp(t)) } -func TestRevokeAccountCredentialsSecret(t *testing.T) { +func TestDeleteAccountCredentials(t *testing.T) { const accountCredentialsPath = v1Path + paths.AccountsBase + paths.CredentialsBase var clientID string - var secretID string - revokeAccountCredentialBeforeEach := func(t *testing.T, authMethods string) string { + deleteAccountCredentialBeforeEach := func(t *testing.T) string { account := CreateTestAccount(t, GenerateFakeAccountData(t, services.AuthProviderGoogle)) - accessToken := GenerateScopedAccountAccessToken(t, &account, []string{tokens.AccountScopeCredentialsWrite}) + accessToken := GenerateScopedAccountAccessToken(t, &account, []string{tokens.AccountScopeCredentialsRead, tokens.AccountScopeCredentialsWrite}) cred, err := GetTestServices(t).CreateAccountCredentials(context.Background(), services.CreateAccountCredentialsOptions{ RequestID: uuid.NewString(), AccountPublicID: account.PublicID, AccountVersion: account.Version(), - Alias: "revoke-cred", + CredentialsType: "service", + Name: "delete-cred", Scopes: []string{"account:admin"}, - Issuers: []string{"https://issuer.example.com"}, - AuthMethod: authMethods, + AuthMethod: "client_secret_basic", + Transport: "https", + ClientURI: "https://delete.example.com", + SoftwareID: "delete-service", + SoftwareVersion: "1.0.0", }) if err != nil { t.Fatalf("Failed to create account credentials: %v", err) } clientID = cred.ClientID - secretID = cred.ClientSecretID return accessToken } - generateFakeKeyID := func(t *testing.T) string { - key, err := utils.GenerateBase64Secret(16) - if err != nil { - t.Fatalf("Failed to generate fake key: %v", err) - } - return key - } - - pathFN := func() string { - return accountCredentialsPath + "/" + clientID + "/secrets/" + secretID - } - - testCases := []TestRequestCase[any]{ - { - Name: "Should return 200 OK on successful secret revoke for client_secret_post", - ReqFn: func(t *testing.T) (any, string) { - accessToken := revokeAccountCredentialBeforeEach(t, "client_secret_post") - return nil, accessToken - }, - ExpStatus: http.StatusOK, - AssertFn: func(t *testing.T, _ any, res *http.Response) { - resBody := AssertTestResponseBody(t, res, dtos.ClientCredentialsSecretDTO{}) - AssertEqual(t, resBody.PublicID, secretID) - AssertEqual(t, resBody.Status, "revoked") - AssertEmpty(t, resBody.ClientSecretJWK) - AssertEmpty(t, resBody.ClientSecret) - }, - PathFn: pathFN, - }, + testCases := []TestRequestCase[any]{ { - Name: "Should return 200 OK on successful secret revoke for client_secret_jwt", + Name: "Should return 204 NO CONTENT on successful delete", ReqFn: func(t *testing.T) (any, string) { - accessToken := revokeAccountCredentialBeforeEach(t, "client_secret_jwt") + accessToken := deleteAccountCredentialBeforeEach(t) return nil, accessToken }, - ExpStatus: http.StatusOK, + ExpStatus: http.StatusNoContent, AssertFn: func(t *testing.T, _ any, res *http.Response) { - resBody := AssertTestResponseBody(t, res, dtos.ClientCredentialsSecretDTO{}) - AssertEqual(t, resBody.PublicID, secretID) - AssertEqual(t, resBody.Status, "revoked") - AssertEmpty(t, resBody.ClientSecretJWK) - AssertEmpty(t, resBody.ClientSecret) - }, - PathFn: pathFN, - }, - { - Name: "Should return 200 OK on successful secret revoke for private_key_jwt", - ReqFn: func(t *testing.T) (any, string) { - accessToken := revokeAccountCredentialBeforeEach(t, "private_key_jwt") - return nil, accessToken + AssertEqual(t, res.StatusCode, http.StatusNoContent) }, - ExpStatus: http.StatusOK, - AssertFn: func(t *testing.T, _ any, res *http.Response) { - resBody := AssertTestResponseBody(t, res, dtos.ClientCredentialsSecretDTO{}) - AssertEqual(t, resBody.PublicID, secretID) - AssertEqual(t, resBody.Status, "revoked") - AssertEmpty(t, resBody.ClientSecretJWK) - AssertEmpty(t, resBody.ClientSecret) + PathFn: func() string { + return accountCredentialsPath + "/" + clientID }, - PathFn: pathFN, }, { Name: "Should return 404 NOT FOUND for non-existent credential", ReqFn: func(t *testing.T) (any, string) { - accessToken := revokeAccountCredentialBeforeEach(t, "client_secret_post") - clientID = utils.Base62UUID() + account := CreateTestAccount(t, GenerateFakeAccountData(t, services.AuthProviderGoogle)) + accessToken := GenerateScopedAccountAccessToken(t, &account, []string{tokens.AccountScopeCredentialsWrite}) return nil, accessToken }, ExpStatus: http.StatusNotFound, AssertFn: AssertNotFoundError[any], - PathFn: pathFN, - }, - { - Name: "Should return 404 NOT FOUND for non-existent secret", - ReqFn: func(t *testing.T) (any, string) { - accessToken := revokeAccountCredentialBeforeEach(t, "client_secret_basic") - secretID = generateFakeKeyID(t) - return nil, accessToken + PathFn: func() string { + return accountCredentialsPath + "/" + utils.Base62UUID() }, - ExpStatus: http.StatusNotFound, - AssertFn: AssertNotFoundError[any], - PathFn: pathFN, }, { Name: "Should return 401 UNAUTHORIZED without access token", ReqFn: func(t *testing.T) (any, string) { - revokeAccountCredentialBeforeEach(t, "client_secret_post") + deleteAccountCredentialBeforeEach(t) return nil, "" }, ExpStatus: http.StatusUnauthorized, AssertFn: AssertUnauthorizedError[any], - PathFn: pathFN, + PathFn: func() string { + return accountCredentialsPath + "/" + clientID + }, }, { Name: "Should return 403 FORBIDDEN without account:credentials:write scope", ReqFn: func(t *testing.T) (any, string) { account := CreateTestAccount(t, GenerateFakeAccountData(t, services.AuthProviderGoogle)) accessToken := GenerateScopedAccountAccessToken(t, &account, []string{tokens.AccountScopeCredentialsRead}) + cred, err := GetTestServices(t).CreateAccountCredentials(context.Background(), services.CreateAccountCredentialsOptions{ RequestID: uuid.NewString(), AccountPublicID: account.PublicID, AccountVersion: account.Version(), - Alias: "forbidden-cred", + CredentialsType: "service", + Name: "forbidden-delete", Scopes: []string{"account:admin"}, AuthMethod: "client_secret_basic", + Transport: "https", + ClientURI: "https://forbidden-delete.example.com", + SoftwareID: "forbidden-delete-service", + SoftwareVersion: "1.0.0", }) if err != nil { t.Fatalf("Failed to create account credentials: %v", err) } clientID = cred.ClientID - secretID = cred.ClientSecretID return nil, accessToken }, ExpStatus: http.StatusForbidden, AssertFn: AssertForbiddenError[any], - PathFn: pathFN, + PathFn: func() string { + return accountCredentialsPath + "/" + clientID + }, }, } @@ -1005,7 +971,7 @@ func TestRevokeAccountCredentialsSecret(t *testing.T) { t.Cleanup(accountCredentialsCleanUp(t)) } -func TestListAccountCredentialsSecret(t *testing.T) { +func TestListAccountCredentialsSecrets(t *testing.T) { const accountCredentialsPath = v1Path + paths.AccountsBase + paths.CredentialsBase var clientID string @@ -1017,10 +983,14 @@ func TestListAccountCredentialsSecret(t *testing.T) { RequestID: uuid.NewString(), AccountPublicID: account.PublicID, AccountVersion: account.Version(), - Alias: "list-cred", + CredentialsType: "service", + Name: "list-cred", Scopes: []string{"account:admin"}, - Issuers: []string{"https://issuer.example.com"}, AuthMethod: authMethods, + Transport: "https", + ClientURI: "https://list.example.com", + SoftwareID: "list-service", + SoftwareVersion: "1.0.0", }) if err != nil { t.Fatalf("Failed to create account credentials: %v", err) @@ -1050,7 +1020,7 @@ func TestListAccountCredentialsSecret(t *testing.T) { PathFn: pathFN, }, { - Name: "Should return 200 OK with secrets for private_key_jwt", + Name: "Should return 200 OK with keys for private_key_jwt", ReqFn: func(t *testing.T) (any, string) { accessToken := listAccountCredentialBeforeEach(t, "private_key_jwt") return nil, accessToken @@ -1090,14 +1060,19 @@ func TestListAccountCredentialsSecret(t *testing.T) { ReqFn: func(t *testing.T) (any, string) { account := CreateTestAccount(t, GenerateFakeAccountData(t, services.AuthProviderGoogle)) accessToken := GenerateScopedAccountAccessToken(t, &account, []string{tokens.AccountScopeCredentialsWrite}) + cred, err := GetTestServices(t).CreateAccountCredentials(context.Background(), services.CreateAccountCredentialsOptions{ RequestID: uuid.NewString(), AccountPublicID: account.PublicID, AccountVersion: account.Version(), - Alias: "forbidden-cred", + CredentialsType: "service", + Name: "forbidden-list", Scopes: []string{"account:admin"}, - Issuers: []string{"https://issuer.example.com"}, AuthMethod: "client_secret_basic", + Transport: "https", + ClientURI: "https://forbidden-list.example.com", + SoftwareID: "forbidden-list-service", + SoftwareVersion: "1.0.0", }) if err != nil { t.Fatalf("Failed to create account credentials: %v", err) @@ -1123,10 +1098,9 @@ func TestListAccountCredentialsSecret(t *testing.T) { func TestCreateAccountCredentialsSecret(t *testing.T) { const accountCredentialsPath = v1Path + paths.AccountsBase + paths.CredentialsBase - var account dtos.AccountDTO - var clientID, secretID string + var clientID string createAccountCredentialBeforeEach := func(t *testing.T, authMethods string) string { - account = CreateTestAccount(t, GenerateFakeAccountData(t, services.AuthProviderGoogle)) + account := CreateTestAccount(t, GenerateFakeAccountData(t, services.AuthProviderGoogle)) accessToken := GenerateScopedAccountAccessToken(t, &account, []string{tokens.AccountScopeCredentialsWrite}) cred, err := GetTestServices(t).CreateAccountCredentials( @@ -1135,10 +1109,14 @@ func TestCreateAccountCredentialsSecret(t *testing.T) { RequestID: uuid.NewString(), AccountPublicID: account.PublicID, AccountVersion: account.Version(), - Alias: "create-secret-cred", + CredentialsType: "service", + Name: "create-secret-cred", Scopes: []string{"account:admin"}, - Issuers: []string{"https://issuer.example.com"}, AuthMethod: authMethods, + Transport: "https", + ClientURI: "https://create-secret.example.com", + SoftwareID: "create-secret-service", + SoftwareVersion: "1.0.0", }, ) if err != nil { @@ -1146,7 +1124,6 @@ func TestCreateAccountCredentialsSecret(t *testing.T) { } clientID = cred.ClientID - secretID = cred.ClientSecretID return accessToken } @@ -1156,48 +1133,9 @@ func TestCreateAccountCredentialsSecret(t *testing.T) { testCases := []TestRequestCase[any]{ { - Name: "Should return 201 CREATED and create new secret for client_secret_post when the main one is revoked", + Name: "Should return 201 CREATED and create new secret for client_secret_post", ReqFn: func(t *testing.T) (any, string) { accessToken := createAccountCredentialBeforeEach(t, "client_secret_post") - if _, err := GetTestServices(t).RevokeAccountCredentialsSecretOrKey( - context.Background(), - services.RevokeAccountCredentialsSecretOrKeyOptions{ - RequestID: uuid.NewString(), - AccountPublicID: account.PublicID, - AccountVersion: account.Version(), - ClientID: clientID, - SecretID: secretID, - }, - ); err != nil { - t.Fatalf("Failed to revoke account credentials secret: %v", err) - } - return nil, accessToken - }, - ExpStatus: http.StatusCreated, - AssertFn: func(t *testing.T, _ any, res *http.Response) { - resBody := AssertTestResponseBody(t, res, dtos.ClientCredentialsSecretDTO{}) - AssertNotEmpty(t, resBody.PublicID) - AssertEqual(t, resBody.Status, "active") - AssertNotEmpty(t, resBody.ClientSecret) - }, - PathFn: pathFN, - }, - { - Name: "Should return 201 CREATED and create new secret for client_secret_jwt when the main one is revoked", - ReqFn: func(t *testing.T) (any, string) { - accessToken := createAccountCredentialBeforeEach(t, "client_secret_jwt") - if _, err := GetTestServices(t).RevokeAccountCredentialsSecretOrKey( - context.Background(), - services.RevokeAccountCredentialsSecretOrKeyOptions{ - RequestID: uuid.NewString(), - AccountPublicID: account.PublicID, - AccountVersion: account.Version(), - ClientID: clientID, - SecretID: secretID, - }, - ); err != nil { - t.Fatalf("Failed to revoke account credentials secret: %v", err) - } return nil, accessToken }, ExpStatus: http.StatusCreated, @@ -1210,21 +1148,9 @@ func TestCreateAccountCredentialsSecret(t *testing.T) { PathFn: pathFN, }, { - Name: "Should return 201 CREATED and create new secret for private_key_jwt when the main one is revoked", + Name: "Should return 201 CREATED and create new key for private_key_jwt", ReqFn: func(t *testing.T) (any, string) { accessToken := createAccountCredentialBeforeEach(t, "private_key_jwt") - if _, err := GetTestServices(t).RevokeAccountCredentialsSecretOrKey( - context.Background(), - services.RevokeAccountCredentialsSecretOrKeyOptions{ - RequestID: uuid.NewString(), - AccountPublicID: account.PublicID, - AccountVersion: account.Version(), - ClientID: clientID, - SecretID: secretID, - }, - ); err != nil { - t.Fatalf("Failed to revoke account credentials secret: %v", err) - } return nil, accessToken }, ExpStatus: http.StatusCreated, @@ -1237,160 +1163,146 @@ func TestCreateAccountCredentialsSecret(t *testing.T) { PathFn: pathFN, }, { - Name: "Should return 201 CREATED and create new secret for client_secret_post when the main one is almost expired", + Name: "Should return 404 NOT FOUND for non-existent credential", ReqFn: func(t *testing.T) (any, string) { accessToken := createAccountCredentialBeforeEach(t, "client_secret_post") - if err := GetTestDatabase(t).UpdateCredentialsSecretExpiresAtAndCreatedAt( - context.Background(), - database.UpdateCredentialsSecretExpiresAtAndCreatedAtParams{ - SecretID: secretID, - ExpiresAt: time.Now().Add(24 * time.Hour), // Set to 24 hours from now - CreatedAt: time.Now().Add(-24 * 365 * time.Hour), // Set to 1 year ago - }, - ); err != nil { - t.Fatalf("Failed to revoke account credentials secret: %v", err) - } + clientID = utils.Base62UUID() return nil, accessToken }, - ExpStatus: http.StatusCreated, - AssertFn: func(t *testing.T, _ any, res *http.Response) { - resBody := AssertTestResponseBody(t, res, dtos.ClientCredentialsSecretDTO{}) - AssertNotEmpty(t, resBody.PublicID) - AssertEqual(t, resBody.Status, "active") - AssertNotEmpty(t, resBody.ClientSecret) - }, - PathFn: pathFN, + ExpStatus: http.StatusNotFound, + AssertFn: AssertNotFoundError[any], + PathFn: pathFN, }, { - Name: "Should return 201 CREATED and create new secret for client_secret_jwt when the main one is almost expired", + Name: "Should return 401 UNAUTHORIZED without access token", ReqFn: func(t *testing.T) (any, string) { - accessToken := createAccountCredentialBeforeEach(t, "client_secret_jwt") - if err := GetTestDatabase(t).UpdateCredentialsSecretExpiresAtAndCreatedAt( - context.Background(), - database.UpdateCredentialsSecretExpiresAtAndCreatedAtParams{ - SecretID: secretID, - ExpiresAt: time.Now().Add(24 * time.Hour), // Set to 24 hours from now - CreatedAt: time.Now().Add(-24 * 365 * time.Hour), // Set to 1 year ago - }, - ); err != nil { - t.Fatalf("Failed to revoke account credentials secret: %v", err) - } - return nil, accessToken - }, - ExpStatus: http.StatusCreated, - AssertFn: func(t *testing.T, _ any, res *http.Response) { - resBody := AssertTestResponseBody(t, res, dtos.ClientCredentialsSecretDTO{}) - AssertNotEmpty(t, resBody.PublicID) - AssertEqual(t, resBody.Status, "active") - AssertNotEmpty(t, resBody.ClientSecret) + createAccountCredentialBeforeEach(t, "client_secret_post") + return nil, "" }, - PathFn: pathFN, + ExpStatus: http.StatusUnauthorized, + AssertFn: AssertUnauthorizedError[any], + PathFn: pathFN, }, { - Name: "Should return 201 CREATED and create new secret for private_key_jwt when the main one is almost expired", + Name: "Should return 403 FORBIDDEN without account:credentials:write scope", ReqFn: func(t *testing.T) (any, string) { - accessToken := createAccountCredentialBeforeEach(t, "private_key_jwt") - if err := GetTestDatabase(t).UpdateCredentialsKeyExpiresAtAndCreatedAt( - context.Background(), - database.UpdateCredentialsKeyExpiresAtAndCreatedAtParams{ - PublicKid: secretID, - ExpiresAt: time.Now().Add(24 * time.Hour), // Set to 24 hours from now - CreatedAt: time.Now().Add(-24 * 365 * time.Hour), // Set to 1 year ago - }, - ); err != nil { - t.Fatalf("Failed to revoke account credentials secret: %v", err) + account := CreateTestAccount(t, GenerateFakeAccountData(t, services.AuthProviderGoogle)) + accessToken := GenerateScopedAccountAccessToken(t, &account, []string{tokens.AccountScopeCredentialsRead}) + + cred, err := GetTestServices(t).CreateAccountCredentials(context.Background(), services.CreateAccountCredentialsOptions{ + RequestID: uuid.NewString(), + AccountPublicID: account.PublicID, + AccountVersion: account.Version(), + CredentialsType: "service", + Name: "forbidden-create-secret", + Scopes: []string{"account:admin"}, + AuthMethod: "client_secret_basic", + Transport: "https", + ClientURI: "https://forbidden-create-secret.example.com", + SoftwareID: "forbidden-create-secret-service", + SoftwareVersion: "1.0.0", + }) + if err != nil { + t.Fatalf("Failed to create account credentials: %v", err) } + clientID = cred.ClientID return nil, accessToken }, - ExpStatus: http.StatusCreated, - AssertFn: func(t *testing.T, _ any, res *http.Response) { - resBody := AssertTestResponseBody(t, res, dtos.ClientCredentialsSecretDTO{}) - AssertNotEmpty(t, resBody.PublicID) - AssertEqual(t, resBody.Status, "active") - AssertNotEmpty(t, resBody.ClientSecretJWK) - }, - PathFn: pathFN, + ExpStatus: http.StatusForbidden, + AssertFn: AssertForbiddenError[any], + PathFn: pathFN, }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + PerformTestRequestCaseWithPathFn(t, http.MethodPost, tc) + }) + } + + t.Cleanup(accountCredentialsCleanUp(t)) +} + +func TestGetAccountCredentialsSecret(t *testing.T) { + const accountCredentialsPath = v1Path + paths.AccountsBase + paths.CredentialsBase + + var clientID, secretID string + getAccountCredentialSecretBeforeEach := func(t *testing.T, authMethods string) string { + account := CreateTestAccount(t, GenerateFakeAccountData(t, services.AuthProviderGoogle)) + accessToken := GenerateScopedAccountAccessToken(t, &account, []string{tokens.AccountScopeCredentialsRead, tokens.AccountScopeCredentialsWrite}) + + cred, err := GetTestServices(t).CreateAccountCredentials(context.Background(), services.CreateAccountCredentialsOptions{ + RequestID: uuid.NewString(), + AccountPublicID: account.PublicID, + AccountVersion: account.Version(), + CredentialsType: "service", + Name: "get-secret-cred", + Scopes: []string{"account:admin"}, + AuthMethod: authMethods, + Transport: "https", + ClientURI: "https://get-secret.example.com", + SoftwareID: "get-secret-service", + SoftwareVersion: "1.0.0", + }) + if err != nil { + t.Fatalf("Failed to create account credentials: %v", err) + } + clientID = cred.ClientID + secretID = cred.ClientSecretID + return accessToken + } + + pathFN := func() string { + return accountCredentialsPath + "/" + clientID + "/secrets/" + secretID + } + + testCases := []TestRequestCase[any]{ { - Name: "Should return 201 CREATED and create new secret for client_secret_post when the main one is expired", + Name: "Should return 200 OK with secret for client_secret_post", ReqFn: func(t *testing.T) (any, string) { - accessToken := createAccountCredentialBeforeEach(t, "client_secret_post") - if err := GetTestDatabase(t).UpdateCredentialsSecretExpiresAtAndCreatedAt( - context.Background(), - database.UpdateCredentialsSecretExpiresAtAndCreatedAtParams{ - SecretID: secretID, - ExpiresAt: time.Now().Add(-24 * time.Hour), // Set to 24 hours from now - CreatedAt: time.Now().Add(-24 * 366 * time.Hour), // Set to 1 year ago - }, - ); err != nil { - t.Fatalf("Failed to revoke account credentials secret: %v", err) - } + accessToken := getAccountCredentialSecretBeforeEach(t, "client_secret_post") return nil, accessToken }, - ExpStatus: http.StatusCreated, + ExpStatus: http.StatusOK, AssertFn: func(t *testing.T, _ any, res *http.Response) { resBody := AssertTestResponseBody(t, res, dtos.ClientCredentialsSecretDTO{}) - AssertNotEmpty(t, resBody.PublicID) + AssertEqual(t, resBody.PublicID, secretID) AssertEqual(t, resBody.Status, "active") - AssertNotEmpty(t, resBody.ClientSecret) + AssertEmpty(t, resBody.ClientSecret) }, PathFn: pathFN, }, { - Name: "Should return 201 CREATED and create new secret for client_secret_jwt when the main one is expired", + Name: "Should return 200 OK with key for private_key_jwt", ReqFn: func(t *testing.T) (any, string) { - accessToken := createAccountCredentialBeforeEach(t, "client_secret_jwt") - if err := GetTestDatabase(t).UpdateCredentialsSecretExpiresAtAndCreatedAt( - context.Background(), - database.UpdateCredentialsSecretExpiresAtAndCreatedAtParams{ - SecretID: secretID, - ExpiresAt: time.Now().Add(-24 * time.Hour), // Set to 24 hours from now - CreatedAt: time.Now().Add(-24 * 366 * time.Hour), // Set to 1 year ago - }, - ); err != nil { - t.Fatalf("Failed to revoke account credentials secret: %v", err) - } + accessToken := getAccountCredentialSecretBeforeEach(t, "private_key_jwt") return nil, accessToken }, - ExpStatus: http.StatusCreated, + ExpStatus: http.StatusOK, AssertFn: func(t *testing.T, _ any, res *http.Response) { resBody := AssertTestResponseBody(t, res, dtos.ClientCredentialsSecretDTO{}) - AssertNotEmpty(t, resBody.PublicID) + AssertEqual(t, resBody.PublicID, secretID) AssertEqual(t, resBody.Status, "active") - AssertNotEmpty(t, resBody.ClientSecret) + AssertNotEmpty(t, resBody.ClientSecretJWK) }, PathFn: pathFN, }, { - Name: "Should return 201 CREATED and create new secret for private_key_jwt when the main one is expired", + Name: "Should return 404 NOT FOUND for non-existent credential", ReqFn: func(t *testing.T) (any, string) { - accessToken := createAccountCredentialBeforeEach(t, "private_key_jwt") - if err := GetTestDatabase(t).UpdateCredentialsKeyExpiresAtAndCreatedAt( - context.Background(), - database.UpdateCredentialsKeyExpiresAtAndCreatedAtParams{ - PublicKid: secretID, - ExpiresAt: time.Now().Add(-24 * time.Hour), // Set to 24 hours from now - CreatedAt: time.Now().Add(-24 * 366 * time.Hour), // Set to 1 year ago - }, - ); err != nil { - t.Fatalf("Failed to revoke account credentials secret: %v", err) - } + accessToken := getAccountCredentialSecretBeforeEach(t, "client_secret_post") + clientID = utils.Base62UUID() return nil, accessToken }, - ExpStatus: http.StatusCreated, - AssertFn: func(t *testing.T, _ any, res *http.Response) { - resBody := AssertTestResponseBody(t, res, dtos.ClientCredentialsSecretDTO{}) - AssertNotEmpty(t, resBody.PublicID) - AssertEqual(t, resBody.Status, "active") - AssertNotEmpty(t, resBody.ClientSecretJWK) - }, - PathFn: pathFN, + ExpStatus: http.StatusNotFound, + AssertFn: AssertNotFoundError[any], + PathFn: pathFN, }, { - Name: "Should return 404 NOT FOUND for non-existent credential", + Name: "Should return 404 NOT FOUND for non-existent secret", ReqFn: func(t *testing.T) (any, string) { - accessToken := createAccountCredentialBeforeEach(t, "client_secret_post") - clientID = utils.Base62UUID() + accessToken := getAccountCredentialSecretBeforeEach(t, "client_secret_basic") + secretID = utils.Base62UUID() return nil, accessToken }, ExpStatus: http.StatusNotFound, @@ -1400,7 +1312,7 @@ func TestCreateAccountCredentialsSecret(t *testing.T) { { Name: "Should return 401 UNAUTHORIZED without access token", ReqFn: func(t *testing.T) (any, string) { - createAccountCredentialBeforeEach(t, "client_secret_post") + getAccountCredentialSecretBeforeEach(t, "client_secret_post") return nil, "" }, ExpStatus: http.StatusUnauthorized, @@ -1408,22 +1320,29 @@ func TestCreateAccountCredentialsSecret(t *testing.T) { PathFn: pathFN, }, { - Name: "Should return 403 FORBIDDEN without account:credentials:write scope", + Name: "Should return 403 FORBIDDEN without account:credentials:read scope", ReqFn: func(t *testing.T) (any, string) { account := CreateTestAccount(t, GenerateFakeAccountData(t, services.AuthProviderGoogle)) - accessToken := GenerateScopedAccountAccessToken(t, &account, []string{tokens.AccountScopeCredentialsRead}) + accessToken := GenerateScopedAccountAccessToken(t, &account, []string{tokens.AccountScopeCredentialsWrite}) + cred, err := GetTestServices(t).CreateAccountCredentials(context.Background(), services.CreateAccountCredentialsOptions{ RequestID: uuid.NewString(), AccountPublicID: account.PublicID, AccountVersion: account.Version(), - Alias: "forbidden-cred", + CredentialsType: "service", + Name: "forbidden-get-secret", Scopes: []string{"account:admin"}, AuthMethod: "client_secret_basic", + Transport: "https", + ClientURI: "https://forbidden-get-secret.example.com", + SoftwareID: "forbidden-get-secret-service", + SoftwareVersion: "1.0.0", }) if err != nil { t.Fatalf("Failed to create account credentials: %v", err) } clientID = cred.ClientID + secretID = cred.ClientSecretID return nil, accessToken }, ExpStatus: http.StatusForbidden, @@ -1434,29 +1353,34 @@ func TestCreateAccountCredentialsSecret(t *testing.T) { for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { - PerformTestRequestCaseWithPathFn(t, http.MethodPost, tc) + PerformTestRequestCaseWithPathFn(t, http.MethodGet, tc) }) } t.Cleanup(accountCredentialsCleanUp(t)) } -func TestGetAccountCredentialsSecret(t *testing.T) { +func TestRevokeAccountCredentialsSecret(t *testing.T) { const accountCredentialsPath = v1Path + paths.AccountsBase + paths.CredentialsBase - var clientID, secretID string - getAccountCredentialSecretBeforeEach := func(t *testing.T, authMethods string) string { + var clientID string + var secretID string + revokeAccountCredentialBeforeEach := func(t *testing.T, authMethods string) string { account := CreateTestAccount(t, GenerateFakeAccountData(t, services.AuthProviderGoogle)) - accessToken := GenerateScopedAccountAccessToken(t, &account, []string{tokens.AccountScopeCredentialsRead, tokens.AccountScopeCredentialsWrite}) + accessToken := GenerateScopedAccountAccessToken(t, &account, []string{tokens.AccountScopeCredentialsWrite}) cred, err := GetTestServices(t).CreateAccountCredentials(context.Background(), services.CreateAccountCredentialsOptions{ RequestID: uuid.NewString(), AccountPublicID: account.PublicID, AccountVersion: account.Version(), - Alias: "get-secret-cred", + CredentialsType: "service", + Name: "revoke-cred", Scopes: []string{"account:admin"}, - Issuers: []string{"https://issuer.example.com"}, AuthMethod: authMethods, + Transport: "https", + ClientURI: "https://revoke.example.com", + SoftwareID: "revoke-service", + SoftwareVersion: "1.0.0", }) if err != nil { t.Fatalf("Failed to create account credentials: %v", err) @@ -1472,39 +1396,41 @@ func TestGetAccountCredentialsSecret(t *testing.T) { testCases := []TestRequestCase[any]{ { - Name: "Should return 200 OK with secret for client_secret_post", + Name: "Should return 200 OK on successful secret revoke for client_secret_post", ReqFn: func(t *testing.T) (any, string) { - accessToken := getAccountCredentialSecretBeforeEach(t, "client_secret_post") + accessToken := revokeAccountCredentialBeforeEach(t, "client_secret_post") return nil, accessToken }, ExpStatus: http.StatusOK, AssertFn: func(t *testing.T, _ any, res *http.Response) { resBody := AssertTestResponseBody(t, res, dtos.ClientCredentialsSecretDTO{}) AssertEqual(t, resBody.PublicID, secretID) - AssertEqual(t, resBody.Status, "active") + AssertEqual(t, resBody.Status, "revoked") + AssertEmpty(t, resBody.ClientSecretJWK) AssertEmpty(t, resBody.ClientSecret) }, PathFn: pathFN, }, { - Name: "Should return 200 OK with secret for private_key_jwt", + Name: "Should return 200 OK on successful key revoke for private_key_jwt", ReqFn: func(t *testing.T) (any, string) { - accessToken := getAccountCredentialSecretBeforeEach(t, "private_key_jwt") + accessToken := revokeAccountCredentialBeforeEach(t, "private_key_jwt") return nil, accessToken }, ExpStatus: http.StatusOK, AssertFn: func(t *testing.T, _ any, res *http.Response) { resBody := AssertTestResponseBody(t, res, dtos.ClientCredentialsSecretDTO{}) AssertEqual(t, resBody.PublicID, secretID) - AssertEqual(t, resBody.Status, "active") + AssertEqual(t, resBody.Status, "revoked") AssertEmpty(t, resBody.ClientSecretJWK) + AssertEmpty(t, resBody.ClientSecret) }, PathFn: pathFN, }, { Name: "Should return 404 NOT FOUND for non-existent credential", ReqFn: func(t *testing.T) (any, string) { - accessToken := getAccountCredentialSecretBeforeEach(t, "client_secret_post") + accessToken := revokeAccountCredentialBeforeEach(t, "client_secret_post") clientID = utils.Base62UUID() return nil, accessToken }, @@ -1515,7 +1441,7 @@ func TestGetAccountCredentialsSecret(t *testing.T) { { Name: "Should return 404 NOT FOUND for non-existent secret", ReqFn: func(t *testing.T) (any, string) { - accessToken := getAccountCredentialSecretBeforeEach(t, "client_secret_basic") + accessToken := revokeAccountCredentialBeforeEach(t, "client_secret_basic") secretID = utils.Base62UUID() return nil, accessToken }, @@ -1526,7 +1452,7 @@ func TestGetAccountCredentialsSecret(t *testing.T) { { Name: "Should return 401 UNAUTHORIZED without access token", ReqFn: func(t *testing.T) (any, string) { - getAccountCredentialSecretBeforeEach(t, "client_secret_post") + revokeAccountCredentialBeforeEach(t, "client_secret_post") return nil, "" }, ExpStatus: http.StatusUnauthorized, @@ -1534,18 +1460,23 @@ func TestGetAccountCredentialsSecret(t *testing.T) { PathFn: pathFN, }, { - Name: "Should return 403 FORBIDDEN without account:credentials:read scope", + Name: "Should return 403 FORBIDDEN without account:credentials:write scope", ReqFn: func(t *testing.T) (any, string) { account := CreateTestAccount(t, GenerateFakeAccountData(t, services.AuthProviderGoogle)) - accessToken := GenerateScopedAccountAccessToken(t, &account, []string{tokens.AccountScopeCredentialsWrite}) + accessToken := GenerateScopedAccountAccessToken(t, &account, []string{tokens.AccountScopeCredentialsRead}) + cred, err := GetTestServices(t).CreateAccountCredentials(context.Background(), services.CreateAccountCredentialsOptions{ RequestID: uuid.NewString(), AccountPublicID: account.PublicID, AccountVersion: account.Version(), - Alias: "forbidden-cred", + CredentialsType: "service", + Name: "forbidden-revoke", Scopes: []string{"account:admin"}, - Issuers: []string{"https://issuer.example.com"}, AuthMethod: "client_secret_basic", + Transport: "https", + ClientURI: "https://forbidden-revoke.example.com", + SoftwareID: "forbidden-revoke-service", + SoftwareVersion: "1.0.0", }) if err != nil { t.Fatalf("Failed to create account credentials: %v", err) @@ -1562,7 +1493,7 @@ func TestGetAccountCredentialsSecret(t *testing.T) { for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { - PerformTestRequestCaseWithPathFn(t, http.MethodGet, tc) + PerformTestRequestCaseWithPathFn(t, http.MethodDelete, tc) }) } diff --git a/idp/tests/auth_test.go b/idp/tests/auth_test.go index ef68cab..7a08370 100644 --- a/idp/tests/auth_test.go +++ b/idp/tests/auth_test.go @@ -1480,7 +1480,7 @@ func TestListAccountAuthProviders(t *testing.T) { AssertFn: func(t *testing.T, _ string, res *http.Response) { resBody := AssertTestResponseBody(t, res, dtos.ItemsDTO[dtos.AuthProviderDTO]{}) AssertEqual(t, len(resBody.Items), 1) - AssertEqual(t, resBody.Items[0].Provider, services.AuthProviderLocal) + AssertEqual(t, resBody.Items[0].Provider, database.AuthProviderLocal) AssertNotEmpty(t, resBody.Items[0].RegisteredAt) }, }, @@ -1549,11 +1549,11 @@ func TestListAccountAuthProviders(t *testing.T) { AssertFn: func(t *testing.T, _ string, res *http.Response) { resBody := AssertTestResponseBody(t, res, dtos.ItemsDTO[dtos.AuthProviderDTO]{}) AssertEqual(t, len(resBody.Items), 3) - AssertEqual(t, resBody.Items[0].Provider, services.AuthProviderGoogle) + AssertEqual(t, resBody.Items[0].Provider, database.AuthProviderGoogle) AssertNotEmpty(t, resBody.Items[0].RegisteredAt) - AssertEqual(t, resBody.Items[1].Provider, services.AuthProviderMicrosoft) + AssertEqual(t, resBody.Items[1].Provider, database.AuthProviderMicrosoft) AssertNotEmpty(t, resBody.Items[1].RegisteredAt) - AssertEqual(t, resBody.Items[2].Provider, services.AuthProviderLocal) + AssertEqual(t, resBody.Items[2].Provider, database.AuthProviderLocal) AssertNotEmpty(t, resBody.Items[2].RegisteredAt) }, }, @@ -1639,7 +1639,7 @@ func TestGetAccountAuthProvider(t *testing.T) { ExpStatus: http.StatusOK, AssertFn: func(t *testing.T, provider string, res *http.Response) { resBody := AssertTestResponseBody(t, res, dtos.AuthProviderDTO{}) - AssertEqual(t, resBody.Provider, services.AuthProviderApple) + AssertEqual(t, resBody.Provider, database.AuthProviderApple) AssertNotEmpty(t, resBody.RegisteredAt) }, Path: authProviderPath + "/apple", @@ -1690,7 +1690,7 @@ func TestGetAccountAuthProvider(t *testing.T) { ExpStatus: http.StatusOK, AssertFn: func(t *testing.T, provider string, res *http.Response) { resBody := AssertTestResponseBody(t, res, dtos.AuthProviderDTO{}) - AssertEqual(t, resBody.Provider, services.AuthProviderGoogle) + AssertEqual(t, resBody.Provider, database.AuthProviderGoogle) AssertNotEmpty(t, resBody.RegisteredAt) }, Path: authProviderPath + "/google", diff --git a/idp/tests/common_test.go b/idp/tests/common_test.go index d8e736f..1fc2e99 100644 --- a/idp/tests/common_test.go +++ b/idp/tests/common_test.go @@ -142,6 +142,7 @@ func initTestServicesAndApp(t *testing.T) { tokensCfg.ConfirmTTL(), tokensCfg.ResetTTL(), tokensCfg.TwoFATTL(), + tokensCfg.DynamicRegistrationTTL(), ) logger.InfoContext(ctx, "Finished building JWT tokens keys") @@ -205,6 +206,9 @@ func initTestServicesAndApp(t *testing.T) { cfg.AccountCCExpDays(), cfg.AppCCExpDays(), cfg.UserCCExpDays(), + cfg.HMACSecretExpDays(), + cfg.AccountDomainVerificationHost(), + cfg.AccountDomainVerificationTTL(), ) _testServer = server.New(ctx, logger, *_testConfig) diff --git a/idp/tests/oauth_test.go b/idp/tests/oauth_test.go index d931516..00a84c8 100644 --- a/idp/tests/oauth_test.go +++ b/idp/tests/oauth_test.go @@ -46,7 +46,7 @@ func TestAccountOAuthURL(t *testing.T) { params.Add("response_type", "code") params.Add("scope", "email profile") params.Add("state", generateState(t)) - params.Add("code_challenge", utils.Sha256HashBase64([]byte(generateState(t)))) + params.Add("code_challenge", utils.Sha256HashBase64(generateState(t))) params.Add("code_challenge_method", "S256") return "?" + params.Encode() } @@ -165,7 +165,7 @@ func callbackBeforeEach(t *testing.T, provider string) (string, string, string) testCache := GetTestCache(t) requestID := uuid.NewString() - challenge := utils.Sha256HashBase64([]byte(state + requestID)) + challenge := utils.Sha256HashBase64(state + requestID) stateOpts := cache.SaveOAuthStateDataOptions{ RequestID: requestID, State: state, @@ -695,7 +695,7 @@ func TestOAuthToken(t *testing.T) { GivenName: account.GivenName, FamilyName: account.FamilyName, Provider: provider, - Challenge: utils.Sha256HashBase64([]byte(challenge)), + Challenge: utils.Sha256HashBase64(challenge), }) if err != nil { t.Fatal("Failed to generate OAuth code", err) @@ -750,10 +750,17 @@ func TestOAuthToken(t *testing.T) { RequestID: uuid.NewString(), AccountPublicID: account.PublicID, AccountVersion: account.Version(), - Alias: "update-cred", + CredentialsType: string(database.AccountCredentialsTypeService), + Name: "update-cred", Scopes: []string{"account:admin"}, AuthMethod: "private_key_jwt", - Issuers: []string{"https://issuer.example.com"}, + Domain: "issuer.example.com", + ClientURI: "https://issuer.example.com", + Transport: "https", + SoftwareID: "test-software", + SoftwareVersion: "1.0.0", + Contacts: []string{"test@example.com"}, + CreationMethod: database.CreationMethodManual, Algorithm: string(algorithm), }) if serviceErr != nil { @@ -826,10 +833,17 @@ func TestOAuthToken(t *testing.T) { RequestID: uuid.NewString(), AccountPublicID: account.PublicID, AccountVersion: account.Version(), - Alias: "update-cred", + CredentialsType: string(database.AccountCredentialsTypeService), + Name: "update-cred", Scopes: []string{"account:admin"}, AuthMethod: am, - Issuers: []string{"https://issuer.example.com"}, + Domain: "issuer.example.com", + ClientURI: "https://issuer.example.com", + Transport: "https", + SoftwareID: "test-software", + SoftwareVersion: "1.0.0", + Contacts: []string{"test@example.com"}, + CreationMethod: database.CreationMethodManual, }) if serviceErr != nil { t.Fatalf("Failed to create account credentials: %v", serviceErr) diff --git a/project.md b/project.md index 36f2bea..8c132a0 100644 --- a/project.md +++ b/project.md @@ -23,16 +23,16 @@ - backend - device - service + - mcp ### IDP On-Going -- Add MCP app support - -### IDP Todo - - Add OAuth Dynamic Registration for: - accounts - apps + +### IDP Todo + - Account key generation - Dynamic OIDC configs - User authentication for each app type: