Enhance user login functionality with options for customization

- Introduce `LoginWithOptions` method to allow for customizable login flows, enabling overrides for scopes, token expiration, and refresh token issuance.
- Add `LoginOptions` struct to encapsulate optional parameters for login, improving flexibility in user authentication.
- Update token handling in `issueTokens` to accommodate new options, ensuring proper management of access and refresh tokens based on user preferences.
- Integrate OTP service initialization into the OpenAPI server setup for enhanced authentication capabilities.
This commit is contained in:
Max 2026-02-21 21:38:39 +08:00
parent b68660b3cd
commit 37ab2bc84b
16 changed files with 2087 additions and 60 deletions

27
go.mod
View file

@ -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/*

73
go.sum
View file

@ -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=

View file

@ -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 {

View file

@ -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)

334
openapi/otp/DESIGN.md Normal file
View file

@ -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/<prefix>/v/<code>`, 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 /<prefix>/v/<code> |
| |<-------------------------------|
| | |
| | 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/<code>)
```
## 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/<code>
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<ApiResponse<{ redirect: string }>> {
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

101
openapi/otp/README.md Normal file
View file

@ -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/<code>` 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

70
openapi/otp/generate.go Normal file
View file

@ -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
}

214
openapi/otp/handler.go Normal file
View file

@ -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")
}

95
openapi/otp/login.go Normal file
View file

@ -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
}

81
openapi/otp/otp.go Normal file
View file

@ -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
}

92
openapi/otp/process.go Normal file
View file

@ -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
}

12
openapi/otp/revoke.go Normal file
View file

@ -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
}

76
openapi/otp/verify.go Normal file
View file

@ -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
}
}

View file

@ -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")
}

View file

@ -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)
}
}

View file

@ -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 ====