Merge pull request #1470 from trheyi/main

Enhance OAuth token handling and refresh logic
This commit is contained in:
Max 2026-02-21 21:54:53 +08:00 committed by GitHub
commit 403991d7ef
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 2456 additions and 185 deletions

View file

@ -138,8 +138,20 @@ func LoadWithRoot(root string) Config {
// DataRoot
if cfg.DataRoot == "" {
cfg.DataRoot = filepath.Join(cfg.Root, "data")
if !filepath.IsAbs(cfg.DataRoot) {
cfg.DataRoot, _ = filepath.Abs(cfg.DataRoot)
}
if !filepath.IsAbs(cfg.DataRoot) {
cfg.DataRoot = filepath.Join(cfg.Root, cfg.DataRoot)
}
// Resolve DB relative paths based on Root
for i, dsn := range cfg.DB.Primary {
if !filepath.IsAbs(dsn) && (cfg.DB.Driver == "sqlite3" || cfg.DB.Driver == "") {
cfg.DB.Primary[i] = filepath.Join(cfg.Root, dsn)
}
}
for i, dsn := range cfg.DB.Secondary {
if !filepath.IsAbs(dsn) && (cfg.DB.Driver == "sqlite3" || cfg.DB.Driver == "") {
cfg.DB.Secondary[i] = filepath.Join(cfg.Root, dsn)
}
}

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

@ -47,36 +47,43 @@ func (s *Service) Guard(c *gin.Context) {
// This method only performs authentication without ACL checks
// Returns true if authentication succeeded, false otherwise
func (s *Service) Authenticate(c *gin.Context) bool {
// Get the token from the request
token := s.getAccessToken(c)
// Validate the token
if token == "" {
response.RespondWithError(c, http.StatusUnauthorized, types.ErrTokenMissing)
c.Abort()
return false
}
// Validate the token
// Try strict verification first (signature + expiration)
claims, err := s.VerifyToken(token)
if err != nil {
response.RespondWithError(c, http.StatusUnauthorized, types.ErrInvalidToken)
c.Abort()
return false
}
// Token invalid — check if it's just expired (signature still valid)
expiredClaims, expErr := s.VerifyTokenAllowExpired(token)
if expErr != nil || expiredClaims == nil {
response.RespondWithError(c, http.StatusUnauthorized, types.ErrInvalidToken)
c.Abort()
return false
}
// Auto refresh the token
if claims.ExpiresAt.Before(time.Now()) {
s.tryAutoRefreshToken(c, claims)
if c.IsAborted() {
// Signature valid but expired — attempt auto refresh
if !expiredClaims.ExpiresAt.IsZero() && expiredClaims.ExpiresAt.Before(time.Now()) {
newClaims, refreshErr := s.TryRefreshToken(c, expiredClaims)
if refreshErr != nil {
log.Error("[OAuth] Token refresh failed: %v", refreshErr)
response.RespondWithError(c, http.StatusUnauthorized, types.ErrInvalidRefreshToken)
c.Abort()
return false
}
claims = newClaims
} else {
response.RespondWithError(c, http.StatusUnauthorized, types.ErrInvalidToken)
c.Abort()
return false
}
}
// Set Authorized Info in context
sessionID := s.getSessionID(c)
authorized.SetInfo(c, claims, sessionID, s.UserID)
return true
}
@ -86,23 +93,108 @@ func GetAuthorizedInfo(c *gin.Context) *types.AuthorizedInfo {
return authorized.GetInfo(c)
}
func (s *Service) tryAutoRefreshToken(c *gin.Context, _ *types.TokenClaims) {
// TryRefreshToken reads the refresh token from the request, verifies it,
// rotates the refresh token (revoke old, issue new), issues a new access token,
// writes both cookies, and returns the new claims.
// expiredClaims may be nil; in that case the identity is derived from the refresh token itself.
// Returns (nil, error) on any failure — the caller decides how to respond.
func (s *Service) TryRefreshToken(c *gin.Context, expiredClaims *types.TokenClaims) (*types.TokenClaims, error) {
refreshToken := s.getRefreshToken(c)
if refreshToken == "" {
response.RespondWithError(c, http.StatusUnauthorized, types.ErrRefreshTokenMissing)
c.Abort()
return
return nil, fmt.Errorf("refresh token missing")
}
// Verify the refresh token
_, err := s.VerifyToken(refreshToken)
refreshClaims, err := s.VerifyRefreshToken(refreshToken)
if err != nil {
response.RespondWithError(c, http.StatusUnauthorized, types.ErrInvalidRefreshToken)
c.Abort()
return
return nil, fmt.Errorf("invalid or expired refresh token: %w", err)
}
// @Todo: Auto refresh the token
// Derive access token TTL from the expired token's own iat/exp so the refreshed
// token keeps the same lifetime that was originally configured at login time.
var accessTTL time.Duration
if expiredClaims != nil && !expiredClaims.IssuedAt.IsZero() && !expiredClaims.ExpiresAt.IsZero() {
accessTTL = expiredClaims.ExpiresAt.Sub(expiredClaims.IssuedAt)
}
if accessTTL <= 0 {
accessTTL = s.config.Token.AccessTokenLifetime
}
if accessTTL <= 0 {
accessTTL = time.Hour
}
// Prefer the expired access token claims; fall back to refresh token claims
sourceClaims := expiredClaims
if sourceClaims == nil {
sourceClaims = refreshClaims
}
extraClaims := sourceClaims.Extra
if extraClaims == nil {
extraClaims = make(map[string]interface{})
}
if sourceClaims.TeamID != "" {
extraClaims["team_id"] = sourceClaims.TeamID
}
if sourceClaims.TenantID != "" {
extraClaims["tenant_id"] = sourceClaims.TenantID
}
// --- Refresh Token Rotation ---
// Revoke the old refresh token so it can never be reused.
s.revokeRefreshToken(refreshToken)
// Calculate remaining refresh lifetime for the new refresh token.
var refreshRemainingSeconds int
if !refreshClaims.ExpiresAt.IsZero() {
refreshRemainingSeconds = int(time.Until(refreshClaims.ExpiresAt).Seconds())
if refreshRemainingSeconds <= 0 {
return nil, fmt.Errorf("refresh token already expired after revocation")
}
} else {
refreshTTL := s.config.Token.RefreshTokenLifetime
if refreshTTL == 0 {
refreshTTL = 24 * time.Hour
}
refreshRemainingSeconds = int(refreshTTL.Seconds())
}
newRefreshToken, err := s.MakeRefreshToken(
sourceClaims.ClientID,
sourceClaims.Scope,
sourceClaims.Subject,
refreshRemainingSeconds,
extraClaims,
)
if err != nil {
return nil, fmt.Errorf("failed to issue new refresh token: %w", err)
}
// Issue new access token
newTokenStr, err := s.MakeAccessToken(
sourceClaims.ClientID,
sourceClaims.Scope,
sourceClaims.Subject,
int(accessTTL.Seconds()),
extraClaims,
)
if err != nil {
return nil, fmt.Errorf("failed to issue access token: %w", err)
}
// Cookie lifetime = new refresh token lifetime
cookieExpires := time.Now().Add(time.Duration(refreshRemainingSeconds) * time.Second)
cookieValue := fmt.Sprintf("Bearer %s", newTokenStr)
response.SendAccessTokenCookieWithExpiry(c, cookieValue, cookieExpires)
response.SendRefreshTokenCookieWithExpiry(c, newRefreshToken, cookieExpires)
newClaims, err := s.VerifyToken(newTokenStr)
if err != nil {
return nil, fmt.Errorf("failed to verify refreshed token: %w", err)
}
log.Info("[OAuth] Token rotated for subject %s (access + refresh)", sourceClaims.Subject)
return newClaims, nil
}
func (s *Service) getAccessToken(c *gin.Context) string {
@ -152,6 +244,11 @@ func (s *Service) GetRefreshToken(c *gin.Context) string {
return s.getRefreshToken(c)
}
// GetSessionID gets the session ID from the request (public method)
func (s *Service) GetSessionID(c *gin.Context) string {
return s.getSessionID(c)
}
// Get Session ID from cookies, headers, or query string
func (s *Service) getSessionID(c *gin.Context) string {

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

@ -454,15 +454,64 @@ func (s *Service) SignToken(tokenType, clientID, scope, subject string, expiresI
// VerifyToken verifies a token based on its format and returns token claims
func (s *Service) VerifyToken(token string) (*types.TokenClaims, error) {
// First try to verify as JWT (JWT tokens contain dots)
if strings.Contains(token, ".") {
return s.verifyJWTToken(token)
}
// Otherwise, verify as opaque token
return s.verifyOpaqueToken(token)
}
// VerifyTokenAllowExpired verifies token signature but allows expired tokens.
// Used by Guard to parse expired access tokens before attempting refresh.
func (s *Service) VerifyTokenAllowExpired(token string) (*types.TokenClaims, error) {
if strings.Contains(token, ".") {
return s.verifyJWTTokenAllowExpired(token)
}
return s.verifyOpaqueToken(token)
}
// VerifyRefreshToken verifies a refresh token based on its format.
// For opaque tokens it looks up the refresh token store (not the access token store).
func (s *Service) VerifyRefreshToken(token string) (*types.TokenClaims, error) {
if strings.Contains(token, ".") {
// JWT refresh tokens can be verified with the same JWT logic
return s.verifyJWTToken(token)
}
return s.verifyOpaqueRefreshToken(token)
}
// verifyOpaqueRefreshToken verifies an opaque refresh token using the refresh token store.
func (s *Service) verifyOpaqueRefreshToken(token string) (*types.TokenClaims, error) {
tokenInfo, err := s.getRefreshTokenData(token)
if err != nil {
return nil, fmt.Errorf("refresh token not found or invalid: %w", err)
}
clientID, _ := tokenInfo["client_id"].(string)
scope, _ := tokenInfo["scope"].(string)
subject, _ := tokenInfo["subject"].(string)
claims := &types.TokenClaims{
Subject: subject,
ClientID: clientID,
Scope: scope,
TokenType: "refresh_token",
Issuer: s.config.IssuerURL,
}
if issuedAt, ok := tokenInfo["issued_at"].(int64); ok {
claims.IssuedAt = time.Unix(issuedAt, 0)
}
if expiresAt, ok := tokenInfo["expires_at"].(int64); ok {
claims.ExpiresAt = time.Unix(expiresAt, 0)
if time.Now().After(claims.ExpiresAt) {
return nil, fmt.Errorf("refresh token expired")
}
}
return claims, nil
}
// SignIDToken signs an ID token with specific parameters and stores it
func (s *Service) SignIDToken(clientID, scope string, expiresIn int, userdata *types.OIDCUserInfo, extraClaims ...map[string]interface{}) (string, error) {
if s.signingCerts == nil || s.signingCerts.SigningKey == nil {
@ -713,42 +762,57 @@ func (s *Service) signJWTToken(tokenType, clientID, scope, subject string, expir
// verifyJWTToken verifies a JWT token and returns its claims
func (s *Service) verifyJWTToken(tokenString string) (*types.TokenClaims, error) {
return s.parseJWTToken(tokenString, false)
}
// verifyJWTTokenAllowExpired parses a JWT token, verifying signature but allowing expiration.
// Returns claims even if the token is expired (signature must still be valid).
func (s *Service) verifyJWTTokenAllowExpired(tokenString string) (*types.TokenClaims, error) {
return s.parseJWTToken(tokenString, true)
}
// parseJWTToken is the shared JWT parsing logic.
// When allowExpired is true, expired tokens are still parsed (signature-only verification).
func (s *Service) parseJWTToken(tokenString string, allowExpired bool) (*types.TokenClaims, error) {
if s.signingCerts == nil || s.signingCerts.SigningCert == nil {
return nil, fmt.Errorf("signing certificates not initialized")
}
// Parse token with MapClaims to support extra claims
parserOpts := []jwt.ParserOption{}
if allowExpired {
parserOpts = append(parserOpts, jwt.WithoutClaimsValidation())
}
token, err := jwt.ParseWithClaims(tokenString, jwt.MapClaims{}, func(token *jwt.Token) (interface{}, error) {
// Validate signing method
expectedMethod := getSigningMethod(s.config.Token.AccessTokenSigningAlg)
if token.Method != expectedMethod {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
// Return public key for verification
return s.signingCerts.GetPublicKey(), nil
})
}, parserOpts...)
if err != nil {
return nil, fmt.Errorf("failed to parse JWT token: %w", err)
}
if !token.Valid {
if !allowExpired && !token.Valid {
return nil, fmt.Errorf("invalid JWT token")
}
// Extract claims
mapClaims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return nil, fmt.Errorf("invalid JWT claims type")
}
// Convert to TokenClaims
return s.extractTokenClaims(mapClaims), nil
}
// extractTokenClaims converts jwt.MapClaims to types.TokenClaims
func (s *Service) extractTokenClaims(mapClaims jwt.MapClaims) *types.TokenClaims {
tokenClaims := &types.TokenClaims{
Extra: make(map[string]interface{}),
}
// Extract standard claims
if sub, ok := mapClaims["sub"].(string); ok {
tokenClaims.Subject = sub
}
@ -768,7 +832,6 @@ func (s *Service) verifyJWTToken(tokenString string) (*types.TokenClaims, error)
tokenClaims.JTI = jti
}
// Extract time claims
if exp, ok := mapClaims["exp"].(float64); ok {
tokenClaims.ExpiresAt = time.Unix(int64(exp), 0)
}
@ -776,7 +839,6 @@ func (s *Service) verifyJWTToken(tokenString string) (*types.TokenClaims, error)
tokenClaims.IssuedAt = time.Unix(int64(iat), 0)
}
// Extract audience
if aud, ok := mapClaims["aud"].(string); ok {
tokenClaims.Audience = []string{aud}
} else if audArray, ok := mapClaims["aud"].([]interface{}); ok {
@ -789,7 +851,6 @@ func (s *Service) verifyJWTToken(tokenString string) (*types.TokenClaims, error)
tokenClaims.Audience = audience
}
// Extract extended claims for multi-tenancy and team support
if teamID, ok := mapClaims["team_id"].(string); ok {
tokenClaims.TeamID = teamID
}
@ -797,7 +858,6 @@ func (s *Service) verifyJWTToken(tokenString string) (*types.TokenClaims, error)
tokenClaims.TenantID = tenantID
}
// Store all extra claims for flexibility
standardClaims := map[string]bool{
"sub": true, "client_id": true, "scope": true, "token_type": true,
"exp": true, "iat": true, "nbf": true, "iss": true, "aud": true, "jti": true,
@ -809,7 +869,7 @@ func (s *Service) verifyJWTToken(tokenString string) (*types.TokenClaims, error)
}
}
return tokenClaims, nil
return tokenClaims
}
// signOpaqueToken signs an opaque token using HMAC or RSA signature

View file

@ -19,6 +19,7 @@ var (
ErrTokenMissing = &ErrorResponse{Code: "token_missing", ErrorDescription: "No access token provided in the request"}
ErrInvalidRefreshToken = &ErrorResponse{Code: "invalid_refresh_token", ErrorDescription: "The refresh token provided is invalid or expired"}
ErrRefreshTokenMissing = &ErrorResponse{Code: "refresh_token_missing", ErrorDescription: "No refresh token provided in the request"}
ErrTokenRefreshFailed = &ErrorResponse{Code: "token_refresh_failed", ErrorDescription: "Failed to refresh access token"}
// Permission related errors
ErrForbidden = &ErrorResponse{Code: "forbidden", ErrorDescription: "You do not have permission to access this resource"}

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

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

@ -0,0 +1,89 @@
# 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~~ | **Disabled** — use `otp.Create` process instead |
| `POST` | `/otp/login` | Public | Verify code, set session cookies |
> **Note:** The `/otp/create` HTTP endpoint is intentionally disabled. Exposing it would allow any team member to generate OTP codes for other members, effectively logging in as them without credentials. OTP codes must be created server-side via the `otp.Create` process only.
### 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
}

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

@ -0,0 +1,216 @@
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.
// NOTE: /otp/create is disabled — OTP codes should only be created via
// server-side Process (otp.Create) to prevent team members from generating
// codes for other members and logging in as them.
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,271 @@
package openapi_test
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/yaoapp/yao/openapi/oauth"
"github.com/yaoapp/yao/openapi/oauth/authorized"
"github.com/yaoapp/yao/openapi/response"
"github.com/yaoapp/yao/openapi/tests/testutils"
)
// TestGuard_ValidToken verifies that a valid, non-expired access token passes through authentication.
func TestGuard_ValidToken(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
_ = serverURL
oauthService := oauth.OAuth
assert.NotNil(t, oauthService, "OAuth service should be initialized")
client := testutils.RegisterTestClient(t, "Guard Valid Token Test", []string{"https://localhost/callback"})
defer testutils.CleanupTestClient(t, client.ClientID)
tokenInfo := testutils.ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile")
router := authenticateRouter(oauthService)
accessCookieName := response.GetCookieName("access_token")
req := httptest.NewRequest("GET", "/guarded", nil)
req.AddCookie(&http.Cookie{Name: accessCookieName, Value: fmt.Sprintf("Bearer %s", tokenInfo.AccessToken)})
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code, "Valid token should pass authentication")
assert.Contains(t, w.Body.String(), `"subject"`, "Response should contain authorized subject")
}
// TestGuard_NoToken verifies that a request without any token is rejected with 401.
func TestGuard_NoToken(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
_ = serverURL
oauthService := oauth.OAuth
assert.NotNil(t, oauthService, "OAuth service should be initialized")
router := authenticateRouter(oauthService)
req := httptest.NewRequest("GET", "/guarded", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code, "No token should return 401")
assert.Contains(t, w.Body.String(), "token_missing", "Error should indicate missing token")
}
// TestGuard_InvalidSignature verifies that a token with an invalid signature is rejected with 401.
func TestGuard_InvalidSignature(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
_ = serverURL
oauthService := oauth.OAuth
assert.NotNil(t, oauthService, "OAuth service should be initialized")
router := authenticateRouter(oauthService)
accessCookieName := response.GetCookieName("access_token")
req := httptest.NewRequest("GET", "/guarded", nil)
req.AddCookie(&http.Cookie{Name: accessCookieName, Value: "Bearer eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJmYWtlIn0.invalidsignature"})
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code, "Invalid signature should return 401")
}
// TestGuard_ExpiredToken_NoRefresh verifies that an expired access token without a refresh token returns 401.
func TestGuard_ExpiredToken_NoRefresh(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
_ = serverURL
oauthService := oauth.OAuth
assert.NotNil(t, oauthService, "OAuth service should be initialized")
client := testutils.RegisterTestClient(t, "Guard Expired No Refresh Test", []string{"https://localhost/callback"})
defer testutils.CleanupTestClient(t, client.ClientID)
expiredToken, err := oauthService.MakeAccessToken(client.ClientID, "openid profile", "test-subject-expired", -1)
assert.NoError(t, err, "Should be able to create expired token")
router := authenticateRouter(oauthService)
accessCookieName := response.GetCookieName("access_token")
req := httptest.NewRequest("GET", "/guarded", nil)
req.AddCookie(&http.Cookie{Name: accessCookieName, Value: fmt.Sprintf("Bearer %s", expiredToken)})
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code, "Expired token without refresh token should return 401")
}
// TestGuard_ExpiredToken_WithValidRefresh verifies that an expired access token with a valid refresh token
// triggers auto-refresh: the request succeeds and a new access_token cookie is set.
func TestGuard_ExpiredToken_WithValidRefresh(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
_ = serverURL
oauthService := oauth.OAuth
assert.NotNil(t, oauthService, "OAuth service should be initialized")
client := testutils.RegisterTestClient(t, "Guard Auto Refresh Test", []string{"https://localhost/callback"})
defer testutils.CleanupTestClient(t, client.ClientID)
subject := "test-subject-auto-refresh"
expiredToken, err := oauthService.MakeAccessToken(client.ClientID, "openid profile", subject, -1)
assert.NoError(t, err, "Should create expired access token")
// Create a JWT-format refresh token so VerifyToken can validate it directly.
// The default opaque format requires store lookup which is separate from the signing path.
refreshToken, err := oauthService.MakeRefreshToken(client.ClientID, "openid profile", subject, 86400)
assert.NoError(t, err, "Should create valid refresh token")
router := authenticateRouter(oauthService)
accessCookieName := response.GetCookieName("access_token")
refreshCookieName := response.GetCookieName("refresh_token")
req := httptest.NewRequest("GET", "/guarded", nil)
req.AddCookie(&http.Cookie{Name: accessCookieName, Value: fmt.Sprintf("Bearer %s", expiredToken)})
req.AddCookie(&http.Cookie{Name: refreshCookieName, Value: fmt.Sprintf("Bearer %s", refreshToken)})
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code, "Expired token + valid refresh should auto-refresh and succeed")
assert.Contains(t, w.Body.String(), `"subject"`, "Response should contain authorized subject")
// Verify that both access_token and refresh_token cookies were rotated
setCookieHeaders := w.Result().Cookies()
foundNewAccessToken := false
foundNewRefreshToken := false
for _, c := range setCookieHeaders {
if c.Name == accessCookieName {
foundNewAccessToken = true
assert.NotEmpty(t, c.Value, "New access token cookie should have a value")
rawValue := strings.TrimPrefix(c.Value, "Bearer ")
assert.NotEqual(t, expiredToken, rawValue, "New token should differ from the expired one")
t.Logf("New access_token cookie set with MaxAge=%d", c.MaxAge)
}
if c.Name == refreshCookieName {
foundNewRefreshToken = true
assert.NotEmpty(t, c.Value, "New refresh token cookie should have a value")
rawValue := strings.TrimPrefix(c.Value, "Bearer ")
assert.NotEqual(t, refreshToken, rawValue, "New refresh token should differ from the old one")
t.Logf("New refresh_token cookie set with MaxAge=%d", c.MaxAge)
}
}
assert.True(t, foundNewAccessToken, "Guard should write a new access_token cookie after auto-refresh")
assert.True(t, foundNewRefreshToken, "Guard should rotate refresh_token cookie after auto-refresh")
}
// TestGuard_ExpiredToken_WithExpiredRefresh verifies that an expired access token paired with an
// also-expired refresh token returns 401.
func TestGuard_ExpiredToken_WithExpiredRefresh(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
_ = serverURL
oauthService := oauth.OAuth
assert.NotNil(t, oauthService, "OAuth service should be initialized")
client := testutils.RegisterTestClient(t, "Guard Expired Refresh Test", []string{"https://localhost/callback"})
defer testutils.CleanupTestClient(t, client.ClientID)
subject := "test-subject-both-expired"
expiredAccess, err := oauthService.MakeAccessToken(client.ClientID, "openid profile", subject, -1)
assert.NoError(t, err)
// Opaque refresh tokens expire via store TTL, not a field in the data.
// Use a 1-second TTL and wait for it to expire from the store.
expiredRefresh, err := oauthService.MakeRefreshToken(client.ClientID, "openid profile", subject, 1)
assert.NoError(t, err)
time.Sleep(2 * time.Second)
router := authenticateRouter(oauthService)
accessCookieName := response.GetCookieName("access_token")
refreshCookieName := response.GetCookieName("refresh_token")
req := httptest.NewRequest("GET", "/guarded", nil)
req.AddCookie(&http.Cookie{Name: accessCookieName, Value: fmt.Sprintf("Bearer %s", expiredAccess)})
req.AddCookie(&http.Cookie{Name: refreshCookieName, Value: fmt.Sprintf("Bearer %s", expiredRefresh)})
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code, "Both tokens expired should return 401")
}
// TestGuard_AuthorizationHeader verifies that the Guard also works with the Authorization header.
func TestGuard_AuthorizationHeader(t *testing.T) {
serverURL := testutils.Prepare(t)
defer testutils.Clean()
_ = serverURL
oauthService := oauth.OAuth
assert.NotNil(t, oauthService, "OAuth service should be initialized")
client := testutils.RegisterTestClient(t, "Guard Header Auth Test", []string{"https://localhost/callback"})
defer testutils.CleanupTestClient(t, client.ClientID)
tokenInfo := testutils.ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile")
router := authenticateRouter(oauthService)
req := httptest.NewRequest("GET", "/guarded", nil)
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", tokenInfo.AccessToken))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code, "Valid Bearer token in Authorization header should pass authentication")
assert.Contains(t, w.Body.String(), `"subject"`, "Response should contain authorized subject")
}
// authenticateRouter creates a Gin router with ONLY the Authenticate middleware (no ACL).
// This isolates the token verification and auto-refresh logic from permission checks.
func authenticateRouter(oauthService *oauth.Service) *gin.Engine {
gin.SetMode(gin.TestMode)
router := gin.New()
handler := func(c *gin.Context) {
info := authorized.GetInfo(c)
if info == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "no authorized info"})
return
}
c.JSON(http.StatusOK, gin.H{
"subject": info.Subject,
"client_id": info.ClientID,
"scope": info.Scope,
"user_id": info.UserID,
"session_id": info.SessionID,
})
}
// Use Authenticate (auth only) instead of Guard (auth + ACL)
router.GET("/guarded", func(c *gin.Context) {
if !oauthService.Authenticate(c) {
return
}
handler(c)
})
return router
}

View file

@ -0,0 +1,590 @@
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 (disabled — HTTP endpoint removed for security) ----------
// Validation tests now covered by TestOTPServiceCreateValidation.
// ---------- Full flow: create (service) -> login (HTTP) ----------
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()
code, err := otp.OTP.Create(&otp.GenerateParams{
UserID: tc.UserID,
TeamID: tc.TeamID,
Redirect: "/test/dashboard",
Consume: true,
})
require.NoError(t, err)
assert.Len(t, code, 12)
// 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()
loginRaw, _ := io.ReadAll(loginResp.Body)
t.Logf("Login response: %d, body: %s", loginResp.StatusCode, string(loginRaw))
require.Equal(t, http.StatusOK, loginResp.StatusCode, "body: %s", string(loginRaw))
var loginResult map[string]interface{}
json.Unmarshal(loginRaw, &loginResult)
assert.Equal(t, "success", loginResult["status"])
assert.Equal(t, "/test/dashboard", loginResult["redirect"])
// Verify the code is consumed (default Consume=true)
loginBody2, _ := json.Marshal(map[string]string{"code": code})
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.StatusUnauthorized, loginResp2.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()
code, err := otp.OTP.Create(&otp.GenerateParams{
TeamID: tc.TeamID,
MemberID: tc.MemberID,
Redirect: "/member-login-test",
Consume: true,
})
require.NoError(t, err)
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)
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, "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()
code, err := otp.OTP.Create(&otp.GenerateParams{
UserID: tc.UserID,
TeamID: tc.TeamID,
Redirect: "/reusable",
Consume: false,
})
require.NoError(t, err)
// 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()
_ = serverURL
code, err := otp.OTP.Create(&otp.GenerateParams{
UserID: "token_ttl_user",
Redirect: "/custom-ttl",
TokenExpiresIn: 600,
Consume: true,
})
require.NoError(t, err)
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 ----------
// NOTE: Cross-team HTTP endpoint test removed — /otp/create is disabled.
// Server-side Process callers are trusted and should validate team membership themselves.
// ---------- 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()
code, err := otp.OTP.Create(&otp.GenerateParams{
UserID: tc.UserID,
TeamID: tc.TeamID,
Redirect: "/cookie-test",
Consume: true,
})
require.NoError(t, err)
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

@ -471,7 +471,6 @@ func createPublicEntryConfig(config *EntryConfig) *EntryConfig {
publicConfig.Token = &TokenConfig{
ExpiresIn: config.Token.ExpiresIn,
RefreshTokenExpiresIn: config.Token.RefreshTokenExpiresIn,
RememberMeExpiresIn: config.Token.RememberMeExpiresIn,
RememberMeRefreshTokenExpiresIn: config.Token.RememberMeRefreshTokenExpiresIn,
}
}

View file

@ -496,93 +496,193 @@ 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()
// Determine token expiration times based on Remember Me setting
// Token expiration strategy:
// - access_token: always short-lived (from expires_in config), same for all login types
// - refresh_token: short for normal login, long for remember_me / OAuth
// Security: a leaked access_token has limited impact window; "keep logged in"
// is achieved by silently refreshing via long-lived refresh_token in Guard.
var expiresIn, refreshTokenExpiresIn int
// Try to get token config from entry config first
locale := ""
if params.LoginCtx != nil && params.LoginCtx.Locale != "" {
locale = params.LoginCtx.Locale
}
entryConfig := GetEntryConfig(locale)
if params.LoginCtx != nil && params.LoginCtx.RememberMe {
// Remember Me mode: use extended token durations
if entryConfig != nil && entryConfig.Token != nil {
// Parse Remember Me access token expires_in
if entryConfig.Token.RememberMeExpiresIn != "" {
normalized, err := normalizeDuration(entryConfig.Token.RememberMeExpiresIn)
if err != nil {
log.Warn("Failed to parse remember_me_expires_in: %s, using default", err.Error())
} else {
duration, err := time.ParseDuration(normalized)
if err == nil {
expiresIn = int(duration.Seconds())
}
}
}
// Parse Remember Me refresh token expires_in
if entryConfig.Token.RememberMeRefreshTokenExpiresIn != "" {
normalized, err := normalizeDuration(entryConfig.Token.RememberMeRefreshTokenExpiresIn)
if err != nil {
log.Warn("Failed to parse remember_me_refresh_token_expires_in: %s, using default", err.Error())
} else {
duration, err := time.ParseDuration(normalized)
if err == nil {
refreshTokenExpiresIn = int(duration.Seconds())
}
}
}
// If refresh token not configured, default to 2x the access token duration
if refreshTokenExpiresIn == 0 && expiresIn > 0 {
refreshTokenExpiresIn = expiresIn * 2
}
}
} else {
// Normal login: use standard token durations from entry config
if entryConfig != nil && entryConfig.Token != nil {
// Parse access token expires_in
if entryConfig.Token.ExpiresIn != "" {
normalized, err := normalizeDuration(entryConfig.Token.ExpiresIn)
if err != nil {
log.Warn("Failed to parse expires_in: %s, using default", err.Error())
} else {
duration, err := time.ParseDuration(normalized)
if err == nil {
expiresIn = int(duration.Seconds())
}
}
}
// Parse refresh token expires_in
if entryConfig.Token.RefreshTokenExpiresIn != "" {
normalized, err := normalizeDuration(entryConfig.Token.RefreshTokenExpiresIn)
if err != nil {
log.Warn("Failed to parse refresh_token_expires_in: %s, using default", err.Error())
} else {
duration, err := time.ParseDuration(normalized)
if err == nil {
refreshTokenExpiresIn = int(duration.Seconds())
}
}
}
// If refresh token not configured, default to 24x the access token duration
if refreshTokenExpiresIn == 0 && expiresIn > 0 {
refreshTokenExpiresIn = expiresIn * 24
// 1. Access token: always use the standard short duration
if entryConfig != nil && entryConfig.Token != nil && entryConfig.Token.ExpiresIn != "" {
normalized, err := normalizeDuration(entryConfig.Token.ExpiresIn)
if err != nil {
log.Warn("Failed to parse expires_in: %s, using default", err.Error())
} else {
duration, err := time.ParseDuration(normalized)
if err == nil {
expiresIn = int(duration.Seconds())
}
}
}
// Fall back to YaoClientConfig defaults if not set from entry config
// 2. Refresh token: depends on remember_me
rememberMe := params.LoginCtx != nil && params.LoginCtx.RememberMe
if rememberMe && entryConfig != nil && entryConfig.Token != nil {
// Remember Me: use extended refresh token duration
if entryConfig.Token.RememberMeRefreshTokenExpiresIn != "" {
normalized, err := normalizeDuration(entryConfig.Token.RememberMeRefreshTokenExpiresIn)
if err != nil {
log.Warn("Failed to parse remember_me_refresh_token_expires_in: %s, using default", err.Error())
} else {
duration, err := time.ParseDuration(normalized)
if err == nil {
refreshTokenExpiresIn = int(duration.Seconds())
}
}
}
} else if entryConfig != nil && entryConfig.Token != nil {
// Normal login: use standard refresh token duration
if entryConfig.Token.RefreshTokenExpiresIn != "" {
normalized, err := normalizeDuration(entryConfig.Token.RefreshTokenExpiresIn)
if err != nil {
log.Warn("Failed to parse refresh_token_expires_in: %s, using default", err.Error())
} else {
duration, err := time.ParseDuration(normalized)
if err == nil {
refreshTokenExpiresIn = int(duration.Seconds())
}
}
}
}
// 3. Default fallbacks
if expiresIn == 0 {
expiresIn = yaoClientConfig.ExpiresIn
}
// Refresh token defaults: remember_me 90d, normal 7d, then client config
if refreshTokenExpiresIn == 0 {
refreshTokenExpiresIn = yaoClientConfig.RefreshTokenExpiresIn
if rememberMe {
refreshTokenExpiresIn = 90 * 24 * 3600 // 90 days
} else if yaoClientConfig.RefreshTokenExpiresIn > 0 {
refreshTokenExpiresIn = yaoClientConfig.RefreshTokenExpiresIn
} else {
refreshTokenExpiresIn = 7 * 24 * 3600 // 7 days
}
}
// 4. Caller overrides (e.g. OTP login with custom token lifetime)
if params.TokenExpiresIn > 0 {
expiresIn = params.TokenExpiresIn
}
// Prepare OIDC user info
@ -731,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{
@ -892,9 +996,13 @@ func GinLogout(c *gin.Context) {
// This includes access token, refresh token, and optionally session ID cookies with appropriate security settings
func SendLoginCookies(c *gin.Context, loginResponse *LoginResponse, sessionID string) {
// Send session ID cookie only if sessionID is provided
// Send session ID cookie - expires with refresh token so session survives token refreshes
if sessionID != "" {
expires := time.Now().Add(time.Duration(yaoClientConfig.ExpiresIn) * time.Second)
sessionExpiry := loginResponse.RefreshTokenExpiresIn
if sessionExpiry <= 0 {
sessionExpiry = loginResponse.ExpiresIn
}
expires := time.Now().Add(time.Duration(sessionExpiry) * time.Second)
options := response.NewSecureCookieOptions().
WithExpires(expires).
WithSameSite("Strict")
@ -911,14 +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
refreshExpires := time.Now().Add(time.Duration(loginResponse.RefreshTokenExpiresIn) * time.Second)
// Send access token cookie
response.SendAccessTokenCookieWithExpiry(c, accessToken, time.Now().Add(time.Duration(loginResponse.ExpiresIn)*time.Second))
// 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

@ -175,6 +175,7 @@ func authback(c *gin.Context) {
// LoginThirdParty(providerID, userInfo)
loginCtx := makeLoginContext(c)
loginCtx.AuthSource = providerID // Set auth source to provider name (google, github, etc.)
loginCtx.RememberMe = true // OAuth login always uses extended token durations
// Use locale from params, fallback to "en" if not provided
locale := params.Locale

View file

@ -78,7 +78,6 @@ type CaptchaConfig struct {
type TokenConfig struct {
ExpiresIn string `json:"expires_in,omitempty"`
RefreshTokenExpiresIn string `json:"refresh_token_expires_in,omitempty"`
RememberMeExpiresIn string `json:"remember_me_expires_in,omitempty"`
RememberMeRefreshTokenExpiresIn string `json:"remember_me_refresh_token_expires_in,omitempty"`
}
@ -266,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 ====

View file

@ -6,6 +6,7 @@ import (
"io"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
@ -95,8 +96,8 @@ func guardCookieTrace(r *Request) error {
}
// OAuth 2.1 guard - authentication only
// This guard validates the token and sets authorized info
// ACL checks are performed separately in Run() for API calls
// This guard validates the token and sets authorized info.
// ACL checks are performed separately in Run() for API calls.
// NOTE: This guard does NOT write HTTP responses on failure, so that
// the caller (Guard/apiGuard) can handle redirects or custom error responses.
func guardOAuth(r *Request) error {
@ -110,23 +111,30 @@ func guardOAuth(r *Request) error {
c := r.context
// Check token first without writing response.
// oauth.Authenticate() writes JSON + aborts on failure, which prevents
// the caller from doing redirects. So we check the token manually first.
token := oauth.OAuth.GetAccessToken(c)
if token == "" {
return fmt.Errorf("Exception|401:Not authenticated")
}
if _, err := oauth.OAuth.VerifyToken(token); err != nil {
return fmt.Errorf("Exception|401:Invalid or expired token")
claims, err := oauth.OAuth.VerifyToken(token)
if err != nil {
// Token invalid — check if just expired (signature still valid)
expiredClaims, expErr := oauth.OAuth.VerifyTokenAllowExpired(token)
if expErr == nil && expiredClaims != nil &&
!expiredClaims.ExpiresAt.IsZero() && expiredClaims.ExpiresAt.Before(time.Now()) {
refreshed, refreshErr := oauth.OAuth.TryRefreshToken(c, expiredClaims)
if refreshErr != nil {
return fmt.Errorf("Exception|401:Token expired and refresh failed")
}
claims = refreshed
} else {
return fmt.Errorf("Exception|401:Invalid token")
}
}
// Token is valid, now call Authenticate to set up the full context
// (session ID, authorized info, etc.). This will succeed since token is valid.
oauth.OAuth.Authenticate(c)
// Set authorized info in context
authorized.SetInfo(c, claims, oauth.OAuth.GetSessionID(c), oauth.OAuth.UserID)
// Get authorized info from context
info := authorized.GetInfo(c)
if info != nil {
r.Sid = info.SessionID