diff --git a/go.mod b/go.mod index 71cca5a6..4b25c0cf 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,8 @@ require ( github.com/blang/semver v3.5.1+incompatible github.com/caarlos0/env/v6 v6.10.1 github.com/dchest/captcha v1.1.0 + github.com/docker/docker v28.5.2+incompatible + github.com/docker/go-connections v0.5.0 github.com/elazarl/go-bindata-assetfs v1.0.1 github.com/emersion/go-imap v1.2.1 github.com/evanw/esbuild v0.25.4 @@ -19,6 +21,7 @@ require ( github.com/gin-gonic/gin v1.10.1 github.com/golang-jwt/jwt/v4 v4.5.2 github.com/google/uuid v1.6.0 + github.com/gorilla/websocket v1.5.3 github.com/hashicorp/go-multierror v1.1.1 github.com/joho/godotenv v1.5.1 github.com/json-iterator/go v1.1.12 @@ -46,7 +49,7 @@ require ( ) require ( - filippo.io/edwards25519 v1.1.0 // indirect + filippo.io/edwards25519 v1.1.1 // indirect github.com/JohannesKaufmann/dom v0.2.0 // indirect github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect @@ -73,8 +76,6 @@ require ( github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/distribution/reference v0.6.0 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect - github.com/docker/docker v28.5.2+incompatible // indirect - github.com/docker/go-connections v0.5.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect @@ -96,7 +97,6 @@ require ( github.com/golang/snappy v1.0.0 // indirect github.com/google/go-github/v30 v30.1.0 // indirect github.com/google/go-querystring v1.1.0 // indirect - github.com/gorilla/websocket v1.5.3 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-hclog v1.6.3 // indirect github.com/hashicorp/go-plugin v1.6.3 // indirect @@ -122,6 +122,7 @@ require ( github.com/mattn/go-sqlite3 v1.14.28 // indirect github.com/miekg/dns v1.1.66 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/sys/atomicwriter v0.1.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/montanaflynn/stats v0.7.1 // indirect @@ -163,11 +164,14 @@ require ( github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect github.com/yuin/goldmark v1.7.16 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect - go.opentelemetry.io/otel v1.37.0 // indirect - go.opentelemetry.io/otel/metric v1.37.0 // indirect - go.opentelemetry.io/otel/trace v1.37.0 // indirect + go.opentelemetry.io/otel v1.40.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect + go.opentelemetry.io/otel/metric v1.40.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.40.0 // indirect + go.opentelemetry.io/otel/trace v1.40.0 // indirect + go.opentelemetry.io/proto/otlp v1.9.0 // indirect golang.org/x/arch v0.17.0 // indirect golang.org/x/image v0.29.0 // indirect golang.org/x/mod v0.29.0 // indirect @@ -175,10 +179,11 @@ require ( golang.org/x/sync v0.18.0 // indirect golang.org/x/sys v0.40.0 // indirect golang.org/x/tools v0.38.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 // indirect - google.golang.org/grpc v1.72.1 // indirect - google.golang.org/protobuf v1.36.6 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 // indirect + google.golang.org/grpc v1.75.1 // indirect + google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect + gotest.tools/v3 v3.5.2 // indirect ) // go env -w GOPRIVATE=github.com/yaoapp/* diff --git a/go.sum b/go.sum index a7118d72..52182b71 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,8 @@ -filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw= +filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +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= github.com/JohannesKaufmann/dom v0.2.0 h1:1bragmEb19K8lHAqgFgqCpiPCFEZMTXzOIEjuxkUfLQ= github.com/JohannesKaufmann/dom v0.2.0/go.mod h1:57iSUl5RKric4bUkgos4zu6Xt5LMHUnw3TF1l5CbGZo= github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.0 h1:mklaPbT4f/EiDr1Q+zPrEt9lgKAkVrIBtWf33d9GpVA= @@ -55,6 +58,8 @@ github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCN github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= github.com/caarlos0/env/v6 v6.10.1 h1:t1mPSxNpei6M5yAeu1qtRdPAK29Nbcf/n3G7x+b3/II= github.com/caarlos0/env/v6 v6.10.1/go.mod h1:hvp/ryKXKipEkcuYjs9mI4bBCg+UI0Yhgm5Zu0ddvwc= +github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= +github.com/cenkalti/backoff/v4 v4.2.1/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/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4= @@ -64,6 +69,8 @@ github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG 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/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -161,6 +168,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -243,6 +252,12 @@ github.com/miekg/dns v1.1.66 h1:FeZXOS3VCVsKnEAd+wBkjMC3D2K+ww66Cq3VnCINuJE= github.com/miekg/dns v1.1.66/go.mod h1:jGFzBsSNbJw6z1HYut1RKBKHA9PBdxeHrZG8J+gC2WE= 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/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= +github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= +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/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -250,6 +265,8 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE= github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/mozillazg/go-pinyin v0.20.0 h1:BtR3DsxpApHfKReaPO1fCqF4pThRwH9uwvXzm+GnMFQ= github.com/mozillazg/go-pinyin v0.20.0/go.mod h1:iR4EnMMRXkfpFVV5FMi4FNB6wGq9NV6uDWbUuPhP4Yc= github.com/neo4j/neo4j-go-driver/v5 v5.28.1 h1:RKWQW7wTgYAY2fU9S+9LaJ9OwRPbRc0I17tlT7nDmAY= @@ -296,8 +313,8 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= -github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sebdah/goldie/v2 v2.8.0 h1:dZb9wR8q5++oplmEiJT+U/5KyotVD+HNGCAc5gNr8rc= github.com/sebdah/goldie/v2 v2.8.0/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI= @@ -377,20 +394,26 @@ github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE= github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= go.mongodb.org/mongo-driver v1.17.3 h1:TQyXhnsWfWtgAhMtOgtYHMTkZIfBTpMTsMnd9ZBeHxQ= go.mongodb.org/mongo-driver v1.17.3/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= -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/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= -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/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/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= -go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= -go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk= -go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w= -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 v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= +go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.21.0 h1:digkEZCJWobwBqMwC0cwCq8/wkkRy/OowZg5OArWZrM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.21.0/go.mod h1:/OpE/y70qVkndM0TrxT4KBoN3RsFZP0QaofcfYrj76I= +go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= +go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= +go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= +go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= +go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= +go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= +go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= +go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= +go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= +go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= golang.org/x/arch v0.17.0 h1:4O3dfLzd+lQewptAHqjewQZQDyEdejz3VwgeYwkZneU= golang.org/x/arch v0.17.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -486,6 +509,8 @@ golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= +golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 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.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= @@ -496,14 +521,18 @@ golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 h1:cJfm9zPbe1e873mHJzmQ1nwVEeRDU/T1wXDK2kUSU34= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= -google.golang.org/grpc v1.72.1 h1:HR03wO6eyZ7lknl75XlxABNVLLFc2PAb6mHlYh756mA= -google.golang.org/grpc v1.72.1/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= -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/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 h1:BIRfGDEjiHRrk0QKZe3Xv2ieMhtgRGeLcZQ0mIVn4EY= +google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 h1:eaY8u2EuxbRv7c3NiGK0/NedzVsCcV6hDuU5qPX5EGE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5/go.mod h1:M4/wBTSeyLxupu3W3tJtOgB14jILAS/XWPSSa3TAlJc= +google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI= +google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -521,4 +550,6 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 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= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= diff --git a/openapi/oauth/oauth.go b/openapi/oauth/oauth.go index cbc9626a..c4c85085 100644 --- a/openapi/oauth/oauth.go +++ b/openapi/oauth/oauth.go @@ -179,6 +179,11 @@ func (s *Service) GetStore() store.Store { return s.store } +// GetKeyPrefix returns the key prefix used for store keys (e.g. "yao_:") +func (s *Service) GetKeyPrefix() string { + return s.prefix +} + // GetSecurityConfig returns the security configuration for the service func (s *Service) GetSecurityConfig() types.SecurityConfig { if s.config == nil { diff --git a/openapi/openapi.go b/openapi/openapi.go index 32c0e64e..505be624 100644 --- a/openapi/openapi.go +++ b/openapi/openapi.go @@ -21,6 +21,7 @@ import ( "github.com/yaoapp/yao/openapi/oauth" "github.com/yaoapp/yao/openapi/oauth/acl" "github.com/yaoapp/yao/openapi/oauth/types" + "github.com/yaoapp/yao/openapi/otp" "github.com/yaoapp/yao/openapi/response" "github.com/yaoapp/yao/openapi/sandbox" "github.com/yaoapp/yao/openapi/team" @@ -86,6 +87,9 @@ func Load(appConfig config.Config) (*OpenAPI, error) { return nil, err } + // Initialize OTP service (shares the OAuth store) + otp.NewService(oauthService.GetStore(), oauthService.GetKeyPrefix()) + // Create the OpenAPI server Server = &OpenAPI{Config: &config, OAuth: oauthService} return Server, nil @@ -160,6 +164,9 @@ func (openapi *OpenAPI) Attach(router *gin.Engine) { // App handlers (menu, etc.) app.Attach(group.Group("/app"), openapi.OAuth) + // OTP handlers (passwordless authentication) + otp.Attach(group.Group("/otp"), openapi.OAuth) + // Sandbox handlers (VNC proxy for visual browser automation) sandbox.SetPathPrefix(baseURL) sandbox.Attach(group.Group("/sandbox"), openapi.OAuth) diff --git a/openapi/otp/DESIGN.md b/openapi/otp/DESIGN.md new file mode 100644 index 00000000..4e854608 --- /dev/null +++ b/openapi/otp/DESIGN.md @@ -0,0 +1,334 @@ +# OTP Passwordless Authentication + +## Overview + +OTP (One-Time Password) provides passwordless authentication via magic links. +AI or system generates a short link `https://host//v/`, user clicks it, +CUI verifies the code against the backend, backend issues tokens, and frontend redirects. + +## Architecture + +``` +AI/System Yao Backend CUI Frontend + | | | + |-- otp.Create(params) ------->| | + |<---- code (nanoid 12) -------| | + | | | + | (compose link, send to user) | | + | | | + | | GET //v/ | + | |<-------------------------------| + | | | + | | POST /api/otp/login {code} | + | |<-------------------------------| + | | | + | |-- otp.Login(code) ------------>| + | | ├─ Verify code in store | + | | ├─ Resolve identity | + | | ├─ LoginByTeamID → tokens | + | | └─ SendLoginCookies | + | | | + | | {redirect} ----------------->| + | | | + | | window.location = redirect +``` + +## Design Principles + +1. **Four clean APIs**: Create, Verify, Login, Revoke — each with a single responsibility. +2. **Verify is pure**: returns stored payload, no side effects. + Login is the full flow: verify + identity resolution + token issuance. +3. **Package location `openapi/otp/`**: can freely import `user` and `oauth`. + Dependency chain is one-directional: `openapi/otp → user → oauth` (no cycles). +4. **Shared store, unified key namespace**: reuses OAuth's store.Store, + keys under `{prefix}oauth:otp:{code}` alongside refresh_token/access_token. + +## Package Structure + +``` +openapi/ + otp/ + DESIGN.md ← this file + otp.go ← Service, Payload, NewService + generate.go ← Create (nanoid + collision check + store.Set) + verify.go ← Verify (store.Get + type coercion) + revoke.go ← Revoke (store.Del) + login.go ← Login (Verify + resolve identity + issue tokens) + handler.go ← GinOTPCreate + GinOTPLogin HTTP handlers + Attach(group, oauth) + process.go ← Yao processors: otp.Create, otp.Verify, otp.Login, otp.Revoke + + openapi.go ← init OTP service in Load(), register route in Attach() +``` + +``` +cui/packages/cui/ + openapi/user/auth.ts ← add OTPLogin method + pages/auth/v/$.tsx ← OTP verification page (route: /v/) +``` + +## Dependency Graph + +``` +openapi/otp + ├── imports user (LoginByTeamID, LoginWithOptions, SendLoginCookies) + ├── imports oauth (OAuth.GetUserProvider, OAuth.GetStore for identity resolution) + └── imports store (store.Store for code persistence) + +user + └── imports oauth (existing, unchanged) +``` + +One-directional: `openapi/otp → user → oauth`. No cycles. + +## Data Structures + +### Payload (stored in store) + +```go +type Payload struct { + TeamID string `json:"team_id,omitempty"` + MemberID string `json:"member_id,omitempty"` + UserID string `json:"user_id,omitempty"` + Redirect string `json:"redirect"` + Scope string `json:"scope,omitempty"` +} +``` + +### GenerateParams + +```go +type GenerateParams struct { + TeamID string + MemberID string + UserID string + ExpiresIn int // seconds, default 24h + Redirect string // required + Scope string // optional, space-separated +} +``` + +## Store Key Format + +``` +{prefix}oauth:otp:{code} +``` + +Example: `yao_:oauth:otp:abc123def456` + +Consistent with existing OAuth keys: +- `{prefix}oauth:refresh_token:{token}` +- `{prefix}oauth:access_token:{token}` +- `{prefix}oauth:otp:{code}` + +NanoID: 12 chars, alphabet `23456789abcdefghjkmnpqrstuvwxyz` (no ambiguous chars). +Collision check: retry up to 5 times. + +## Processor Interface + +Four processors, CRUD-style naming. + +### otp.Create + +```javascript +code = Process("otp.Create", { + "user_id": "user_xxx", // required (or member_id) + "team_id": "team_xxx", // optional + "member_id": "member_xxx", // optional; when set, team_id is required + "expires_in": 86400, // optional; seconds, default 24h + "redirect": "/chat", // required; target path after login + "scope": "read write" // optional; space-separated scopes +}) +// returns: "abc123def456" (string) +// developer composes the full link: `${host}/${prefix}/v/${code}` +``` + +Single map argument. Returns code string. + +### otp.Verify + +```javascript +payload = Process("otp.Verify", "abc123def456") +// returns: { +// "team_id": "team_xxx", +// "member_id": "member_xxx", +// "user_id": "user_xxx", +// "redirect": "/chat", +// "scope": "read write" +// } +``` + +Pure validation. Returns stored Payload. Does NOT consume code (valid within TTL). +Use case: inspect payload before login, or use OTP for non-login purposes. + +### otp.Login + +```javascript +result = Process("otp.Login", "abc123def456", "zh-CN") +// returns: { +// "access_token": "Bearer ...", +// "id_token": "eyJ...", +// "redirect": "/chat", +// "expires_in": 3600, +// ... +// } +``` + +Full login flow: verify code -> resolve identity -> issue tokens. +- args[0]: code (string, required) +- args[1]: locale (string, optional) + +Internally: +1. Verify(code) -> Payload +2. Resolve identity (member_id -> user_id if needed) +3. LoginByTeamID or LoginWithOptions (when scope override) +4. Return LoginResponse + redirect + +Does NOT set HTTP cookies (no gin.Context). The HTTP handler wraps this +and additionally calls SendLoginCookies. + +### otp.Revoke + +```javascript +Process("otp.Revoke", "abc123def456") +// returns: null +``` + +Immediately removes code from store. Silent on missing/expired. + +## HTTP APIs + +### POST /api/otp/create (protected) + +Requires authentication (OpenAPI Guard). Permission managed by Scope/ACL. +The caller's `team_id` is forced from the authenticated identity; request body `team_id` is ignored. +The `member_id` must belong to the caller's team. + +**Request:** +```json +{ + "member_id": "member_xxx", + "user_id": "user_xxx", + "expires_in": 86400, + "redirect": "/chat", + "scope": "read write" +} +``` + +**Success (200):** +```json +{ "code": "abc123def456" } +``` + +**Errors:** 400 (missing fields), 403 (member not in team), 500 (internal). + +### POST /api/otp/login (public) + +Public endpoint (no auth guard). The OTP code itself is the credential. + +**Request:** +```json +{ "code": "abc123def456", "locale": "zh-CN" } +``` + +**Success (200):** +```json +{ "redirect": "/chat" } +``` +Cookies set: `access_token`, `refresh_token`, `session_id`. + +**Errors:** 400 (missing code), 401 (invalid/expired), 500 (internal). + +### Handler Flows (handler.go) + +#### GinOTPCreate +``` +1. authorized.GetInfo(c) -> authInfo (teamID, userID) +2. Bind JSON -> request body +3. Force teamID from authInfo +4. Validate member belongs to team +5. service.Create(params) -> code +6. Respond {code} +``` + +#### GinOTPLogin +``` +1. Bind JSON -> {code, locale} +2. service.Login(code, locale) -> LoginResponse + Payload +3. sessionID = utils.GetSessionID(c) or generateSessionID() +4. user.SendLoginCookies(c, loginResp, sessionID) +5. Respond {redirect: payload.Redirect} +``` + +The handlers are thin — business logic lives in the Service methods. + +## Scope Override (user package change) + +When `payload.Scope` is non-empty, `LoginByTeamID` cannot be used directly +because it resolves scopes internally. Add one function to `user` package: + +```go +// user/types.go +type LoginOptions struct { + Scopes []string +} + +// user/login.go +func LoginWithOptions(userid, teamID string, loginCtx *LoginContext, opts *LoginOptions) (*LoginResponse, error) +``` + +Same logic as `LoginByTeamID`, uses `opts.Scopes` when non-nil. +This is the **only** change to `user` package. + +## Initialization (openapi.go) + +In `Load()`: +```go +otp.NewService(oauth.OAuth.GetStore(), oauth.OAuth.GetPrefix()) +``` + +Note: Since oauth.Service.prefix is private, the OTP service constructs +the prefix independently using `share.App.Prefix` for consistency. + +In `Attach()`: +```go +otp.Attach(group.Group("/otp"), openapi.OAuth) +``` + +## CUI Page (pages/auth/v/$.tsx) + +``` +1. Extract code from URL path: /v/ +2. Call userClient.auth.OTPLogin(code, locale) +3. On success: + - Call GetProfile() to get UserInfo (cookies already set by backend) + - AfterLogin(global, { user: profileData, entry: redirect }) + - window.location.href = redirect +4. On error: show error UI with "Go Back" button +``` + +### auth.ts Addition + +```typescript +async OTPLogin(code: string, locale?: string): Promise> { + return this.api.Post<{ redirect: string }>('/otp/login', { code, locale: locale || '' }) +} +``` + +## Test Plan + +- **Unit tests** (openapi/otp package): + - Create: produces 12-char code, stores payload, respects TTL + - Create: validates required fields (user_id/member_id, redirect) + - Create: handles collision retry + - Verify: returns payload, rejects expired/invalid/empty + - Verify: does NOT consume code (multi-verify within TTL) + - Revoke: removes code, silent on missing + - Login: full flow with valid code returns tokens + - Login: rejects invalid code +- **Handler tests**: + - POST /api/otp/create with valid auth -> 200 + code + - POST /api/otp/create without auth -> 401 + - POST /api/otp/create with cross-team member -> 403 + - POST /api/otp/login with valid code -> 200 + cookies + redirect + - POST /api/otp/login with invalid code -> 401 + - POST /api/otp/login with missing code -> 400 diff --git a/openapi/otp/README.md b/openapi/otp/README.md new file mode 100644 index 00000000..571be47e --- /dev/null +++ b/openapi/otp/README.md @@ -0,0 +1,101 @@ +# OTP — Passwordless Authentication + +One-time password (OTP) module for passwordless login. An authorized caller generates a short-lived code bound to a user/member and a redirect URL. The recipient opens `/v/` in a browser to authenticate without credentials. + +## Process + +| Process | Args | Returns | Description | +|---|---|---|---| +| `otp.Create` | `params` (map) | code (string) | Generate an OTP code | +| `otp.Verify` | `code` | payload (map) | Look up a code without consuming it | +| `otp.Login` | `code`, `locale?` | LoginResult | Verify code, issue access token, optionally consume | +| `otp.Revoke` | `code` | nil | Delete a code immediately | + +### otp.Create + +``` +yao run otp.Create '::{"team_id":"T1","member_id":"M1","redirect":"/dashboard"}' +``` + +**Parameters:** + +| Field | Type | Required | Default | Description | +|---|---|---|---|---| +| `team_id` | string | When `member_id` set | — | Team context | +| `member_id` | string | Either this or `user_id` | — | Target member (resolved to user_id at login) | +| `user_id` | string | Either this or `member_id` | — | Target user | +| `redirect` | string | Yes | — | Post-login redirect URL | +| `expires_in` | int | No | 86400 | Code TTL in seconds | +| `token_expires_in` | int | No | system default | Access token lifetime override (seconds) | +| `scope` | string | No | — | Space-separated scopes for the issued token | +| `consume` | bool | No | true | Revoke code after first login | + +### otp.Verify + +``` +yao run otp.Verify abc123def456 +``` + +Returns the stored payload without consuming the code. + +### otp.Login + +``` +yao run otp.Login abc123def456 en-US +``` + +Verifies the code, resolves identity (`member_id` → `user_id` if needed), issues an access token (no refresh token), and returns `LoginResult`. Consumes the code if `consume` is true. + +### otp.Revoke + +``` +yao run otp.Revoke abc123def456 +``` + +Deletes the code. Silent if the code does not exist. + +## HTTP API + +| Method | Path | Auth | Description | +|---|---|---|---| +| `POST` | `/otp/create` | Bearer token (team context) | Create an OTP code | +| `POST` | `/otp/login` | Public | Verify code, set session cookies | + +### POST /otp/create + +Requires a valid access token with team context. The `team_id` is forced from the caller's identity. Validates that the target user/member belongs to the same team. + +**Request:** +```json +{"member_id": "M1", "redirect": "/agents/keeper/entry/xxx"} +``` + +**Response:** +```json +{"code": "abc123def456"} +``` + +### POST /otp/login + +Public endpoint. Checks for an existing valid session first — if found, returns `already_logged_in` without issuing new tokens. Otherwise performs login and sets `access_token` cookie (no `refresh_token`). + +**Request:** +```json +{"code": "abc123def456", "locale": "en-US"} +``` + +**Response:** +```json +{"status": "success", "redirect": "/agents/keeper/entry/xxx"} +``` + +Status is either `success` (new session) or `already_logged_in` (existing session). + +## Security + +- Codes are 12-char NanoID (`[2-9a-hjkmnp-z]`), ~62 bits of entropy +- Default TTL: 24 hours +- Codes are single-use by default (`consume: true`) +- No refresh token issued — access token only +- `POST /otp/create` enforces team membership validation +- `team_id` is always derived from the caller's token, not the request body diff --git a/openapi/otp/generate.go b/openapi/otp/generate.go new file mode 100644 index 00000000..00dc0343 --- /dev/null +++ b/openapi/otp/generate.go @@ -0,0 +1,70 @@ +package otp + +import ( + "fmt" + + nanoid "github.com/matoous/go-nanoid/v2" +) + +// Create generates a new OTP code, stores the payload, and returns the code. +// It validates required fields and retries on NanoID collision. +func (s *Service) Create(params *GenerateParams) (string, error) { + if err := validateCreateParams(params); err != nil { + return "", err + } + + data := map[string]interface{}{ + "redirect": params.Redirect, + "consume": params.Consume, + } + if params.TeamID != "" { + data["team_id"] = params.TeamID + } + if params.MemberID != "" { + data["member_id"] = params.MemberID + } + if params.UserID != "" { + data["user_id"] = params.UserID + } + if params.Scope != "" { + data["scope"] = params.Scope + } + if params.TokenExpiresIn != 0 { + data["token_expires_in"] = params.TokenExpiresIn + } + + for i := 0; i < maxCollisionRetry; i++ { + code, err := nanoid.Generate(codeAlphabet, codeLength) + if err != nil { + return "", fmt.Errorf("failed to generate OTP code: %w", err) + } + + key := s.storeKey(code) + if s.store.Has(key) { + continue + } + + if err := s.store.Set(key, data, ttl(params.ExpiresIn)); err != nil { + return "", fmt.Errorf("failed to store OTP code: %w", err) + } + return code, nil + } + + return "", fmt.Errorf("failed to generate unique OTP code after %d attempts", maxCollisionRetry) +} + +func validateCreateParams(p *GenerateParams) error { + if p == nil { + return fmt.Errorf("params is required") + } + if p.UserID == "" && p.MemberID == "" { + return fmt.Errorf("user_id or member_id is required") + } + if p.MemberID != "" && p.TeamID == "" { + return fmt.Errorf("team_id is required when member_id is set") + } + if p.Redirect == "" { + return fmt.Errorf("redirect is required") + } + return nil +} diff --git a/openapi/otp/handler.go b/openapi/otp/handler.go new file mode 100644 index 00000000..9d88b603 --- /dev/null +++ b/openapi/otp/handler.go @@ -0,0 +1,214 @@ +package otp + +import ( + "context" + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/yaoapp/gou/session" + "github.com/yaoapp/yao/openapi/oauth" + "github.com/yaoapp/yao/openapi/oauth/authorized" + "github.com/yaoapp/yao/openapi/oauth/types" + "github.com/yaoapp/yao/openapi/response" + "github.com/yaoapp/yao/openapi/user" + "github.com/yaoapp/yao/openapi/utils" +) + +// OTPCreateRequest is the JSON body for POST /otp/create. +type OTPCreateRequest struct { + MemberID string `json:"member_id,omitempty"` + UserID string `json:"user_id,omitempty"` + ExpiresIn int `json:"expires_in,omitempty"` + Redirect string `json:"redirect" binding:"required"` + Scope string `json:"scope,omitempty"` + TokenExpiresIn int `json:"token_expires_in,omitempty"` // access_token lifetime override (seconds) + Consume *bool `json:"consume,omitempty"` // revoke code after login; nil means default (true) +} + +// OTPLoginRequest is the JSON body for POST /otp/login. +type OTPLoginRequest struct { + Code string `json:"code" binding:"required"` + Locale string `json:"locale,omitempty"` +} + +// Attach registers OTP HTTP routes on the given router group. +// The create endpoint requires authentication; login is public. +func Attach(group *gin.RouterGroup, auth types.OAuth) { + group.POST("/create", auth.Guard, GinOTPCreate) + group.POST("/login", GinOTPLogin) +} + +// GinOTPCreate handles POST /otp/create (protected). +// It forces team_id from the caller's identity and validates that the +// target member belongs to the same team. +func GinOTPCreate(c *gin.Context) { + authInfo := authorized.GetInfo(c) + if authInfo == nil || authInfo.TeamID == "" { + response.RespondWithError(c, http.StatusForbidden, &response.ErrorResponse{ + Code: "forbidden", + ErrorDescription: "team context is required", + }) + return + } + + var req OTPCreateRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.RespondWithError(c, http.StatusBadRequest, &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: err.Error(), + }) + return + } + + if req.UserID == "" && req.MemberID == "" { + response.RespondWithError(c, http.StatusBadRequest, &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: "user_id or member_id is required", + }) + return + } + + teamID := authInfo.TeamID + + // Validate that the target member/user belongs to the caller's team + if err := validateTeamMembership(teamID, req.UserID, req.MemberID); err != nil { + response.RespondWithError(c, http.StatusForbidden, &response.ErrorResponse{ + Code: "forbidden", + ErrorDescription: err.Error(), + }) + return + } + + consume := true + if req.Consume != nil { + consume = *req.Consume + } + + code, err := OTP.Create(&GenerateParams{ + TeamID: teamID, + MemberID: req.MemberID, + UserID: req.UserID, + ExpiresIn: req.ExpiresIn, + Redirect: req.Redirect, + Scope: req.Scope, + TokenExpiresIn: req.TokenExpiresIn, + Consume: consume, + }) + if err != nil { + response.RespondWithError(c, http.StatusInternalServerError, &response.ErrorResponse{ + Code: "server_error", + ErrorDescription: err.Error(), + }) + return + } + + response.RespondWithSuccess(c, http.StatusOK, gin.H{"code": code}) +} + +// GinOTPLogin handles POST /otp/login (public). +// Smart login: checks existing session first, issues tokens only when needed. +func GinOTPLogin(c *gin.Context) { + var req OTPLoginRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.RespondWithError(c, http.StatusBadRequest, &response.ErrorResponse{ + Code: response.ErrInvalidRequest.Code, + ErrorDescription: err.Error(), + }) + return + } + + payload, err := OTP.Verify(req.Code) + if err != nil { + response.RespondWithError(c, http.StatusUnauthorized, &response.ErrorResponse{ + Code: "invalid_otp", + ErrorDescription: err.Error(), + }) + return + } + + var status string + + // Check if the caller already has a valid session + existingToken := oauth.OAuth.GetAccessToken(c) + if existingToken != "" { + if _, verifyErr := oauth.OAuth.VerifyToken(existingToken); verifyErr == nil { + status = "already_logged_in" + } + } + + // No valid session: perform OTP login and set cookies + if status == "" { + result, err := OTP.Login(req.Code, req.Locale) + if err != nil { + response.RespondWithError(c, http.StatusUnauthorized, &response.ErrorResponse{ + Code: "otp_login_failed", + ErrorDescription: err.Error(), + }) + return + } + + sid := utils.GetSessionID(c) + if sid == "" { + sid = session.ID() + } + + loginResp := &user.LoginResponse{ + UserID: result.UserID, + Subject: result.Subject, + AccessToken: result.AccessToken, + IDToken: result.IDToken, + RefreshToken: result.RefreshToken, + ExpiresIn: result.ExpiresIn, + RefreshTokenExpiresIn: result.RefreshTokenExpiresIn, + TokenType: result.TokenType, + Scope: result.Scope, + Status: user.LoginStatusSuccess, + } + user.SendLoginCookies(c, loginResp, sid) + status = "success" + } + + // Consume OTP code if configured + if payload.Consume { + _ = OTP.Revoke(req.Code) + } + + response.RespondWithSuccess(c, http.StatusOK, gin.H{ + "status": status, + "redirect": payload.Redirect, + }) +} + +// validateTeamMembership checks that the given user or member belongs to the specified team. +func validateTeamMembership(teamID, userID, memberID string) error { + userProvider, err := oauth.OAuth.GetUserProvider() + if err != nil { + return fmt.Errorf("failed to get user provider: %w", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if memberID != "" { + member, err := userProvider.GetMemberByMemberID(ctx, memberID) + if err != nil { + return fmt.Errorf("member not found: %s", memberID) + } + memberTeam := utils.ToString(member["team_id"]) + if memberTeam != teamID { + return fmt.Errorf("member %s does not belong to team %s", memberID, teamID) + } + return nil + } + + if userID != "" { + _, err := userProvider.GetMember(ctx, teamID, userID) + if err != nil { + return fmt.Errorf("user %s is not a member of team %s", userID, teamID) + } + return nil + } + + return fmt.Errorf("user_id or member_id is required") +} diff --git a/openapi/otp/login.go b/openapi/otp/login.go new file mode 100644 index 00000000..bbd9cc6a --- /dev/null +++ b/openapi/otp/login.go @@ -0,0 +1,95 @@ +package otp + +import ( + "context" + "fmt" + "strings" + + "github.com/yaoapp/yao/openapi/oauth" + oauthtypes "github.com/yaoapp/yao/openapi/oauth/types" + "github.com/yaoapp/yao/openapi/user" + "github.com/yaoapp/yao/openapi/utils" +) + +// Login verifies an OTP code, resolves the user identity, issues tokens, +// and returns the result. It does NOT set HTTP cookies. +func (s *Service) Login(code string, locale string) (*LoginResult, error) { + payload, err := s.Verify(code) + if err != nil { + return nil, err + } + + userID, teamID, err := s.resolveIdentity(payload) + if err != nil { + return nil, fmt.Errorf("failed to resolve OTP identity: %w", err) + } + + loginCtx := &oauthtypes.LoginContext{ + Locale: locale, + AuthSource: "otp", + } + + opts := &user.LoginOptions{ + SkipRefreshToken: true, + TokenExpiresIn: payload.TokenExpiresIn, + } + if payload.Scope != "" { + opts.Scopes = strings.Fields(payload.Scope) + } + + loginResp, err := user.LoginWithOptions(userID, teamID, loginCtx, opts) + if err != nil { + return nil, fmt.Errorf("OTP login failed: %w", err) + } + + return &LoginResult{ + UserID: loginResp.UserID, + Subject: loginResp.Subject, + AccessToken: loginResp.AccessToken, + IDToken: loginResp.IDToken, + RefreshToken: loginResp.RefreshToken, + ExpiresIn: loginResp.ExpiresIn, + RefreshTokenExpiresIn: loginResp.RefreshTokenExpiresIn, + TokenType: loginResp.TokenType, + Scope: loginResp.Scope, + Redirect: payload.Redirect, + }, nil +} + +// resolveIdentity determines the user_id and team_id from the OTP payload. +// When member_id is present, it resolves the user_id from the member record. +func (s *Service) resolveIdentity(payload *Payload) (userID string, teamID string, err error) { + teamID = payload.TeamID + + if payload.UserID != "" { + return payload.UserID, teamID, nil + } + + if payload.MemberID == "" { + return "", "", fmt.Errorf("payload has neither user_id nor member_id") + } + + userProvider, err := oauth.OAuth.GetUserProvider() + if err != nil { + return "", "", fmt.Errorf("failed to get user provider: %w", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + member, err := userProvider.GetMemberByMemberID(ctx, payload.MemberID) + if err != nil { + return "", "", fmt.Errorf("failed to resolve member %s: %w", payload.MemberID, err) + } + + userID = utils.ToString(member["user_id"]) + if userID == "" { + return "", "", fmt.Errorf("member %s has no associated user_id", payload.MemberID) + } + + if teamID == "" { + teamID = utils.ToString(member["team_id"]) + } + + return userID, teamID, nil +} diff --git a/openapi/otp/otp.go b/openapi/otp/otp.go new file mode 100644 index 00000000..6fc3fafd --- /dev/null +++ b/openapi/otp/otp.go @@ -0,0 +1,81 @@ +package otp + +import ( + "fmt" + "time" + + "github.com/yaoapp/gou/store" +) + +// OTP is the global OTP service instance, initialized by NewService. +var OTP *Service + +const ( + defaultExpiresIn = 24 * 60 * 60 // 24 hours in seconds + storeKeyInfix = "oauth:otp:" + codeAlphabet = "23456789abcdefghjkmnpqrstuvwxyz" + codeLength = 12 + maxCollisionRetry = 5 +) + +// Service manages OTP code lifecycle. +type Service struct { + store store.Store + prefix string // key prefix, e.g. "yao_:" +} + +// Payload is the data stored alongside an OTP code. +type Payload struct { + TeamID string `json:"team_id,omitempty"` + MemberID string `json:"member_id,omitempty"` + UserID string `json:"user_id,omitempty"` + Redirect string `json:"redirect"` + Scope string `json:"scope,omitempty"` + TokenExpiresIn int `json:"token_expires_in,omitempty"` + Consume bool `json:"consume"` +} + +// GenerateParams holds the input for creating an OTP code. +type GenerateParams struct { + TeamID string `json:"team_id,omitempty"` + MemberID string `json:"member_id,omitempty"` + UserID string `json:"user_id,omitempty"` + ExpiresIn int `json:"expires_in,omitempty"` // seconds; 0 means default (24h) + Redirect string `json:"redirect"` + Scope string `json:"scope,omitempty"` + TokenExpiresIn int `json:"token_expires_in,omitempty"` // access_token lifetime override (seconds); 0 means system default + Consume bool `json:"consume"` // revoke code after login; default true +} + +// LoginResult wraps user.LoginResponse with the OTP redirect path. +type LoginResult struct { + UserID string `json:"user_id,omitempty"` + Subject string `json:"subject,omitempty"` + AccessToken string `json:"access_token"` + IDToken string `json:"id_token,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` + ExpiresIn int `json:"expires_in,omitempty"` + RefreshTokenExpiresIn int `json:"refresh_token_expires_in,omitempty"` + TokenType string `json:"token_type,omitempty"` + Scope string `json:"scope,omitempty"` + Redirect string `json:"redirect"` +} + +// NewService creates and registers a global OTP service. +func NewService(s store.Store, prefix string) *Service { + OTP = &Service{store: s, prefix: prefix} + return OTP +} + +// storeKey builds a namespaced store key for the given OTP code. +func (s *Service) storeKey(code string) string { + return fmt.Sprintf("%s%s%s", s.prefix, storeKeyInfix, code) +} + +// ttl returns the effective TTL as a time.Duration. +func ttl(expiresIn int) time.Duration { + if expiresIn <= 0 { + expiresIn = defaultExpiresIn + } + return time.Duration(expiresIn) * time.Second +} diff --git a/openapi/otp/process.go b/openapi/otp/process.go new file mode 100644 index 00000000..854e24a8 --- /dev/null +++ b/openapi/otp/process.go @@ -0,0 +1,92 @@ +package otp + +import ( + "github.com/yaoapp/gou/process" + "github.com/yaoapp/kun/exception" + "github.com/yaoapp/yao/openapi/utils" +) + +func init() { + process.RegisterGroup("otp", map[string]process.Handler{ + "create": processCreate, + "verify": processVerify, + "login": processLogin, + "revoke": processRevoke, + }) +} + +// processCreate handles otp.Create(params). +// args[0]: map with team_id, member_id, user_id, expires_in, redirect, scope, +// +// token_expires_in, consume. +func processCreate(p *process.Process) interface{} { + p.ValidateArgNums(1) + + raw := p.ArgsMap(0) + params := &GenerateParams{ + TeamID: utils.ToString(raw["team_id"]), + MemberID: utils.ToString(raw["member_id"]), + UserID: utils.ToString(raw["user_id"]), + Redirect: utils.ToString(raw["redirect"]), + Scope: utils.ToString(raw["scope"]), + Consume: true, + } + if v, ok := raw["expires_in"]; ok { + params.ExpiresIn = utils.ToInt(v) + } + if v, ok := raw["token_expires_in"]; ok { + params.TokenExpiresIn = utils.ToInt(v) + } + if v, ok := raw["consume"]; ok { + params.Consume = utils.ToBool(v) + } + + code, err := OTP.Create(params) + if err != nil { + exception.New(err.Error(), 400).Throw() + } + return code +} + +// processVerify handles otp.Verify(code). +// args[0]: code string. +func processVerify(p *process.Process) interface{} { + p.ValidateArgNums(1) + code := p.ArgsString(0) + + payload, err := OTP.Verify(code) + if err != nil { + exception.New(err.Error(), 400).Throw() + } + return payload +} + +// processLogin handles otp.Login(code, locale?). +// args[0]: code string; args[1]: locale string (optional). +func processLogin(p *process.Process) interface{} { + p.ValidateArgNums(1) + code := p.ArgsString(0) + + locale := "" + if p.NumOfArgs() > 1 { + locale = p.ArgsString(1) + } + + result, err := OTP.Login(code, locale) + if err != nil { + exception.New(err.Error(), 401).Throw() + } + return result +} + +// processRevoke handles otp.Revoke(code). +// args[0]: code string. +func processRevoke(p *process.Process) interface{} { + p.ValidateArgNums(1) + code := p.ArgsString(0) + + if err := OTP.Revoke(code); err != nil { + exception.New(err.Error(), 500).Throw() + } + return nil +} diff --git a/openapi/otp/revoke.go b/openapi/otp/revoke.go new file mode 100644 index 00000000..d1d5d417 --- /dev/null +++ b/openapi/otp/revoke.go @@ -0,0 +1,12 @@ +package otp + +// Revoke removes an OTP code from the store immediately. +// It is silent when the code does not exist or has already expired. +func (s *Service) Revoke(code string) error { + if code == "" { + return nil + } + key := s.storeKey(code) + _ = s.store.Del(key) + return nil +} diff --git a/openapi/otp/verify.go b/openapi/otp/verify.go new file mode 100644 index 00000000..de115146 --- /dev/null +++ b/openapi/otp/verify.go @@ -0,0 +1,76 @@ +package otp + +import ( + "encoding/json" + "fmt" +) + +// Verify looks up an OTP code and returns its stored Payload. +// It does NOT consume the code — the code remains valid within its TTL. +func (s *Service) Verify(code string) (*Payload, error) { + if code == "" { + return nil, fmt.Errorf("code is required") + } + + key := s.storeKey(code) + val, ok := s.store.Get(key) + if !ok || val == nil { + return nil, fmt.Errorf("invalid or expired OTP code") + } + + return coercePayload(val) +} + +// coercePayload converts a store value into a *Payload. +// The store may return *Payload, map[string]interface{}, or raw JSON bytes. +func coercePayload(val interface{}) (*Payload, error) { + switch v := val.(type) { + case *Payload: + return v, nil + + case Payload: + return &v, nil + + case map[string]interface{}: + p := &Payload{Consume: true} + if s, ok := v["team_id"].(string); ok { + p.TeamID = s + } + if s, ok := v["member_id"].(string); ok { + p.MemberID = s + } + if s, ok := v["user_id"].(string); ok { + p.UserID = s + } + if s, ok := v["redirect"].(string); ok { + p.Redirect = s + } + if s, ok := v["scope"].(string); ok { + p.Scope = s + } + switch te := v["token_expires_in"].(type) { + case float64: + p.TokenExpiresIn = int(te) + case int: + p.TokenExpiresIn = te + case int64: + p.TokenExpiresIn = int(te) + } + if b, ok := v["consume"].(bool); ok { + p.Consume = b + } + return p, nil + + default: + // JSON fallback for serialised store backends + raw, err := json.Marshal(val) + if err != nil { + return nil, fmt.Errorf("unexpected OTP payload type: %T", val) + } + p := &Payload{Consume: true} + if err := json.Unmarshal(raw, p); err != nil { + return nil, fmt.Errorf("failed to decode OTP payload: %w", err) + } + return p, nil + } +} diff --git a/openapi/tests/otp/otp_test.go b/openapi/tests/otp/otp_test.go new file mode 100644 index 00000000..3bd6be9c --- /dev/null +++ b/openapi/tests/otp/otp_test.go @@ -0,0 +1,777 @@ +package otp_test + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/yaoapp/yao/openapi" + "github.com/yaoapp/yao/openapi/oauth" + "github.com/yaoapp/yao/openapi/otp" + "github.com/yaoapp/yao/openapi/tests/testutils" +) + +type otpTestContext struct { + UserID string + TeamID string + MemberID string + Token string // access token with team context + ClientID string + Scope string +} + +// setupTestData creates a real user, team type, team, and member for OTP tests. +// Returns an otpTestContext and a cleanup function. +func setupTestData(t *testing.T, serverURL string) (*otpTestContext, func()) { + t.Helper() + provider := testutils.GetUserProvider(t) + ctx := context.Background() + + client := testutils.RegisterTestClient(t, "OTP Test Client", []string{"https://localhost/callback"}) + tokenInfo := testutils.ObtainAccessTokenWithRootPermission(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile") + + // Step 1: Create a team type + teamTypeID := fmt.Sprintf("otp_team_type_%d", time.Now().UnixNano()) + _, err := provider.CreateType(ctx, map[string]interface{}{ + "type_id": teamTypeID, + "name": "OTP Test Team Type", + "locale": "en-US", + "description": "Team type for OTP tests", + "is_active": true, + "created_at": time.Now(), + "updated_at": time.Now(), + }) + require.NoError(t, err, "Failed to create team type") + + // Step 2: Create a team with role_id (required for ACL) + teamID := fmt.Sprintf("otp_test_team_%d", time.Now().UnixNano()) + _, err = provider.CreateTeam(ctx, map[string]interface{}{ + "team_id": teamID, + "name": "OTP Test Team", + "description": "Team for OTP integration tests", + "owner_id": tokenInfo.UserID, + "type_id": teamTypeID, + "role_id": "system:root", + "status": "active", + "is_verified": true, + "created_at": time.Now(), + "updated_at": time.Now(), + }) + require.NoError(t, err, "Failed to create test team") + + // Step 3: Add user as team member with system:root role + memberID, err := provider.CreateMember(ctx, map[string]interface{}{ + "team_id": teamID, + "user_id": tokenInfo.UserID, + "member_type": "user", + "role_id": "system:root", + "is_owner": true, + "status": "active", + "joined_at": time.Now(), + "created_at": time.Now(), + "updated_at": time.Now(), + }) + require.NoError(t, err, "Failed to create team member") + + // Step 4: Get team details for extra claims + team, err := provider.GetTeamByMember(ctx, teamID, tokenInfo.UserID) + require.NoError(t, err) + + // Step 5: Create access token with team context + oauthService := oauth.OAuth + require.NotNil(t, oauthService, "OAuth service not initialized") + + subject, err := oauthService.Subject(client.ClientID, tokenInfo.UserID) + require.NoError(t, err) + + extraClaims := map[string]interface{}{ + "user_id": tokenInfo.UserID, + "team_id": teamID, + } + if tenantID, ok := team["tenant_id"].(string); ok && tenantID != "" { + extraClaims["tenant_id"] = tenantID + } + if ownerID, ok := team["owner_id"].(string); ok && ownerID != "" { + extraClaims["owner_id"] = ownerID + } + if typeID, ok := team["type_id"].(string); ok && typeID != "" { + extraClaims["type_id"] = typeID + } + + scope := "openid profile email system:root" + accessToken, err := oauthService.MakeAccessToken(client.ClientID, scope, subject, 3600, extraClaims) + require.NoError(t, err) + + cleanup := func() { + provider.DeleteTeam(ctx, teamID) + provider.DeleteType(ctx, teamTypeID) + testutils.CleanupTestClient(t, client.ClientID) + } + + return &otpTestContext{ + UserID: tokenInfo.UserID, + TeamID: teamID, + MemberID: memberID, + Token: accessToken, + ClientID: client.ClientID, + Scope: scope, + }, cleanup +} + +// ---------- POST /otp/login (public) ---------- + +func TestOTPLoginInvalidCode(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + + baseURL := "" + if openapi.Server != nil && openapi.Server.Config != nil { + baseURL = openapi.Server.Config.BaseURL + } + + body, _ := json.Marshal(map[string]string{"code": "nonexistent_code"}) + resp, err := http.Post(serverURL+baseURL+"/otp/login", "application/json", bytes.NewBuffer(body)) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + assert.Equal(t, "invalid_otp", result["error"]) +} + +func TestOTPLoginMissingCode(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + + baseURL := "" + if openapi.Server != nil && openapi.Server.Config != nil { + baseURL = openapi.Server.Config.BaseURL + } + + body, _ := json.Marshal(map[string]string{}) + resp, err := http.Post(serverURL+baseURL+"/otp/login", "application/json", bytes.NewBuffer(body)) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) +} + +func TestOTPLoginInvalidJSON(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + + baseURL := "" + if openapi.Server != nil && openapi.Server.Config != nil { + baseURL = openapi.Server.Config.BaseURL + } + + resp, err := http.Post(serverURL+baseURL+"/otp/login", "application/json", bytes.NewBufferString("not json")) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) +} + +// ---------- POST /otp/create (protected) ---------- + +func TestOTPCreateUnauthorized(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + + baseURL := "" + if openapi.Server != nil && openapi.Server.Config != nil { + baseURL = openapi.Server.Config.BaseURL + } + + body, _ := json.Marshal(map[string]interface{}{ + "user_id": "someone", + "redirect": "/dashboard", + }) + resp, err := http.Post(serverURL+baseURL+"/otp/create", "application/json", bytes.NewBuffer(body)) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) +} + +func TestOTPCreateMissingRedirect(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + + baseURL := "" + if openapi.Server != nil && openapi.Server.Config != nil { + baseURL = openapi.Server.Config.BaseURL + } + + tc, cleanup := setupTestData(t, serverURL) + defer cleanup() + + body, _ := json.Marshal(map[string]interface{}{ + "user_id": tc.UserID, + }) + req, _ := http.NewRequest("POST", serverURL+baseURL+"/otp/create", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+tc.Token) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + rawBody, _ := io.ReadAll(resp.Body) + t.Logf("Response status: %d, body: %s", resp.StatusCode, string(rawBody)) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) +} + +func TestOTPCreateMissingUserAndMember(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + + baseURL := "" + if openapi.Server != nil && openapi.Server.Config != nil { + baseURL = openapi.Server.Config.BaseURL + } + + tc, cleanup := setupTestData(t, serverURL) + defer cleanup() + + body, _ := json.Marshal(map[string]interface{}{ + "redirect": "/dashboard", + }) + req, _ := http.NewRequest("POST", serverURL+baseURL+"/otp/create", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+tc.Token) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + rawBody, _ := io.ReadAll(resp.Body) + t.Logf("Response status: %d, body: %s", resp.StatusCode, string(rawBody)) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) +} + +// ---------- Full flow: create -> login ---------- + +func TestOTPCreateAndLogin(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + + baseURL := "" + if openapi.Server != nil && openapi.Server.Config != nil { + baseURL = openapi.Server.Config.BaseURL + } + + tc, cleanup := setupTestData(t, serverURL) + defer cleanup() + + // Step 1: Create OTP code via API + createBody, _ := json.Marshal(map[string]interface{}{ + "user_id": tc.UserID, + "redirect": "/test/dashboard", + }) + createReq, _ := http.NewRequest("POST", serverURL+baseURL+"/otp/create", bytes.NewBuffer(createBody)) + createReq.Header.Set("Content-Type", "application/json") + createReq.Header.Set("Authorization", "Bearer "+tc.Token) + + createResp, err := http.DefaultClient.Do(createReq) + require.NoError(t, err) + defer createResp.Body.Close() + + createBody2, _ := io.ReadAll(createResp.Body) + t.Logf("Create response status: %d, body: %s", createResp.StatusCode, string(createBody2)) + require.Equal(t, http.StatusOK, createResp.StatusCode, "OTP create should succeed, body: %s", string(createBody2)) + + var createResult map[string]interface{} + err = json.Unmarshal(createBody2, &createResult) + require.NoError(t, err) + + code, ok := createResult["code"].(string) + require.True(t, ok, "Response should contain a code string") + assert.Len(t, code, 12, "OTP code should be 12 characters long") + t.Logf("Created OTP code: %s", code) + + // Step 2: Login with the OTP code (public endpoint) + loginBody, _ := json.Marshal(map[string]string{ + "code": code, + "locale": "en-US", + }) + loginResp, err := http.Post(serverURL+baseURL+"/otp/login", "application/json", bytes.NewBuffer(loginBody)) + require.NoError(t, err) + defer loginResp.Body.Close() + + loginBody2, _ := io.ReadAll(loginResp.Body) + t.Logf("Login response status: %d, body: %s", loginResp.StatusCode, string(loginBody2)) + require.Equal(t, http.StatusOK, loginResp.StatusCode, "OTP login should succeed, body: %s", string(loginBody2)) + + var loginResult map[string]interface{} + err = json.Unmarshal(loginBody2, &loginResult) + require.NoError(t, err) + + assert.Equal(t, "success", loginResult["status"]) + assert.Equal(t, "/test/dashboard", loginResult["redirect"]) + + // Step 3: Verify the code is consumed (default Consume=true) + loginBody3, _ := json.Marshal(map[string]string{"code": code}) + loginResp3, err := http.Post(serverURL+baseURL+"/otp/login", "application/json", bytes.NewBuffer(loginBody3)) + require.NoError(t, err) + defer loginResp3.Body.Close() + + assert.Equal(t, http.StatusUnauthorized, loginResp3.StatusCode, "Code should be consumed after first login") +} + +func TestOTPCreateAndLoginWithMemberID(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + + baseURL := "" + if openapi.Server != nil && openapi.Server.Config != nil { + baseURL = openapi.Server.Config.BaseURL + } + + tc, cleanup := setupTestData(t, serverURL) + defer cleanup() + + // Create OTP with member_id instead of user_id + createBody, _ := json.Marshal(map[string]interface{}{ + "team_id": tc.TeamID, + "member_id": tc.MemberID, + "redirect": "/member-login-test", + }) + createReq, _ := http.NewRequest("POST", serverURL+baseURL+"/otp/create", bytes.NewBuffer(createBody)) + createReq.Header.Set("Content-Type", "application/json") + createReq.Header.Set("Authorization", "Bearer "+tc.Token) + + createResp, err := http.DefaultClient.Do(createReq) + require.NoError(t, err) + defer createResp.Body.Close() + + createRaw, _ := io.ReadAll(createResp.Body) + t.Logf("Create response: %d, body: %s", createResp.StatusCode, string(createRaw)) + require.Equal(t, http.StatusOK, createResp.StatusCode, "body: %s", string(createRaw)) + + var createResult map[string]interface{} + json.Unmarshal(createRaw, &createResult) + code := createResult["code"].(string) + + // Verify the stored payload has member_id + payload, err := otp.OTP.Verify(code) + require.NoError(t, err) + assert.Equal(t, tc.MemberID, payload.MemberID) + assert.Equal(t, tc.TeamID, payload.TeamID) + assert.Equal(t, "", payload.UserID, "user_id should be empty when member_id is used") + + // Login with the OTP code — should resolve member_id to user_id + loginBody, _ := json.Marshal(map[string]string{"code": code, "locale": "en-US"}) + loginResp, err := http.Post(serverURL+baseURL+"/otp/login", "application/json", bytes.NewBuffer(loginBody)) + require.NoError(t, err) + defer loginResp.Body.Close() + + loginRaw, _ := io.ReadAll(loginResp.Body) + t.Logf("Login response: %d, body: %s", loginResp.StatusCode, string(loginRaw)) + require.Equal(t, http.StatusOK, loginResp.StatusCode, "OTP login with member_id should succeed, body: %s", string(loginRaw)) + + var loginResult map[string]interface{} + json.Unmarshal(loginRaw, &loginResult) + assert.Equal(t, "success", loginResult["status"]) + assert.Equal(t, "/member-login-test", loginResult["redirect"]) +} + +func TestOTPCreateWithConsumeDisabled(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + + baseURL := "" + if openapi.Server != nil && openapi.Server.Config != nil { + baseURL = openapi.Server.Config.BaseURL + } + + tc, cleanup := setupTestData(t, serverURL) + defer cleanup() + + consumeFalse := false + createBody, _ := json.Marshal(map[string]interface{}{ + "user_id": tc.UserID, + "redirect": "/reusable", + "consume": &consumeFalse, + }) + createReq, _ := http.NewRequest("POST", serverURL+baseURL+"/otp/create", bytes.NewBuffer(createBody)) + createReq.Header.Set("Content-Type", "application/json") + createReq.Header.Set("Authorization", "Bearer "+tc.Token) + + createResp, err := http.DefaultClient.Do(createReq) + require.NoError(t, err) + defer createResp.Body.Close() + + createRaw, _ := io.ReadAll(createResp.Body) + t.Logf("Create response: %d, body: %s", createResp.StatusCode, string(createRaw)) + require.Equal(t, http.StatusOK, createResp.StatusCode, "body: %s", string(createRaw)) + + var createResult map[string]interface{} + json.Unmarshal(createRaw, &createResult) + code := createResult["code"].(string) + t.Logf("Created reusable OTP code: %s", code) + + // First login + loginBody, _ := json.Marshal(map[string]string{"code": code, "locale": "en-US"}) + loginResp, err := http.Post(serverURL+baseURL+"/otp/login", "application/json", bytes.NewBuffer(loginBody)) + require.NoError(t, err) + defer loginResp.Body.Close() + require.Equal(t, http.StatusOK, loginResp.StatusCode) + + // Second login should also work (Consume=false) + loginBody2, _ := json.Marshal(map[string]string{"code": code, "locale": "en-US"}) + loginResp2, err := http.Post(serverURL+baseURL+"/otp/login", "application/json", bytes.NewBuffer(loginBody2)) + require.NoError(t, err) + defer loginResp2.Body.Close() + + assert.Equal(t, http.StatusOK, loginResp2.StatusCode, "Reusable OTP code should allow multiple logins") +} + +func TestOTPCreateWithTokenExpiresIn(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + + baseURL := "" + if openapi.Server != nil && openapi.Server.Config != nil { + baseURL = openapi.Server.Config.BaseURL + } + + tc, cleanup := setupTestData(t, serverURL) + defer cleanup() + + createBody, _ := json.Marshal(map[string]interface{}{ + "user_id": tc.UserID, + "redirect": "/custom-ttl", + "token_expires_in": 600, + }) + createReq, _ := http.NewRequest("POST", serverURL+baseURL+"/otp/create", bytes.NewBuffer(createBody)) + createReq.Header.Set("Content-Type", "application/json") + createReq.Header.Set("Authorization", "Bearer "+tc.Token) + + createResp, err := http.DefaultClient.Do(createReq) + require.NoError(t, err) + defer createResp.Body.Close() + + createRaw, _ := io.ReadAll(createResp.Body) + t.Logf("Create response: %d, body: %s", createResp.StatusCode, string(createRaw)) + require.Equal(t, http.StatusOK, createResp.StatusCode, "body: %s", string(createRaw)) + + var createResult map[string]interface{} + json.Unmarshal(createRaw, &createResult) + code := createResult["code"].(string) + + payload, err := otp.OTP.Verify(code) + require.NoError(t, err) + assert.Equal(t, 600, payload.TokenExpiresIn) + assert.Equal(t, "/custom-ttl", payload.Redirect) + assert.True(t, payload.Consume) +} + +// ---------- Service-level tests (direct API) ---------- + +func TestOTPServiceCreateAndVerify(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + _ = serverURL + + code, err := otp.OTP.Create(&otp.GenerateParams{ + UserID: "test_user_direct", + TeamID: "test_team_direct", + Redirect: "/direct-test", + Consume: true, + }) + require.NoError(t, err) + assert.Len(t, code, 12) + + payload, err := otp.OTP.Verify(code) + require.NoError(t, err) + assert.Equal(t, "test_user_direct", payload.UserID) + assert.Equal(t, "test_team_direct", payload.TeamID) + assert.Equal(t, "/direct-test", payload.Redirect) + assert.True(t, payload.Consume) + assert.Equal(t, 0, payload.TokenExpiresIn) +} + +func TestOTPServiceCreateAndVerifyWithMemberID(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + _ = serverURL + + code, err := otp.OTP.Create(&otp.GenerateParams{ + TeamID: "team_member_test", + MemberID: "member_12345", + Redirect: "/member-redirect", + Scope: "read:data", + TokenExpiresIn: 900, + Consume: false, + }) + require.NoError(t, err) + assert.Len(t, code, 12) + + payload, err := otp.OTP.Verify(code) + require.NoError(t, err) + assert.Equal(t, "", payload.UserID) + assert.Equal(t, "team_member_test", payload.TeamID) + assert.Equal(t, "member_12345", payload.MemberID) + assert.Equal(t, "/member-redirect", payload.Redirect) + assert.Equal(t, "read:data", payload.Scope) + assert.Equal(t, 900, payload.TokenExpiresIn) + assert.False(t, payload.Consume) +} + +func TestOTPServiceCreateStoresMapPayload(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + _ = serverURL + + code, err := otp.OTP.Create(&otp.GenerateParams{ + TeamID: "map_team", + MemberID: "map_member", + Redirect: "$dashboard/assistants", + Scope: "openid profile", + TokenExpiresIn: 600, + Consume: true, + }) + require.NoError(t, err) + + payload, err := otp.OTP.Verify(code) + require.NoError(t, err) + assert.Equal(t, "map_team", payload.TeamID) + assert.Equal(t, "map_member", payload.MemberID) + assert.Equal(t, "$dashboard/assistants", payload.Redirect) + assert.Equal(t, "openid profile", payload.Scope) + assert.Equal(t, 600, payload.TokenExpiresIn) + assert.True(t, payload.Consume) +} + +func TestOTPServiceVerifyEmptyCode(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + _ = serverURL + + _, err := otp.OTP.Verify("") + assert.Error(t, err) + assert.Contains(t, err.Error(), "code is required") +} + +func TestOTPServiceVerifyNonexistent(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + _ = serverURL + + _, err := otp.OTP.Verify("doesnotexist1") + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid or expired") +} + +func TestOTPServiceRevoke(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + _ = serverURL + + code, err := otp.OTP.Create(&otp.GenerateParams{ + UserID: "revoke_user", + Redirect: "/revoke-test", + Consume: true, + }) + require.NoError(t, err) + + _, err = otp.OTP.Verify(code) + require.NoError(t, err) + + err = otp.OTP.Revoke(code) + require.NoError(t, err) + + _, err = otp.OTP.Verify(code) + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid or expired") +} + +func TestOTPServiceRevokeEmpty(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + _ = serverURL + + err := otp.OTP.Revoke("") + assert.NoError(t, err, "Revoking empty code should be silent") +} + +func TestOTPServiceCreateValidation(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + _ = serverURL + + tests := []struct { + name string + params *otp.GenerateParams + errMsg string + }{ + {"nil params", nil, "params is required"}, + {"missing user and member", &otp.GenerateParams{Redirect: "/test"}, "user_id or member_id is required"}, + {"missing redirect", &otp.GenerateParams{UserID: "u1"}, "redirect is required"}, + {"member_id without team_id", &otp.GenerateParams{MemberID: "m1", Redirect: "/test"}, "team_id is required"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := otp.OTP.Create(tt.params) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.errMsg) + }) + } +} + +func TestOTPServiceCreateWithScope(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + _ = serverURL + + code, err := otp.OTP.Create(&otp.GenerateParams{ + UserID: "scope_user", + TeamID: "scope_team", + Redirect: "/scoped", + Scope: "read:data write:data", + Consume: false, + }) + require.NoError(t, err) + + payload, err := otp.OTP.Verify(code) + require.NoError(t, err) + assert.Equal(t, "read:data write:data", payload.Scope) + assert.False(t, payload.Consume) +} + +func TestOTPServiceCreateWithCustomExpiry(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + _ = serverURL + + code, err := otp.OTP.Create(&otp.GenerateParams{ + UserID: "expiry_user", + Redirect: "/custom-expiry", + ExpiresIn: 60, + TokenExpiresIn: 300, + Consume: true, + }) + require.NoError(t, err) + + payload, err := otp.OTP.Verify(code) + require.NoError(t, err) + assert.Equal(t, 300, payload.TokenExpiresIn) +} + +// ---------- Cross-team validation ---------- + +func TestOTPCreateCrossTeamRejected(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + + baseURL := "" + if openapi.Server != nil && openapi.Server.Config != nil { + baseURL = openapi.Server.Config.BaseURL + } + + tc, cleanup := setupTestData(t, serverURL) + defer cleanup() + + body, _ := json.Marshal(map[string]interface{}{ + "user_id": "user_not_in_team_" + fmt.Sprintf("%d", time.Now().UnixNano()), + "redirect": "/cross-team", + }) + req, _ := http.NewRequest("POST", serverURL+baseURL+"/otp/create", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+tc.Token) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusForbidden, resp.StatusCode, "Should reject OTP creation for user not in team") +} + +// ---------- OTP Login sets cookies (no refresh token) ---------- + +func TestOTPLoginSetsAccessTokenCookieOnly(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + + baseURL := "" + if openapi.Server != nil && openapi.Server.Config != nil { + baseURL = openapi.Server.Config.BaseURL + } + + tc, cleanup := setupTestData(t, serverURL) + defer cleanup() + + createBody, _ := json.Marshal(map[string]interface{}{ + "user_id": tc.UserID, + "redirect": "/cookie-test", + }) + createReq, _ := http.NewRequest("POST", serverURL+baseURL+"/otp/create", bytes.NewBuffer(createBody)) + createReq.Header.Set("Content-Type", "application/json") + createReq.Header.Set("Authorization", "Bearer "+tc.Token) + + createResp, err := http.DefaultClient.Do(createReq) + require.NoError(t, err) + defer createResp.Body.Close() + + createRaw, _ := io.ReadAll(createResp.Body) + t.Logf("Create response: %d, body: %s", createResp.StatusCode, string(createRaw)) + require.Equal(t, http.StatusOK, createResp.StatusCode, "body: %s", string(createRaw)) + + var createResult map[string]interface{} + json.Unmarshal(createRaw, &createResult) + code := createResult["code"].(string) + + loginBody, _ := json.Marshal(map[string]string{"code": code}) + loginResp, err := http.Post(serverURL+baseURL+"/otp/login", "application/json", bytes.NewBuffer(loginBody)) + require.NoError(t, err) + defer loginResp.Body.Close() + require.Equal(t, http.StatusOK, loginResp.StatusCode) + + cookies := loginResp.Cookies() + hasAccessToken := false + hasRefreshToken := false + for _, c := range cookies { + t.Logf("Cookie: %s", c.Name) + if strings.HasSuffix(c.Name, "access_token") { + hasAccessToken = true + } + if strings.HasSuffix(c.Name, "refresh_token") { + hasRefreshToken = true + } + } + assert.True(t, hasAccessToken, "OTP login should set access_token cookie") + assert.False(t, hasRefreshToken, "OTP login should NOT set refresh_token cookie (SkipRefreshToken)") +} + +// ---------- Code uniqueness ---------- + +func TestOTPCodeUniqueness(t *testing.T) { + serverURL := testutils.Prepare(t) + defer testutils.Clean() + _ = serverURL + + codes := make(map[string]bool) + for i := 0; i < 50; i++ { + code, err := otp.OTP.Create(&otp.GenerateParams{ + UserID: fmt.Sprintf("unique_user_%d", i), + Redirect: "/unique", + Consume: true, + }) + require.NoError(t, err) + assert.False(t, codes[code], "Duplicate OTP code generated: %s", code) + codes[code] = true + } + assert.Len(t, codes, 50, "All 50 codes should be unique") +} diff --git a/openapi/user/login.go b/openapi/user/login.go index b50d1ac6..fb951f8f 100644 --- a/openapi/user/login.go +++ b/openapi/user/login.go @@ -496,6 +496,115 @@ func LoginByTeamID(userid string, teamID string, loginCtx *LoginContext) (*Login return resp, nil } +// LoginWithOptions performs the same login flow as LoginByTeamID but allows +// overriding scopes via opts. When opts.Scopes is non-nil, those scopes are +// used instead of the user/client defaults. +func LoginWithOptions(userid string, teamID string, loginCtx *LoginContext, opts *LoginOptions) (*LoginResponse, error) { + if opts == nil { + return LoginByTeamID(userid, teamID, loginCtx) + } + + hasOverrides := opts.Scopes != nil || opts.TokenExpiresIn > 0 || opts.SkipRefreshToken + if !hasOverrides { + return LoginByTeamID(userid, teamID, loginCtx) + } + + userProvider, err := oauth.OAuth.GetUserProvider() + if err != nil { + return nil, err + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + userData, err := userProvider.GetUserWithScopes(ctx, userid) + if err != nil { + return nil, err + } + + // Resolve scopes: use opts.Scopes when provided, otherwise fall back to + // the same default resolution as LoginByTeamID (user scopes or client config). + scopes := opts.Scopes + if scopes == nil { + yaoClientConfig := GetYaoClientConfig() + scopes = yaoClientConfig.Scopes + if v, ok := userData["scopes"].([]string); ok { + scopes = v + } + } + + subject, err := oauth.OAuth.Subject(GetYaoClientConfig().ClientID, userid) + if err != nil { + log.Warn("Failed to store user fingerprint: %s", err.Error()) + } + + if teamID == "" || teamID == "personal" { + resp, err := issueTokens(ctx, &IssueTokensParams{ + UserID: userid, + TeamID: "", + Team: nil, + Member: nil, + User: userData, + Subject: subject, + Scopes: scopes, + LoginCtx: loginCtx, + TokenExpiresIn: opts.TokenExpiresIn, + SkipRefreshToken: opts.SkipRefreshToken, + }) + if err != nil { + return nil, err + } + locale := "" + if loginCtx != nil { + locale = loginCtx.Locale + } + go prepareUserKBCollection(userid, "", locale) + return resp, nil + } + + team, err := userProvider.GetTeamByMember(ctx, teamID, userid) + if err != nil { + return nil, fmt.Errorf("access denied: you are not a member of this team") + } + + member, err := userProvider.GetMember(ctx, teamID, userid) + if err != nil { + log.Warn("Failed to get member profile: %s", err.Error()) + member = nil + } + + if loginCtx != nil { + err = userProvider.UpdateUserLastLogin(ctx, userid, loginCtx) + if err != nil { + log.Warn("Failed to update last login: %s", err.Error()) + } + } + + resp, err := issueTokens(ctx, &IssueTokensParams{ + UserID: userid, + TeamID: teamID, + Team: team, + Member: member, + User: userData, + Subject: subject, + Scopes: scopes, + LoginCtx: loginCtx, + TokenExpiresIn: opts.TokenExpiresIn, + SkipRefreshToken: opts.SkipRefreshToken, + }) + if err != nil { + return nil, err + } + + locale := "" + if loginCtx != nil { + locale = loginCtx.Locale + } + go prepareUserKBCollection(userid, teamID, locale) + + return resp, nil +} + // issueTokens is the core function that issues all necessary tokens (ID token, access token, refresh token) func issueTokens(ctx context.Context, params *IssueTokensParams) (*LoginResponse, error) { yaoClientConfig := GetYaoClientConfig() @@ -571,6 +680,11 @@ func issueTokens(ctx context.Context, params *IssueTokensParams) (*LoginResponse } } + // 4. Caller overrides (e.g. OTP login with custom token lifetime) + if params.TokenExpiresIn > 0 { + expiresIn = params.TokenExpiresIn + } + // Prepare OIDC user info oidcUserInfo := oauthtypes.MakeOIDCUserInfo(params.User) oidcUserInfo.Sub = params.Subject @@ -717,15 +831,19 @@ func issueTokens(ctx context.Context, params *IssueTokensParams) (*LoginResponse return nil, fmt.Errorf("failed to sign access token: %w", err) } - // Sign Refresh Token + // Sign Refresh Token (skip for temporary sessions like OTP) var refreshToken string - if len(extraClaims) > 0 { - refreshToken, err = oauth.OAuth.MakeRefreshToken(yaoClientConfig.ClientID, strings.Join(params.Scopes, " "), params.Subject, refreshTokenExpiresIn, extraClaims) + if params.SkipRefreshToken { + refreshTokenExpiresIn = 0 } else { - refreshToken, err = oauth.OAuth.MakeRefreshToken(yaoClientConfig.ClientID, strings.Join(params.Scopes, " "), params.Subject, refreshTokenExpiresIn) - } - if err != nil { - return nil, fmt.Errorf("failed to sign refresh token: %w", err) + if len(extraClaims) > 0 { + refreshToken, err = oauth.OAuth.MakeRefreshToken(yaoClientConfig.ClientID, strings.Join(params.Scopes, " "), params.Subject, refreshTokenExpiresIn, extraClaims) + } else { + refreshToken, err = oauth.OAuth.MakeRefreshToken(yaoClientConfig.ClientID, strings.Join(params.Scopes, " "), params.Subject, refreshTokenExpiresIn) + } + if err != nil { + return nil, fmt.Errorf("failed to sign refresh token: %w", err) + } } return &LoginResponse{ @@ -901,17 +1019,17 @@ func SendLoginCookies(c *gin.Context, loginResponse *LoginResponse, sessionID st // Normal Access Token accessToken := fmt.Sprintf("%s %s", loginResponse.TokenType, loginResponse.AccessToken) - refreshToken := fmt.Sprintf("%s %s", loginResponse.TokenType, loginResponse.RefreshToken) - // Calculate expiration times - // access_token cookie lives as long as refresh_token so the browser keeps sending the - // (JWT-expired) access token — the Guard can then use the refresh token to issue a new one. - // The JWT's own `exp` claim handles the real expiration check on the server side. - refreshExpires := time.Now().Add(time.Duration(loginResponse.RefreshTokenExpiresIn) * time.Second) - - // Send access token cookie (cookie lifetime = refresh token lifetime) - response.SendAccessTokenCookieWithExpiry(c, accessToken, refreshExpires) - - // Send refresh token cookie - response.SendRefreshTokenCookieWithExpiry(c, refreshToken, refreshExpires) + if loginResponse.RefreshToken != "" { + refreshToken := fmt.Sprintf("%s %s", loginResponse.TokenType, loginResponse.RefreshToken) + // access_token cookie lives as long as refresh_token so the browser keeps sending the + // (JWT-expired) access token — the Guard can then use the refresh token to issue a new one. + refreshExpires := time.Now().Add(time.Duration(loginResponse.RefreshTokenExpiresIn) * time.Second) + response.SendAccessTokenCookieWithExpiry(c, accessToken, refreshExpires) + response.SendRefreshTokenCookieWithExpiry(c, refreshToken, refreshExpires) + } else { + // No refresh token (e.g. OTP login): cookie expires with the access token + accessExpires := time.Now().Add(time.Duration(loginResponse.ExpiresIn) * time.Second) + response.SendAccessTokenCookieWithExpiry(c, accessToken, accessExpires) + } } diff --git a/openapi/user/types.go b/openapi/user/types.go index 623acad5..248ef269 100644 --- a/openapi/user/types.go +++ b/openapi/user/types.go @@ -265,17 +265,26 @@ type LoginSuccessResponse struct { // LoginContext is an alias for the oauth types LoginContext type LoginContext = oauthtypes.LoginContext +// LoginOptions provides optional overrides for the login flow. +type LoginOptions struct { + Scopes []string // When non-nil, overrides the default scope resolution + TokenExpiresIn int // When > 0, overrides default access_token expiration (seconds) + SkipRefreshToken bool // When true, do not issue refresh_token (for OTP/temporary sessions) +} + // IssueTokensParams represents parameters for issueTokens function type IssueTokensParams struct { - UserID string // User ID - TeamID string // Team ID (empty for personal account) - Team map[string]interface{} // Team data (nil for personal account) - Member map[string]interface{} // Member profile data (nil for personal account or if not available) - User map[string]interface{} // User data - Subject string // Token subject - Scopes []string // Token scopes - LoginCtx *LoginContext // Login context (IP, user agent, etc.) - AuthSource string // Authentication source (password, google, github, etc.) + UserID string // User ID + TeamID string // Team ID (empty for personal account) + Team map[string]interface{} // Team data (nil for personal account) + Member map[string]interface{} // Member profile data (nil for personal account or if not available) + User map[string]interface{} // User data + Subject string // Token subject + Scopes []string // Token scopes + LoginCtx *LoginContext // Login context (IP, user agent, etc.) + AuthSource string // Authentication source (password, google, github, etc.) + TokenExpiresIn int // When > 0, overrides default access_token expiration (seconds) + SkipRefreshToken bool // When true, skip refresh_token generation } // ==== Entry Verification Types ====