Add OAuth files - authentication system

Migrated from app/:
- oauth.go - OAuthManager, config loading, handle/DID resolution
- oauth_session.go - SessionStore, encrypted cookies, token storage
- oauth_middleware.go - RequireAuth middleware, token refresh
- oauth_handlers.go - Login, callback, logout, JWKS endpoints

Changed *DB to *shared.DB, using shared.StringValue/NullableString helpers.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
primal
2026-02-02 12:48:23 -05:00
parent 8a12713221
commit 53919fa31e
6 changed files with 1582 additions and 5 deletions
+60 -1
View File
@@ -2,16 +2,75 @@ module github.com/1440news/dashboard
go 1.24.0
require github.com/1440news/shared v0.0.0
require (
github.com/1440news/shared v0.0.0
github.com/haileyok/atproto-oauth-golang v0.0.3
github.com/lestrrat-go/jwx/v2 v2.1.6
)
require (
github.com/bluesky-social/indigo v0.0.0-20250616202859-d4516ea1d6cf // indirect
github.com/carlmjohnson/versioninfo v0.22.5 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/goccy/go-json v0.10.3 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.5 // indirect
github.com/hashicorp/golang-lru v1.0.2 // indirect
github.com/ipfs/bbloom v0.0.4 // indirect
github.com/ipfs/go-block-format v0.2.0 // indirect
github.com/ipfs/go-cid v0.4.1 // indirect
github.com/ipfs/go-datastore v0.6.0 // indirect
github.com/ipfs/go-ipfs-blockstore v1.3.1 // indirect
github.com/ipfs/go-ipfs-ds-help v1.1.1 // indirect
github.com/ipfs/go-ipfs-util v0.0.3 // indirect
github.com/ipfs/go-ipld-cbor v0.1.0 // indirect
github.com/ipfs/go-ipld-format v0.6.0 // indirect
github.com/ipfs/go-log v1.0.5 // indirect
github.com/ipfs/go-log/v2 v2.5.1 // indirect
github.com/ipfs/go-metrics-interface v0.0.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.7.5 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jbenet/goprocess v0.1.4 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/lestrrat-go/blackmagic v1.0.3 // indirect
github.com/lestrrat-go/httpcc v1.0.1 // indirect
github.com/lestrrat-go/httprc v1.0.6 // indirect
github.com/lestrrat-go/iter v1.0.2 // indirect
github.com/lestrrat-go/option v1.0.1 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/minio/sha256-simd v1.0.1 // indirect
github.com/mr-tron/base58 v1.2.0 // indirect
github.com/multiformats/go-base32 v0.1.0 // indirect
github.com/multiformats/go-base36 v0.2.0 // indirect
github.com/multiformats/go-multibase v0.2.0 // indirect
github.com/multiformats/go-multihash v0.2.3 // indirect
github.com/multiformats/go-varint v0.0.7 // indirect
github.com/opentracing/opentracing-go v1.2.0 // indirect
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect
github.com/segmentio/asm v1.2.0 // indirect
github.com/spaolacci/murmur3 v1.1.0 // indirect
github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 // indirect
go.opentelemetry.io/otel v1.29.0 // indirect
go.opentelemetry.io/otel/metric v1.29.0 // indirect
go.opentelemetry.io/otel/trace v1.29.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.26.0 // indirect
golang.org/x/crypto v0.47.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect
lukechampine.com/blake3 v1.2.1 // indirect
)
replace github.com/1440news/shared => ../shared
+233 -4
View File
@@ -1,6 +1,74 @@
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/bluesky-social/indigo v0.0.0-20250616202859-d4516ea1d6cf h1:LFlwtY9r95lAI1yYKolCLTQnwK5VjgWO87mNsKdj3Qs=
github.com/bluesky-social/indigo v0.0.0-20250616202859-d4516ea1d6cf/go.mod h1:8FlFpF5cIq3DQG0kEHqyTkPV/5MDQoaWLcVwza5ZPJU=
github.com/carlmjohnson/versioninfo v0.22.5 h1:O00sjOLUAFxYQjlN/bzYTuZiS0y6fWDQjMRvwtKgwwc=
github.com/carlmjohnson/versioninfo v0.22.5/go.mod h1:QT9mph3wcVfISUKd0i9sZfVrPviHuSF+cUtLjm2WSf8=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
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=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0=
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
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/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/haileyok/atproto-oauth-golang v0.0.3 h1:LdYSl6sgz11wnv8YD5e9WtopANEg4bCfMIXHMMnkOiI=
github.com/haileyok/atproto-oauth-golang v0.0.3/go.mod h1:vVRo6BPEmWOZnYk9LtXLzBPzfkY63fUaBahA+o4h55Q=
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxCsHI=
github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ=
github.com/hashicorp/go-retryablehttp v0.7.5 h1:bJj+Pj19UZMIweq/iie+1u5YCdGrnxCT9yvm0e+Nd5M=
github.com/hashicorp/go-retryablehttp v0.7.5/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8=
github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c=
github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs=
github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0=
github.com/ipfs/go-block-format v0.2.0 h1:ZqrkxBA2ICbDRbK8KJs/u0O3dlp6gmAuuXUJNiW1Ycs=
github.com/ipfs/go-block-format v0.2.0/go.mod h1:+jpL11nFx5A/SPpsoBn6Bzkra/zaArfSmsknbPMYgzM=
github.com/ipfs/go-cid v0.4.1 h1:A/T3qGvxi4kpKWWcPC/PgbvDA2bjVLO7n4UeVwnbs/s=
github.com/ipfs/go-cid v0.4.1/go.mod h1:uQHwDeX4c6CtyrFwdqyhpNcxVewur1M7l7fNU7LKwZk=
github.com/ipfs/go-datastore v0.6.0 h1:JKyz+Gvz1QEZw0LsX1IBn+JFCJQH4SJVFtM4uWU0Myk=
github.com/ipfs/go-datastore v0.6.0/go.mod h1:rt5M3nNbSO/8q1t4LNkLyUwRs8HupMeN/8O4Vn9YAT8=
github.com/ipfs/go-detect-race v0.0.1 h1:qX/xay2W3E4Q1U7d9lNs1sU9nvguX0a7319XbyQ6cOk=
github.com/ipfs/go-detect-race v0.0.1/go.mod h1:8BNT7shDZPo99Q74BpGMK+4D8Mn4j46UU0LZ723meps=
github.com/ipfs/go-ipfs-blockstore v1.3.1 h1:cEI9ci7V0sRNivqaOr0elDsamxXFxJMMMy7PTTDQNsQ=
github.com/ipfs/go-ipfs-blockstore v1.3.1/go.mod h1:KgtZyc9fq+P2xJUiCAzbRdhhqJHvsw8u2Dlqy2MyRTE=
github.com/ipfs/go-ipfs-ds-help v1.1.1 h1:B5UJOH52IbcfS56+Ul+sv8jnIV10lbjLF5eOO0C66Nw=
github.com/ipfs/go-ipfs-ds-help v1.1.1/go.mod h1:75vrVCkSdSFidJscs8n4W+77AtTpCIAdDGAwjitJMIo=
github.com/ipfs/go-ipfs-util v0.0.3 h1:2RFdGez6bu2ZlZdI+rWfIdbQb1KudQp3VGwPtdNCmE0=
github.com/ipfs/go-ipfs-util v0.0.3/go.mod h1:LHzG1a0Ig4G+iZ26UUOMjHd+lfM84LZCrn17xAKWBvs=
github.com/ipfs/go-ipld-cbor v0.1.0 h1:dx0nS0kILVivGhfWuB6dUpMa/LAwElHPw1yOGYopoYs=
github.com/ipfs/go-ipld-cbor v0.1.0/go.mod h1:U2aYlmVrJr2wsUBU67K4KgepApSZddGRDWBYR0H4sCk=
github.com/ipfs/go-ipld-format v0.6.0 h1:VEJlA2kQ3LqFSIm5Vu6eIlSxD/Ze90xtc4Meten1F5U=
github.com/ipfs/go-ipld-format v0.6.0/go.mod h1:g4QVMTn3marU3qXchwjpKPKgJv+zF+OlaKMyhJ4LHPg=
github.com/ipfs/go-log v1.0.5 h1:2dOuUCB1Z7uoczMWgAyDck5JLb72zHzrMnGnCNNbvY8=
github.com/ipfs/go-log v1.0.5/go.mod h1:j0b8ZoR+7+R99LD9jZ6+AJsrzkPbSXbZfGakb5JPtIo=
github.com/ipfs/go-log/v2 v2.1.3/go.mod h1:/8d0SH3Su5Ooc31QlL1WysJhvyOTDCjcCZ9Axpmri6g=
github.com/ipfs/go-log/v2 v2.5.1 h1:1XdUzF7048prq4aBjDQQ4SL5RxftpRGdXhNRwKSAlcY=
github.com/ipfs/go-log/v2 v2.5.1/go.mod h1:prSpmC1Gpllc9UYWxDiZDreBYw7zp4Iqp1kOLU9U5UI=
github.com/ipfs/go-metrics-interface v0.0.1 h1:j+cpbjYvu4R8zbleSs36gvB7jR+wsL2fGD6n0jO4kdg=
github.com/ipfs/go-metrics-interface v0.0.1/go.mod h1:6s6euYU4zowdslK0GKHmqaIZ3j/b/tL7HTWtJ4VPgWY=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@@ -9,20 +77,181 @@ github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs=
github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/jbenet/go-cienv v0.1.0/go.mod h1:TqNnHUmJgXau0nCzC7kXWeotg3J9W34CUv5Djy1+FlA=
github.com/jbenet/goprocess v0.1.4 h1:DRGOFReOMqqDNXwW70QkacFW0YN9QnwLV0Vqk+3oU0o=
github.com/jbenet/goprocess v0.1.4/go.mod h1:5yspPrukOVuOLORacaBi858NqyClJPQxYZlqdZVfqY4=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs=
github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k=
github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo=
github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI=
github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4=
github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVfecA=
github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU=
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM=
github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8=
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE=
github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI=
github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0=
github.com/multiformats/go-base36 v0.2.0/go.mod h1:qvnKE++v+2MWCfePClUEjE78Z7P2a1UV0xHgWc0hkp4=
github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g=
github.com/multiformats/go-multibase v0.2.0/go.mod h1:bFBZX4lKCA/2lyOFSAoKH5SS6oPyjtnzK/XTFDPkNuk=
github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U=
github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM=
github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8=
github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU=
github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs=
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f h1:VXTQfuJj9vKR4TCkEuWIckKvdHFeJH/huIFJ9/cXOB0=
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f/go.mod h1:/zvteZs/GwLtCgZ4BL6CBsk9IKIlexP43ObX9AxTqTw=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
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/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/smartystreets/assertions v1.2.0 h1:42S6lae5dvLc7BrLu/0ugRtcFVjoJNMC/N3yZFZkDFs=
github.com/smartystreets/assertions v1.2.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo=
github.com/smartystreets/goconvey v1.7.2 h1:9RBaZCeXEQ3UselpuwUQHltGVXvdwm6cv1hgR6gDIPg=
github.com/smartystreets/goconvey v1.7.2/go.mod h1:Vw0tHAZW6lzCRk3xgdin6fKYcG+G3Pg9vgXWeJpQFMM=
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0 h1:GDDkbFiaK8jsSDJfjId/PEGEShv6ugrt4kYsC5UIDaQ=
github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw=
github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e h1:28X54ciEwwUxyHn9yrZfl5ojgF4CBNLWX7LR0rvBkf4=
github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1 h1:aFJWCqJMNjENlcleuuOkGAPH82y0yULBScfXcIEdS24=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.46.1/go.mod h1:sEGXWArGqc3tVa+ekntsN65DmVbVeW+7lTKTjZF3/Fo=
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8=
go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc=
go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8=
go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk=
go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo=
go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA=
go.uber.org/zap v1.16.0/go.mod h1:MA8QOfq0BHJwdXa996Y4dYkAqRKB8/1K1QMMZVaNZjQ=
go.uber.org/zap v1.19.1/go.mod h1:j3DNczoxDZroyBnOT1L/Q79cfUMGZxlv/9dzN7SM1rI=
go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU=
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
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-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/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=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
lukechampine.com/blake3 v1.2.1 h1:YuqqRuaqsGV71BV/nm9xlI0MKUv4QC54jQnBChWbGnI=
lukechampine.com/blake3 v1.2.1/go.mod h1:0OFRp7fBtAylGVCO40o87sbupkyIGgbpv1+M1k1LM6k=
+288
View File
@@ -0,0 +1,288 @@
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
neturl "net/url"
"os"
"strings"
"time"
"github.com/1440news/shared"
oauth "github.com/haileyok/atproto-oauth-golang"
"github.com/haileyok/atproto-oauth-golang/helpers"
"github.com/lestrrat-go/jwx/v2/jwk"
)
// OAuthManager handles OAuth 2.0 authentication for the dashboard
type OAuthManager struct {
client *oauth.Client
clientID string
redirectURI string
privateJWK jwk.Key
publicJWK jwk.Key
sessions *SessionStore
cookieSecret []byte
allowedScope string
}
// OAuthConfig holds configuration for the OAuth manager
type OAuthConfig struct {
ClientID string // URL to client metadata (e.g., https://app.1440.news/.well-known/oauth-client-metadata)
RedirectURI string // OAuth callback URL (e.g., https://app.1440.news/auth/callback)
CookieSecret string // 32-byte hex string for AES-256-GCM encryption
PrivateJWK string // ES256 private key as JSON
}
// NewOAuthManager creates a new OAuth manager
func NewOAuthManager(cfg OAuthConfig, db *shared.DB) (*OAuthManager, error) {
// Parse cookie secret (must be 32 bytes for AES-256)
cookieSecret, err := parseHexSecret(cfg.CookieSecret)
if err != nil {
return nil, fmt.Errorf("invalid cookie secret: %v", err)
}
if len(cookieSecret) != 32 {
return nil, fmt.Errorf("cookie secret must be 32 bytes, got %d", len(cookieSecret))
}
// Parse private JWK
privateJWK, err := helpers.ParseJWKFromBytes([]byte(cfg.PrivateJWK))
if err != nil {
return nil, fmt.Errorf("invalid private JWK: %v", err)
}
// Extract public key
publicJWK, err := privateJWK.PublicKey()
if err != nil {
return nil, fmt.Errorf("failed to extract public key: %v", err)
}
// Create HTTP client with longer timeout
httpClient := &http.Client{
Timeout: 30 * time.Second,
}
// Create OAuth client
client, err := oauth.NewClient(oauth.ClientArgs{
Http: httpClient,
ClientJwk: privateJWK,
ClientId: cfg.ClientID,
RedirectUri: cfg.RedirectURI,
})
if err != nil {
return nil, fmt.Errorf("failed to create OAuth client: %v", err)
}
return &OAuthManager{
client: client,
clientID: cfg.ClientID,
redirectURI: cfg.RedirectURI,
privateJWK: privateJWK,
publicJWK: publicJWK,
sessions: NewSessionStore(db),
cookieSecret: cookieSecret,
allowedScope: "atproto",
}, nil
}
// LoadOAuthConfig loads OAuth configuration from environment or oauth.env file
func LoadOAuthConfig(baseURL string) (*OAuthConfig, error) {
cfg := &OAuthConfig{
ClientID: baseURL + "/.well-known/oauth-client-metadata",
RedirectURI: baseURL + "/auth/callback",
}
// Try environment variables first
cfg.CookieSecret = os.Getenv("OAUTH_COOKIE_SECRET")
cfg.PrivateJWK = os.Getenv("OAUTH_PRIVATE_JWK")
// Fall back to oauth.env file
if cfg.CookieSecret == "" || cfg.PrivateJWK == "" {
if data, err := os.ReadFile("oauth.env"); err == nil {
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "#") || line == "" {
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) == 2 {
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
switch key {
case "OAUTH_COOKIE_SECRET":
cfg.CookieSecret = value
case "OAUTH_PRIVATE_JWK":
cfg.PrivateJWK = value
}
}
}
}
}
// Validate required fields
if cfg.CookieSecret == "" {
return nil, fmt.Errorf("OAUTH_COOKIE_SECRET not configured")
}
if cfg.PrivateJWK == "" {
return nil, fmt.Errorf("OAUTH_PRIVATE_JWK not configured")
}
return cfg, nil
}
// parseHexSecret converts a hex string to bytes
func parseHexSecret(hex string) ([]byte, error) {
if len(hex)%2 != 0 {
return nil, fmt.Errorf("hex string must have even length")
}
b := make([]byte, len(hex)/2)
for i := 0; i < len(hex); i += 2 {
var val byte
for j := 0; j < 2; j++ {
c := hex[i+j]
switch {
case c >= '0' && c <= '9':
val = val*16 + (c - '0')
case c >= 'a' && c <= 'f':
val = val*16 + (c - 'a' + 10)
case c >= 'A' && c <= 'F':
val = val*16 + (c - 'A' + 10)
default:
return nil, fmt.Errorf("invalid hex character: %c", c)
}
}
b[i/2] = val
}
return b, nil
}
// resolveHandle resolves a Bluesky handle to a DID
func resolveHandle(ctx context.Context, handle string) (string, error) {
// Normalize handle (remove @ prefix and whitespace)
handle = strings.TrimSpace(handle)
handle = strings.TrimPrefix(handle, "@")
handle = strings.ToLower(handle)
// Try DNS-based resolution first
url := fmt.Sprintf("https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=%s", neturl.QueryEscape(handle))
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return "", err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("resolve handle failed: %s", string(body))
}
var result struct {
DID string `json:"did"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
}
return result.DID, nil
}
// resolveDIDToHandle resolves a DID to the current handle
func resolveDIDToHandle(ctx context.Context, did string) (string, error) {
// Fetch DID document
var docURL string
if strings.HasPrefix(did, "did:plc:") {
docURL = fmt.Sprintf("https://plc.directory/%s", did)
} else if strings.HasPrefix(did, "did:web:") {
domain := strings.TrimPrefix(did, "did:web:")
docURL = fmt.Sprintf("https://%s/.well-known/did.json", domain)
} else {
return "", fmt.Errorf("unsupported DID method: %s", did)
}
req, err := http.NewRequestWithContext(ctx, "GET", docURL, nil)
if err != nil {
return "", err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("failed to fetch DID document: %d", resp.StatusCode)
}
var doc struct {
AlsoKnownAs []string `json:"alsoKnownAs"`
}
if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil {
return "", err
}
// Find the at:// handle
for _, aka := range doc.AlsoKnownAs {
if strings.HasPrefix(aka, "at://") {
return strings.TrimPrefix(aka, "at://"), nil
}
}
return "", fmt.Errorf("no handle found for DID %s", did)
}
// resolveDIDToService gets the PDS service URL from a DID
func resolveDIDToService(ctx context.Context, did string) (string, error) {
var docURL string
if strings.HasPrefix(did, "did:plc:") {
docURL = fmt.Sprintf("https://plc.directory/%s", did)
} else if strings.HasPrefix(did, "did:web:") {
domain := strings.TrimPrefix(did, "did:web:")
docURL = fmt.Sprintf("https://%s/.well-known/did.json", domain)
} else {
return "", fmt.Errorf("unsupported DID method: %s", did)
}
req, err := http.NewRequestWithContext(ctx, "GET", docURL, nil)
if err != nil {
return "", err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("failed to fetch DID document: %d", resp.StatusCode)
}
var doc struct {
Service []struct {
ID string `json:"id"`
Type string `json:"type"`
ServiceEndpoint string `json:"serviceEndpoint"`
} `json:"service"`
}
if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil {
return "", err
}
// Find the atproto_pds service
for _, svc := range doc.Service {
if svc.Type == "AtprotoPersonalDataServer" || svc.ID == "#atproto_pds" {
return svc.ServiceEndpoint, nil
}
}
return "", fmt.Errorf("no PDS service found for DID %s", did)
}
+521
View File
@@ -0,0 +1,521 @@
package main
import (
"context"
"encoding/json"
"fmt"
"html/template"
"net/http"
"net/url"
"strings"
"time"
"github.com/haileyok/atproto-oauth-golang/helpers"
)
var allowedHandles = map[string]bool{
"1440.news": true,
"wehrv.bsky.social": true,
}
// HandleClientMetadata serves the OAuth client metadata
func (m *OAuthManager) HandleClientMetadata(w http.ResponseWriter, r *http.Request) {
// Get the JWKS URI from the same host
scheme := "https"
if r.TLS == nil && (r.Host == "localhost" || r.Host == "127.0.0.1" || r.Host == "app.1440.localhost:4321") {
scheme = "http"
}
baseURL := scheme + "://" + r.Host
metadata := map[string]interface{}{
"client_id": m.clientID,
"client_name": "1440.news Dashboard",
"client_uri": baseURL,
"redirect_uris": []string{m.redirectURI},
"grant_types": []string{"authorization_code", "refresh_token"},
"response_types": []string{"code"},
"scope": "atproto",
"token_endpoint_auth_method": "private_key_jwt",
"token_endpoint_auth_signing_alg": "ES256",
"dpop_bound_access_tokens": true,
"jwks_uri": baseURL + "/.well-known/jwks.json",
"application_type": "web",
"subject_type": "public",
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(metadata)
}
// HandleJWKS serves the public JWK set
func (m *OAuthManager) HandleJWKS(w http.ResponseWriter, r *http.Request) {
jwks := helpers.CreateJwksResponseObject(m.publicJWK)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(jwks)
}
// HandleLogin serves the login page or initiates OAuth flow
func (m *OAuthManager) HandleLogin(w http.ResponseWriter, r *http.Request) {
// Check if already logged in
if session := m.GetSessionFromCookie(r); session != nil {
http.Redirect(w, r, "/dashboard", http.StatusFound)
return
}
// If handle is provided, start OAuth flow
handle := r.URL.Query().Get("handle")
if handle != "" {
// Save handle to cookie for prefill on next visit
http.SetCookie(w, &http.Cookie{
Name: "last_handle",
Value: handle,
Path: "/",
MaxAge: 86400 * 365, // 1 year
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
m.startOAuthFlow(w, r, handle)
return
}
// Get last handle from cookie for prefill
lastHandle := ""
if cookie, err := r.Cookie("last_handle"); err == nil {
lastHandle = cookie.Value
}
// Serve login page
w.Header().Set("Content-Type", "text/html; charset=utf-8")
tmpl := template.Must(template.New("login").Parse(loginPageHTML))
tmpl.Execute(w, map[string]string{"LastHandle": lastHandle})
}
// startOAuthFlow initiates the OAuth flow for a given handle
func (m *OAuthManager) startOAuthFlow(w http.ResponseWriter, r *http.Request, handle string) {
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
// Auto-append .bsky.social if handle has no dots
if !strings.Contains(handle, ".") {
handle = handle + ".bsky.social"
}
fmt.Printf("OAuth: starting flow for handle: %s\n", handle)
// Resolve handle to DID
did, err := resolveHandle(ctx, handle)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to resolve handle: %v", err), http.StatusBadRequest)
return
}
fmt.Printf("OAuth: resolved DID: %s\n", did)
// Resolve DID to PDS service URL
pdsURL, err := resolveDIDToService(ctx, did)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to resolve PDS: %v", err), http.StatusBadRequest)
return
}
fmt.Printf("OAuth: PDS URL: %s\n", pdsURL)
// Get auth server from PDS
authServerURL, err := m.client.ResolvePdsAuthServer(ctx, pdsURL)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to resolve auth server: %v", err), http.StatusBadRequest)
return
}
fmt.Printf("OAuth: auth server: %s\n", authServerURL)
// Fetch auth server metadata
authMeta, err := m.client.FetchAuthServerMetadata(ctx, authServerURL)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to fetch auth metadata: %v", err), http.StatusBadRequest)
return
}
fmt.Printf("OAuth: auth endpoint: %s\n", authMeta.AuthorizationEndpoint)
// Generate DPoP private key for this auth flow
dpopKey, err := helpers.GenerateKey(nil)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to generate DPoP key: %v", err), http.StatusInternalServerError)
return
}
dpopKeyBytes, err := json.Marshal(dpopKey)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to marshal DPoP key: %v", err), http.StatusInternalServerError)
return
}
// Send PAR (Pushed Authorization Request)
fmt.Printf("OAuth: sending PAR to %s\n", authServerURL)
parResp, err := m.client.SendParAuthRequest(
ctx,
authServerURL,
authMeta,
handle,
m.allowedScope,
dpopKey,
)
if err != nil {
fmt.Printf("OAuth: PAR failed: %v\n", err)
http.Error(w, fmt.Sprintf("PAR request failed: %v", err), http.StatusBadRequest)
return
}
fmt.Printf("OAuth: PAR success, request_uri: %s\n", parResp.RequestUri)
// Save pending auth state
pending := &PendingAuth{
State: parResp.State,
PkceVerifier: parResp.PkceVerifier,
DpopPrivateJWK: string(dpopKeyBytes),
DpopNonce: parResp.DpopAuthserverNonce,
DID: did,
PdsURL: pdsURL,
AuthserverIss: authMeta.Issuer,
}
m.sessions.SavePending(parResp.State, pending)
// Build authorization URL
authURL, err := url.Parse(authMeta.AuthorizationEndpoint)
if err != nil {
http.Error(w, fmt.Sprintf("Invalid auth endpoint: %v", err), http.StatusInternalServerError)
return
}
q := authURL.Query()
q.Set("client_id", m.clientID)
q.Set("request_uri", parResp.RequestUri)
authURL.RawQuery = q.Encode()
fmt.Printf("OAuth: redirecting to: %s\n", authURL.String())
http.Redirect(w, r, authURL.String(), http.StatusFound)
}
// HandleCallback handles the OAuth callback
func (m *OAuthManager) HandleCallback(w http.ResponseWriter, r *http.Request) {
fmt.Printf("OAuth callback: received request from %s\n", r.URL.String())
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
// Get callback parameters
code := r.URL.Query().Get("code")
state := r.URL.Query().Get("state")
iss := r.URL.Query().Get("iss")
errorParam := r.URL.Query().Get("error")
errorDesc := r.URL.Query().Get("error_description")
codePreview := code
if len(codePreview) > 10 {
codePreview = codePreview[:10]
}
statePreview := state
if len(statePreview) > 10 {
statePreview = statePreview[:10]
}
fmt.Printf("OAuth callback: code=%s..., state=%s..., iss=%s, error=%s\n",
codePreview, statePreview, iss, errorParam)
// Check for errors from auth server
if errorParam != "" {
http.Error(w, fmt.Sprintf("Authorization error: %s - %s", errorParam, errorDesc), http.StatusBadRequest)
return
}
if code == "" || state == "" {
http.Error(w, "Missing code or state parameter", http.StatusBadRequest)
return
}
// Retrieve pending auth state
pending := m.sessions.GetPending(state)
if pending == nil {
fmt.Printf("OAuth callback: no pending state found for %s\n", state)
http.Error(w, "Invalid or expired state", http.StatusBadRequest)
return
}
fmt.Printf("OAuth callback: found pending state for DID %s\n", pending.DID)
// Verify issuer matches
if iss != "" && iss != pending.AuthserverIss {
http.Error(w, "Issuer mismatch", http.StatusBadRequest)
return
}
// Parse DPoP private key
dpopKey, err := helpers.ParseJWKFromBytes([]byte(pending.DpopPrivateJWK))
if err != nil {
http.Error(w, fmt.Sprintf("Failed to parse DPoP key: %v", err), http.StatusInternalServerError)
return
}
// Exchange code for tokens
fmt.Printf("OAuth callback: exchanging code for tokens at %s\n", pending.AuthserverIss)
tokenResp, err := m.client.InitialTokenRequest(
ctx,
code,
pending.AuthserverIss,
pending.PkceVerifier,
pending.DpopNonce,
dpopKey,
)
if err != nil {
fmt.Printf("OAuth callback: token exchange failed: %v\n", err)
http.Error(w, fmt.Sprintf("Token exchange failed: %v", err), http.StatusBadRequest)
return
}
fmt.Printf("OAuth callback: token exchange success, sub=%s, scope=%s\n", tokenResp.Sub, tokenResp.Scope)
// Verify scope
if tokenResp.Scope != m.allowedScope {
fmt.Printf("OAuth callback: scope mismatch: expected %s, got %s\n", m.allowedScope, tokenResp.Scope)
http.Error(w, fmt.Sprintf("Invalid scope: expected %s, got %s", m.allowedScope, tokenResp.Scope), http.StatusForbidden)
return
}
// Resolve DID to handle
fmt.Printf("OAuth callback: resolving DID %s to handle\n", tokenResp.Sub)
handle, err := resolveDIDToHandle(ctx, tokenResp.Sub)
if err != nil {
fmt.Printf("OAuth callback: failed to resolve handle: %v\n", err)
http.Error(w, fmt.Sprintf("Failed to resolve handle: %v", err), http.StatusInternalServerError)
return
}
fmt.Printf("OAuth callback: resolved handle: %s\n", handle)
// CRITICAL: Verify user is allowed
if !allowedHandles[handle] {
fmt.Printf("OAuth callback: access denied for handle: %s (allowed: %v)\n", handle, allowedHandles)
http.Error(w, "Access denied.", http.StatusForbidden)
return
}
fmt.Printf("OAuth callback: handle %s is allowed\n", handle)
// Create session
fmt.Printf("OAuth callback: creating session for %s\n", handle)
session, err := m.sessions.CreateSession(tokenResp.Sub, handle)
if err != nil {
fmt.Printf("OAuth callback: failed to create session: %v\n", err)
http.Error(w, fmt.Sprintf("Failed to create session: %v", err), http.StatusInternalServerError)
return
}
fmt.Printf("OAuth callback: session created with ID %s\n", session.ID)
// Store token info in session
session.AccessToken = tokenResp.AccessToken
session.RefreshToken = tokenResp.RefreshToken
session.TokenExpiry = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
session.DpopPrivateJWK = pending.DpopPrivateJWK
session.DpopAuthserverNonce = tokenResp.DpopAuthserverNonce
session.PdsURL = pending.PdsURL
session.AuthserverIss = pending.AuthserverIss
m.sessions.UpdateSession(session)
// Set session cookie
fmt.Printf("OAuth callback: setting session cookie\n")
if err := m.SetSessionCookie(w, r, session.ID); err != nil {
fmt.Printf("OAuth callback: failed to set cookie: %v\n", err)
http.Error(w, fmt.Sprintf("Failed to set cookie: %v", err), http.StatusInternalServerError)
return
}
// Redirect to dashboard
fmt.Printf("OAuth callback: success! redirecting to /dashboard\n")
http.Redirect(w, r, "/dashboard", http.StatusFound)
}
// HandleLogout clears the session and redirects to login
func (m *OAuthManager) HandleLogout(w http.ResponseWriter, r *http.Request) {
// Get current session
session := m.GetSessionFromCookie(r)
if session != nil {
// Delete session from store
m.sessions.DeleteSession(session.ID)
}
// Clear cookie
m.ClearSessionCookie(w)
// Handle API vs browser request
if r.Method == http.MethodPost || isAPIRequest(r) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"status": "logged out",
})
return
}
// Redirect to login for browser requests
http.Redirect(w, r, "/auth/login", http.StatusFound)
}
// HandleSessionInfo returns current session info (for API calls)
func (m *OAuthManager) HandleSessionInfo(w http.ResponseWriter, r *http.Request) {
session := m.GetSessionFromCookie(r)
if session == nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]string{
"error": "not authenticated",
})
return
}
info := &SessionInfo{
DID: session.DID,
Handle: session.Handle,
ExpiresAt: session.ExpiresAt,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(info)
}
const loginPageHTML = `<!DOCTYPE html>
<html>
<head>
<title>Sign In - 1440.news Dashboard</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0a0a0a;
color: #e0e0e0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.container {
max-width: 400px;
width: 100%;
text-align: center;
}
.logo {
font-size: 3em;
color: #fff;
margin-bottom: 10px;
}
.tagline {
color: #888;
margin-bottom: 40px;
}
.login-card {
background: #151515;
border: 1px solid #252525;
border-radius: 12px;
padding: 30px;
}
h2 {
color: #fff;
margin-bottom: 20px;
}
.form-group {
margin-bottom: 20px;
text-align: left;
}
label {
display: block;
color: #888;
font-size: 0.9em;
margin-bottom: 5px;
}
input[type="text"] {
width: 100%;
padding: 12px;
background: #0a0a0a;
border: 1px solid #333;
border-radius: 6px;
color: #fff;
font-size: 1em;
}
input[type="text"]:focus {
outline: none;
border-color: #0af;
}
input[type="text"]::placeholder {
color: #555;
}
.login-btn {
width: 100%;
padding: 14px;
background: #0af;
color: #000;
border: none;
border-radius: 6px;
font-size: 1em;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.login-btn:hover {
background: #0cf;
}
.login-btn:disabled {
background: #555;
cursor: not-allowed;
}
.error {
background: #2a1515;
border: 1px solid #533;
color: #f88;
padding: 10px;
border-radius: 6px;
margin-bottom: 15px;
display: none;
}
.bluesky-icon {
display: inline-block;
width: 20px;
height: 20px;
margin-right: 8px;
vertical-align: middle;
}
</style>
</head>
<body>
<div class="container">
<div class="logo">1440.news</div>
<p class="tagline">Dashboard Authentication</p>
<div class="login-card">
<h2>Sign In with Bluesky</h2>
<div class="error" id="error"></div>
<form id="loginForm" action="/auth/login" method="get">
<div class="form-group">
<label for="handle">Bluesky Handle</label>
<input type="text" id="handle" name="handle" placeholder="username or full.handle" value="{{.LastHandle}}" required autofocus>
</div>
<button type="submit" class="login-btn" id="loginBtn">
<svg class="bluesky-icon" viewBox="0 0 568 501" fill="currentColor">
<path d="M123.121 33.6637C188.241 82.5526 258.281 181.681 284 234.873C309.719 181.681 379.759 82.5526 444.879 33.6637C491.866 -1.61183 568 -28.9064 568 57.9464C568 75.2916 558.055 203.659 552.222 224.501C531.947 296.954 458.067 315.434 392.347 304.249C507.222 323.8 536.444 388.56 473.333 453.32C353.473 576.312 301.061 422.461 287.631 383.039C286.251 378.892 284.991 374.834 284 371.019C283.009 374.834 281.749 378.892 280.369 383.039C266.939 422.461 214.527 576.312 94.6667 453.32C31.5556 388.56 60.7778 323.8 175.653 304.249C109.933 315.434 36.0533 296.954 15.7778 224.501C9.94533 203.659 0 75.2916 0 57.9464C0 -28.9064 76.1345 -1.61183 123.121 33.6637Z"/>
</svg>
Sign In with Bluesky
</button>
</form>
</div>
</div>
<script>
document.getElementById('loginForm').addEventListener('submit', function(e) {
const handle = document.getElementById('handle').value.trim();
if (!handle) {
e.preventDefault();
document.getElementById('error').style.display = 'block';
document.getElementById('error').textContent = 'Please enter your handle';
return;
}
document.getElementById('loginBtn').disabled = true;
document.getElementById('loginBtn').textContent = 'Redirecting...';
});
</script>
</body>
</html>
`
+126
View File
@@ -0,0 +1,126 @@
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/haileyok/atproto-oauth-golang/helpers"
)
// RequireAuth is middleware that protects routes requiring authentication
func (m *OAuthManager) RequireAuth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
session := m.GetSessionFromCookie(r)
if session == nil {
fmt.Printf("RequireAuth: no session found for %s\n", r.URL.Path)
// Check if this is an API call (wants JSON response)
if isAPIRequest(r) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]string{
"error": "unauthorized",
})
return
}
// Redirect to login for browser requests
http.Redirect(w, r, "/auth/login", http.StatusFound)
return
}
// Check if token needs refresh (refresh when within 5 minutes of expiry)
if time.Until(session.TokenExpiry) < 5*time.Minute {
if err := m.refreshToken(r.Context(), session); err != nil {
// Token refresh failed - clear session and redirect to login
m.sessions.DeleteSession(session.ID)
m.ClearSessionCookie(w)
if isAPIRequest(r) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]string{
"error": "session expired",
})
return
}
http.Redirect(w, r, "/auth/login", http.StatusFound)
return
}
}
// Add session to request context
ctx := context.WithValue(r.Context(), sessionContextKey, session)
next(w, r.WithContext(ctx))
}
}
// sessionContextKey is the context key for the OAuth session
type contextKey string
const sessionContextKey contextKey = "oauth_session"
// GetSession retrieves the session from request context
func GetSession(r *http.Request) *OAuthSession {
session, _ := r.Context().Value(sessionContextKey).(*OAuthSession)
return session
}
// isAPIRequest checks if the request expects JSON response
func isAPIRequest(r *http.Request) bool {
// Check Accept header
accept := r.Header.Get("Accept")
if strings.Contains(accept, "application/json") {
return true
}
// Check URL path
if strings.HasPrefix(r.URL.Path, "/api/") {
return true
}
// Check X-Requested-With header (for AJAX)
if r.Header.Get("X-Requested-With") == "XMLHttpRequest" {
return true
}
return false
}
// refreshToken refreshes the OAuth access token
func (m *OAuthManager) refreshToken(ctx context.Context, session *OAuthSession) error {
if session.RefreshToken == "" {
return nil // No refresh token available
}
// Parse the DPoP private key
dpopKey, err := helpers.ParseJWKFromBytes([]byte(session.DpopPrivateJWK))
if err != nil {
return err
}
// Refresh the token
tokenResp, err := m.client.RefreshTokenRequest(
ctx,
session.RefreshToken,
session.AuthserverIss,
session.DpopAuthserverNonce,
dpopKey,
)
if err != nil {
return err
}
// Update session with new tokens
session.AccessToken = tokenResp.AccessToken
session.RefreshToken = tokenResp.RefreshToken
session.TokenExpiry = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
session.DpopAuthserverNonce = tokenResp.DpopAuthserverNonce
// Save updated session
m.sessions.UpdateSession(session)
return nil
}
+354
View File
@@ -0,0 +1,354 @@
package main
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"sync"
"time"
"github.com/1440news/shared"
)
const (
sessionCookieName = "1440_session"
sessionTTL = 24 * time.Hour
)
// OAuthSession stores the OAuth session state for a user
type OAuthSession struct {
ID string `json:"id"`
DID string `json:"did"`
Handle string `json:"handle"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
// OAuth tokens (stored server-side only)
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
TokenExpiry time.Time `json:"token_expiry"`
// DPoP state
DpopPrivateJWK string `json:"dpop_private_jwk"`
DpopAuthserverNonce string `json:"dpop_authserver_nonce"`
DpopPdsNonce string `json:"dpop_pds_nonce"`
// Auth server info
PdsURL string `json:"pds_url"`
AuthserverIss string `json:"authserver_iss"`
}
// PendingAuth stores state during the OAuth flow (before callback)
type PendingAuth struct {
State string `json:"state"`
PkceVerifier string `json:"pkce_verifier"`
DpopPrivateJWK string `json:"dpop_private_jwk"`
DpopNonce string `json:"dpop_nonce"`
DID string `json:"did"`
PdsURL string `json:"pds_url"`
AuthserverIss string `json:"authserver_iss"`
CreatedAt time.Time `json:"created_at"`
}
// SessionStore manages sessions in the database
type SessionStore struct {
db *shared.DB
pending map[string]*PendingAuth // keyed by state (short-lived, kept in memory)
mu sync.RWMutex
cleanupOnce sync.Once
}
// NewSessionStore creates a new session store
func NewSessionStore(db *shared.DB) *SessionStore {
s := &SessionStore{
db: db,
pending: make(map[string]*PendingAuth),
}
s.startCleanup()
return s
}
// startCleanup starts a background goroutine to clean up expired sessions
func (s *SessionStore) startCleanup() {
s.cleanupOnce.Do(func() {
go func() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for range ticker.C {
s.cleanup()
}
}()
})
}
// cleanup removes expired sessions and pending auths
func (s *SessionStore) cleanup() {
// Clean up expired sessions from database
s.db.Exec("DELETE FROM oauth_sessions WHERE expires_at < NOW()")
// Clean up old pending auths (10 minute timeout) from memory
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
for state, pending := range s.pending {
if now.Sub(pending.CreatedAt) > 10*time.Minute {
delete(s.pending, state)
}
}
}
// CreateSession creates a new session and returns it
func (s *SessionStore) CreateSession(did, handle string) (*OAuthSession, error) {
id, err := generateRandomID()
if err != nil {
return nil, err
}
now := time.Now()
session := &OAuthSession{
ID: id,
DID: did,
Handle: handle,
CreatedAt: now,
ExpiresAt: now.Add(sessionTTL),
}
_, err = s.db.Exec(`
INSERT INTO oauth_sessions (id, did, handle, created_at, expires_at)
VALUES ($1, $2, $3, $4, $5)
`, session.ID, session.DID, session.Handle, session.CreatedAt, session.ExpiresAt)
if err != nil {
return nil, err
}
return session, nil
}
// GetSession retrieves a session by ID
func (s *SessionStore) GetSession(id string) *OAuthSession {
row := s.db.QueryRow(`
SELECT id, did, handle, created_at, expires_at,
access_token, refresh_token, token_expiry,
dpop_private_jwk, dpop_authserver_nonce, dpop_pds_nonce,
pds_url, authserver_iss
FROM oauth_sessions
WHERE id = $1 AND expires_at > NOW()
`, id)
var session OAuthSession
var accessToken, refreshToken, dpopJwk, dpopAuthNonce, dpopPdsNonce, pdsURL, authIss *string
var tokenExpiry *time.Time
err := row.Scan(
&session.ID, &session.DID, &session.Handle, &session.CreatedAt, &session.ExpiresAt,
&accessToken, &refreshToken, &tokenExpiry,
&dpopJwk, &dpopAuthNonce, &dpopPdsNonce,
&pdsURL, &authIss,
)
if err != nil {
return nil
}
session.AccessToken = shared.StringValue(accessToken)
session.RefreshToken = shared.StringValue(refreshToken)
if tokenExpiry != nil {
session.TokenExpiry = *tokenExpiry
}
session.DpopPrivateJWK = shared.StringValue(dpopJwk)
session.DpopAuthserverNonce = shared.StringValue(dpopAuthNonce)
session.DpopPdsNonce = shared.StringValue(dpopPdsNonce)
session.PdsURL = shared.StringValue(pdsURL)
session.AuthserverIss = shared.StringValue(authIss)
return &session
}
// UpdateSession updates a session
func (s *SessionStore) UpdateSession(session *OAuthSession) {
s.db.Exec(`
UPDATE oauth_sessions SET
access_token = $2,
refresh_token = $3,
token_expiry = $4,
dpop_private_jwk = $5,
dpop_authserver_nonce = $6,
dpop_pds_nonce = $7,
pds_url = $8,
authserver_iss = $9
WHERE id = $1
`,
session.ID,
shared.NullableString(session.AccessToken),
shared.NullableString(session.RefreshToken),
shared.NullableTime(session.TokenExpiry),
shared.NullableString(session.DpopPrivateJWK),
shared.NullableString(session.DpopAuthserverNonce),
shared.NullableString(session.DpopPdsNonce),
shared.NullableString(session.PdsURL),
shared.NullableString(session.AuthserverIss),
)
}
// DeleteSession removes a session
func (s *SessionStore) DeleteSession(id string) {
s.db.Exec("DELETE FROM oauth_sessions WHERE id = $1", id)
}
// SavePending saves pending OAuth state (kept in memory - short lived)
func (s *SessionStore) SavePending(state string, pending *PendingAuth) {
s.mu.Lock()
defer s.mu.Unlock()
pending.CreatedAt = time.Now()
s.pending[state] = pending
}
// GetPending retrieves and removes pending OAuth state
func (s *SessionStore) GetPending(state string) *PendingAuth {
s.mu.Lock()
defer s.mu.Unlock()
pending, ok := s.pending[state]
if ok {
delete(s.pending, state)
}
return pending
}
// generateRandomID generates a random session ID
func generateRandomID() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(b), nil
}
// encryptSessionID encrypts a session ID using AES-256-GCM
func encryptSessionID(sessionID string, key []byte) (string, error) {
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
ciphertext := gcm.Seal(nonce, nonce, []byte(sessionID), nil)
return base64.URLEncoding.EncodeToString(ciphertext), nil
}
// decryptSessionID decrypts a session ID using AES-256-GCM
func decryptSessionID(encrypted string, key []byte) (string, error) {
ciphertext, err := base64.URLEncoding.DecodeString(encrypted)
if err != nil {
return "", err
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
if len(ciphertext) < gcm.NonceSize() {
return "", fmt.Errorf("ciphertext too short")
}
nonce, ciphertext := ciphertext[:gcm.NonceSize()], ciphertext[gcm.NonceSize():]
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return "", err
}
return string(plaintext), nil
}
// SetSessionCookie sets an encrypted session cookie
func (m *OAuthManager) SetSessionCookie(w http.ResponseWriter, r *http.Request, sessionID string) error {
encrypted, err := encryptSessionID(sessionID, m.cookieSecret)
if err != nil {
return err
}
// Only set Secure flag for HTTPS connections
secure := r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: encrypted,
Path: "/",
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
MaxAge: int(sessionTTL.Seconds()),
})
return nil
}
// GetSessionFromCookie retrieves the session from the request cookie
func (m *OAuthManager) GetSessionFromCookie(r *http.Request) *OAuthSession {
cookie, err := r.Cookie(sessionCookieName)
if err != nil {
fmt.Printf("GetSessionFromCookie: no cookie found: %v\n", err)
return nil
}
fmt.Printf("GetSessionFromCookie: found cookie, length=%d\n", len(cookie.Value))
sessionID, err := decryptSessionID(cookie.Value, m.cookieSecret)
if err != nil {
fmt.Printf("GetSessionFromCookie: decrypt failed: %v\n", err)
return nil
}
fmt.Printf("GetSessionFromCookie: decrypted session ID: %s\n", sessionID)
session := m.sessions.GetSession(sessionID)
if session == nil {
fmt.Printf("GetSessionFromCookie: session not found in store\n")
} else {
fmt.Printf("GetSessionFromCookie: found session for %s\n", session.Handle)
}
return session
}
// ClearSessionCookie removes the session cookie
func (m *OAuthManager) ClearSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: "",
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
MaxAge: -1,
})
}
// SessionInfo is the public session info returned to the client
type SessionInfo struct {
DID string `json:"did"`
Handle string `json:"handle"`
ExpiresAt time.Time `json:"expires_at"`
}
// MarshalJSON converts SessionInfo to JSON
func (s *SessionInfo) MarshalJSON() ([]byte, error) {
type Alias SessionInfo
return json.Marshal((*Alias)(s))
}