An atproto PDS written in Go

oauth (#15)

authored by hailey.at and committed by GitHub 5967b43b 6cf863b3

+2
.env.example
··· 6 6 COCOON_RELAYS=https://bsky.network 7 7 # Generate with `openssl rand -hex 16` 8 8 COCOON_ADMIN_PASSWORD= 9 + # openssl rand -hex 32 10 + COCOON_SESSION_SECRET=
+1
.gitignore
··· 2 2 .env 3 3 /cocoon 4 4 *.key 5 + *.secret
+21
LICENSE
··· 1 + MIT License 2 + 3 + Copyright (c) 2025 me@haileyok.com 4 + 5 + Permission is hereby granted, free of charge, to any person obtaining a copy 6 + of this software and associated documentation files (the "Software"), to deal 7 + in the Software without restriction, including without limitation the rights 8 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 + copies of the Software, and to permit persons to whom the Software is 10 + furnished to do so, subject to the following conditions: 11 + 12 + The above copyright notice and this permission notice shall be included in all 13 + copies or substantial portions of the Software. 14 + 15 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 + SOFTWARE.
+5
README.md
··· 71 71 - [ ] com.atproto.moderation.createReport 72 72 - [x] app.bsky.actor.getPreferences 73 73 - [x] app.bsky.actor.putPreferences 74 + 75 + 76 + ## License 77 + 78 + This project is licensed under MIT license. `server/static/pico.css` is also licensed under MIT license, available at [https://github.com/picocss/pico/](https://github.com/picocss/pico/).
+5
cmd/cocoon/main.go
··· 115 115 Name: "s3-secret-key", 116 116 EnvVars: []string{"COCOON_S3_SECRET_KEY"}, 117 117 }, 118 + &cli.StringFlag{ 119 + Name: "session-secret", 120 + EnvVars: []string{"COCOON_SESSION_SECRET"}, 121 + }, 118 122 }, 119 123 Commands: []*cli.Command{ 120 124 run, ··· 158 162 AccessKey: cmd.String("s3-access-key"), 159 163 SecretKey: cmd.String("s3-secret-key"), 160 164 }, 165 + SessionSecret: cmd.String("session-secret"), 161 166 }) 162 167 if err != nil { 163 168 fmt.Printf("error creating cocoon: %v", err)
+19 -14
go.mod
··· 8 8 github.com/bluesky-social/indigo v0.0.0-20250414202759-826fcdeaa36b 9 9 github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792 10 10 github.com/domodwyer/mailyak/v3 v3.6.2 11 + github.com/go-pkgz/expirable-cache/v3 v3.0.0 11 12 github.com/go-playground/validator v9.31.0+incompatible 12 13 github.com/golang-jwt/jwt/v4 v4.5.2 13 14 github.com/google/uuid v1.4.0 15 + github.com/gorilla/sessions v1.4.0 14 16 github.com/gorilla/websocket v1.5.1 15 17 github.com/hashicorp/golang-lru/v2 v2.0.7 16 18 github.com/ipfs/go-block-format v0.2.0 ··· 18 20 github.com/ipfs/go-ipld-cbor v0.1.0 19 21 github.com/ipld/go-car v0.6.1-0.20230509095817-92d28eb23ba4 20 22 github.com/joho/godotenv v1.5.1 23 + github.com/labstack/echo-contrib v0.17.4 21 24 github.com/labstack/echo/v4 v4.13.3 22 25 github.com/lestrrat-go/jwx/v2 v2.0.12 23 26 github.com/multiformats/go-multihash v0.2.3 ··· 25 28 github.com/urfave/cli/v2 v2.27.6 26 29 github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e 27 30 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b 28 - golang.org/x/crypto v0.36.0 31 + golang.org/x/crypto v0.38.0 29 32 gorm.io/driver/sqlite v1.5.7 30 33 gorm.io/gorm v1.25.12 31 34 ) ··· 35 38 github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b // indirect 36 39 github.com/beorn7/perks v1.0.1 // indirect 37 40 github.com/carlmjohnson/versioninfo v0.22.5 // indirect 38 - github.com/cespare/xxhash/v2 v2.2.0 // indirect 41 + github.com/cespare/xxhash/v2 v2.3.0 // indirect 39 42 github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect 40 43 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect 41 44 github.com/felixge/httpsnoop v1.0.4 // indirect ··· 47 50 github.com/gocql/gocql v1.7.0 // indirect 48 51 github.com/gogo/protobuf v1.3.2 // indirect 49 52 github.com/golang/snappy v0.0.4 // indirect 53 + github.com/gorilla/context v1.1.2 // indirect 54 + github.com/gorilla/securecookie v1.1.2 // indirect 50 55 github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed // indirect 51 56 github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 52 57 github.com/hashicorp/go-retryablehttp v0.7.5 // indirect ··· 84 89 github.com/lestrrat-go/httprc v1.0.4 // indirect 85 90 github.com/lestrrat-go/iter v1.0.2 // indirect 86 91 github.com/lestrrat-go/option v1.0.1 // indirect 87 - github.com/mattn/go-colorable v0.1.13 // indirect 92 + github.com/mattn/go-colorable v0.1.14 // indirect 88 93 github.com/mattn/go-isatty v0.0.20 // indirect 89 94 github.com/mattn/go-sqlite3 v1.14.22 // indirect 90 - github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect 91 95 github.com/minio/sha256-simd v1.0.1 // indirect 92 96 github.com/mr-tron/base58 v1.2.0 // indirect 93 97 github.com/multiformats/go-base32 v0.1.0 // indirect 94 98 github.com/multiformats/go-base36 v0.2.0 // indirect 95 99 github.com/multiformats/go-multibase v0.2.0 // indirect 96 100 github.com/multiformats/go-varint v0.0.7 // indirect 101 + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 97 102 github.com/opentracing/opentracing-go v1.2.0 // indirect 98 103 github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect 99 - github.com/prometheus/client_golang v1.17.0 // indirect 100 - github.com/prometheus/client_model v0.5.0 // indirect 101 - github.com/prometheus/common v0.45.0 // indirect 102 - github.com/prometheus/procfs v0.12.0 // indirect 104 + github.com/prometheus/client_golang v1.22.0 // indirect 105 + github.com/prometheus/client_model v0.6.2 // indirect 106 + github.com/prometheus/common v0.63.0 // indirect 107 + github.com/prometheus/procfs v0.16.1 // indirect 103 108 github.com/russross/blackfriday/v2 v2.1.0 // indirect 104 109 github.com/samber/lo v1.49.1 // indirect 105 110 github.com/segmentio/asm v1.2.0 // indirect ··· 115 120 go.uber.org/atomic v1.11.0 // indirect 116 121 go.uber.org/multierr v1.11.0 // indirect 117 122 go.uber.org/zap v1.26.0 // indirect 118 - golang.org/x/net v0.33.0 // indirect 119 - golang.org/x/sync v0.12.0 // indirect 120 - golang.org/x/sys v0.31.0 // indirect 121 - golang.org/x/text v0.23.0 // indirect 122 - golang.org/x/time v0.8.0 // indirect 123 + golang.org/x/net v0.40.0 // indirect 124 + golang.org/x/sync v0.14.0 // indirect 125 + golang.org/x/sys v0.33.0 // indirect 126 + golang.org/x/text v0.25.0 // indirect 127 + golang.org/x/time v0.11.0 // indirect 123 128 golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect 124 - google.golang.org/protobuf v1.33.0 // indirect 129 + google.golang.org/protobuf v1.36.6 // indirect 125 130 gopkg.in/go-playground/assert.v1 v1.2.1 // indirect 126 131 gopkg.in/inf.v0 v0.9.1 // indirect 127 132 gorm.io/driver/postgres v1.5.7 // indirect
+42 -32
go.sum
··· 24 24 github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY= 25 25 github.com/carlmjohnson/versioninfo v0.22.5 h1:O00sjOLUAFxYQjlN/bzYTuZiS0y6fWDQjMRvwtKgwwc= 26 26 github.com/carlmjohnson/versioninfo v0.22.5/go.mod h1:QT9mph3wcVfISUKd0i9sZfVrPviHuSF+cUtLjm2WSf8= 27 - github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= 28 - github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 27 + github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 28 + github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 29 29 github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= 30 30 github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc= 31 31 github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= ··· 48 48 github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 49 49 github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 50 50 github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 51 + github.com/go-pkgz/expirable-cache/v3 v3.0.0 h1:u3/gcu3sabLYiTCevoRKv+WzjIn5oo7P8XtiXBeRDLw= 52 + github.com/go-pkgz/expirable-cache/v3 v3.0.0/go.mod h1:2OQiDyEGQalYecLWmXprm3maPXeVb5/6/X7yRPYTzec= 51 53 github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= 52 54 github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 53 55 github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= ··· 68 70 github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= 69 71 github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= 70 72 github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= 71 - github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 72 - github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 73 + github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 74 + github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 75 + github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 76 + github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 73 77 github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8= 74 78 github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo= 75 79 github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= ··· 77 81 github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 78 82 github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= 79 83 github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= 84 + github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o= 85 + github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM= 86 + github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= 87 + github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= 88 + github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= 89 + github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= 80 90 github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= 81 91 github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= 82 92 github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed h1:5upAirOpQc1Q53c0bnx2ufif5kANL7bfZWcc6VJWJd8= ··· 196 206 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 197 207 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 198 208 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 209 + github.com/labstack/echo-contrib v0.17.4 h1:g5mfsrJfJTKv+F5uNKCyrjLK7js+ZW6HTjg4FnDxxgk= 210 + github.com/labstack/echo-contrib v0.17.4/go.mod h1:9O7ZPAHUeMGTOAfg80YqQduHzt0CzLak36PZRldYrZ0= 199 211 github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY= 200 212 github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g= 201 213 github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= ··· 233 245 github.com/libp2p/go-nat v0.1.0/go.mod h1:X7teVkwRHNInVNWQiO/tAiAVRwSr5zoRz4YSTC3uRBM= 234 246 github.com/libp2p/go-netroute v0.2.1 h1:V8kVrpD8GK0Riv15/7VN6RbUQ3URNZVosw7H2v9tksU= 235 247 github.com/libp2p/go-netroute v0.2.1/go.mod h1:hraioZr0fhBjG0ZRXJJ6Zj2IVEVNx6tDTFQfSmcq7mQ= 236 - github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 237 - github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 248 + github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= 249 + github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 238 250 github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= 239 - github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 240 251 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 241 252 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 242 253 github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= 243 254 github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 244 - github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg= 245 - github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k= 246 255 github.com/miekg/dns v1.1.50 h1:DQUfb9uc6smULcREF09Uc+/Gd46YWqJd5DbpPE9xkcA= 247 256 github.com/miekg/dns v1.1.50/go.mod h1:e3IlAVfNqAllflbibAZEWOXOQ+Ynzk/dDozDxY7XnME= 248 257 github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= ··· 269 278 github.com/multiformats/go-multistream v0.4.1/go.mod h1:Mz5eykRVAjJWckE2U78c6xqdtyNUEhKSM0Lwar2p77Q= 270 279 github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8= 271 280 github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU= 281 + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 282 + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 272 283 github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= 273 284 github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= 274 285 github.com/petar/GoLLRB v0.0.0-20210522233825-ae3b015fd3e9 h1:1/WtZae0yGtPq+TI6+Tv1WTxkukpXeMlviSxvL7SRgk= ··· 278 289 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 279 290 github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f h1:VXTQfuJj9vKR4TCkEuWIckKvdHFeJH/huIFJ9/cXOB0= 280 291 github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f/go.mod h1:/zvteZs/GwLtCgZ4BL6CBsk9IKIlexP43ObX9AxTqTw= 281 - github.com/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q= 282 - github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY= 283 - github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= 284 - github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= 285 - github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lneoxM= 286 - github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY= 287 - github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= 288 - github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= 292 + github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= 293 + github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= 294 + github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= 295 + github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= 296 + github.com/prometheus/common v0.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k= 297 + github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18= 298 + github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= 299 + github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= 289 300 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 290 301 github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= 291 302 github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= ··· 373 384 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 374 385 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 375 386 golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= 376 - golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= 377 - golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= 387 + golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= 388 + golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= 378 389 golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ= 379 390 golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE= 380 391 golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= ··· 396 407 golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 397 408 golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 398 409 golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 399 - golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= 400 - golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= 410 + golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= 411 + golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= 401 412 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 402 413 golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 403 414 golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 404 415 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 405 416 golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 406 417 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 407 - golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= 408 - golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 418 + golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= 419 + golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 409 420 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 410 421 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 411 422 golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= ··· 417 428 golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 418 429 golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 419 430 golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 420 - golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 421 431 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 422 432 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 423 433 golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 424 434 golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 425 - golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= 426 - golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 435 + golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 436 + golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 427 437 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 428 438 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 429 439 golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= ··· 435 445 golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 436 446 golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 437 447 golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 438 - golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= 439 - golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= 440 - golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= 441 - golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 448 + golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= 449 + golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= 450 + golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= 451 + golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= 442 452 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 443 453 golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 444 454 golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= ··· 459 469 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 460 470 golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU= 461 471 golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= 462 - google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= 463 - google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 472 + google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= 473 + google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= 464 474 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 465 475 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 466 476 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+47
internal/helpers/helpers.go
··· 1 1 package helpers 2 2 3 3 import ( 4 + crand "crypto/rand" 5 + "encoding/hex" 6 + "errors" 4 7 "math/rand" 8 + "net/url" 5 9 6 10 "github.com/labstack/echo/v4" 11 + "github.com/lestrrat-go/jwx/v2/jwk" 7 12 ) 8 13 9 14 // This will confirm to the regex in the application if 5 chars are used for each side of the - ··· 39 44 } 40 45 return string(b) 41 46 } 47 + 48 + func RandomHex(n int) (string, error) { 49 + bytes := make([]byte, n) 50 + if _, err := crand.Read(bytes); err != nil { 51 + return "", err 52 + } 53 + return hex.EncodeToString(bytes), nil 54 + } 55 + 56 + func RandomBytes(n int) []byte { 57 + bs := make([]byte, n) 58 + crand.Read(bs) 59 + return bs 60 + } 61 + 62 + func ParseJWKFromBytes(b []byte) (jwk.Key, error) { 63 + return jwk.ParseKey(b) 64 + } 65 + 66 + func OauthParseHtu(htu string) (string, error) { 67 + u, err := url.Parse(htu) 68 + if err != nil { 69 + return "", errors.New("`htu` is not a valid URL") 70 + } 71 + 72 + if u.User != nil { 73 + _, containsPass := u.User.Password() 74 + if u.User.Username() != "" || containsPass { 75 + return "", errors.New("`htu` must not contain credentials") 76 + } 77 + } 78 + 79 + if u.Scheme != "http" && u.Scheme != "https" { 80 + return "", errors.New("`htu` must be http or https") 81 + } 82 + 83 + return OauthNormalizeHtu(u), nil 84 + } 85 + 86 + func OauthNormalizeHtu(u *url.URL) string { 87 + return u.Scheme + "://" + u.Host + u.RawPath 88 + }
+8
oauth/client.go
··· 1 + package oauth 2 + 3 + import "github.com/lestrrat-go/jwx/v2/jwk" 4 + 5 + type Client struct { 6 + Metadata *ClientMetadata 7 + JWKS jwk.Key 8 + }
+390
oauth/client_manager/client_manager.go
··· 1 + package client_manager 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "errors" 7 + "fmt" 8 + "io" 9 + "log/slog" 10 + "net/http" 11 + "net/url" 12 + "slices" 13 + "strings" 14 + "time" 15 + 16 + cache "github.com/go-pkgz/expirable-cache/v3" 17 + "github.com/haileyok/cocoon/internal/helpers" 18 + "github.com/haileyok/cocoon/oauth" 19 + "github.com/lestrrat-go/jwx/v2/jwk" 20 + ) 21 + 22 + type ClientManager struct { 23 + cli *http.Client 24 + logger *slog.Logger 25 + jwksCache cache.Cache[string, jwk.Key] 26 + metadataCache cache.Cache[string, oauth.ClientMetadata] 27 + } 28 + 29 + type Args struct { 30 + Cli *http.Client 31 + Logger *slog.Logger 32 + } 33 + 34 + func New(args Args) *ClientManager { 35 + if args.Logger == nil { 36 + args.Logger = slog.Default() 37 + } 38 + 39 + if args.Cli == nil { 40 + args.Cli = http.DefaultClient 41 + } 42 + 43 + jwksCache := cache.NewCache[string, jwk.Key]().WithLRU().WithMaxKeys(500).WithTTL(5 * time.Minute) 44 + metadataCache := cache.NewCache[string, oauth.ClientMetadata]().WithLRU().WithMaxKeys(500).WithTTL(5 * time.Minute) 45 + 46 + return &ClientManager{ 47 + cli: args.Cli, 48 + logger: args.Logger, 49 + jwksCache: jwksCache, 50 + metadataCache: metadataCache, 51 + } 52 + } 53 + 54 + func (cm *ClientManager) GetClient(ctx context.Context, clientId string) (*oauth.Client, error) { 55 + metadata, err := cm.getClientMetadata(ctx, clientId) 56 + if err != nil { 57 + return nil, err 58 + } 59 + 60 + var jwks jwk.Key 61 + if metadata.JWKS != nil { 62 + // TODO: this is kinda bad but whatever for now. there could obviously be more than one jwk, and we need to 63 + // make sure we use the right one 64 + k, err := helpers.ParseJWKFromBytes((*metadata.JWKS)[0]) 65 + if err != nil { 66 + return nil, err 67 + } 68 + jwks = k 69 + } else if metadata.JWKSURI != nil { 70 + maybeJwks, err := cm.getClientJwks(ctx, clientId, *metadata.JWKSURI) 71 + if err != nil { 72 + return nil, err 73 + } 74 + 75 + jwks = maybeJwks 76 + } 77 + 78 + return &oauth.Client{ 79 + Metadata: metadata, 80 + JWKS: jwks, 81 + }, nil 82 + } 83 + 84 + func (cm *ClientManager) getClientMetadata(ctx context.Context, clientId string) (*oauth.ClientMetadata, error) { 85 + metadataCached, ok := cm.metadataCache.Get(clientId) 86 + if !ok { 87 + req, err := http.NewRequestWithContext(ctx, "GET", clientId, nil) 88 + if err != nil { 89 + return nil, err 90 + } 91 + 92 + resp, err := cm.cli.Do(req) 93 + if err != nil { 94 + return nil, err 95 + } 96 + defer resp.Body.Close() 97 + 98 + if resp.StatusCode != http.StatusOK { 99 + io.Copy(io.Discard, resp.Body) 100 + return nil, fmt.Errorf("fetching client metadata returned response code %d", resp.StatusCode) 101 + } 102 + 103 + b, err := io.ReadAll(resp.Body) 104 + if err != nil { 105 + return nil, fmt.Errorf("error reading bytes from client response: %w", err) 106 + } 107 + 108 + validated, err := validateAndParseMetadata(clientId, b) 109 + if err != nil { 110 + return nil, err 111 + } 112 + 113 + return validated, nil 114 + } else { 115 + return &metadataCached, nil 116 + } 117 + } 118 + 119 + func (cm *ClientManager) getClientJwks(ctx context.Context, clientId, jwksUri string) (jwk.Key, error) { 120 + jwks, ok := cm.jwksCache.Get(clientId) 121 + if !ok { 122 + req, err := http.NewRequestWithContext(ctx, "GET", jwksUri, nil) 123 + if err != nil { 124 + return nil, err 125 + } 126 + 127 + resp, err := cm.cli.Do(req) 128 + if err != nil { 129 + return nil, err 130 + } 131 + defer resp.Body.Close() 132 + 133 + if resp.StatusCode != http.StatusOK { 134 + io.Copy(io.Discard, resp.Body) 135 + return nil, fmt.Errorf("fetching client jwks returned response code %d", resp.StatusCode) 136 + } 137 + 138 + type Keys struct { 139 + Keys []map[string]any `json:"keys"` 140 + } 141 + 142 + var keys Keys 143 + if err := json.NewDecoder(resp.Body).Decode(&keys); err != nil { 144 + return nil, fmt.Errorf("error unmarshaling keys response: %w", err) 145 + } 146 + 147 + if len(keys.Keys) == 0 { 148 + return nil, errors.New("no keys in jwks response") 149 + } 150 + 151 + // TODO: this is again bad, we should be figuring out which one we need to use... 152 + b, err := json.Marshal(keys.Keys[0]) 153 + if err != nil { 154 + return nil, fmt.Errorf("could not marshal key: %w", err) 155 + } 156 + 157 + k, err := helpers.ParseJWKFromBytes(b) 158 + if err != nil { 159 + return nil, err 160 + } 161 + 162 + jwks = k 163 + } 164 + 165 + return jwks, nil 166 + } 167 + 168 + func validateAndParseMetadata(clientId string, b []byte) (*oauth.ClientMetadata, error) { 169 + var metadataMap map[string]any 170 + if err := json.Unmarshal(b, &metadataMap); err != nil { 171 + return nil, fmt.Errorf("error unmarshaling metadata: %w", err) 172 + } 173 + 174 + _, jwksOk := metadataMap["jwks"].(string) 175 + _, jwksUriOk := metadataMap["jwks_uri"].(string) 176 + if jwksOk && jwksUriOk { 177 + return nil, errors.New("jwks_uri and jwks are mutually exclusive") 178 + } 179 + 180 + for _, k := range []string{ 181 + "default_max_age", 182 + "userinfo_signed_response_alg", 183 + "id_token_signed_response_alg", 184 + "userinfo_encryhpted_response_alg", 185 + "authorization_encrypted_response_enc", 186 + "authorization_encrypted_response_alg", 187 + "tls_client_certificate_bound_access_tokens", 188 + } { 189 + _, kOk := metadataMap[k] 190 + if kOk { 191 + return nil, fmt.Errorf("unsupported `%s` parameter", k) 192 + } 193 + } 194 + 195 + var metadata oauth.ClientMetadata 196 + if err := json.Unmarshal(b, &metadata); err != nil { 197 + return nil, fmt.Errorf("error unmarshaling metadata: %w", err) 198 + } 199 + 200 + u, err := url.Parse(metadata.ClientURI) 201 + if err != nil { 202 + return nil, fmt.Errorf("unable to parse client uri: %w", err) 203 + } 204 + 205 + if isLocalHostname(u.Hostname()) { 206 + return nil, errors.New("`client_uri` hostname is invalid") 207 + } 208 + 209 + if metadata.Scope == "" { 210 + return nil, errors.New("missing `scopes` scope") 211 + } 212 + 213 + scopes := strings.Split(metadata.Scope, " ") 214 + if !slices.Contains(scopes, "atproto") { 215 + return nil, errors.New("missing `atproto` scope") 216 + } 217 + 218 + scopesMap := map[string]bool{} 219 + for _, scope := range scopes { 220 + if scopesMap[scope] { 221 + return nil, fmt.Errorf("duplicate scope `%s`", scope) 222 + } 223 + 224 + // TODO: check for unsupported scopes 225 + 226 + scopesMap[scope] = true 227 + } 228 + 229 + grantTypesMap := map[string]bool{} 230 + for _, gt := range metadata.GrantTypes { 231 + if grantTypesMap[gt] { 232 + return nil, fmt.Errorf("duplicate grant type `%s`", gt) 233 + } 234 + 235 + switch gt { 236 + case "implicit": 237 + return nil, errors.New("grantg type `implicit` is not allowed") 238 + case "authorization_code", "refresh_token": 239 + // TODO check if this grant type is supported 240 + default: 241 + return nil, fmt.Errorf("grant tyhpe `%s` is not supported", gt) 242 + } 243 + 244 + grantTypesMap[gt] = true 245 + } 246 + 247 + if metadata.ClientID != clientId { 248 + return nil, errors.New("`client_id` does not match") 249 + } 250 + 251 + subjectType, subjectTypeOk := metadataMap["subject_type"].(string) 252 + if subjectTypeOk && subjectType != "public" { 253 + return nil, errors.New("only public `subject_type` is supported") 254 + } 255 + 256 + switch metadata.TokenEndpointAuthMethod { 257 + case "none": 258 + if metadata.TokenEndpointAuthSigningAlg != "" { 259 + return nil, errors.New("token_endpoint_auth_method `none` must not have token_endpoint_auth_signing_alg") 260 + } 261 + case "private_key_jwt": 262 + if metadata.JWKS == nil && metadata.JWKSURI == nil { 263 + return nil, errors.New("private_key_jwt auth method requires jwks or jwks_uri") 264 + } 265 + 266 + if metadata.JWKS != nil && len(*metadata.JWKS) == 0 { 267 + return nil, errors.New("private_key_jwt auth method requires atleast one key in jwks") 268 + } 269 + 270 + if metadata.TokenEndpointAuthSigningAlg == "" { 271 + return nil, errors.New("missing token_endpoint_auth_signing_alg in client metadata") 272 + } 273 + default: 274 + return nil, fmt.Errorf("unsupported client authentication method `%s`", metadata.TokenEndpointAuthMethod) 275 + } 276 + 277 + if !metadata.DpopBoundAccessTokens { 278 + return nil, errors.New("dpop_bound_access_tokens must be true") 279 + } 280 + 281 + if !slices.Contains(metadata.ResponseTypes, "code") { 282 + return nil, errors.New("response_types must inclue `code`") 283 + } 284 + 285 + if !slices.Contains(metadata.GrantTypes, "authorization_code") { 286 + return nil, errors.New("the `code` response type requires that `grant_types` contains `authorization_code`") 287 + } 288 + 289 + if len(metadata.RedirectURIs) == 0 { 290 + return nil, errors.New("at least one `redirect_uri` is required") 291 + } 292 + 293 + if metadata.ApplicationType == "native" && metadata.TokenEndpointAuthMethod == "none" { 294 + return nil, errors.New("native clients must authenticate using `none` method") 295 + } 296 + 297 + if metadata.ApplicationType == "web" && slices.Contains(metadata.GrantTypes, "implicit") { 298 + for _, ruri := range metadata.RedirectURIs { 299 + u, err := url.Parse(ruri) 300 + if err != nil { 301 + return nil, fmt.Errorf("error parsing redirect uri: %w", err) 302 + } 303 + 304 + if u.Scheme != "https" { 305 + return nil, errors.New("web clients must use https redirect uris") 306 + } 307 + 308 + if u.Hostname() == "localhost" { 309 + return nil, errors.New("web clients must not use localhost as the hostname") 310 + } 311 + } 312 + } 313 + 314 + for _, ruri := range metadata.RedirectURIs { 315 + u, err := url.Parse(ruri) 316 + if err != nil { 317 + return nil, fmt.Errorf("error parsing redirect uri: %w", err) 318 + } 319 + 320 + if u.User != nil { 321 + if u.User.Username() != "" { 322 + return nil, fmt.Errorf("redirect uri %s must not contain credentials", ruri) 323 + } 324 + 325 + if _, hasPass := u.User.Password(); hasPass { 326 + return nil, fmt.Errorf("redirect uri %s must not contain credentials", ruri) 327 + } 328 + } 329 + 330 + switch true { 331 + case u.Hostname() == "localhost": 332 + return nil, errors.New("loopback redirect uri is not allowed (use explicit ips instead)") 333 + case u.Hostname() == "127.0.0.1", u.Hostname() == "[::1]": 334 + if metadata.ApplicationType != "native" { 335 + return nil, errors.New("loopback redirect uris are only allowed for native apps") 336 + } 337 + 338 + if u.Port() != "" { 339 + // reference impl doesn't do anything with this? 340 + } 341 + 342 + if u.Scheme != "http" { 343 + return nil, fmt.Errorf("loopback redirect uri %s must use http", ruri) 344 + } 345 + 346 + break 347 + case u.Scheme == "http": 348 + return nil, errors.New("only loopbvack redirect uris are allowed to use the `http` scheme") 349 + case u.Scheme == "https": 350 + if isLocalHostname(u.Hostname()) { 351 + return nil, fmt.Errorf("redirect uri %s's domain must not be a local hostname", ruri) 352 + } 353 + break 354 + case strings.Contains(u.Scheme, "."): 355 + if metadata.ApplicationType != "native" { 356 + return nil, errors.New("private-use uri scheme redirect uris are only allowed for native apps") 357 + } 358 + 359 + revdomain := reverseDomain(u.Scheme) 360 + 361 + if isLocalHostname(revdomain) { 362 + return nil, errors.New("private use uri scheme redirect uris must not be local hostnames") 363 + } 364 + 365 + if strings.HasPrefix(u.String(), fmt.Sprintf("%s://", u.Scheme)) || u.Hostname() != "" || u.Port() != "" { 366 + return nil, fmt.Errorf("private use uri scheme must be in the form ") 367 + } 368 + default: 369 + return nil, fmt.Errorf("invalid redirect uri scheme `%s`", u.Scheme) 370 + } 371 + } 372 + 373 + return &metadata, nil 374 + } 375 + 376 + func isLocalHostname(hostname string) bool { 377 + pts := strings.Split(hostname, ".") 378 + if len(pts) < 2 { 379 + return true 380 + } 381 + 382 + tld := strings.ToLower(pts[len(pts)-1]) 383 + return tld == "test" || tld == "local" || tld == "localhost" || tld == "invalid" || tld == "example" 384 + } 385 + 386 + func reverseDomain(domain string) string { 387 + pts := strings.Split(domain, ".") 388 + slices.Reverse(pts) 389 + return strings.Join(pts, ".") 390 + }
+20
oauth/client_metadata.go
··· 1 + package oauth 2 + 3 + type ClientMetadata struct { 4 + ClientID string `json:"client_id"` 5 + ClientName string `json:"client_name"` 6 + ClientURI string `json:"client_uri"` 7 + LogoURI string `json:"logo_uri"` 8 + TOSURI string `json:"tos_uri"` 9 + PolicyURI string `json:"policy_uri"` 10 + RedirectURIs []string `json:"redirect_uris"` 11 + GrantTypes []string `json:"grant_types"` 12 + ResponseTypes []string `json:"response_types"` 13 + ApplicationType string `json:"application_type"` 14 + DpopBoundAccessTokens bool `json:"dpop_bound_access_tokens"` 15 + JWKSURI *string `json:"jwks_uri,omitempty"` 16 + JWKS *[][]byte `json:"jwks,omitempty"` 17 + Scope string `json:"scope"` 18 + TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` 19 + TokenEndpointAuthSigningAlg string `json:"token_endpoint_auth_signing_alg"` 20 + }
+52
oauth/constants/constants.go
··· 1 + package constants 2 + 3 + import "time" 4 + 5 + const ( 6 + MaxDpopAge = 10 * time.Second 7 + DpopCheckTolerance = 5 * time.Second 8 + 9 + NonceSecretByteLength = 32 10 + 11 + NonceMaxRotationInterval = DpopNonceMaxAge / 3 12 + NonceMinRotationInterval = 1 * time.Second 13 + 14 + JTICacheSize = 100_000 15 + JTITtl = 24 * time.Hour 16 + 17 + ClientAssertionTypeJwtBearer = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" 18 + ParExpiresIn = 5 * time.Minute 19 + 20 + ClientAssertionMaxAge = 1 * time.Minute 21 + 22 + DeviceIdPrefix = "dev-" 23 + DeviceIdBytesLength = 16 24 + 25 + SessionIdPrefix = "ses-" 26 + SessionIdBytesLength = 16 27 + 28 + RefreshTokenPrefix = "ref-" 29 + RefreshTokenBytesLength = 32 30 + 31 + RequestIdPrefix = "req-" 32 + RequestIdBytesLength = 16 33 + RequestUriPrefix = "urn:ietf:params:oauth:request_uri:" 34 + 35 + CodePrefix = "cod-" 36 + CodeBytesLength = 32 37 + 38 + TokenIdPrefix = "tok-" 39 + TokenIdBytesLength = 16 40 + 41 + TokenMaxAge = 60 * time.Minute 42 + 43 + AuthorizationInactivityTimeout = 5 * time.Minute 44 + 45 + DpopNonceMaxAge = 3 * time.Minute 46 + 47 + ConfidentialClientSessionLifetime = 2 * 365 * 24 * time.Hour // 2 years 48 + ConfidentialClientRefreshLifetime = 3 * 30 * 24 * time.Hour // 3 months 49 + 50 + PublicClientSessionLifetime = 2 * 7 * 24 * time.Hour // 2 weeks 51 + PublicClientRefreshLifetime = PublicClientSessionLifetime 52 + )
+251
oauth/dpop/dpop_manager/dpop_manager.go
··· 1 + package dpop_manager 2 + 3 + import ( 4 + "crypto" 5 + "crypto/sha256" 6 + "encoding/base64" 7 + "encoding/json" 8 + "errors" 9 + "fmt" 10 + "log/slog" 11 + "net/http" 12 + "net/url" 13 + "strings" 14 + "time" 15 + 16 + "github.com/golang-jwt/jwt/v4" 17 + "github.com/haileyok/cocoon/internal/helpers" 18 + "github.com/haileyok/cocoon/oauth/constants" 19 + "github.com/haileyok/cocoon/oauth/dpop" 20 + "github.com/haileyok/cocoon/oauth/dpop/nonce" 21 + "github.com/lestrrat-go/jwx/v2/jwa" 22 + "github.com/lestrrat-go/jwx/v2/jwk" 23 + ) 24 + 25 + type DpopManager struct { 26 + nonce *nonce.Nonce 27 + jtiCache *jtiCache 28 + logger *slog.Logger 29 + hostname string 30 + } 31 + 32 + type Args struct { 33 + NonceSecret []byte 34 + NonceRotationInterval time.Duration 35 + OnNonceSecretCreated func([]byte) 36 + JTICacheSize int 37 + Logger *slog.Logger 38 + Hostname string 39 + } 40 + 41 + func New(args Args) *DpopManager { 42 + if args.Logger == nil { 43 + args.Logger = slog.Default() 44 + } 45 + 46 + if args.JTICacheSize == 0 { 47 + args.JTICacheSize = 100_000 48 + } 49 + 50 + if args.NonceSecret == nil { 51 + args.Logger.Warn("nonce secret passed to dpop manager was nil. existing sessions may break. consider saving and restoring your nonce.") 52 + } 53 + 54 + return &DpopManager{ 55 + nonce: nonce.NewNonce(nonce.Args{ 56 + RotationInterval: args.NonceRotationInterval, 57 + Secret: args.NonceSecret, 58 + OnSecretCreated: args.OnNonceSecretCreated, 59 + }), 60 + jtiCache: newJTICache(args.JTICacheSize), 61 + logger: args.Logger, 62 + hostname: args.Hostname, 63 + } 64 + } 65 + 66 + func (dm *DpopManager) CheckProof(reqMethod, reqUrl string, headers http.Header, accessToken *string) (*dpop.Proof, error) { 67 + if reqMethod == "" { 68 + return nil, errors.New("HTTP method is required") 69 + } 70 + 71 + if !strings.HasPrefix(reqUrl, "https://") { 72 + reqUrl = "https://" + dm.hostname + reqUrl 73 + } 74 + 75 + proof := extractProof(headers) 76 + 77 + if proof == "" { 78 + return nil, nil 79 + } 80 + 81 + parser := jwt.NewParser(jwt.WithoutClaimsValidation()) 82 + var token *jwt.Token 83 + 84 + token, _, err := parser.ParseUnverified(proof, jwt.MapClaims{}) 85 + if err != nil { 86 + return nil, fmt.Errorf("could not parse dpop proof jwt: %w", err) 87 + } 88 + 89 + typ, _ := token.Header["typ"].(string) 90 + if typ != "dpop+jwt" { 91 + return nil, errors.New(`invalid dpop proof jwt: "typ" must be 'dpop+jwt'`) 92 + } 93 + 94 + dpopJwk, jwkOk := token.Header["jwk"].(map[string]any) 95 + if !jwkOk { 96 + return nil, errors.New(`invalid dpop proof jwt: "jwk" is missing in header`) 97 + } 98 + 99 + jwkb, err := json.Marshal(dpopJwk) 100 + if err != nil { 101 + return nil, fmt.Errorf("failed to marshal jwk: %w", err) 102 + } 103 + 104 + key, err := jwk.ParseKey(jwkb) 105 + if err != nil { 106 + return nil, fmt.Errorf("failed to parse jwk: %w", err) 107 + } 108 + 109 + var pubKey any 110 + if err := key.Raw(&pubKey); err != nil { 111 + return nil, fmt.Errorf("failed to get raw public key: %w", err) 112 + } 113 + 114 + token, err = jwt.Parse(proof, func(t *jwt.Token) (any, error) { 115 + alg := t.Header["alg"].(string) 116 + 117 + switch key.KeyType() { 118 + case jwa.EC: 119 + if !strings.HasPrefix(alg, "ES") { 120 + return nil, fmt.Errorf("algorithm %s doesn't match EC key type", alg) 121 + } 122 + case jwa.RSA: 123 + if !strings.HasPrefix(alg, "RS") && !strings.HasPrefix(alg, "PS") { 124 + return nil, fmt.Errorf("algorithm %s doesn't match RSA key type", alg) 125 + } 126 + case jwa.OKP: 127 + if alg != "EdDSA" { 128 + return nil, fmt.Errorf("algorithm %s doesn't match OKP key type", alg) 129 + } 130 + } 131 + 132 + return pubKey, nil 133 + }, jwt.WithValidMethods([]string{"ES256", "ES384", "ES512", "RS256", "RS384", "RS512", "PS256", "PS384", "PS512", "EdDSA"})) 134 + if err != nil { 135 + return nil, fmt.Errorf("could not verify dpop proof jwt: %w", err) 136 + } 137 + 138 + if !token.Valid { 139 + return nil, errors.New("dpop proof jwt is invalid") 140 + } 141 + 142 + claims, ok := token.Claims.(jwt.MapClaims) 143 + if !ok { 144 + return nil, errors.New("no claims in dpop proof jwt") 145 + } 146 + 147 + iat, iatOk := claims["iat"].(float64) 148 + if !iatOk { 149 + return nil, errors.New(`invalid dpop proof jwt: "iat" is missing`) 150 + } 151 + 152 + iatTime := time.Unix(int64(iat), 0) 153 + now := time.Now() 154 + 155 + if now.Sub(iatTime) > constants.DpopNonceMaxAge+constants.DpopCheckTolerance { 156 + return nil, errors.New("dpop proof too old") 157 + } 158 + 159 + if iatTime.Sub(now) > constants.DpopCheckTolerance { 160 + return nil, errors.New("dpop proof iat is in the future") 161 + } 162 + 163 + jti, _ := claims["jti"].(string) 164 + if jti == "" { 165 + return nil, errors.New(`invalid dpop proof jwt: "jti" is missing`) 166 + } 167 + 168 + if dm.jtiCache.add(jti) { 169 + return nil, errors.New("dpop proof replay detected") 170 + } 171 + 172 + htm, _ := claims["htm"].(string) 173 + if htm == "" { 174 + return nil, errors.New(`invalid dpop proof jwt: "htm" is missing`) 175 + } 176 + 177 + if htm != reqMethod { 178 + return nil, errors.New(`invalid dpop proof jwt: "htm" mismatch`) 179 + } 180 + 181 + htu, _ := claims["htu"].(string) 182 + if htu == "" { 183 + return nil, errors.New(`invalid dpop proof jwt: "htu" is missing`) 184 + } 185 + 186 + parsedHtu, err := helpers.OauthParseHtu(htu) 187 + if err != nil { 188 + return nil, errors.New(`invalid dpop proof jwt: "htu" could not be parsed`) 189 + } 190 + 191 + u, _ := url.Parse(reqUrl) 192 + if parsedHtu != helpers.OauthNormalizeHtu(u) { 193 + return nil, fmt.Errorf(`invalid dpop proof jwt: "htu" mismatch. reqUrl: %s, parsed: %s, normalized: %s`, reqUrl, parsedHtu, helpers.OauthNormalizeHtu(u)) 194 + } 195 + 196 + nonce, _ := claims["nonce"].(string) 197 + if nonce == "" { 198 + // WARN: this _must_ be `use_dpop_nonce` for clients know they should make another request 199 + return nil, errors.New("use_dpop_nonce") 200 + } 201 + 202 + if nonce != "" && !dm.nonce.Check(nonce) { 203 + // WARN: this _must_ be `use_dpop_nonce` so that clients will fetch a new nonce 204 + return nil, errors.New("use_dpop_nonce") 205 + } 206 + 207 + ath, _ := claims["ath"].(string) 208 + 209 + if accessToken != nil && *accessToken != "" { 210 + if ath == "" { 211 + return nil, errors.New(`invalid dpop proof jwt: "ath" is required with access token`) 212 + } 213 + 214 + hash := sha256.Sum256([]byte(*accessToken)) 215 + if ath != base64.RawURLEncoding.EncodeToString(hash[:]) { 216 + return nil, errors.New(`invalid dpop proof jwt: "ath" mismatch`) 217 + } 218 + } else if ath != "" { 219 + return nil, errors.New(`invalid dpop proof jwt: "ath" claim not allowed`) 220 + } 221 + 222 + thumbBytes, err := key.Thumbprint(crypto.SHA256) 223 + if err != nil { 224 + return nil, fmt.Errorf("failed to calculate thumbprint: %w", err) 225 + } 226 + 227 + thumb := base64.RawURLEncoding.EncodeToString(thumbBytes) 228 + 229 + return &dpop.Proof{ 230 + JTI: jti, 231 + JKT: thumb, 232 + HTM: htm, 233 + HTU: htu, 234 + }, nil 235 + } 236 + 237 + func extractProof(headers http.Header) string { 238 + dpopHeaders := headers["Dpop"] 239 + switch len(dpopHeaders) { 240 + case 0: 241 + return "" 242 + case 1: 243 + return dpopHeaders[0] 244 + default: 245 + return "" 246 + } 247 + } 248 + 249 + func (dm *DpopManager) NextNonce() string { 250 + return dm.nonce.NextNonce() 251 + }
+28
oauth/dpop/dpop_manager/jti_cache.go
··· 1 + package dpop_manager 2 + 3 + import ( 4 + "sync" 5 + "time" 6 + 7 + cache "github.com/go-pkgz/expirable-cache/v3" 8 + "github.com/haileyok/cocoon/oauth/constants" 9 + ) 10 + 11 + type jtiCache struct { 12 + mu sync.Mutex 13 + cache cache.Cache[string, bool] 14 + } 15 + 16 + func newJTICache(size int) *jtiCache { 17 + cache := cache.NewCache[string, bool]().WithTTL(24 * time.Hour).WithLRU().WithTTL(constants.JTITtl) 18 + return &jtiCache{ 19 + cache: cache, 20 + mu: sync.Mutex{}, 21 + } 22 + } 23 + 24 + func (c *jtiCache) add(jti string) bool { 25 + c.mu.Lock() 26 + defer c.mu.Unlock() 27 + return c.cache.Add(jti, true) 28 + }
+108
oauth/dpop/nonce/nonce.go
··· 1 + package nonce 2 + 3 + import ( 4 + "crypto/hmac" 5 + "crypto/sha256" 6 + "encoding/base64" 7 + "encoding/binary" 8 + "sync" 9 + "time" 10 + 11 + "github.com/haileyok/cocoon/internal/helpers" 12 + "github.com/haileyok/cocoon/oauth/constants" 13 + ) 14 + 15 + type Nonce struct { 16 + rotationInterval time.Duration 17 + secret []byte 18 + 19 + mu sync.RWMutex 20 + 21 + counter int64 22 + prev string 23 + curr string 24 + next string 25 + } 26 + 27 + type Args struct { 28 + RotationInterval time.Duration 29 + Secret []byte 30 + OnSecretCreated func([]byte) 31 + } 32 + 33 + func NewNonce(args Args) *Nonce { 34 + if args.RotationInterval == 0 { 35 + args.RotationInterval = constants.NonceMaxRotationInterval / 3 36 + } 37 + 38 + if args.RotationInterval > constants.NonceMaxRotationInterval { 39 + args.RotationInterval = constants.NonceMaxRotationInterval 40 + } 41 + 42 + if args.Secret == nil { 43 + args.Secret = helpers.RandomBytes(constants.NonceSecretByteLength) 44 + args.OnSecretCreated(args.Secret) 45 + } 46 + 47 + n := &Nonce{ 48 + rotationInterval: args.RotationInterval, 49 + secret: args.Secret, 50 + mu: sync.RWMutex{}, 51 + } 52 + 53 + n.counter = n.currentCounter() 54 + n.prev = n.compute(n.counter - 1) 55 + n.curr = n.compute(n.counter) 56 + n.next = n.compute(n.counter + 1) 57 + 58 + return n 59 + } 60 + 61 + func (n *Nonce) currentCounter() int64 { 62 + return time.Now().UnixNano() / int64(n.rotationInterval) 63 + } 64 + 65 + func (n *Nonce) compute(counter int64) string { 66 + h := hmac.New(sha256.New, n.secret) 67 + counterBytes := make([]byte, 8) 68 + binary.BigEndian.PutUint64(counterBytes, uint64(counter)) 69 + h.Write(counterBytes) 70 + return base64.RawURLEncoding.EncodeToString(h.Sum(nil)) 71 + } 72 + 73 + func (n *Nonce) rotate() { 74 + counter := n.currentCounter() 75 + diff := counter - n.counter 76 + 77 + switch diff { 78 + case 0: 79 + // counter == n.counter, do nothing 80 + case 1: 81 + n.prev = n.curr 82 + n.curr = n.next 83 + n.next = n.compute(counter + 1) 84 + case 2: 85 + n.prev = n.next 86 + n.curr = n.compute(counter) 87 + n.next = n.compute(counter + 1) 88 + default: 89 + n.prev = n.compute(counter - 1) 90 + n.curr = n.compute(counter) 91 + n.next = n.compute(counter + 1) 92 + } 93 + 94 + n.counter = counter 95 + } 96 + 97 + func (n *Nonce) NextNonce() string { 98 + n.mu.Lock() 99 + defer n.mu.Unlock() 100 + n.rotate() 101 + return n.next 102 + } 103 + 104 + func (n *Nonce) Check(nonce string) bool { 105 + n.mu.RLock() 106 + defer n.mu.RUnlock() 107 + return nonce == n.prev || nonce == n.curr || nonce == n.next 108 + }
+8
oauth/dpop/proof.go
··· 1 + package dpop 2 + 3 + type Proof struct { 4 + JTI string 5 + JKT string 6 + HTM string 7 + HTU string 8 + }
+48
oauth/helpers.go
··· 1 + package oauth 2 + 3 + import ( 4 + "errors" 5 + "fmt" 6 + "net/url" 7 + 8 + "github.com/haileyok/cocoon/internal/helpers" 9 + "github.com/haileyok/cocoon/oauth/constants" 10 + ) 11 + 12 + func GenerateCode() string { 13 + h, _ := helpers.RandomHex(constants.CodeBytesLength) 14 + return constants.CodePrefix + h 15 + } 16 + 17 + func GenerateTokenId() string { 18 + h, _ := helpers.RandomHex(constants.TokenIdBytesLength) 19 + return constants.TokenIdPrefix + h 20 + } 21 + 22 + func GenerateRefreshToken() string { 23 + h, _ := helpers.RandomHex(constants.RefreshTokenBytesLength) 24 + return constants.RefreshTokenPrefix + h 25 + } 26 + 27 + func GenerateRequestId() string { 28 + h, _ := helpers.RandomHex(constants.RequestIdBytesLength) 29 + return constants.RequestIdPrefix + h 30 + } 31 + 32 + func EncodeRequestUri(reqId string) string { 33 + return constants.RequestUriPrefix + url.QueryEscape(reqId) 34 + } 35 + 36 + func DecodeRequestUri(reqUri string) (string, error) { 37 + if len(reqUri) < len(constants.RequestUriPrefix) { 38 + return "", errors.New("invalid request uri") 39 + } 40 + 41 + reqIdEnc := reqUri[len(constants.RequestUriPrefix):] 42 + reqId, err := url.QueryUnescape(reqIdEnc) 43 + if err != nil { 44 + return "", fmt.Errorf("could not unescape request id: %w", err) 45 + } 46 + 47 + return reqId, nil 48 + }
+175
oauth/provider/client_auth.go
··· 1 + package provider 2 + 3 + import ( 4 + "context" 5 + "crypto" 6 + "database/sql/driver" 7 + "encoding/base64" 8 + "encoding/json" 9 + "errors" 10 + "fmt" 11 + "time" 12 + 13 + "github.com/golang-jwt/jwt/v4" 14 + "github.com/haileyok/cocoon/oauth" 15 + "github.com/haileyok/cocoon/oauth/constants" 16 + "github.com/haileyok/cocoon/oauth/dpop" 17 + ) 18 + 19 + type ClientAuth struct { 20 + Method string 21 + Alg string 22 + Kid string 23 + Jkt string 24 + Jti string 25 + Exp *float64 26 + } 27 + 28 + func (ca *ClientAuth) Scan(value any) error { 29 + b, ok := value.([]byte) 30 + if !ok { 31 + return fmt.Errorf("failed to unmarshal OauthParRequest value") 32 + } 33 + return json.Unmarshal(b, ca) 34 + } 35 + 36 + func (ca ClientAuth) Value() (driver.Value, error) { 37 + return json.Marshal(ca) 38 + } 39 + 40 + type AuthenticateClientOptions struct { 41 + AllowMissingDpopProof bool 42 + } 43 + 44 + type AuthenticateClientRequestBase struct { 45 + ClientID string `form:"client_id" json:"client_id" validate:"required"` 46 + ClientAssertionType *string `form:"client_assertion_type" json:"client_assertion_type,omitempty"` 47 + ClientAssertion *string `form:"client_assertion" json:"client_assertion,omitempty"` 48 + } 49 + 50 + func (p *Provider) AuthenticateClient(ctx context.Context, req AuthenticateClientRequestBase, proof *dpop.Proof, opts *AuthenticateClientOptions) (*oauth.Client, *ClientAuth, error) { 51 + client, err := p.ClientManager.GetClient(ctx, req.ClientID) 52 + if err != nil { 53 + return nil, nil, fmt.Errorf("failed to get client: %w", err) 54 + } 55 + 56 + if client.Metadata.DpopBoundAccessTokens && proof == nil && (opts == nil || !opts.AllowMissingDpopProof) { 57 + return nil, nil, errors.New("dpop proof required") 58 + } 59 + 60 + if proof != nil && !client.Metadata.DpopBoundAccessTokens { 61 + return nil, nil, errors.New("dpop proof not allowed for this client") 62 + } 63 + 64 + clientAuth, err := p.Authenticate(ctx, req, client) 65 + if err != nil { 66 + return nil, nil, err 67 + } 68 + 69 + return client, clientAuth, nil 70 + } 71 + 72 + func (p *Provider) Authenticate(_ context.Context, req AuthenticateClientRequestBase, client *oauth.Client) (*ClientAuth, error) { 73 + metadata := client.Metadata 74 + 75 + if metadata.TokenEndpointAuthMethod == "none" { 76 + return &ClientAuth{ 77 + Method: "none", 78 + }, nil 79 + } 80 + 81 + if metadata.TokenEndpointAuthMethod == "private_key_jwt" { 82 + if req.ClientAssertion == nil { 83 + return nil, errors.New(`client authentication method "private_key_jwt" requires a "client_assertion`) 84 + } 85 + 86 + if req.ClientAssertionType == nil || *req.ClientAssertionType != constants.ClientAssertionTypeJwtBearer { 87 + return nil, fmt.Errorf("unsupported client_assertion_type %s", *req.ClientAssertionType) 88 + } 89 + 90 + token, _, err := jwt.NewParser().ParseUnverified(*req.ClientAssertion, jwt.MapClaims{}) 91 + if err != nil { 92 + return nil, fmt.Errorf("error parsing client assertion: %w", err) 93 + } 94 + 95 + kid, ok := token.Header["kid"].(string) 96 + if !ok || kid == "" { 97 + return nil, errors.New(`"kid" required in client_assertion`) 98 + } 99 + 100 + var rawKey any 101 + if err := client.JWKS.Raw(&rawKey); err != nil { 102 + return nil, fmt.Errorf("failed to extract raw key: %w", err) 103 + } 104 + 105 + token, err = jwt.Parse(*req.ClientAssertion, func(token *jwt.Token) (any, error) { 106 + if token.Method.Alg() != jwt.SigningMethodES256.Alg() { 107 + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) 108 + } 109 + 110 + return rawKey, nil 111 + }) 112 + if err != nil { 113 + return nil, fmt.Errorf(`unable to verify "client_assertion" jwt: %w`, err) 114 + } 115 + 116 + if !token.Valid { 117 + return nil, errors.New("client_assertion jwt is invalid") 118 + } 119 + 120 + claims, ok := token.Claims.(jwt.MapClaims) 121 + if !ok { 122 + return nil, errors.New("no claims in client_assertion jwt") 123 + } 124 + 125 + sub, _ := claims["sub"].(string) 126 + if sub != metadata.ClientID { 127 + return nil, errors.New("subject must be client_id") 128 + } 129 + 130 + aud, _ := claims["aud"].(string) 131 + if aud != "" && aud != "https://"+p.hostname { 132 + return nil, fmt.Errorf("audience must be %s, got %s", "https://"+p.hostname, aud) 133 + } 134 + 135 + iat, iatOk := claims["iat"].(float64) 136 + if !iatOk { 137 + return nil, errors.New(`invalid client_assertion jwt: "iat" is missing`) 138 + } 139 + 140 + iatTime := time.Unix(int64(iat), 0) 141 + if time.Since(iatTime) > constants.ClientAssertionMaxAge { 142 + return nil, errors.New("client_assertion jwt too old") 143 + } 144 + 145 + jti, _ := claims["jti"].(string) 146 + if jti == "" { 147 + return nil, errors.New(`invalid client_assertion jwt: "jti" is missing`) 148 + } 149 + 150 + var exp *float64 151 + if maybeExp, ok := claims["exp"].(float64); ok { 152 + exp = &maybeExp 153 + } 154 + 155 + alg := token.Header["alg"].(string) 156 + 157 + thumbBytes, err := client.JWKS.Thumbprint(crypto.SHA256) 158 + if err != nil { 159 + return nil, fmt.Errorf("failed to calculate thumbprint: %w", err) 160 + } 161 + 162 + thumb := base64.RawURLEncoding.EncodeToString(thumbBytes) 163 + 164 + return &ClientAuth{ 165 + Method: "private_key_jwt", 166 + Jti: jti, 167 + Exp: exp, 168 + Jkt: thumb, 169 + Alg: alg, 170 + Kid: kid, 171 + }, nil 172 + } 173 + 174 + return nil, fmt.Errorf("auth method %s is not implemented in this pds", metadata.TokenEndpointAuthMethod) 175 + }
+20
oauth/provider/middleware.go
··· 1 + package provider 2 + 3 + import ( 4 + "github.com/labstack/echo/v4" 5 + ) 6 + 7 + func (p *Provider) BaseMiddleware(next echo.HandlerFunc) echo.HandlerFunc { 8 + return func(e echo.Context) error { 9 + e.Response().Header().Set("cache-control", "no-store") 10 + e.Response().Header().Set("pragma", "no-cache") 11 + 12 + nonce := p.NextNonce() 13 + if nonce != "" { 14 + e.Response().Header().Set("DPoP-Nonce", nonce) 15 + e.Response().Header().Add("access-control-expose-headers", "DPoP-Nonce") 16 + } 17 + 18 + return next(e) 19 + } 20 + }
+87
oauth/provider/provider.go
··· 1 + package provider 2 + 3 + import ( 4 + "database/sql/driver" 5 + "encoding/json" 6 + "fmt" 7 + "time" 8 + 9 + "github.com/haileyok/cocoon/oauth/client_manager" 10 + "github.com/haileyok/cocoon/oauth/dpop/dpop_manager" 11 + "gorm.io/gorm" 12 + ) 13 + 14 + type Provider struct { 15 + ClientManager *client_manager.ClientManager 16 + DpopManager *dpop_manager.DpopManager 17 + 18 + hostname string 19 + } 20 + 21 + type Args struct { 22 + Hostname string 23 + ClientManagerArgs client_manager.Args 24 + DpopManagerArgs dpop_manager.Args 25 + } 26 + 27 + func NewProvider(args Args) *Provider { 28 + return &Provider{ 29 + ClientManager: client_manager.New(args.ClientManagerArgs), 30 + DpopManager: dpop_manager.New(args.DpopManagerArgs), 31 + hostname: args.Hostname, 32 + } 33 + } 34 + 35 + func (p *Provider) NextNonce() string { 36 + return p.DpopManager.NextNonce() 37 + } 38 + 39 + type ParRequest struct { 40 + AuthenticateClientRequestBase 41 + ResponseType string `form:"response_type" json:"response_type" validate:"required"` 42 + CodeChallenge *string `form:"code_challenge" json:"code_challenge" validate:"required"` 43 + CodeChallengeMethod string `form:"code_challenge_method" json:"code_challenge_method" validate:"required"` 44 + State string `form:"state" json:"state" validate:"required"` 45 + RedirectURI string `form:"redirect_uri" json:"redirect_uri" validate:"required"` 46 + Scope string `form:"scope" json:"scope" validate:"required"` 47 + LoginHint *string `form:"login_hint" json:"login_hint,omitempty"` 48 + DpopJkt *string `form:"dpop_jkt" json:"dpop_jkt,omitempty"` 49 + } 50 + 51 + func (opr *ParRequest) Scan(value any) error { 52 + b, ok := value.([]byte) 53 + if !ok { 54 + return fmt.Errorf("failed to unmarshal OauthParRequest value") 55 + } 56 + return json.Unmarshal(b, opr) 57 + } 58 + 59 + func (opr ParRequest) Value() (driver.Value, error) { 60 + return json.Marshal(opr) 61 + } 62 + 63 + type OauthToken struct { 64 + gorm.Model 65 + ClientId string `gorm:"index"` 66 + ClientAuth ClientAuth `gorm:"type:json"` 67 + Parameters ParRequest `gorm:"type:json"` 68 + ExpiresAt time.Time `gorm:"index"` 69 + DeviceId string 70 + Sub string `gorm:"index"` 71 + Code string `gorm:"index"` 72 + Token string `gorm:"uniqueIndex"` 73 + RefreshToken string `gorm:"uniqueIndex"` 74 + } 75 + 76 + type OauthAuthorizationRequest struct { 77 + gorm.Model 78 + RequestId string `gorm:"primaryKey"` 79 + ClientId string `gorm:"index"` 80 + ClientAuth ClientAuth `gorm:"type:json"` 81 + Parameters ParRequest `gorm:"type:json"` 82 + ExpiresAt time.Time `gorm:"index"` 83 + DeviceId *string 84 + Sub *string 85 + Code *string 86 + Accepted *bool 87 + }
+44
server/handle_account.go
··· 1 + package server 2 + 3 + import ( 4 + "time" 5 + 6 + "github.com/haileyok/cocoon/oauth/provider" 7 + "github.com/labstack/echo/v4" 8 + ) 9 + 10 + func (s *Server) handleAccount(e echo.Context) error { 11 + repo, sess, err := s.getSessionRepoOrErr(e) 12 + if err != nil { 13 + return e.Redirect(303, "/account/signin") 14 + } 15 + 16 + now := time.Now() 17 + 18 + var tokens []provider.OauthToken 19 + if err := s.db.Raw("SELECT * FROM oauth_tokens WHERE sub = ? AND expires_at >= ? ORDER BY created_at ASC", nil, repo.Repo.Did, now).Scan(&tokens).Error; err != nil { 20 + s.logger.Error("couldnt fetch oauth sessions for account", "did", repo.Repo.Did, "error", err) 21 + sess.AddFlash("Unable to fetch sessions. See server logs for more details.", "error") 22 + sess.Save(e.Request(), e.Response()) 23 + return e.Render(200, "account.html", map[string]any{ 24 + "flashes": getFlashesFromSession(e, sess), 25 + }) 26 + } 27 + 28 + tokenInfo := []map[string]string{} 29 + for _, t := range tokens { 30 + tokenInfo = append(tokenInfo, map[string]string{ 31 + "ClientId": t.ClientId, 32 + "CreatedAt": t.CreatedAt.Format("02 Jan 06 15:04 MST"), 33 + "UpdatedAt": t.CreatedAt.Format("02 Jan 06 15:04 MST"), 34 + "ExpiresAt": t.CreatedAt.Format("02 Jan 06 15:04 MST"), 35 + "Token": t.Token, 36 + }) 37 + } 38 + 39 + return e.Render(200, "account.html", map[string]any{ 40 + "Repo": repo, 41 + "Tokens": tokenInfo, 42 + "flashes": getFlashesFromSession(e, sess), 43 + }) 44 + }
+34
server/handle_account_revoke.go
··· 1 + package server 2 + 3 + import ( 4 + "github.com/haileyok/cocoon/internal/helpers" 5 + "github.com/labstack/echo/v4" 6 + ) 7 + 8 + type AccountRevokeRequest struct { 9 + Token string `form:"token"` 10 + } 11 + 12 + func (s *Server) handleAccountRevoke(e echo.Context) error { 13 + var req AccountRevokeRequest 14 + if err := e.Bind(&req); err != nil { 15 + s.logger.Error("could not bind account revoke request", "error", err) 16 + return helpers.ServerError(e, nil) 17 + } 18 + 19 + repo, sess, err := s.getSessionRepoOrErr(e) 20 + if err != nil { 21 + return e.Redirect(303, "/account/signin") 22 + } 23 + 24 + if err := s.db.Exec("DELETE FROM oauth_tokens WHERE sub = ? AND token = ?", nil, repo.Repo.Did, req.Token).Error; err != nil { 25 + s.logger.Error("couldnt delete oauth session for account", "did", repo.Repo.Did, "token", req.Token, "error", err) 26 + sess.AddFlash("Unable to revoke session. See server logs for more details.", "error") 27 + sess.Save(e.Request(), e.Response()) 28 + return e.Redirect(303, "/account") 29 + } 30 + 31 + sess.AddFlash("Session successfully revoked!", "success") 32 + sess.Save(e.Request(), e.Response()) 33 + return e.Redirect(303, "/account") 34 + }
+130
server/handle_account_signin.go
··· 1 + package server 2 + 3 + import ( 4 + "errors" 5 + "strings" 6 + 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 + "github.com/gorilla/sessions" 9 + "github.com/haileyok/cocoon/internal/helpers" 10 + "github.com/haileyok/cocoon/models" 11 + "github.com/labstack/echo-contrib/session" 12 + "github.com/labstack/echo/v4" 13 + "golang.org/x/crypto/bcrypt" 14 + "gorm.io/gorm" 15 + ) 16 + 17 + type OauthSigninRequest struct { 18 + Username string `form:"username"` 19 + Password string `form:"password"` 20 + QueryParams string `form:"query_params"` 21 + } 22 + 23 + func (s *Server) getSessionRepoOrErr(e echo.Context) (*models.RepoActor, *sessions.Session, error) { 24 + sess, err := session.Get("session", e) 25 + if err != nil { 26 + return nil, nil, err 27 + } 28 + 29 + did, ok := sess.Values["did"].(string) 30 + if !ok { 31 + return nil, sess, errors.New("did was not set in session") 32 + } 33 + 34 + repo, err := s.getRepoActorByDid(did) 35 + if err != nil { 36 + return nil, sess, err 37 + } 38 + 39 + return repo, sess, nil 40 + } 41 + 42 + func getFlashesFromSession(e echo.Context, sess *sessions.Session) map[string]any { 43 + defer sess.Save(e.Request(), e.Response()) 44 + return map[string]any{ 45 + "errors": sess.Flashes("error"), 46 + "successes": sess.Flashes("success"), 47 + } 48 + } 49 + 50 + func (s *Server) handleAccountSigninGet(e echo.Context) error { 51 + _, sess, err := s.getSessionRepoOrErr(e) 52 + if err == nil { 53 + return e.Redirect(303, "/account") 54 + } 55 + 56 + return e.Render(200, "signin.html", map[string]any{ 57 + "flashes": getFlashesFromSession(e, sess), 58 + "QueryParams": e.QueryParams().Encode(), 59 + }) 60 + } 61 + 62 + func (s *Server) handleAccountSigninPost(e echo.Context) error { 63 + var req OauthSigninRequest 64 + if err := e.Bind(&req); err != nil { 65 + s.logger.Error("error binding sign in req", "error", err) 66 + return helpers.ServerError(e, nil) 67 + } 68 + 69 + sess, _ := session.Get("session", e) 70 + 71 + req.Username = strings.ToLower(req.Username) 72 + var idtype string 73 + if _, err := syntax.ParseDID(req.Username); err == nil { 74 + idtype = "did" 75 + } else if _, err := syntax.ParseHandle(req.Username); err == nil { 76 + idtype = "handle" 77 + } else { 78 + idtype = "email" 79 + } 80 + 81 + // TODO: we should make this a helper since we do it for the base create_session as well 82 + var repo models.RepoActor 83 + var err error 84 + switch idtype { 85 + case "did": 86 + err = s.db.Raw("SELECT r.*, a.* FROM repos r LEFT JOIN actors a ON r.did = a.did WHERE r.did = ?", nil, req.Username).Scan(&repo).Error 87 + case "handle": 88 + err = s.db.Raw("SELECT r.*, a.* FROM actors a LEFT JOIN repos r ON a.did = r.did WHERE a.handle = ?", nil, req.Username).Scan(&repo).Error 89 + case "email": 90 + err = s.db.Raw("SELECT r.*, a.* FROM repos r LEFT JOIN actors a ON r.did = a.did WHERE r.email = ?", nil, req.Username).Scan(&repo).Error 91 + } 92 + if err != nil { 93 + if err == gorm.ErrRecordNotFound { 94 + sess.AddFlash("Handle or password is incorrect", "error") 95 + } else { 96 + sess.AddFlash("Something went wrong!", "error") 97 + } 98 + sess.Save(e.Request(), e.Response()) 99 + return e.Redirect(303, "/account/signin") 100 + } 101 + 102 + if err := bcrypt.CompareHashAndPassword([]byte(repo.Password), []byte(req.Password)); err != nil { 103 + if err != bcrypt.ErrMismatchedHashAndPassword { 104 + sess.AddFlash("Handle or password is incorrect", "error") 105 + } else { 106 + sess.AddFlash("Something went wrong!", "error") 107 + } 108 + sess.Save(e.Request(), e.Response()) 109 + return e.Redirect(303, "/account/signin") 110 + } 111 + 112 + sess.Options = &sessions.Options{ 113 + Path: "/", 114 + MaxAge: int(AccountSessionMaxAge.Seconds()), 115 + HttpOnly: true, 116 + } 117 + 118 + sess.Values = map[any]any{} 119 + sess.Values["did"] = repo.Repo.Did 120 + 121 + if err := sess.Save(e.Request(), e.Response()); err != nil { 122 + return err 123 + } 124 + 125 + if req.QueryParams != "" { 126 + return e.Redirect(303, "/oauth/authorize?"+req.QueryParams) 127 + } else { 128 + return e.Redirect(303, "/account") 129 + } 130 + }
+35
server/handle_account_signout.go
··· 1 + package server 2 + 3 + import ( 4 + "github.com/gorilla/sessions" 5 + "github.com/labstack/echo-contrib/session" 6 + "github.com/labstack/echo/v4" 7 + ) 8 + 9 + func (s *Server) handleAccountSignout(e echo.Context) error { 10 + sess, err := session.Get("session", e) 11 + if err != nil { 12 + return err 13 + } 14 + 15 + sess.Options = &sessions.Options{ 16 + Path: "/", 17 + MaxAge: -1, 18 + HttpOnly: true, 19 + } 20 + 21 + sess.Values = map[any]any{} 22 + 23 + if err := sess.Save(e.Request(), e.Response()); err != nil { 24 + return err 25 + } 26 + 27 + reqUri := e.QueryParam("request_uri") 28 + 29 + redirect := "/account/signin" 30 + if reqUri != "" { 31 + redirect += "?" + e.QueryParams().Encode() 32 + } 33 + 34 + return e.Redirect(303, redirect) 35 + }
+132
server/handle_oauth_authorize.go
··· 1 + package server 2 + 3 + import ( 4 + "net/url" 5 + "strings" 6 + "time" 7 + 8 + "github.com/Azure/go-autorest/autorest/to" 9 + "github.com/haileyok/cocoon/internal/helpers" 10 + "github.com/haileyok/cocoon/oauth" 11 + "github.com/haileyok/cocoon/oauth/provider" 12 + "github.com/labstack/echo/v4" 13 + ) 14 + 15 + func (s *Server) handleOauthAuthorizeGet(e echo.Context) error { 16 + reqUri := e.QueryParam("request_uri") 17 + if reqUri == "" { 18 + // render page for logged out dev 19 + if s.config.Version == "dev" { 20 + return e.Render(200, "authorize.html", map[string]any{ 21 + "Scopes": []string{"atproto", "transition:generic"}, 22 + "AppName": "DEV MODE AUTHORIZATION PAGE", 23 + "Handle": "paula.cocoon.social", 24 + "RequestUri": "", 25 + }) 26 + } 27 + return helpers.InputError(e, to.StringPtr("no request uri")) 28 + } 29 + 30 + repo, _, err := s.getSessionRepoOrErr(e) 31 + if err != nil { 32 + return e.Redirect(303, "/account/signin?"+e.QueryParams().Encode()) 33 + } 34 + 35 + reqId, err := oauth.DecodeRequestUri(reqUri) 36 + if err != nil { 37 + return helpers.InputError(e, to.StringPtr(err.Error())) 38 + } 39 + 40 + var req provider.OauthAuthorizationRequest 41 + if err := s.db.Raw("SELECT * FROM oauth_authorization_requests WHERE request_id = ?", nil, reqId).Scan(&req).Error; err != nil { 42 + return helpers.ServerError(e, to.StringPtr(err.Error())) 43 + } 44 + 45 + clientId := e.QueryParam("client_id") 46 + if clientId != req.ClientId { 47 + return helpers.InputError(e, to.StringPtr("client id does not match the client id for the supplied request")) 48 + } 49 + 50 + client, err := s.oauthProvider.ClientManager.GetClient(e.Request().Context(), req.ClientId) 51 + if err != nil { 52 + return helpers.ServerError(e, to.StringPtr(err.Error())) 53 + } 54 + 55 + scopes := strings.Split(req.Parameters.Scope, " ") 56 + appName := client.Metadata.ClientName 57 + 58 + data := map[string]any{ 59 + "Scopes": scopes, 60 + "AppName": appName, 61 + "RequestUri": reqUri, 62 + "QueryParams": e.QueryParams().Encode(), 63 + "Handle": repo.Actor.Handle, 64 + } 65 + 66 + return e.Render(200, "authorize.html", data) 67 + } 68 + 69 + type OauthAuthorizePostRequest struct { 70 + RequestUri string `form:"request_uri"` 71 + AcceptOrRejct string `form:"accept_or_reject"` 72 + } 73 + 74 + func (s *Server) handleOauthAuthorizePost(e echo.Context) error { 75 + repo, _, err := s.getSessionRepoOrErr(e) 76 + if err != nil { 77 + return e.Redirect(303, "/account/signin") 78 + } 79 + 80 + var req OauthAuthorizePostRequest 81 + if err := e.Bind(&req); err != nil { 82 + s.logger.Error("error binding authorize post request", "error", err) 83 + return helpers.InputError(e, nil) 84 + } 85 + 86 + reqId, err := oauth.DecodeRequestUri(req.RequestUri) 87 + if err != nil { 88 + return helpers.InputError(e, to.StringPtr(err.Error())) 89 + } 90 + 91 + var authReq provider.OauthAuthorizationRequest 92 + if err := s.db.Raw("SELECT * FROM oauth_authorization_requests WHERE request_id = ?", nil, reqId).Scan(&authReq).Error; err != nil { 93 + return helpers.ServerError(e, to.StringPtr(err.Error())) 94 + } 95 + 96 + client, err := s.oauthProvider.ClientManager.GetClient(e.Request().Context(), authReq.ClientId) 97 + if err != nil { 98 + return helpers.ServerError(e, to.StringPtr(err.Error())) 99 + } 100 + 101 + // TODO: figure out how im supposed to actually redirect 102 + if req.AcceptOrRejct == "reject" { 103 + return e.Redirect(303, client.Metadata.ClientURI) 104 + } 105 + 106 + if time.Now().After(authReq.ExpiresAt) { 107 + return helpers.InputError(e, to.StringPtr("the request has expired")) 108 + } 109 + 110 + if authReq.Sub != nil || authReq.Code != nil { 111 + return helpers.InputError(e, to.StringPtr("this request was already authorized")) 112 + } 113 + 114 + code := oauth.GenerateCode() 115 + 116 + if err := s.db.Exec("UPDATE oauth_authorization_requests SET sub = ?, code = ?, accepted = ? WHERE request_id = ?", nil, repo.Repo.Did, code, true, reqId).Error; err != nil { 117 + s.logger.Error("error updating authorization request", "error", err) 118 + return helpers.ServerError(e, nil) 119 + } 120 + 121 + q := url.Values{} 122 + q.Set("state", authReq.Parameters.State) 123 + q.Set("iss", "https://"+s.config.Hostname) 124 + q.Set("code", code) 125 + 126 + hashOrQuestion := "?" 127 + if authReq.ClientAuth.Method != "private_key_jwt" { 128 + hashOrQuestion = "#" 129 + } 130 + 131 + return e.Redirect(303, authReq.Parameters.RedirectURI+hashOrQuestion+q.Encode()) 132 + }
+12
server/handle_oauth_jwks.go
··· 1 + package server 2 + 3 + import "github.com/labstack/echo/v4" 4 + 5 + type OauthJwksResponse struct { 6 + Keys []any `json:"keys"` 7 + } 8 + 9 + // TODO: ? 10 + func (s *Server) handleOauthJwks(e echo.Context) error { 11 + return e.JSON(200, OauthJwksResponse{Keys: []any{}}) 12 + }
+88
server/handle_oauth_par.go
··· 1 + package server 2 + 3 + import ( 4 + "time" 5 + 6 + "github.com/Azure/go-autorest/autorest/to" 7 + "github.com/haileyok/cocoon/internal/helpers" 8 + "github.com/haileyok/cocoon/oauth" 9 + "github.com/haileyok/cocoon/oauth/constants" 10 + "github.com/haileyok/cocoon/oauth/provider" 11 + "github.com/labstack/echo/v4" 12 + ) 13 + 14 + type OauthParResponse struct { 15 + ExpiresIn int64 `json:"expires_in"` 16 + RequestURI string `json:"request_uri"` 17 + } 18 + 19 + func (s *Server) handleOauthPar(e echo.Context) error { 20 + var parRequest provider.ParRequest 21 + if err := e.Bind(&parRequest); err != nil { 22 + s.logger.Error("error binding for par request", "error", err) 23 + return helpers.ServerError(e, nil) 24 + } 25 + 26 + if err := e.Validate(parRequest); err != nil { 27 + s.logger.Error("missing parameters for par request", "error", err) 28 + return helpers.InputError(e, nil) 29 + } 30 + 31 + // TODO: this seems wrong. should be a way to get the entire request url i believe, but this will work for now 32 + dpopProof, err := s.oauthProvider.DpopManager.CheckProof(e.Request().Method, "https://"+s.config.Hostname+e.Request().URL.String(), e.Request().Header, nil) 33 + if err != nil { 34 + s.logger.Error("error getting dpop proof", "error", err) 35 + return helpers.InputError(e, to.StringPtr(err.Error())) 36 + } 37 + 38 + client, clientAuth, err := s.oauthProvider.AuthenticateClient(e.Request().Context(), parRequest.AuthenticateClientRequestBase, dpopProof, &provider.AuthenticateClientOptions{ 39 + // rfc9449 40 + // https://github.com/bluesky-social/atproto/blob/main/packages/oauth/oauth-provider/src/oauth-provider.ts#L473 41 + AllowMissingDpopProof: true, 42 + }) 43 + if err != nil { 44 + s.logger.Error("error authenticating client", "error", err) 45 + return helpers.InputError(e, to.StringPtr(err.Error())) 46 + } 47 + 48 + if parRequest.DpopJkt == nil { 49 + if client.Metadata.DpopBoundAccessTokens { 50 + parRequest.DpopJkt = to.StringPtr(dpopProof.JKT) 51 + } 52 + } else { 53 + if !client.Metadata.DpopBoundAccessTokens { 54 + msg := "dpop bound access tokens are not enabled for this client" 55 + s.logger.Error(msg) 56 + return helpers.InputError(e, &msg) 57 + } 58 + 59 + if dpopProof.JKT != *parRequest.DpopJkt { 60 + msg := "supplied dpop jkt does not match header dpop jkt" 61 + s.logger.Error(msg) 62 + return helpers.InputError(e, &msg) 63 + } 64 + } 65 + 66 + eat := time.Now().Add(constants.ParExpiresIn) 67 + id := oauth.GenerateRequestId() 68 + 69 + authRequest := &provider.OauthAuthorizationRequest{ 70 + RequestId: id, 71 + ClientId: client.Metadata.ClientID, 72 + ClientAuth: *clientAuth, 73 + Parameters: parRequest, 74 + ExpiresAt: eat, 75 + } 76 + 77 + if err := s.db.Create(authRequest, nil).Error; err != nil { 78 + s.logger.Error("error creating auth request in db", "error", err) 79 + return helpers.ServerError(e, nil) 80 + } 81 + 82 + uri := oauth.EncodeRequestUri(id) 83 + 84 + return e.JSON(201, OauthParResponse{ 85 + ExpiresIn: int64(constants.ParExpiresIn.Seconds()), 86 + RequestURI: uri, 87 + }) 88 + }
+276
server/handle_oauth_token.go
··· 1 + package server 2 + 3 + import ( 4 + "bytes" 5 + "crypto/sha256" 6 + "encoding/base64" 7 + "fmt" 8 + "slices" 9 + "time" 10 + 11 + "github.com/Azure/go-autorest/autorest/to" 12 + "github.com/golang-jwt/jwt/v4" 13 + "github.com/haileyok/cocoon/internal/helpers" 14 + "github.com/haileyok/cocoon/oauth" 15 + "github.com/haileyok/cocoon/oauth/constants" 16 + "github.com/haileyok/cocoon/oauth/provider" 17 + "github.com/labstack/echo/v4" 18 + ) 19 + 20 + type OauthTokenRequest struct { 21 + provider.AuthenticateClientRequestBase 22 + GrantType string `form:"grant_type" json:"grant_type"` 23 + Code *string `form:"code" json:"code,omitempty"` 24 + CodeVerifier *string `form:"code_verifier" json:"code_verifier,omitempty"` 25 + RedirectURI *string `form:"redirect_uri" json:"redirect_uri,omitempty"` 26 + RefreshToken *string `form:"refresh_token" json:"refresh_token,omitempty"` 27 + } 28 + 29 + type OauthTokenResponse struct { 30 + AccessToken string `json:"access_token"` 31 + TokenType string `json:"token_type"` 32 + RefreshToken string `json:"refresh_token"` 33 + Scope string `json:"scope"` 34 + ExpiresIn int64 `json:"expires_in"` 35 + Sub string `json:"sub"` 36 + } 37 + 38 + func (s *Server) handleOauthToken(e echo.Context) error { 39 + var req OauthTokenRequest 40 + if err := e.Bind(&req); err != nil { 41 + s.logger.Error("error binding token request", "error", err) 42 + return helpers.ServerError(e, nil) 43 + } 44 + 45 + proof, err := s.oauthProvider.DpopManager.CheckProof(e.Request().Method, e.Request().URL.String(), e.Request().Header, nil) 46 + if err != nil { 47 + s.logger.Error("error getting dpop proof", "error", err) 48 + return helpers.InputError(e, to.StringPtr(err.Error())) 49 + } 50 + 51 + client, clientAuth, err := s.oauthProvider.AuthenticateClient(e.Request().Context(), req.AuthenticateClientRequestBase, proof, &provider.AuthenticateClientOptions{ 52 + AllowMissingDpopProof: true, 53 + }) 54 + if err != nil { 55 + s.logger.Error("error authenticating client", "error", err) 56 + return helpers.InputError(e, to.StringPtr(err.Error())) 57 + } 58 + 59 + // TODO: this should come from an oauth provier config 60 + if !slices.Contains([]string{"authorization_code", "refresh_token"}, req.GrantType) { 61 + return helpers.InputError(e, to.StringPtr(fmt.Sprintf(`"%s" grant type is not supported by the server`, req.GrantType))) 62 + } 63 + 64 + if !slices.Contains(client.Metadata.GrantTypes, req.GrantType) { 65 + return helpers.InputError(e, to.StringPtr(fmt.Sprintf(`"%s" grant type is not supported by the client`, req.GrantType))) 66 + } 67 + 68 + if req.GrantType == "authorization_code" { 69 + if req.Code == nil { 70 + return helpers.InputError(e, to.StringPtr(`"code" is required"`)) 71 + } 72 + 73 + var authReq provider.OauthAuthorizationRequest 74 + // get the lil guy and delete him 75 + if err := s.db.Raw("DELETE FROM oauth_authorization_requests WHERE code = ? RETURNING *", nil, *req.Code).Scan(&authReq).Error; err != nil { 76 + s.logger.Error("error finding authorization request", "error", err) 77 + return helpers.ServerError(e, nil) 78 + } 79 + 80 + if req.RedirectURI == nil || *req.RedirectURI != authReq.Parameters.RedirectURI { 81 + return helpers.InputError(e, to.StringPtr(`"redirect_uri" mismatch`)) 82 + } 83 + 84 + if authReq.Parameters.CodeChallenge != nil { 85 + if req.CodeVerifier == nil { 86 + return helpers.InputError(e, to.StringPtr(`"code_verifier" is required`)) 87 + } 88 + 89 + if len(*req.CodeVerifier) < 43 { 90 + return helpers.InputError(e, to.StringPtr(`"code_verifier" is too short`)) 91 + } 92 + 93 + switch *&authReq.Parameters.CodeChallengeMethod { 94 + case "", "plain": 95 + if authReq.Parameters.CodeChallenge != req.CodeVerifier { 96 + return helpers.InputError(e, to.StringPtr("invalid code_verifier")) 97 + } 98 + case "S256": 99 + inputChal, err := base64.RawURLEncoding.DecodeString(*authReq.Parameters.CodeChallenge) 100 + if err != nil { 101 + s.logger.Error("error decoding code challenge", "error", err) 102 + return helpers.ServerError(e, nil) 103 + } 104 + 105 + h := sha256.New() 106 + h.Write([]byte(*req.CodeVerifier)) 107 + compdChal := h.Sum(nil) 108 + 109 + if !bytes.Equal(inputChal, compdChal) { 110 + return helpers.InputError(e, to.StringPtr("invalid code_verifier")) 111 + } 112 + default: 113 + return helpers.InputError(e, to.StringPtr("unsupported code_challenge_method "+*&authReq.Parameters.CodeChallengeMethod)) 114 + } 115 + } else if req.CodeVerifier != nil { 116 + return helpers.InputError(e, to.StringPtr("code_challenge parameter wasn't provided")) 117 + } 118 + 119 + repo, err := s.getRepoActorByDid(*authReq.Sub) 120 + if err != nil { 121 + helpers.InputError(e, to.StringPtr("unable to find actor")) 122 + } 123 + 124 + now := time.Now() 125 + eat := now.Add(constants.TokenMaxAge) 126 + id := oauth.GenerateTokenId() 127 + 128 + refreshToken := oauth.GenerateRefreshToken() 129 + 130 + accessClaims := jwt.MapClaims{ 131 + "scope": authReq.Parameters.Scope, 132 + "aud": s.config.Did, 133 + "sub": repo.Repo.Did, 134 + "iat": now.Unix(), 135 + "exp": eat.Unix(), 136 + "jti": id, 137 + "client_id": authReq.ClientId, 138 + } 139 + 140 + if authReq.Parameters.DpopJkt != nil { 141 + accessClaims["cnf"] = *authReq.Parameters.DpopJkt 142 + } 143 + 144 + accessToken := jwt.NewWithClaims(jwt.SigningMethodES256, accessClaims) 145 + accessString, err := accessToken.SignedString(s.privateKey) 146 + if err != nil { 147 + return err 148 + } 149 + 150 + if err := s.db.Create(&provider.OauthToken{ 151 + ClientId: authReq.ClientId, 152 + ClientAuth: *clientAuth, 153 + Parameters: authReq.Parameters, 154 + ExpiresAt: eat, 155 + DeviceId: "", 156 + Sub: repo.Repo.Did, 157 + Code: *authReq.Code, 158 + Token: accessString, 159 + RefreshToken: refreshToken, 160 + }, nil).Error; err != nil { 161 + s.logger.Error("error creating token in db", "error", err) 162 + return helpers.ServerError(e, nil) 163 + } 164 + 165 + // prob not needed 166 + tokenType := "Bearer" 167 + if authReq.Parameters.DpopJkt != nil { 168 + tokenType = "DPoP" 169 + } 170 + 171 + e.Response().Header().Set("content-type", "application/json") 172 + 173 + return e.JSON(200, OauthTokenResponse{ 174 + AccessToken: accessString, 175 + RefreshToken: refreshToken, 176 + TokenType: tokenType, 177 + Scope: authReq.Parameters.Scope, 178 + ExpiresIn: int64(eat.Sub(time.Now()).Seconds()), 179 + Sub: repo.Repo.Did, 180 + }) 181 + } 182 + 183 + if req.GrantType == "refresh_token" { 184 + if req.RefreshToken == nil { 185 + return helpers.InputError(e, to.StringPtr(`"refresh_token" is required`)) 186 + } 187 + 188 + var oauthToken provider.OauthToken 189 + if err := s.db.Raw("SELECT * FROM oauth_tokens WHERE refresh_token = ?", nil, req.RefreshToken).Scan(&oauthToken).Error; err != nil { 190 + s.logger.Error("error finding oauth token by refresh token", "error", err, "refresh_token", req.RefreshToken) 191 + return helpers.ServerError(e, nil) 192 + } 193 + 194 + if client.Metadata.ClientID != oauthToken.ClientId { 195 + return helpers.InputError(e, to.StringPtr(`"client_id" mismatch`)) 196 + } 197 + 198 + if clientAuth.Method != oauthToken.ClientAuth.Method { 199 + return helpers.InputError(e, to.StringPtr(`"client authentication method mismatch`)) 200 + } 201 + 202 + if *oauthToken.Parameters.DpopJkt != proof.JKT { 203 + return helpers.InputError(e, to.StringPtr("dpop proof does not match expected jkt")) 204 + } 205 + 206 + sessionLifetime := constants.PublicClientSessionLifetime 207 + refreshLifetime := constants.PublicClientRefreshLifetime 208 + if clientAuth.Method != "none" { 209 + sessionLifetime = constants.ConfidentialClientSessionLifetime 210 + refreshLifetime = constants.ConfidentialClientRefreshLifetime 211 + } 212 + 213 + sessionAge := time.Since(oauthToken.CreatedAt) 214 + if sessionAge > sessionLifetime { 215 + return helpers.InputError(e, to.StringPtr("Session expired")) 216 + } 217 + 218 + refreshAge := time.Since(oauthToken.UpdatedAt) 219 + if refreshAge > refreshLifetime { 220 + return helpers.InputError(e, to.StringPtr("Refresh token expired")) 221 + } 222 + 223 + if client.Metadata.DpopBoundAccessTokens && oauthToken.Parameters.DpopJkt == nil { 224 + // why? ref impl 225 + return helpers.InputError(e, to.StringPtr("dpop jkt is required for dpop bound access tokens")) 226 + } 227 + 228 + nextTokenId := oauth.GenerateTokenId() 229 + nextRefreshToken := oauth.GenerateRefreshToken() 230 + 231 + now := time.Now() 232 + eat := now.Add(constants.TokenMaxAge) 233 + 234 + accessClaims := jwt.MapClaims{ 235 + "scope": oauthToken.Parameters.Scope, 236 + "aud": s.config.Did, 237 + "sub": oauthToken.Sub, 238 + "iat": now.Unix(), 239 + "exp": eat.Unix(), 240 + "jti": nextTokenId, 241 + "client_id": oauthToken.ClientId, 242 + } 243 + 244 + if oauthToken.Parameters.DpopJkt != nil { 245 + accessClaims["cnf"] = *&oauthToken.Parameters.DpopJkt 246 + } 247 + 248 + accessToken := jwt.NewWithClaims(jwt.SigningMethodES256, accessClaims) 249 + accessString, err := accessToken.SignedString(s.privateKey) 250 + if err != nil { 251 + return err 252 + } 253 + 254 + if err := s.db.Exec("UPDATE oauth_tokens SET token = ?, refresh_token = ?, expires_at = ?, updated_at = ? WHERE refresh_token = ?", nil, accessString, nextRefreshToken, eat, now, *req.RefreshToken).Error; err != nil { 255 + s.logger.Error("error updating token", "error", err) 256 + return helpers.ServerError(e, nil) 257 + } 258 + 259 + // prob not needed 260 + tokenType := "Bearer" 261 + if oauthToken.Parameters.DpopJkt != nil { 262 + tokenType = "DPoP" 263 + } 264 + 265 + return e.JSON(200, OauthTokenResponse{ 266 + AccessToken: accessString, 267 + RefreshToken: nextRefreshToken, 268 + TokenType: tokenType, 269 + Scope: oauthToken.Parameters.Scope, 270 + ExpiresIn: int64(eat.Sub(time.Now()).Seconds()), 271 + Sub: oauthToken.Sub, 272 + }) 273 + } 274 + 275 + return helpers.InputError(e, to.StringPtr(fmt.Sprintf(`grant type "%s" is not supported`, req.GrantType))) 276 + }
+112
server/handle_server_get_service_auth.go
··· 1 + package server 2 + 3 + import ( 4 + "crypto/rand" 5 + "crypto/sha256" 6 + "encoding/base64" 7 + "encoding/json" 8 + "fmt" 9 + "strings" 10 + "time" 11 + 12 + "github.com/Azure/go-autorest/autorest/to" 13 + "github.com/google/uuid" 14 + "github.com/haileyok/cocoon/internal/helpers" 15 + "github.com/haileyok/cocoon/models" 16 + "github.com/labstack/echo/v4" 17 + secp256k1secec "gitlab.com/yawning/secp256k1-voi/secec" 18 + ) 19 + 20 + type ServerGetServiceAuthRequest struct { 21 + Aud string `query:"aud" validate:"required,atproto-did"` 22 + Exp int64 `query:"exp"` 23 + Lxm string `query:"lxm" validate:"required,atproto-nsid"` 24 + } 25 + 26 + func (s *Server) handleServerGetServiceAuth(e echo.Context) error { 27 + var req ServerGetServiceAuthRequest 28 + if err := e.Bind(&req); err != nil { 29 + s.logger.Error("could not bind service auth request", "error", err) 30 + return helpers.ServerError(e, nil) 31 + } 32 + 33 + if err := e.Validate(req); err != nil { 34 + return helpers.InputError(e, nil) 35 + } 36 + 37 + now := time.Now().Unix() 38 + if req.Exp == 0 { 39 + req.Exp = now + 60 // default 40 + } 41 + 42 + if req.Lxm == "com.atproto.server.getServiceAuth" { 43 + return helpers.InputError(e, to.StringPtr("may not generate auth tokens recursively")) 44 + } 45 + 46 + maxExp := now + (60 * 30) 47 + if req.Exp > maxExp { 48 + return helpers.InputError(e, to.StringPtr("expiration too big. smoller please")) 49 + } 50 + 51 + repo := e.Get("repo").(*models.RepoActor) 52 + 53 + header := map[string]string{ 54 + "alg": "ES256K", 55 + "crv": "secp256k1", 56 + "typ": "JWT", 57 + } 58 + hj, err := json.Marshal(header) 59 + if err != nil { 60 + s.logger.Error("error marshaling header", "error", err) 61 + return helpers.ServerError(e, nil) 62 + } 63 + 64 + encheader := strings.TrimRight(base64.RawURLEncoding.EncodeToString(hj), "=") 65 + 66 + payload := map[string]any{ 67 + "iss": repo.Repo.Did, 68 + "aud": req.Aud, 69 + "lxm": req.Lxm, 70 + "jti": uuid.NewString(), 71 + "exp": req.Exp, 72 + "iat": now, 73 + } 74 + pj, err := json.Marshal(payload) 75 + if err != nil { 76 + s.logger.Error("error marashaling payload", "error", err) 77 + return helpers.ServerError(e, nil) 78 + } 79 + 80 + encpayload := strings.TrimRight(base64.RawURLEncoding.EncodeToString(pj), "=") 81 + 82 + input := fmt.Sprintf("%s.%s", encheader, encpayload) 83 + hash := sha256.Sum256([]byte(input)) 84 + 85 + sk, err := secp256k1secec.NewPrivateKey(repo.SigningKey) 86 + if err != nil { 87 + s.logger.Error("can't load private key", "error", err) 88 + return err 89 + } 90 + 91 + R, S, _, err := sk.SignRaw(rand.Reader, hash[:]) 92 + if err != nil { 93 + s.logger.Error("error signing", "error", err) 94 + return helpers.ServerError(e, nil) 95 + } 96 + 97 + rBytes := R.Bytes() 98 + sBytes := S.Bytes() 99 + 100 + rPadded := make([]byte, 32) 101 + sPadded := make([]byte, 32) 102 + copy(rPadded[32-len(rBytes):], rBytes) 103 + copy(sPadded[32-len(sBytes):], sBytes) 104 + 105 + rawsig := append(rPadded, sPadded...) 106 + encsig := strings.TrimRight(base64.RawURLEncoding.EncodeToString(rawsig), "=") 107 + token := fmt.Sprintf("%s.%s", input, encsig) 108 + 109 + return e.JSON(200, map[string]string{ 110 + "token": token, 111 + }) 112 + }
+88
server/handle_well_known.go
··· 1 1 package server 2 2 3 3 import ( 4 + "fmt" 5 + 6 + "github.com/Azure/go-autorest/autorest/to" 4 7 "github.com/labstack/echo/v4" 5 8 ) 6 9 10 + var ( 11 + CocoonSupportedScopes = []string{ 12 + "atproto", 13 + "transition:email", 14 + "transition:generic", 15 + "transition:chat.bsky", 16 + } 17 + ) 18 + 19 + type OauthAuthorizationMetadata struct { 20 + Issuer string `json:"issuer"` 21 + RequestParameterSupported bool `json:"request_parameter_supported"` 22 + RequestUriParameterSupported bool `json:"request_uri_parameter_supported"` 23 + RequireRequestUriRegistration *bool `json:"require_request_uri_registration,omitempty"` 24 + ScopesSupported []string `json:"scopes_supported"` 25 + SubjectTypesSupported []string `json:"subject_types_supported"` 26 + ResponseTypesSupported []string `json:"response_types_supported"` 27 + ResponseModesSupported []string `json:"response_modes_supported"` 28 + GrantTypesSupported []string `json:"grant_types_supported"` 29 + CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"` 30 + UILocalesSupported []string `json:"ui_locales_supported"` 31 + DisplayValuesSupported []string `json:"display_values_supported"` 32 + RequestObjectSigningAlgValuesSupported []string `json:"request_object_signing_alg_values_supported"` 33 + AuthorizationResponseISSParameterSupported bool `json:"authorization_response_iss_parameter_supported"` 34 + RequestObjectEncryptionAlgValuesSupported []string `json:"request_object_encryption_alg_values_supported"` 35 + RequestObjectEncryptionEncValuesSupported []string `json:"request_object_encryption_enc_values_supported"` 36 + JwksUri string `json:"jwks_uri"` 37 + AuthorizationEndpoint string `json:"authorization_endpoint"` 38 + TokenEndpoint string `json:"token_endpoint"` 39 + TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported"` 40 + TokenEndpointAuthSigningAlgValuesSupported []string `json:"token_endpoint_auth_signing_alg_values_supported"` 41 + RevocationEndpoint string `json:"revocation_endpoint"` 42 + IntrospectionEndpoint string `json:"introspection_endpoint"` 43 + PushedAuthorizationRequestEndpoint string `json:"pushed_authorization_request_endpoint"` 44 + RequirePushedAuthorizationRequests bool `json:"require_pushed_authorization_requests"` 45 + DpopSigningAlgValuesSupported []string `json:"dpop_signing_alg_values_supported"` 46 + ProtectedResources []string `json:"protected_resources"` 47 + ClientIDMetadataDocumentSupported bool `json:"client_id_metadata_document_supported"` 48 + } 49 + 7 50 func (s *Server) handleWellKnown(e echo.Context) error { 8 51 return e.JSON(200, map[string]any{ 9 52 "@context": []string{ ··· 19 62 }, 20 63 }) 21 64 } 65 + 66 + func (s *Server) handleOauthProtectedResource(e echo.Context) error { 67 + return e.JSON(200, map[string]any{ 68 + "resource": "https://" + s.config.Hostname, 69 + "authorization_servers": []string{ 70 + "https://" + s.config.Hostname, 71 + }, 72 + "scopes_supported": []string{}, 73 + "bearer_methods_supported": []string{"header"}, 74 + "resource_documentation": "https://atproto.com", 75 + }) 76 + } 77 + 78 + func (s *Server) handleOauthAuthorizationServer(e echo.Context) error { 79 + return e.JSON(200, OauthAuthorizationMetadata{ 80 + Issuer: "https://" + s.config.Hostname, 81 + RequestParameterSupported: true, 82 + RequestUriParameterSupported: true, 83 + RequireRequestUriRegistration: to.BoolPtr(true), 84 + ScopesSupported: CocoonSupportedScopes, 85 + SubjectTypesSupported: []string{"public"}, 86 + ResponseTypesSupported: []string{"code"}, 87 + ResponseModesSupported: []string{"query", "fragment", "form_post"}, 88 + GrantTypesSupported: []string{"authorization_code", "refresh_token"}, 89 + CodeChallengeMethodsSupported: []string{"S256"}, 90 + UILocalesSupported: []string{"en-US"}, 91 + DisplayValuesSupported: []string{"page", "popup", "touch"}, 92 + RequestObjectSigningAlgValuesSupported: []string{"ES256"}, // only es256 for now... 93 + AuthorizationResponseISSParameterSupported: true, 94 + RequestObjectEncryptionAlgValuesSupported: []string{}, 95 + RequestObjectEncryptionEncValuesSupported: []string{}, 96 + JwksUri: fmt.Sprintf("https://%s/oauth/jwks", s.config.Hostname), 97 + AuthorizationEndpoint: fmt.Sprintf("https://%s/oauth/authorize", s.config.Hostname), 98 + TokenEndpoint: fmt.Sprintf("https://%s/oauth/token", s.config.Hostname), 99 + TokenEndpointAuthMethodsSupported: []string{"none", "private_key_jwt"}, 100 + TokenEndpointAuthSigningAlgValuesSupported: []string{"ES256"}, // Same as above, just es256 101 + RevocationEndpoint: fmt.Sprintf("https://%s/oauth/revoke", s.config.Hostname), 102 + IntrospectionEndpoint: fmt.Sprintf("https://%s/oauth/introspect", s.config.Hostname), 103 + PushedAuthorizationRequestEndpoint: fmt.Sprintf("https://%s/oauth/par", s.config.Hostname), 104 + RequirePushedAuthorizationRequests: true, 105 + DpopSigningAlgValuesSupported: []string{"ES256"}, // again same as above 106 + ProtectedResources: []string{"https://" + s.config.Hostname}, 107 + ClientIDMetadataDocumentSupported: true, 108 + }) 109 + }
+16
server/mail.go
··· 3 3 import "fmt" 4 4 5 5 func (s *Server) sendWelcomeMail(email, handle string) error { 6 + if s.mail == nil { 7 + return nil 8 + } 9 + 6 10 s.mailLk.Lock() 7 11 defer s.mailLk.Unlock() 8 12 ··· 18 22 } 19 23 20 24 func (s *Server) sendPasswordReset(email, handle, code string) error { 25 + if s.mail == nil { 26 + return nil 27 + } 28 + 21 29 s.mailLk.Lock() 22 30 defer s.mailLk.Unlock() 23 31 ··· 33 41 } 34 42 35 43 func (s *Server) sendEmailUpdate(email, handle, code string) error { 44 + if s.mail == nil { 45 + return nil 46 + } 47 + 36 48 s.mailLk.Lock() 37 49 defer s.mailLk.Unlock() 38 50 ··· 48 60 } 49 61 50 62 func (s *Server) sendEmailVerification(email, handle, code string) error { 63 + if s.mail == nil { 64 + return nil 65 + } 66 + 51 67 s.mailLk.Lock() 52 68 defer s.mailLk.Unlock() 53 69
+230 -35
server/server.go
··· 4 4 "bytes" 5 5 "context" 6 6 "crypto/ecdsa" 7 + "embed" 7 8 "errors" 8 9 "fmt" 9 10 "io" ··· 11 12 "net/http" 12 13 "net/smtp" 13 14 "os" 15 + "path/filepath" 14 16 "strings" 15 17 "sync" 18 + "text/template" 16 19 "time" 17 20 18 21 "github.com/Azure/go-autorest/autorest/to" ··· 28 31 "github.com/domodwyer/mailyak/v3" 29 32 "github.com/go-playground/validator" 30 33 "github.com/golang-jwt/jwt/v4" 34 + "github.com/gorilla/sessions" 31 35 "github.com/haileyok/cocoon/identity" 32 36 "github.com/haileyok/cocoon/internal/db" 33 37 "github.com/haileyok/cocoon/internal/helpers" 34 38 "github.com/haileyok/cocoon/models" 39 + "github.com/haileyok/cocoon/oauth/client_manager" 40 + "github.com/haileyok/cocoon/oauth/constants" 41 + "github.com/haileyok/cocoon/oauth/dpop/dpop_manager" 42 + "github.com/haileyok/cocoon/oauth/provider" 35 43 "github.com/haileyok/cocoon/plc" 44 + echo_session "github.com/labstack/echo-contrib/session" 36 45 "github.com/labstack/echo/v4" 37 46 "github.com/labstack/echo/v4/middleware" 38 - "github.com/lestrrat-go/jwx/v2/jwk" 39 47 slogecho "github.com/samber/slog-echo" 40 48 "gorm.io/driver/sqlite" 41 49 "gorm.io/gorm" 50 + ) 51 + 52 + const ( 53 + AccountSessionMaxAge = 30 * 24 * time.Hour // one week 42 54 ) 43 55 44 56 type S3Config struct { ··· 51 63 } 52 64 53 65 type Server struct { 54 - http *http.Client 55 - httpd *http.Server 56 - mail *mailyak.MailYak 57 - mailLk *sync.Mutex 58 - echo *echo.Echo 59 - db *db.DB 60 - plcClient *plc.Client 61 - logger *slog.Logger 62 - config *config 63 - privateKey *ecdsa.PrivateKey 64 - repoman *RepoMan 65 - evtman *events.EventManager 66 - passport *identity.Passport 66 + http *http.Client 67 + httpd *http.Server 68 + mail *mailyak.MailYak 69 + mailLk *sync.Mutex 70 + echo *echo.Echo 71 + db *db.DB 72 + plcClient *plc.Client 73 + logger *slog.Logger 74 + config *config 75 + privateKey *ecdsa.PrivateKey 76 + repoman *RepoMan 77 + oauthProvider *provider.Provider 78 + evtman *events.EventManager 79 + passport *identity.Passport 67 80 68 81 dbName string 69 82 s3Config *S3Config ··· 90 103 SmtpName string 91 104 92 105 S3Config *S3Config 106 + 107 + SessionSecret string 93 108 } 94 109 95 110 type config struct { ··· 132 147 return nil 133 148 } 134 149 150 + //go:embed templates/* 151 + var templateFS embed.FS 152 + 153 + //go:embed static/* 154 + var staticFS embed.FS 155 + 156 + type TemplateRenderer struct { 157 + templates *template.Template 158 + isDev bool 159 + templatePath string 160 + } 161 + 162 + func (s *Server) loadTemplates() { 163 + absPath, _ := filepath.Abs("server/templates/*.html") 164 + if s.config.Version == "dev" { 165 + tmpl := template.Must(template.ParseGlob(absPath)) 166 + s.echo.Renderer = &TemplateRenderer{ 167 + templates: tmpl, 168 + isDev: true, 169 + templatePath: absPath, 170 + } 171 + } else { 172 + tmpl := template.Must(template.ParseFS(templateFS, "templates/*.html")) 173 + s.echo.Renderer = &TemplateRenderer{ 174 + templates: tmpl, 175 + isDev: false, 176 + } 177 + } 178 + } 179 + 180 + func (t *TemplateRenderer) Render(w io.Writer, name string, data any, c echo.Context) error { 181 + if t.isDev { 182 + tmpl, err := template.ParseGlob(t.templatePath) 183 + if err != nil { 184 + return err 185 + } 186 + t.templates = tmpl 187 + } 188 + 189 + if viewContext, isMap := data.(map[string]any); isMap { 190 + viewContext["reverse"] = c.Echo().Reverse 191 + } 192 + 193 + return t.templates.ExecuteTemplate(w, name, data) 194 + } 195 + 135 196 func (s *Server) handleAdminMiddleware(next echo.HandlerFunc) echo.HandlerFunc { 136 197 return func(e echo.Context) error { 137 198 username, password, ok := e.Request().BasicAuth() ··· 147 208 } 148 209 } 149 210 150 - func (s *Server) handleSessionMiddleware(next echo.HandlerFunc) echo.HandlerFunc { 211 + func (s *Server) handleLegacySessionMiddleware(next echo.HandlerFunc) echo.HandlerFunc { 151 212 return func(e echo.Context) error { 152 213 authheader := e.Request().Header.Get("authorization") 153 214 if authheader == "" { ··· 157 218 pts := strings.Split(authheader, " ") 158 219 if len(pts) != 2 { 159 220 return helpers.ServerError(e, nil) 221 + } 222 + 223 + if pts[0] == "DPoP" { 224 + return next(e) 160 225 } 161 226 162 227 tokenstr := pts[1] ··· 238 303 } 239 304 } 240 305 306 + func (s *Server) handleOauthSessionMiddleware(next echo.HandlerFunc) echo.HandlerFunc { 307 + return func(e echo.Context) error { 308 + authheader := e.Request().Header.Get("authorization") 309 + if authheader == "" { 310 + return e.JSON(401, map[string]string{"error": "Unauthorized"}) 311 + } 312 + 313 + pts := strings.Split(authheader, " ") 314 + if len(pts) != 2 { 315 + return helpers.ServerError(e, nil) 316 + } 317 + 318 + if pts[0] != "DPoP" { 319 + return next(e) 320 + } 321 + 322 + accessToken := pts[1] 323 + 324 + proof, err := s.oauthProvider.DpopManager.CheckProof(e.Request().Method, "https://"+s.config.Hostname+e.Request().URL.String(), e.Request().Header, to.StringPtr(accessToken)) 325 + if err != nil { 326 + s.logger.Error("invalid dpop proof", "error", err) 327 + return helpers.InputError(e, to.StringPtr(err.Error())) 328 + } 329 + 330 + var oauthToken provider.OauthToken 331 + if err := s.db.Raw("SELECT * FROM oauth_tokens WHERE token = ?", nil, accessToken).Scan(&oauthToken).Error; err != nil { 332 + s.logger.Error("error finding access token in db", "error", err) 333 + return helpers.InputError(e, nil) 334 + } 335 + 336 + if oauthToken.Token == "" { 337 + return helpers.InputError(e, to.StringPtr("InvalidToken")) 338 + } 339 + 340 + if *oauthToken.Parameters.DpopJkt != proof.JKT { 341 + s.logger.Error("jkt mismatch", "token", oauthToken.Parameters.DpopJkt, "proof", proof.JKT) 342 + return helpers.InputError(e, to.StringPtr("dpop jkt mismatch")) 343 + } 344 + 345 + if time.Now().After(oauthToken.ExpiresAt) { 346 + return e.JSON(400, map[string]string{"error": "ExpiredToken", "message": "token has expired"}) 347 + } 348 + 349 + repo, err := s.getRepoActorByDid(oauthToken.Sub) 350 + if err != nil { 351 + s.logger.Error("could not find actor in db", "error", err) 352 + return helpers.ServerError(e, nil) 353 + } 354 + 355 + nonce := s.oauthProvider.NextNonce() 356 + if nonce != "" { 357 + e.Response().Header().Set("DPoP-Nonce", nonce) 358 + e.Response().Header().Add("access-control-expose-headers", "DPoP-Nonce") 359 + } 360 + 361 + e.Set("repo", repo) 362 + e.Set("did", repo.Repo.Did) 363 + e.Set("token", accessToken) 364 + e.Set("scopes", strings.Split(oauthToken.Parameters.Scope, " ")) 365 + 366 + return next(e) 367 + } 368 + } 369 + 241 370 func New(args *Args) (*Server, error) { 242 371 if args.Addr == "" { 243 372 return nil, fmt.Errorf("addr must be set") ··· 271 400 args.Logger = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{})) 272 401 } 273 402 403 + if args.SessionSecret == "" { 404 + panic("SESSION SECRET WAS NOT SET. THIS IS REQUIRED. ") 405 + } 406 + 274 407 e := echo.New() 275 408 276 409 e.Pre(middleware.RemoveTrailingSlash()) 277 410 e.Pre(slogecho.New(args.Logger)) 411 + e.Use(echo_session.Middleware(sessions.NewCookieStore([]byte(args.SessionSecret)))) 278 412 e.Use(middleware.CORSWithConfig(middleware.CORSConfig{ 279 413 AllowOrigins: []string{"*"}, 280 414 AllowHeaders: []string{"*"}, ··· 348 482 return nil, err 349 483 } 350 484 351 - key, err := jwk.ParseKey(jwkbytes) 485 + key, err := helpers.ParseJWKFromBytes(jwkbytes) 352 486 if err != nil { 353 487 return nil, err 354 488 } ··· 358 492 return nil, err 359 493 } 360 494 495 + oauthCli := &http.Client{ 496 + Timeout: 10 * time.Second, 497 + } 498 + 499 + var nonceSecret []byte 500 + maybeSecret, err := os.ReadFile("nonce.secret") 501 + if err != nil && !os.IsNotExist(err) { 502 + args.Logger.Error("error attempting to read nonce secret", "error", err) 503 + } else { 504 + nonceSecret = maybeSecret 505 + } 506 + 361 507 s := &Server{ 362 508 http: h, 363 509 httpd: httpd, ··· 382 528 383 529 dbName: args.DbName, 384 530 s3Config: args.S3Config, 531 + 532 + oauthProvider: provider.NewProvider(provider.Args{ 533 + Hostname: args.Hostname, 534 + ClientManagerArgs: client_manager.Args{ 535 + Cli: oauthCli, 536 + Logger: args.Logger, 537 + }, 538 + DpopManagerArgs: dpop_manager.Args{ 539 + NonceSecret: nonceSecret, 540 + NonceRotationInterval: constants.NonceMaxRotationInterval / 3, 541 + OnNonceSecretCreated: func(newNonce []byte) { 542 + if err := os.WriteFile("nonce.secret", newNonce, 0644); err != nil { 543 + args.Logger.Error("error writing new nonce secret", "error", err) 544 + } 545 + }, 546 + Logger: args.Logger, 547 + Hostname: args.Hostname, 548 + }, 549 + }), 385 550 } 551 + 552 + s.loadTemplates() 386 553 387 554 s.repoman = NewRepoMan(s) // TODO: this is way too lazy, stop it 388 555 ··· 402 569 } 403 570 404 571 func (s *Server) addRoutes() { 572 + // static 573 + if s.config.Version == "dev" { 574 + s.echo.Static("/static", "server/static") 575 + } else { 576 + s.echo.GET("/static/*", echo.WrapHandler(http.FileServer(http.FS(staticFS)))) 577 + } 578 + 405 579 // random stuff 406 580 s.echo.GET("/", s.handleRoot) 407 581 s.echo.GET("/xrpc/_health", s.handleHealth) 408 582 s.echo.GET("/.well-known/did.json", s.handleWellKnown) 583 + s.echo.GET("/.well-known/oauth-protected-resource", s.handleOauthProtectedResource) 584 + s.echo.GET("/.well-known/oauth-authorization-server", s.handleOauthAuthorizationServer) 409 585 s.echo.GET("/robots.txt", s.handleRobots) 410 586 411 587 // public ··· 428 604 s.echo.GET("/xrpc/com.atproto.sync.listBlobs", s.handleSyncListBlobs) 429 605 s.echo.GET("/xrpc/com.atproto.sync.getBlob", s.handleSyncGetBlob) 430 606 607 + // account 608 + s.echo.GET("/account", s.handleAccount) 609 + s.echo.POST("/account/revoke", s.handleAccountRevoke) 610 + s.echo.GET("/account/signin", s.handleAccountSigninGet) 611 + s.echo.POST("/account/signin", s.handleAccountSigninPost) 612 + s.echo.GET("/account/signout", s.handleAccountSignout) 613 + 614 + // oauth account 615 + s.echo.GET("/oauth/jwks", s.handleOauthJwks) 616 + s.echo.GET("/oauth/authorize", s.handleOauthAuthorizeGet) 617 + s.echo.POST("/oauth/authorize", s.handleOauthAuthorizePost) 618 + 619 + // oauth authorization 620 + s.echo.POST("/oauth/par", s.handleOauthPar, s.oauthProvider.BaseMiddleware) 621 + s.echo.POST("/oauth/token", s.handleOauthToken, s.oauthProvider.BaseMiddleware) 622 + 431 623 // authed 432 - s.echo.GET("/xrpc/com.atproto.server.getSession", s.handleGetSession, s.handleSessionMiddleware) 433 - s.echo.POST("/xrpc/com.atproto.server.refreshSession", s.handleRefreshSession, s.handleSessionMiddleware) 434 - s.echo.POST("/xrpc/com.atproto.server.deleteSession", s.handleDeleteSession, s.handleSessionMiddleware) 435 - s.echo.POST("/xrpc/com.atproto.identity.updateHandle", s.handleIdentityUpdateHandle, s.handleSessionMiddleware) 436 - s.echo.POST("/xrpc/com.atproto.server.confirmEmail", s.handleServerConfirmEmail, s.handleSessionMiddleware) 437 - s.echo.POST("/xrpc/com.atproto.server.requestEmailConfirmation", s.handleServerRequestEmailConfirmation, s.handleSessionMiddleware) 624 + s.echo.GET("/xrpc/com.atproto.server.getSession", s.handleGetSession, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 625 + s.echo.POST("/xrpc/com.atproto.server.refreshSession", s.handleRefreshSession, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 626 + s.echo.POST("/xrpc/com.atproto.server.deleteSession", s.handleDeleteSession, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 627 + s.echo.POST("/xrpc/com.atproto.identity.updateHandle", s.handleIdentityUpdateHandle, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 628 + s.echo.POST("/xrpc/com.atproto.server.confirmEmail", s.handleServerConfirmEmail, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 629 + s.echo.POST("/xrpc/com.atproto.server.requestEmailConfirmation", s.handleServerRequestEmailConfirmation, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 438 630 s.echo.POST("/xrpc/com.atproto.server.requestPasswordReset", s.handleServerRequestPasswordReset) // AUTH NOT REQUIRED FOR THIS ONE 439 - s.echo.POST("/xrpc/com.atproto.server.requestEmailUpdate", s.handleServerRequestEmailUpdate, s.handleSessionMiddleware) 440 - s.echo.POST("/xrpc/com.atproto.server.resetPassword", s.handleServerResetPassword, s.handleSessionMiddleware) 441 - s.echo.POST("/xrpc/com.atproto.server.updateEmail", s.handleServerUpdateEmail, s.handleSessionMiddleware) 631 + s.echo.POST("/xrpc/com.atproto.server.requestEmailUpdate", s.handleServerRequestEmailUpdate, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 632 + s.echo.POST("/xrpc/com.atproto.server.resetPassword", s.handleServerResetPassword, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 633 + s.echo.POST("/xrpc/com.atproto.server.updateEmail", s.handleServerUpdateEmail, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 634 + s.echo.GET("/xrpc/com.atproto.server.getServiceAuth", s.handleServerGetServiceAuth, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 442 635 443 636 // repo 444 - s.echo.POST("/xrpc/com.atproto.repo.createRecord", s.handleCreateRecord, s.handleSessionMiddleware) 445 - s.echo.POST("/xrpc/com.atproto.repo.putRecord", s.handlePutRecord, s.handleSessionMiddleware) 446 - s.echo.POST("/xrpc/com.atproto.repo.deleteRecord", s.handleDeleteRecord, s.handleSessionMiddleware) 447 - s.echo.POST("/xrpc/com.atproto.repo.applyWrites", s.handleApplyWrites, s.handleSessionMiddleware) 448 - s.echo.POST("/xrpc/com.atproto.repo.uploadBlob", s.handleRepoUploadBlob, s.handleSessionMiddleware) 449 - s.echo.POST("/xrpc/com.atproto.repo.importRepo", s.handleRepoImportRepo, s.handleSessionMiddleware) 637 + s.echo.POST("/xrpc/com.atproto.repo.createRecord", s.handleCreateRecord, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 638 + s.echo.POST("/xrpc/com.atproto.repo.putRecord", s.handlePutRecord, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 639 + s.echo.POST("/xrpc/com.atproto.repo.deleteRecord", s.handleDeleteRecord, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 640 + s.echo.POST("/xrpc/com.atproto.repo.applyWrites", s.handleApplyWrites, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 641 + s.echo.POST("/xrpc/com.atproto.repo.uploadBlob", s.handleRepoUploadBlob, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 642 + s.echo.POST("/xrpc/com.atproto.repo.importRepo", s.handleRepoImportRepo, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 450 643 451 644 // stupid silly endpoints 452 - s.echo.GET("/xrpc/app.bsky.actor.getPreferences", s.handleActorGetPreferences, s.handleSessionMiddleware) 453 - s.echo.POST("/xrpc/app.bsky.actor.putPreferences", s.handleActorPutPreferences, s.handleSessionMiddleware) 645 + s.echo.GET("/xrpc/app.bsky.actor.getPreferences", s.handleActorGetPreferences, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 646 + s.echo.POST("/xrpc/app.bsky.actor.putPreferences", s.handleActorPutPreferences, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 454 647 455 648 // are there any routes that we should be allowing without auth? i dont think so but idk 456 - s.echo.GET("/xrpc/*", s.handleProxy, s.handleSessionMiddleware) 457 - s.echo.POST("/xrpc/*", s.handleProxy, s.handleSessionMiddleware) 649 + s.echo.GET("/xrpc/*", s.handleProxy, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 650 + s.echo.POST("/xrpc/*", s.handleProxy, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware) 458 651 459 652 // admin routes 460 653 s.echo.POST("/xrpc/com.atproto.server.createInviteCode", s.handleCreateInviteCode, s.handleAdminMiddleware) ··· 476 669 &models.Record{}, 477 670 &models.Blob{}, 478 671 &models.BlobPart{}, 672 + &provider.OauthToken{}, 673 + &provider.OauthAuthorizationRequest{}, 479 674 ) 480 675 481 676 s.logger.Info("starting cocoon")
+4
server/static/pico.css
··· 1 + @charset "UTF-8";/*! 2 + * Pico CSS ✨ v2.1.1 (https://picocss.com) 3 + * Copyright 2019-2025 - Licensed under MIT 4 + */:host,:root{--pico-font-family-emoji:"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--pico-font-family-sans-serif:system-ui,"Segoe UI",Roboto,Oxygen,Ubuntu,Cantarell,Helvetica,Arial,"Helvetica Neue",sans-serif,var(--pico-font-family-emoji);--pico-font-family-monospace:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace,var(--pico-font-family-emoji);--pico-font-family:var(--pico-font-family-sans-serif);--pico-line-height:1.5;--pico-font-weight:400;--pico-font-size:100%;--pico-text-underline-offset:0.1rem;--pico-border-radius:0.25rem;--pico-border-width:0.0625rem;--pico-outline-width:0.125rem;--pico-transition:0.2s ease-in-out;--pico-spacing:1rem;--pico-typography-spacing-vertical:1rem;--pico-block-spacing-vertical:var(--pico-spacing);--pico-block-spacing-horizontal:var(--pico-spacing);--pico-grid-column-gap:var(--pico-spacing);--pico-grid-row-gap:var(--pico-spacing);--pico-form-element-spacing-vertical:0.75rem;--pico-form-element-spacing-horizontal:1rem;--pico-group-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-group-box-shadow-focus-with-button:0 0 0 var(--pico-outline-width) var(--pico-primary-focus);--pico-group-box-shadow-focus-with-input:0 0 0 0.0625rem var(--pico-form-element-border-color);--pico-modal-overlay-backdrop-filter:blur(0.375rem);--pico-nav-element-spacing-vertical:1rem;--pico-nav-element-spacing-horizontal:0.5rem;--pico-nav-link-spacing-vertical:0.5rem;--pico-nav-link-spacing-horizontal:0.5rem;--pico-nav-breadcrumb-divider:">";--pico-icon-checkbox:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-minus:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='5' y1='12' x2='19' y2='12'%3E%3C/line%3E%3C/svg%3E");--pico-icon-chevron:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-date:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='18' height='18' rx='2' ry='2'%3E%3C/rect%3E%3Cline x1='16' y1='2' x2='16' y2='6'%3E%3C/line%3E%3Cline x1='8' y1='2' x2='8' y2='6'%3E%3C/line%3E%3Cline x1='3' y1='10' x2='21' y2='10'%3E%3C/line%3E%3C/svg%3E");--pico-icon-time:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cpolyline points='12 6 12 12 16 14'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-search:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'%3E%3C/circle%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'%3E%3C/line%3E%3C/svg%3E");--pico-icon-close:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='18' y1='6' x2='6' y2='18'%3E%3C/line%3E%3Cline x1='6' y1='6' x2='18' y2='18'%3E%3C/line%3E%3C/svg%3E");--pico-icon-loading:url("data:image/svg+xml,%3Csvg fill='none' height='24' width='24' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg' %3E%3Cstyle%3E g %7B animation: rotate 2s linear infinite; transform-origin: center center; %7D circle %7B stroke-dasharray: 75,100; stroke-dashoffset: -5; animation: dash 1.5s ease-in-out infinite; stroke-linecap: round; %7D @keyframes rotate %7B 0%25 %7B transform: rotate(0deg); %7D 100%25 %7B transform: rotate(360deg); %7D %7D @keyframes dash %7B 0%25 %7B stroke-dasharray: 1,100; stroke-dashoffset: 0; %7D 50%25 %7B stroke-dasharray: 44.5,100; stroke-dashoffset: -17.5; %7D 100%25 %7B stroke-dasharray: 44.5,100; stroke-dashoffset: -62; %7D %7D %3C/style%3E%3Cg%3E%3Ccircle cx='12' cy='12' r='10' fill='none' stroke='rgb(136, 145, 164)' stroke-width='4' /%3E%3C/g%3E%3C/svg%3E")}@media (min-width:576px){:host,:root{--pico-font-size:106.25%}}@media (min-width:768px){:host,:root{--pico-font-size:112.5%}}@media (min-width:1024px){:host,:root{--pico-font-size:118.75%}}@media (min-width:1280px){:host,:root{--pico-font-size:125%}}@media (min-width:1536px){:host,:root{--pico-font-size:131.25%}}a{--pico-text-decoration:underline}a.contrast,a.secondary{--pico-text-decoration:underline}small{--pico-font-size:0.875em}h1,h2,h3,h4,h5,h6{--pico-font-weight:700}h1{--pico-font-size:2rem;--pico-line-height:1.125;--pico-typography-spacing-top:3rem}h2{--pico-font-size:1.75rem;--pico-line-height:1.15;--pico-typography-spacing-top:2.625rem}h3{--pico-font-size:1.5rem;--pico-line-height:1.175;--pico-typography-spacing-top:2.25rem}h4{--pico-font-size:1.25rem;--pico-line-height:1.2;--pico-typography-spacing-top:1.874rem}h5{--pico-font-size:1.125rem;--pico-line-height:1.225;--pico-typography-spacing-top:1.6875rem}h6{--pico-font-size:1rem;--pico-line-height:1.25;--pico-typography-spacing-top:1.5rem}tfoot td,tfoot th,thead td,thead th{--pico-font-weight:600;--pico-border-width:0.1875rem}code,kbd,pre,samp{--pico-font-family:var(--pico-font-family-monospace)}kbd{--pico-font-weight:bolder}:where(select,textarea),input:not([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]){--pico-outline-width:0.0625rem}[type=search]{--pico-border-radius:5rem}[type=checkbox],[type=radio]{--pico-border-width:0.125rem}[type=checkbox][role=switch]{--pico-border-width:0.1875rem}details.dropdown summary:not([role=button]){--pico-outline-width:0.0625rem}nav details.dropdown summary:focus-visible{--pico-outline-width:0.125rem}[role=search]{--pico-border-radius:5rem}[role=group]:has(button.secondary:focus,[type=submit].secondary:focus,[type=button].secondary:focus,[role=button].secondary:focus),[role=search]:has(button.secondary:focus,[type=submit].secondary:focus,[type=button].secondary:focus,[role=button].secondary:focus){--pico-group-box-shadow-focus-with-button:0 0 0 var(--pico-outline-width) var(--pico-secondary-focus)}[role=group]:has(button.contrast:focus,[type=submit].contrast:focus,[type=button].contrast:focus,[role=button].contrast:focus),[role=search]:has(button.contrast:focus,[type=submit].contrast:focus,[type=button].contrast:focus,[role=button].contrast:focus){--pico-group-box-shadow-focus-with-button:0 0 0 var(--pico-outline-width) var(--pico-contrast-focus)}[role=group] [role=button],[role=group] [type=button],[role=group] [type=submit],[role=group] button,[role=search] [role=button],[role=search] [type=button],[role=search] [type=submit],[role=search] button{--pico-form-element-spacing-horizontal:2rem}details summary[role=button]:not(.outline)::after{filter:brightness(0) invert(1)}[aria-busy=true]:not(input,select,textarea):is(button,[type=submit],[type=button],[type=reset],[role=button]):not(.outline)::before{filter:brightness(0) invert(1)}:host(:not([data-theme=dark])),:root:not([data-theme=dark]),[data-theme=light]{color-scheme:light;--pico-background-color:#fff;--pico-color:#373c44;--pico-text-selection-color:rgba(116, 139, 248, 0.25);--pico-muted-color:#646b79;--pico-muted-border-color:rgb(231, 234, 239.5);--pico-primary:#2060df;--pico-primary-background:#2060df;--pico-primary-border:var(--pico-primary-background);--pico-primary-underline:rgba(32, 96, 223, 0.5);--pico-primary-hover:#184eb8;--pico-primary-hover-background:#1d59d0;--pico-primary-hover-border:var(--pico-primary-hover-background);--pico-primary-hover-underline:var(--pico-primary-hover);--pico-primary-focus:rgba(116, 139, 248, 0.5);--pico-primary-inverse:#fff;--pico-secondary:#5d6b89;--pico-secondary-background:#525f7a;--pico-secondary-border:var(--pico-secondary-background);--pico-secondary-underline:rgba(93, 107, 137, 0.5);--pico-secondary-hover:#48536b;--pico-secondary-hover-background:#48536b;--pico-secondary-hover-border:var(--pico-secondary-hover-background);--pico-secondary-hover-underline:var(--pico-secondary-hover);--pico-secondary-focus:rgba(93, 107, 137, 0.25);--pico-secondary-inverse:#fff;--pico-contrast:#181c25;--pico-contrast-background:#181c25;--pico-contrast-border:var(--pico-contrast-background);--pico-contrast-underline:rgba(24, 28, 37, 0.5);--pico-contrast-hover:#000;--pico-contrast-hover-background:#000;--pico-contrast-hover-border:var(--pico-contrast-hover-background);--pico-contrast-hover-underline:var(--pico-secondary-hover);--pico-contrast-focus:rgba(93, 107, 137, 0.25);--pico-contrast-inverse:#fff;--pico-box-shadow:0.0145rem 0.029rem 0.174rem rgba(129, 145, 181, 0.01698),0.0335rem 0.067rem 0.402rem rgba(129, 145, 181, 0.024),0.0625rem 0.125rem 0.75rem rgba(129, 145, 181, 0.03),0.1125rem 0.225rem 1.35rem rgba(129, 145, 181, 0.036),0.2085rem 0.417rem 2.502rem rgba(129, 145, 181, 0.04302),0.5rem 1rem 6rem rgba(129, 145, 181, 0.06),0 0 0 0.0625rem rgba(129, 145, 181, 0.015);--pico-h1-color:#2d3138;--pico-h2-color:#373c44;--pico-h3-color:#424751;--pico-h4-color:#4d535e;--pico-h5-color:#5c6370;--pico-h6-color:#646b79;--pico-mark-background-color:rgb(252.5, 230.5, 191.5);--pico-mark-color:#0f1114;--pico-ins-color:rgb(28.5, 105.5, 84);--pico-del-color:rgb(136, 56.5, 53);--pico-blockquote-border-color:var(--pico-muted-border-color);--pico-blockquote-footer-color:var(--pico-muted-color);--pico-button-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-button-hover-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-table-border-color:var(--pico-muted-border-color);--pico-table-row-stripped-background-color:rgba(111, 120, 135, 0.0375);--pico-code-background-color:rgb(243, 244.5, 246.75);--pico-code-color:#646b79;--pico-code-kbd-background-color:var(--pico-color);--pico-code-kbd-color:var(--pico-background-color);--pico-form-element-background-color:rgb(251, 251.5, 252.25);--pico-form-element-selected-background-color:#dfe3eb;--pico-form-element-border-color:#cfd5e2;--pico-form-element-color:#23262c;--pico-form-element-placeholder-color:var(--pico-muted-color);--pico-form-element-active-background-color:#fff;--pico-form-element-active-border-color:var(--pico-primary-border);--pico-form-element-focus-color:var(--pico-primary-border);--pico-form-element-disabled-opacity:0.5;--pico-form-element-invalid-border-color:rgb(183.5, 105.5, 106.5);--pico-form-element-invalid-active-border-color:rgb(200.25, 79.25, 72.25);--pico-form-element-invalid-focus-color:var(--pico-form-element-invalid-active-border-color);--pico-form-element-valid-border-color:rgb(76, 154.5, 137.5);--pico-form-element-valid-active-border-color:rgb(39, 152.75, 118.75);--pico-form-element-valid-focus-color:var(--pico-form-element-valid-active-border-color);--pico-switch-background-color:#bfc7d9;--pico-switch-checked-background-color:var(--pico-primary-background);--pico-switch-color:#fff;--pico-switch-thumb-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-range-border-color:#dfe3eb;--pico-range-active-border-color:#bfc7d9;--pico-range-thumb-border-color:var(--pico-background-color);--pico-range-thumb-color:var(--pico-secondary-background);--pico-range-thumb-active-color:var(--pico-primary-background);--pico-accordion-border-color:var(--pico-muted-border-color);--pico-accordion-active-summary-color:var(--pico-primary-hover);--pico-accordion-close-summary-color:var(--pico-color);--pico-accordion-open-summary-color:var(--pico-muted-color);--pico-card-background-color:var(--pico-background-color);--pico-card-border-color:var(--pico-muted-border-color);--pico-card-box-shadow:var(--pico-box-shadow);--pico-card-sectioning-background-color:rgb(251, 251.5, 252.25);--pico-dropdown-background-color:#fff;--pico-dropdown-border-color:#eff1f4;--pico-dropdown-box-shadow:var(--pico-box-shadow);--pico-dropdown-color:var(--pico-color);--pico-dropdown-hover-background-color:#eff1f4;--pico-loading-spinner-opacity:0.5;--pico-modal-overlay-background-color:rgba(232, 234, 237, 0.75);--pico-progress-background-color:#dfe3eb;--pico-progress-color:var(--pico-primary-background);--pico-tooltip-background-color:var(--pico-contrast-background);--pico-tooltip-color:var(--pico-contrast-inverse);--pico-icon-valid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(76, 154.5, 137.5)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-invalid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(200.25, 79.25, 72.25)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E")}:host(:not([data-theme=dark])) input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]),:root:not([data-theme=dark]) input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]),[data-theme=light] input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]){--pico-form-element-focus-color:var(--pico-primary-focus)}@media only screen and (prefers-color-scheme:dark){:host(:not([data-theme])),:root:not([data-theme]){color-scheme:dark;--pico-background-color:rgb(19, 22.5, 30.5);--pico-color:#c2c7d0;--pico-text-selection-color:rgba(137, 153, 249, 0.1875);--pico-muted-color:#7b8495;--pico-muted-border-color:#202632;--pico-primary:#8999f9;--pico-primary-background:#2060df;--pico-primary-border:var(--pico-primary-background);--pico-primary-underline:rgba(137, 153, 249, 0.5);--pico-primary-hover:#aeb5fb;--pico-primary-hover-background:#3c71f7;--pico-primary-hover-border:var(--pico-primary-hover-background);--pico-primary-hover-underline:var(--pico-primary-hover);--pico-primary-focus:rgba(137, 153, 249, 0.375);--pico-primary-inverse:#fff;--pico-secondary:#969eaf;--pico-secondary-background:#525f7a;--pico-secondary-border:var(--pico-secondary-background);--pico-secondary-underline:rgba(150, 158, 175, 0.5);--pico-secondary-hover:#b3b9c5;--pico-secondary-hover-background:#5d6b89;--pico-secondary-hover-border:var(--pico-secondary-hover-background);--pico-secondary-hover-underline:var(--pico-secondary-hover);--pico-secondary-focus:rgba(144, 158, 190, 0.25);--pico-secondary-inverse:#fff;--pico-contrast:#dfe3eb;--pico-contrast-background:#eff1f4;--pico-contrast-border:var(--pico-contrast-background);--pico-contrast-underline:rgba(223, 227, 235, 0.5);--pico-contrast-hover:#fff;--pico-contrast-hover-background:#fff;--pico-contrast-hover-border:var(--pico-contrast-hover-background);--pico-contrast-hover-underline:var(--pico-contrast-hover);--pico-contrast-focus:rgba(207, 213, 226, 0.25);--pico-contrast-inverse:#000;--pico-box-shadow:0.0145rem 0.029rem 0.174rem rgba(7, 8.5, 12, 0.01698),0.0335rem 0.067rem 0.402rem rgba(7, 8.5, 12, 0.024),0.0625rem 0.125rem 0.75rem rgba(7, 8.5, 12, 0.03),0.1125rem 0.225rem 1.35rem rgba(7, 8.5, 12, 0.036),0.2085rem 0.417rem 2.502rem rgba(7, 8.5, 12, 0.04302),0.5rem 1rem 6rem rgba(7, 8.5, 12, 0.06),0 0 0 0.0625rem rgba(7, 8.5, 12, 0.015);--pico-h1-color:#f0f1f3;--pico-h2-color:#e0e3e7;--pico-h3-color:#c2c7d0;--pico-h4-color:#b3b9c5;--pico-h5-color:#a4acba;--pico-h6-color:#8891a4;--pico-mark-background-color:#014063;--pico-mark-color:#fff;--pico-ins-color:#62af9a;--pico-del-color:rgb(205.5, 126, 123);--pico-blockquote-border-color:var(--pico-muted-border-color);--pico-blockquote-footer-color:var(--pico-muted-color);--pico-button-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-button-hover-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-table-border-color:var(--pico-muted-border-color);--pico-table-row-stripped-background-color:rgba(111, 120, 135, 0.0375);--pico-code-background-color:rgb(26, 30.5, 40.25);--pico-code-color:#8891a4;--pico-code-kbd-background-color:var(--pico-color);--pico-code-kbd-color:var(--pico-background-color);--pico-form-element-background-color:rgb(28, 33, 43.5);--pico-form-element-selected-background-color:#2a3140;--pico-form-element-border-color:#2a3140;--pico-form-element-color:#e0e3e7;--pico-form-element-placeholder-color:#8891a4;--pico-form-element-active-background-color:rgb(26, 30.5, 40.25);--pico-form-element-active-border-color:var(--pico-primary-border);--pico-form-element-focus-color:var(--pico-primary-border);--pico-form-element-disabled-opacity:0.5;--pico-form-element-invalid-border-color:rgb(149.5, 74, 80);--pico-form-element-invalid-active-border-color:rgb(183.25, 63.5, 59);--pico-form-element-invalid-focus-color:var(--pico-form-element-invalid-active-border-color);--pico-form-element-valid-border-color:#2a7b6f;--pico-form-element-valid-active-border-color:rgb(22, 137, 105.5);--pico-form-element-valid-focus-color:var(--pico-form-element-valid-active-border-color);--pico-switch-background-color:#333c4e;--pico-switch-checked-background-color:var(--pico-primary-background);--pico-switch-color:#fff;--pico-switch-thumb-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-range-border-color:#202632;--pico-range-active-border-color:#2a3140;--pico-range-thumb-border-color:var(--pico-background-color);--pico-range-thumb-color:var(--pico-secondary-background);--pico-range-thumb-active-color:var(--pico-primary-background);--pico-accordion-border-color:var(--pico-muted-border-color);--pico-accordion-active-summary-color:var(--pico-primary-hover);--pico-accordion-close-summary-color:var(--pico-color);--pico-accordion-open-summary-color:var(--pico-muted-color);--pico-card-background-color:#181c25;--pico-card-border-color:var(--pico-card-background-color);--pico-card-box-shadow:var(--pico-box-shadow);--pico-card-sectioning-background-color:rgb(26, 30.5, 40.25);--pico-dropdown-background-color:#181c25;--pico-dropdown-border-color:#202632;--pico-dropdown-box-shadow:var(--pico-box-shadow);--pico-dropdown-color:var(--pico-color);--pico-dropdown-hover-background-color:#202632;--pico-loading-spinner-opacity:0.5;--pico-modal-overlay-background-color:rgba(7.5, 8.5, 10, 0.75);--pico-progress-background-color:#202632;--pico-progress-color:var(--pico-primary-background);--pico-tooltip-background-color:var(--pico-contrast-background);--pico-tooltip-color:var(--pico-contrast-inverse);--pico-icon-valid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(42, 123, 111)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-invalid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(149.5, 74, 80)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E")}:host(:not([data-theme])) input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]),:root:not([data-theme]) input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]){--pico-form-element-focus-color:var(--pico-primary-focus)}:host(:not([data-theme])) details summary[role=button].contrast:not(.outline)::after,:root:not([data-theme]) details summary[role=button].contrast:not(.outline)::after{filter:brightness(0)}:host(:not([data-theme])) [aria-busy=true]:not(input,select,textarea).contrast:is(button,[type=submit],[type=button],[type=reset],[role=button]):not(.outline)::before,:root:not([data-theme]) [aria-busy=true]:not(input,select,textarea).contrast:is(button,[type=submit],[type=button],[type=reset],[role=button]):not(.outline)::before{filter:brightness(0)}}[data-theme=dark]{color-scheme:dark;--pico-background-color:rgb(19, 22.5, 30.5);--pico-color:#c2c7d0;--pico-text-selection-color:rgba(137, 153, 249, 0.1875);--pico-muted-color:#7b8495;--pico-muted-border-color:#202632;--pico-primary:#8999f9;--pico-primary-background:#2060df;--pico-primary-border:var(--pico-primary-background);--pico-primary-underline:rgba(137, 153, 249, 0.5);--pico-primary-hover:#aeb5fb;--pico-primary-hover-background:#3c71f7;--pico-primary-hover-border:var(--pico-primary-hover-background);--pico-primary-hover-underline:var(--pico-primary-hover);--pico-primary-focus:rgba(137, 153, 249, 0.375);--pico-primary-inverse:#fff;--pico-secondary:#969eaf;--pico-secondary-background:#525f7a;--pico-secondary-border:var(--pico-secondary-background);--pico-secondary-underline:rgba(150, 158, 175, 0.5);--pico-secondary-hover:#b3b9c5;--pico-secondary-hover-background:#5d6b89;--pico-secondary-hover-border:var(--pico-secondary-hover-background);--pico-secondary-hover-underline:var(--pico-secondary-hover);--pico-secondary-focus:rgba(144, 158, 190, 0.25);--pico-secondary-inverse:#fff;--pico-contrast:#dfe3eb;--pico-contrast-background:#eff1f4;--pico-contrast-border:var(--pico-contrast-background);--pico-contrast-underline:rgba(223, 227, 235, 0.5);--pico-contrast-hover:#fff;--pico-contrast-hover-background:#fff;--pico-contrast-hover-border:var(--pico-contrast-hover-background);--pico-contrast-hover-underline:var(--pico-contrast-hover);--pico-contrast-focus:rgba(207, 213, 226, 0.25);--pico-contrast-inverse:#000;--pico-box-shadow:0.0145rem 0.029rem 0.174rem rgba(7, 8.5, 12, 0.01698),0.0335rem 0.067rem 0.402rem rgba(7, 8.5, 12, 0.024),0.0625rem 0.125rem 0.75rem rgba(7, 8.5, 12, 0.03),0.1125rem 0.225rem 1.35rem rgba(7, 8.5, 12, 0.036),0.2085rem 0.417rem 2.502rem rgba(7, 8.5, 12, 0.04302),0.5rem 1rem 6rem rgba(7, 8.5, 12, 0.06),0 0 0 0.0625rem rgba(7, 8.5, 12, 0.015);--pico-h1-color:#f0f1f3;--pico-h2-color:#e0e3e7;--pico-h3-color:#c2c7d0;--pico-h4-color:#b3b9c5;--pico-h5-color:#a4acba;--pico-h6-color:#8891a4;--pico-mark-background-color:#014063;--pico-mark-color:#fff;--pico-ins-color:#62af9a;--pico-del-color:rgb(205.5, 126, 123);--pico-blockquote-border-color:var(--pico-muted-border-color);--pico-blockquote-footer-color:var(--pico-muted-color);--pico-button-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-button-hover-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-table-border-color:var(--pico-muted-border-color);--pico-table-row-stripped-background-color:rgba(111, 120, 135, 0.0375);--pico-code-background-color:rgb(26, 30.5, 40.25);--pico-code-color:#8891a4;--pico-code-kbd-background-color:var(--pico-color);--pico-code-kbd-color:var(--pico-background-color);--pico-form-element-background-color:rgb(28, 33, 43.5);--pico-form-element-selected-background-color:#2a3140;--pico-form-element-border-color:#2a3140;--pico-form-element-color:#e0e3e7;--pico-form-element-placeholder-color:#8891a4;--pico-form-element-active-background-color:rgb(26, 30.5, 40.25);--pico-form-element-active-border-color:var(--pico-primary-border);--pico-form-element-focus-color:var(--pico-primary-border);--pico-form-element-disabled-opacity:0.5;--pico-form-element-invalid-border-color:rgb(149.5, 74, 80);--pico-form-element-invalid-active-border-color:rgb(183.25, 63.5, 59);--pico-form-element-invalid-focus-color:var(--pico-form-element-invalid-active-border-color);--pico-form-element-valid-border-color:#2a7b6f;--pico-form-element-valid-active-border-color:rgb(22, 137, 105.5);--pico-form-element-valid-focus-color:var(--pico-form-element-valid-active-border-color);--pico-switch-background-color:#333c4e;--pico-switch-checked-background-color:var(--pico-primary-background);--pico-switch-color:#fff;--pico-switch-thumb-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-range-border-color:#202632;--pico-range-active-border-color:#2a3140;--pico-range-thumb-border-color:var(--pico-background-color);--pico-range-thumb-color:var(--pico-secondary-background);--pico-range-thumb-active-color:var(--pico-primary-background);--pico-accordion-border-color:var(--pico-muted-border-color);--pico-accordion-active-summary-color:var(--pico-primary-hover);--pico-accordion-close-summary-color:var(--pico-color);--pico-accordion-open-summary-color:var(--pico-muted-color);--pico-card-background-color:#181c25;--pico-card-border-color:var(--pico-card-background-color);--pico-card-box-shadow:var(--pico-box-shadow);--pico-card-sectioning-background-color:rgb(26, 30.5, 40.25);--pico-dropdown-background-color:#181c25;--pico-dropdown-border-color:#202632;--pico-dropdown-box-shadow:var(--pico-box-shadow);--pico-dropdown-color:var(--pico-color);--pico-dropdown-hover-background-color:#202632;--pico-loading-spinner-opacity:0.5;--pico-modal-overlay-background-color:rgba(7.5, 8.5, 10, 0.75);--pico-progress-background-color:#202632;--pico-progress-color:var(--pico-primary-background);--pico-tooltip-background-color:var(--pico-contrast-background);--pico-tooltip-color:var(--pico-contrast-inverse);--pico-icon-valid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(42, 123, 111)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-invalid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(149.5, 74, 80)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E")}[data-theme=dark] input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]){--pico-form-element-focus-color:var(--pico-primary-focus)}[data-theme=dark] details summary[role=button].contrast:not(.outline)::after{filter:brightness(0)}[data-theme=dark] [aria-busy=true]:not(input,select,textarea).contrast:is(button,[type=submit],[type=button],[type=reset],[role=button]):not(.outline)::before{filter:brightness(0)}[type=checkbox],[type=radio],[type=range],progress{accent-color:var(--pico-primary)}*,::after,::before{box-sizing:border-box;background-repeat:no-repeat}::after,::before{text-decoration:inherit;vertical-align:inherit}:where(:host),:where(:root){-webkit-tap-highlight-color:transparent;-webkit-text-size-adjust:100%;-moz-text-size-adjust:100%;text-size-adjust:100%;background-color:var(--pico-background-color);color:var(--pico-color);font-weight:var(--pico-font-weight);font-size:var(--pico-font-size);line-height:var(--pico-line-height);font-family:var(--pico-font-family);text-underline-offset:var(--pico-text-underline-offset);text-rendering:optimizeLegibility;overflow-wrap:break-word;-moz-tab-size:4;-o-tab-size:4;tab-size:4}body{width:100%;margin:0}main{display:block}body>footer,body>header,body>main{padding-block:var(--pico-block-spacing-vertical)}section{margin-bottom:var(--pico-block-spacing-vertical)}.container,.container-fluid{width:100%;margin-right:auto;margin-left:auto;padding-right:var(--pico-spacing);padding-left:var(--pico-spacing)}@media (min-width:576px){.container{max-width:510px;padding-right:0;padding-left:0}}@media (min-width:768px){.container{max-width:700px}}@media (min-width:1024px){.container{max-width:950px}}@media (min-width:1280px){.container{max-width:1200px}}@media (min-width:1536px){.container{max-width:1450px}}.grid{grid-column-gap:var(--pico-grid-column-gap);grid-row-gap:var(--pico-grid-row-gap);display:grid;grid-template-columns:1fr}@media (min-width:768px){.grid{grid-template-columns:repeat(auto-fit,minmax(0%,1fr))}}.grid>*{min-width:0}.overflow-auto{overflow:auto}b,strong{font-weight:bolder}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}address,blockquote,dl,ol,p,pre,table,ul{margin-top:0;margin-bottom:var(--pico-typography-spacing-vertical);color:var(--pico-color);font-style:normal;font-weight:var(--pico-font-weight)}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:var(--pico-typography-spacing-vertical);color:var(--pico-color);font-weight:var(--pico-font-weight);font-size:var(--pico-font-size);line-height:var(--pico-line-height);font-family:var(--pico-font-family)}h1{--pico-color:var(--pico-h1-color)}h2{--pico-color:var(--pico-h2-color)}h3{--pico-color:var(--pico-h3-color)}h4{--pico-color:var(--pico-h4-color)}h5{--pico-color:var(--pico-h5-color)}h6{--pico-color:var(--pico-h6-color)}:where(article,address,blockquote,dl,figure,form,ol,p,pre,table,ul)~:is(h1,h2,h3,h4,h5,h6){margin-top:var(--pico-typography-spacing-top)}p{margin-bottom:var(--pico-typography-spacing-vertical)}hgroup{margin-bottom:var(--pico-typography-spacing-vertical)}hgroup>*{margin-top:0;margin-bottom:0}hgroup>:not(:first-child):last-child{--pico-color:var(--pico-muted-color);--pico-font-weight:unset;font-size:1rem}:where(ol,ul) li{margin-bottom:calc(var(--pico-typography-spacing-vertical) * .25)}:where(dl,ol,ul) :where(dl,ol,ul){margin:0;margin-top:calc(var(--pico-typography-spacing-vertical) * .25)}ul li{list-style:square}mark{padding:.125rem .25rem;background-color:var(--pico-mark-background-color);color:var(--pico-mark-color);vertical-align:baseline}blockquote{display:block;margin:var(--pico-typography-spacing-vertical) 0;padding:var(--pico-spacing);border-right:none;border-left:.25rem solid var(--pico-blockquote-border-color);border-inline-start:0.25rem solid var(--pico-blockquote-border-color);border-inline-end:none}blockquote footer{margin-top:calc(var(--pico-typography-spacing-vertical) * .5);color:var(--pico-blockquote-footer-color)}abbr[title]{border-bottom:1px dotted;text-decoration:none;cursor:help}ins{color:var(--pico-ins-color);text-decoration:none}del{color:var(--pico-del-color)}::-moz-selection{background-color:var(--pico-text-selection-color)}::selection{background-color:var(--pico-text-selection-color)}:where(a:not([role=button])),[role=link]{--pico-color:var(--pico-primary);--pico-background-color:transparent;--pico-underline:var(--pico-primary-underline);outline:0;background-color:var(--pico-background-color);color:var(--pico-color);-webkit-text-decoration:var(--pico-text-decoration);text-decoration:var(--pico-text-decoration);text-decoration-color:var(--pico-underline);text-underline-offset:0.125em;transition:background-color var(--pico-transition),color var(--pico-transition),box-shadow var(--pico-transition),-webkit-text-decoration var(--pico-transition);transition:background-color var(--pico-transition),color var(--pico-transition),text-decoration var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),color var(--pico-transition),text-decoration var(--pico-transition),box-shadow var(--pico-transition),-webkit-text-decoration var(--pico-transition)}:where(a:not([role=button])):is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[role=link]:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-primary-hover);--pico-underline:var(--pico-primary-hover-underline);--pico-text-decoration:underline}:where(a:not([role=button])):focus-visible,[role=link]:focus-visible{box-shadow:0 0 0 var(--pico-outline-width) var(--pico-primary-focus)}:where(a:not([role=button])).secondary,[role=link].secondary{--pico-color:var(--pico-secondary);--pico-underline:var(--pico-secondary-underline)}:where(a:not([role=button])).secondary:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[role=link].secondary:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-secondary-hover);--pico-underline:var(--pico-secondary-hover-underline)}:where(a:not([role=button])).contrast,[role=link].contrast{--pico-color:var(--pico-contrast);--pico-underline:var(--pico-contrast-underline)}:where(a:not([role=button])).contrast:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[role=link].contrast:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-contrast-hover);--pico-underline:var(--pico-contrast-hover-underline)}a[role=button]{display:inline-block}button{margin:0;overflow:visible;font-family:inherit;text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[role=button],[type=button],[type=file]::file-selector-button,[type=reset],[type=submit],button{--pico-background-color:var(--pico-primary-background);--pico-border-color:var(--pico-primary-border);--pico-color:var(--pico-primary-inverse);--pico-box-shadow:var(--pico-button-box-shadow, 0 0 0 rgba(0, 0, 0, 0));padding:var(--pico-form-element-spacing-vertical) var(--pico-form-element-spacing-horizontal);border:var(--pico-border-width) solid var(--pico-border-color);border-radius:var(--pico-border-radius);outline:0;background-color:var(--pico-background-color);box-shadow:var(--pico-box-shadow);color:var(--pico-color);font-weight:var(--pico-font-weight);font-size:1rem;line-height:var(--pico-line-height);text-align:center;text-decoration:none;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;transition:background-color var(--pico-transition),border-color var(--pico-transition),color var(--pico-transition),box-shadow var(--pico-transition)}[role=button]:is(:hover,:active,:focus),[role=button]:is([aria-current]:not([aria-current=false])),[type=button]:is(:hover,:active,:focus),[type=button]:is([aria-current]:not([aria-current=false])),[type=file]::file-selector-button:is(:hover,:active,:focus),[type=file]::file-selector-button:is([aria-current]:not([aria-current=false])),[type=reset]:is(:hover,:active,:focus),[type=reset]:is([aria-current]:not([aria-current=false])),[type=submit]:is(:hover,:active,:focus),[type=submit]:is([aria-current]:not([aria-current=false])),button:is(:hover,:active,:focus),button:is([aria-current]:not([aria-current=false])){--pico-background-color:var(--pico-primary-hover-background);--pico-border-color:var(--pico-primary-hover-border);--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0));--pico-color:var(--pico-primary-inverse)}[role=button]:focus,[role=button]:is([aria-current]:not([aria-current=false])):focus,[type=button]:focus,[type=button]:is([aria-current]:not([aria-current=false])):focus,[type=file]::file-selector-button:focus,[type=file]::file-selector-button:is([aria-current]:not([aria-current=false])):focus,[type=reset]:focus,[type=reset]:is([aria-current]:not([aria-current=false])):focus,[type=submit]:focus,[type=submit]:is([aria-current]:not([aria-current=false])):focus,button:focus,button:is([aria-current]:not([aria-current=false])):focus{--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--pico-outline-width) var(--pico-primary-focus)}[type=button],[type=reset],[type=submit]{margin-bottom:var(--pico-spacing)}:is(button,[type=submit],[type=button],[role=button]).secondary,[type=file]::file-selector-button,[type=reset]{--pico-background-color:var(--pico-secondary-background);--pico-border-color:var(--pico-secondary-border);--pico-color:var(--pico-secondary-inverse);cursor:pointer}:is(button,[type=submit],[type=button],[role=button]).secondary:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[type=file]::file-selector-button:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[type=reset]:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-background-color:var(--pico-secondary-hover-background);--pico-border-color:var(--pico-secondary-hover-border);--pico-color:var(--pico-secondary-inverse)}:is(button,[type=submit],[type=button],[role=button]).secondary:focus,:is(button,[type=submit],[type=button],[role=button]).secondary:is([aria-current]:not([aria-current=false])):focus,[type=file]::file-selector-button:focus,[type=file]::file-selector-button:is([aria-current]:not([aria-current=false])):focus,[type=reset]:focus,[type=reset]:is([aria-current]:not([aria-current=false])):focus{--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--pico-outline-width) var(--pico-secondary-focus)}:is(button,[type=submit],[type=button],[role=button]).contrast{--pico-background-color:var(--pico-contrast-background);--pico-border-color:var(--pico-contrast-border);--pico-color:var(--pico-contrast-inverse)}:is(button,[type=submit],[type=button],[role=button]).contrast:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-background-color:var(--pico-contrast-hover-background);--pico-border-color:var(--pico-contrast-hover-border);--pico-color:var(--pico-contrast-inverse)}:is(button,[type=submit],[type=button],[role=button]).contrast:focus,:is(button,[type=submit],[type=button],[role=button]).contrast:is([aria-current]:not([aria-current=false])):focus{--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--pico-outline-width) var(--pico-contrast-focus)}:is(button,[type=submit],[type=button],[role=button]).outline,[type=reset].outline{--pico-background-color:transparent;--pico-color:var(--pico-primary);--pico-border-color:var(--pico-primary)}:is(button,[type=submit],[type=button],[role=button]).outline:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[type=reset].outline:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-background-color:transparent;--pico-color:var(--pico-primary-hover);--pico-border-color:var(--pico-primary-hover)}:is(button,[type=submit],[type=button],[role=button]).outline.secondary,[type=reset].outline{--pico-color:var(--pico-secondary);--pico-border-color:var(--pico-secondary)}:is(button,[type=submit],[type=button],[role=button]).outline.secondary:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[type=reset].outline:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-secondary-hover);--pico-border-color:var(--pico-secondary-hover)}:is(button,[type=submit],[type=button],[role=button]).outline.contrast{--pico-color:var(--pico-contrast);--pico-border-color:var(--pico-contrast)}:is(button,[type=submit],[type=button],[role=button]).outline.contrast:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-contrast-hover);--pico-border-color:var(--pico-contrast-hover)}:where(button,[type=submit],[type=reset],[type=button],[role=button])[disabled],:where(fieldset[disabled]) :is(button,[type=submit],[type=button],[type=reset],[role=button]){opacity:.5;pointer-events:none}:where(table){width:100%;border-collapse:collapse;border-spacing:0;text-indent:0}td,th{padding:calc(var(--pico-spacing)/ 2) var(--pico-spacing);border-bottom:var(--pico-border-width) solid var(--pico-table-border-color);background-color:var(--pico-background-color);color:var(--pico-color);font-weight:var(--pico-font-weight);text-align:left;text-align:start}tfoot td,tfoot th{border-top:var(--pico-border-width) solid var(--pico-table-border-color);border-bottom:0}table.striped tbody tr:nth-child(odd) td,table.striped tbody tr:nth-child(odd) th{background-color:var(--pico-table-row-stripped-background-color)}:where(audio,canvas,iframe,img,svg,video){vertical-align:middle}audio,video{display:inline-block}audio:not([controls]){display:none;height:0}:where(iframe){border-style:none}img{max-width:100%;height:auto;border-style:none}:where(svg:not([fill])){fill:currentColor}svg:not(:host),svg:not(:root){overflow:hidden}code,kbd,pre,samp{font-size:.875em;font-family:var(--pico-font-family)}pre code,pre samp{font-size:inherit;font-family:inherit}pre{-ms-overflow-style:scrollbar;overflow:auto}code,kbd,pre,samp{border-radius:var(--pico-border-radius);background:var(--pico-code-background-color);color:var(--pico-code-color);font-weight:var(--pico-font-weight);line-height:initial}code,kbd,samp{display:inline-block;padding:.375rem}pre{display:block;margin-bottom:var(--pico-spacing);overflow-x:auto}pre>code,pre>samp{display:block;padding:var(--pico-spacing);background:0 0;line-height:var(--pico-line-height)}kbd{background-color:var(--pico-code-kbd-background-color);color:var(--pico-code-kbd-color);vertical-align:baseline}figure{display:block;margin:0;padding:0}figure figcaption{padding:calc(var(--pico-spacing) * .5) 0;color:var(--pico-muted-color)}hr{height:0;margin:var(--pico-typography-spacing-vertical) 0;border:0;border-top:1px solid var(--pico-muted-border-color);color:inherit}[hidden],template{display:none!important}canvas{display:inline-block}input,optgroup,select,textarea{margin:0;font-size:1rem;line-height:var(--pico-line-height);font-family:inherit;letter-spacing:inherit}input{overflow:visible}select{text-transform:none}legend{max-width:100%;padding:0;color:inherit;white-space:normal}textarea{overflow:auto}[type=checkbox],[type=radio]{padding:0}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}::-moz-focus-inner{padding:0;border-style:none}:-moz-focusring{outline:0}:-moz-ui-invalid{box-shadow:none}::-ms-expand{display:none}[type=file],[type=range]{padding:0;border-width:0}input:not([type=checkbox],[type=radio],[type=range]){height:calc(1rem * var(--pico-line-height) + var(--pico-form-element-spacing-vertical) * 2 + var(--pico-border-width) * 2)}fieldset{width:100%;margin:0;margin-bottom:var(--pico-spacing);padding:0;border:0}fieldset legend,label{display:block;margin-bottom:calc(var(--pico-spacing) * .375);color:var(--pico-color);font-weight:var(--pico-form-label-font-weight,var(--pico-font-weight))}fieldset legend{margin-bottom:calc(var(--pico-spacing) * .5)}button[type=submit],input:not([type=checkbox],[type=radio]),select,textarea{width:100%}input:not([type=checkbox],[type=radio],[type=range],[type=file]),select,textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;padding:var(--pico-form-element-spacing-vertical) var(--pico-form-element-spacing-horizontal)}input,select,textarea{--pico-background-color:var(--pico-form-element-background-color);--pico-border-color:var(--pico-form-element-border-color);--pico-color:var(--pico-form-element-color);--pico-box-shadow:none;border:var(--pico-border-width) solid var(--pico-border-color);border-radius:var(--pico-border-radius);outline:0;background-color:var(--pico-background-color);box-shadow:var(--pico-box-shadow);color:var(--pico-color);font-weight:var(--pico-font-weight);transition:background-color var(--pico-transition),border-color var(--pico-transition),color var(--pico-transition),box-shadow var(--pico-transition)}:where(select,textarea):not([readonly]):is(:active,:focus),input:not([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[readonly]):is(:active,:focus){--pico-background-color:var(--pico-form-element-active-background-color)}:where(select,textarea):not([readonly]):is(:active,:focus),input:not([type=submit],[type=button],[type=reset],[role=switch],[readonly]):is(:active,:focus){--pico-border-color:var(--pico-form-element-active-border-color)}:where(select,textarea):not([readonly]):focus,input:not([type=submit],[type=button],[type=reset],[type=range],[type=file],[readonly]):focus{--pico-box-shadow:0 0 0 var(--pico-outline-width) var(--pico-form-element-focus-color)}:where(fieldset[disabled]) :is(input:not([type=submit],[type=button],[type=reset]),select,textarea),input:not([type=submit],[type=button],[type=reset])[disabled],label[aria-disabled=true],select[disabled],textarea[disabled]{opacity:var(--pico-form-element-disabled-opacity);pointer-events:none}label[aria-disabled=true] input[disabled]{opacity:1}:where(input,select,textarea):not([type=checkbox],[type=radio],[type=date],[type=datetime-local],[type=month],[type=time],[type=week],[type=range])[aria-invalid]{padding-right:calc(var(--pico-form-element-spacing-horizontal) + 1.5rem)!important;padding-left:var(--pico-form-element-spacing-horizontal);padding-inline-start:var(--pico-form-element-spacing-horizontal)!important;padding-inline-end:calc(var(--pico-form-element-spacing-horizontal) + 1.5rem)!important;background-position:center right .75rem;background-size:1rem auto;background-repeat:no-repeat}:where(input,select,textarea):not([type=checkbox],[type=radio],[type=date],[type=datetime-local],[type=month],[type=time],[type=week],[type=range])[aria-invalid=false]:not(select){background-image:var(--pico-icon-valid)}:where(input,select,textarea):not([type=checkbox],[type=radio],[type=date],[type=datetime-local],[type=month],[type=time],[type=week],[type=range])[aria-invalid=true]:not(select){background-image:var(--pico-icon-invalid)}:where(input,select,textarea)[aria-invalid=false]{--pico-border-color:var(--pico-form-element-valid-border-color)}:where(input,select,textarea)[aria-invalid=false]:is(:active,:focus){--pico-border-color:var(--pico-form-element-valid-active-border-color)!important}:where(input,select,textarea)[aria-invalid=false]:is(:active,:focus):not([type=checkbox],[type=radio]){--pico-box-shadow:0 0 0 var(--pico-outline-width) var(--pico-form-element-valid-focus-color)!important}:where(input,select,textarea)[aria-invalid=true]{--pico-border-color:var(--pico-form-element-invalid-border-color)}:where(input,select,textarea)[aria-invalid=true]:is(:active,:focus){--pico-border-color:var(--pico-form-element-invalid-active-border-color)!important}:where(input,select,textarea)[aria-invalid=true]:is(:active,:focus):not([type=checkbox],[type=radio]){--pico-box-shadow:0 0 0 var(--pico-outline-width) var(--pico-form-element-invalid-focus-color)!important}[dir=rtl] :where(input,select,textarea):not([type=checkbox],[type=radio]):is([aria-invalid],[aria-invalid=true],[aria-invalid=false]){background-position:center left .75rem}input::-webkit-input-placeholder,input::placeholder,select:invalid,textarea::-webkit-input-placeholder,textarea::placeholder{color:var(--pico-form-element-placeholder-color);opacity:1}input:not([type=checkbox],[type=radio]),select,textarea{margin-bottom:var(--pico-spacing)}select::-ms-expand{border:0;background-color:transparent}select:not([multiple],[size]){padding-right:calc(var(--pico-form-element-spacing-horizontal) + 1.5rem);padding-left:var(--pico-form-element-spacing-horizontal);padding-inline-start:var(--pico-form-element-spacing-horizontal);padding-inline-end:calc(var(--pico-form-element-spacing-horizontal) + 1.5rem);background-image:var(--pico-icon-chevron);background-position:center right .75rem;background-size:1rem auto;background-repeat:no-repeat}select[multiple] option:checked{background:var(--pico-form-element-selected-background-color);color:var(--pico-form-element-color)}[dir=rtl] select:not([multiple],[size]){background-position:center left .75rem}textarea{display:block;resize:vertical}textarea[aria-invalid]{--pico-icon-height:calc(1rem * var(--pico-line-height) + var(--pico-form-element-spacing-vertical) * 2 + var(--pico-border-width) * 2);background-position:top right .75rem!important;background-size:1rem var(--pico-icon-height)!important}:where(input,select,textarea,fieldset,.grid)+small{display:block;width:100%;margin-top:calc(var(--pico-spacing) * -.75);margin-bottom:var(--pico-spacing);color:var(--pico-muted-color)}:where(input,select,textarea,fieldset,.grid)[aria-invalid=false]+small{color:var(--pico-ins-color)}:where(input,select,textarea,fieldset,.grid)[aria-invalid=true]+small{color:var(--pico-del-color)}label>:where(input,select,textarea){margin-top:calc(var(--pico-spacing) * .25)}label:has([type=checkbox],[type=radio]){width:-moz-fit-content;width:fit-content;cursor:pointer}[type=checkbox],[type=radio]{-webkit-appearance:none;-moz-appearance:none;appearance:none;width:1.25em;height:1.25em;margin-top:-.125em;margin-inline-end:.5em;border-width:var(--pico-border-width);vertical-align:middle;cursor:pointer}[type=checkbox]::-ms-check,[type=radio]::-ms-check{display:none}[type=checkbox]:checked,[type=checkbox]:checked:active,[type=checkbox]:checked:focus,[type=radio]:checked,[type=radio]:checked:active,[type=radio]:checked:focus{--pico-background-color:var(--pico-primary-background);--pico-border-color:var(--pico-primary-border);background-image:var(--pico-icon-checkbox);background-position:center;background-size:.75em auto;background-repeat:no-repeat}[type=checkbox]~label,[type=radio]~label{display:inline-block;margin-bottom:0;cursor:pointer}[type=checkbox]~label:not(:last-of-type),[type=radio]~label:not(:last-of-type){margin-inline-end:1em}[type=checkbox]:indeterminate{--pico-background-color:var(--pico-primary-background);--pico-border-color:var(--pico-primary-border);background-image:var(--pico-icon-minus);background-position:center;background-size:.75em auto;background-repeat:no-repeat}[type=radio]{border-radius:50%}[type=radio]:checked,[type=radio]:checked:active,[type=radio]:checked:focus{--pico-background-color:var(--pico-primary-inverse);border-width:.35em;background-image:none}[type=checkbox][role=switch]{--pico-background-color:var(--pico-switch-background-color);--pico-color:var(--pico-switch-color);width:2.25em;height:1.25em;border:var(--pico-border-width) solid var(--pico-border-color);border-radius:1.25em;background-color:var(--pico-background-color);line-height:1.25em}[type=checkbox][role=switch]:not([aria-invalid]){--pico-border-color:var(--pico-switch-background-color)}[type=checkbox][role=switch]:before{display:block;aspect-ratio:1;height:100%;border-radius:50%;background-color:var(--pico-color);box-shadow:var(--pico-switch-thumb-box-shadow);content:"";transition:margin .1s ease-in-out}[type=checkbox][role=switch]:focus{--pico-background-color:var(--pico-switch-background-color);--pico-border-color:var(--pico-switch-background-color)}[type=checkbox][role=switch]:checked{--pico-background-color:var(--pico-switch-checked-background-color);--pico-border-color:var(--pico-switch-checked-background-color);background-image:none}[type=checkbox][role=switch]:checked::before{margin-inline-start:calc(2.25em - 1.25em)}[type=checkbox][role=switch][disabled]{--pico-background-color:var(--pico-border-color)}[type=checkbox][aria-invalid=false]:checked,[type=checkbox][aria-invalid=false]:checked:active,[type=checkbox][aria-invalid=false]:checked:focus,[type=checkbox][role=switch][aria-invalid=false]:checked,[type=checkbox][role=switch][aria-invalid=false]:checked:active,[type=checkbox][role=switch][aria-invalid=false]:checked:focus{--pico-background-color:var(--pico-form-element-valid-border-color)}[type=checkbox]:checked:active[aria-invalid=true],[type=checkbox]:checked:focus[aria-invalid=true],[type=checkbox]:checked[aria-invalid=true],[type=checkbox][role=switch]:checked:active[aria-invalid=true],[type=checkbox][role=switch]:checked:focus[aria-invalid=true],[type=checkbox][role=switch]:checked[aria-invalid=true]{--pico-background-color:var(--pico-form-element-invalid-border-color)}[type=checkbox][aria-invalid=false]:checked,[type=checkbox][aria-invalid=false]:checked:active,[type=checkbox][aria-invalid=false]:checked:focus,[type=checkbox][role=switch][aria-invalid=false]:checked,[type=checkbox][role=switch][aria-invalid=false]:checked:active,[type=checkbox][role=switch][aria-invalid=false]:checked:focus,[type=radio][aria-invalid=false]:checked,[type=radio][aria-invalid=false]:checked:active,[type=radio][aria-invalid=false]:checked:focus{--pico-border-color:var(--pico-form-element-valid-border-color)}[type=checkbox]:checked:active[aria-invalid=true],[type=checkbox]:checked:focus[aria-invalid=true],[type=checkbox]:checked[aria-invalid=true],[type=checkbox][role=switch]:checked:active[aria-invalid=true],[type=checkbox][role=switch]:checked:focus[aria-invalid=true],[type=checkbox][role=switch]:checked[aria-invalid=true],[type=radio]:checked:active[aria-invalid=true],[type=radio]:checked:focus[aria-invalid=true],[type=radio]:checked[aria-invalid=true]{--pico-border-color:var(--pico-form-element-invalid-border-color)}[type=color]::-webkit-color-swatch-wrapper{padding:0}[type=color]::-moz-focus-inner{padding:0}[type=color]::-webkit-color-swatch{border:0;border-radius:calc(var(--pico-border-radius) * .5)}[type=color]::-moz-color-swatch{border:0;border-radius:calc(var(--pico-border-radius) * .5)}input:not([type=checkbox],[type=radio],[type=range],[type=file]):is([type=date],[type=datetime-local],[type=month],[type=time],[type=week]){--pico-icon-position:0.75rem;--pico-icon-width:1rem;padding-right:calc(var(--pico-icon-width) + var(--pico-icon-position));background-image:var(--pico-icon-date);background-position:center right var(--pico-icon-position);background-size:var(--pico-icon-width) auto;background-repeat:no-repeat}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=time]{background-image:var(--pico-icon-time)}[type=date]::-webkit-calendar-picker-indicator,[type=datetime-local]::-webkit-calendar-picker-indicator,[type=month]::-webkit-calendar-picker-indicator,[type=time]::-webkit-calendar-picker-indicator,[type=week]::-webkit-calendar-picker-indicator{width:var(--pico-icon-width);margin-right:calc(var(--pico-icon-width) * -1);margin-left:var(--pico-icon-position);opacity:0}@-moz-document url-prefix(){[type=date],[type=datetime-local],[type=month],[type=time],[type=week]{padding-right:var(--pico-form-element-spacing-horizontal)!important;background-image:none!important}}[dir=rtl] :is([type=date],[type=datetime-local],[type=month],[type=time],[type=week]){text-align:right}[type=file]{--pico-color:var(--pico-muted-color);margin-left:calc(var(--pico-outline-width) * -1);padding:calc(var(--pico-form-element-spacing-vertical) * .5) 0;padding-left:var(--pico-outline-width);border:0;border-radius:0;background:0 0}[type=file]::file-selector-button{margin-right:calc(var(--pico-spacing)/ 2);padding:calc(var(--pico-form-element-spacing-vertical) * .5) var(--pico-form-element-spacing-horizontal)}[type=file]:is(:hover,:active,:focus)::file-selector-button{--pico-background-color:var(--pico-secondary-hover-background);--pico-border-color:var(--pico-secondary-hover-border)}[type=file]:focus::file-selector-button{--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--pico-outline-width) var(--pico-secondary-focus)}[type=range]{-webkit-appearance:none;-moz-appearance:none;appearance:none;width:100%;height:1.25rem;background:0 0}[type=range]::-webkit-slider-runnable-track{width:100%;height:.375rem;border-radius:var(--pico-border-radius);background-color:var(--pico-range-border-color);-webkit-transition:background-color var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),box-shadow var(--pico-transition)}[type=range]::-moz-range-track{width:100%;height:.375rem;border-radius:var(--pico-border-radius);background-color:var(--pico-range-border-color);-moz-transition:background-color var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),box-shadow var(--pico-transition)}[type=range]::-ms-track{width:100%;height:.375rem;border-radius:var(--pico-border-radius);background-color:var(--pico-range-border-color);-ms-transition:background-color var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),box-shadow var(--pico-transition)}[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.4375rem;border:2px solid var(--pico-range-thumb-border-color);border-radius:50%;background-color:var(--pico-range-thumb-color);cursor:pointer;-webkit-transition:background-color var(--pico-transition),transform var(--pico-transition);transition:background-color var(--pico-transition),transform var(--pico-transition)}[type=range]::-moz-range-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.4375rem;border:2px solid var(--pico-range-thumb-border-color);border-radius:50%;background-color:var(--pico-range-thumb-color);cursor:pointer;-moz-transition:background-color var(--pico-transition),transform var(--pico-transition);transition:background-color var(--pico-transition),transform var(--pico-transition)}[type=range]::-ms-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.4375rem;border:2px solid var(--pico-range-thumb-border-color);border-radius:50%;background-color:var(--pico-range-thumb-color);cursor:pointer;-ms-transition:background-color var(--pico-transition),transform var(--pico-transition);transition:background-color var(--pico-transition),transform var(--pico-transition)}[type=range]:active,[type=range]:focus-within{--pico-range-border-color:var(--pico-range-active-border-color);--pico-range-thumb-color:var(--pico-range-thumb-active-color)}[type=range]:active::-webkit-slider-thumb{transform:scale(1.25)}[type=range]:active::-moz-range-thumb{transform:scale(1.25)}[type=range]:active::-ms-thumb{transform:scale(1.25)}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search]{padding-inline-start:calc(var(--pico-form-element-spacing-horizontal) + 1.75rem);background-image:var(--pico-icon-search);background-position:center left calc(var(--pico-form-element-spacing-horizontal) + .125rem);background-size:1rem auto;background-repeat:no-repeat}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid]{padding-inline-start:calc(var(--pico-form-element-spacing-horizontal) + 1.75rem)!important;background-position:center left 1.125rem,center right .75rem}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid=false]{background-image:var(--pico-icon-search),var(--pico-icon-valid)}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid=true]{background-image:var(--pico-icon-search),var(--pico-icon-invalid)}[dir=rtl] :where(input):not([type=checkbox],[type=radio],[type=range],[type=file])[type=search]{background-position:center right 1.125rem}[dir=rtl] :where(input):not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid]{background-position:center right 1.125rem,center left .75rem}details{display:block;margin-bottom:var(--pico-spacing)}details summary{line-height:1rem;list-style-type:none;cursor:pointer;transition:color var(--pico-transition)}details summary:not([role]){color:var(--pico-accordion-close-summary-color)}details summary::-webkit-details-marker{display:none}details summary::marker{display:none}details summary::-moz-list-bullet{list-style-type:none}details summary::after{display:block;width:1rem;height:1rem;margin-inline-start:calc(var(--pico-spacing,1rem) * .5);float:right;transform:rotate(-90deg);background-image:var(--pico-icon-chevron);background-position:right center;background-size:1rem auto;background-repeat:no-repeat;content:"";transition:transform var(--pico-transition)}details summary:focus{outline:0}details summary:focus:not([role]){color:var(--pico-accordion-active-summary-color)}details summary:focus-visible:not([role]){outline:var(--pico-outline-width) solid var(--pico-primary-focus);outline-offset:calc(var(--pico-spacing,1rem) * 0.5);color:var(--pico-primary)}details summary[role=button]{width:100%;text-align:left}details summary[role=button]::after{height:calc(1rem * var(--pico-line-height,1.5))}details[open]>summary{margin-bottom:var(--pico-spacing)}details[open]>summary:not([role]):not(:focus){color:var(--pico-accordion-open-summary-color)}details[open]>summary::after{transform:rotate(0)}[dir=rtl] details summary{text-align:right}[dir=rtl] details summary::after{float:left;background-position:left center}article{margin-bottom:var(--pico-block-spacing-vertical);padding:var(--pico-block-spacing-vertical) var(--pico-block-spacing-horizontal);border-radius:var(--pico-border-radius);background:var(--pico-card-background-color);box-shadow:var(--pico-card-box-shadow)}article>footer,article>header{margin-right:calc(var(--pico-block-spacing-horizontal) * -1);margin-left:calc(var(--pico-block-spacing-horizontal) * -1);padding:calc(var(--pico-block-spacing-vertical) * .66) var(--pico-block-spacing-horizontal);background-color:var(--pico-card-sectioning-background-color)}article>header{margin-top:calc(var(--pico-block-spacing-vertical) * -1);margin-bottom:var(--pico-block-spacing-vertical);border-bottom:var(--pico-border-width) solid var(--pico-card-border-color);border-top-right-radius:var(--pico-border-radius);border-top-left-radius:var(--pico-border-radius)}article>footer{margin-top:var(--pico-block-spacing-vertical);margin-bottom:calc(var(--pico-block-spacing-vertical) * -1);border-top:var(--pico-border-width) solid var(--pico-card-border-color);border-bottom-right-radius:var(--pico-border-radius);border-bottom-left-radius:var(--pico-border-radius)}details.dropdown{position:relative;border-bottom:none}details.dropdown>a::after,details.dropdown>button::after,details.dropdown>summary::after{display:block;width:1rem;height:calc(1rem * var(--pico-line-height,1.5));margin-inline-start:.25rem;float:right;transform:rotate(0) translateX(.2rem);background-image:var(--pico-icon-chevron);background-position:right center;background-size:1rem auto;background-repeat:no-repeat;content:""}nav details.dropdown{margin-bottom:0}details.dropdown>summary:not([role]){height:calc(1rem * var(--pico-line-height) + var(--pico-form-element-spacing-vertical) * 2 + var(--pico-border-width) * 2);padding:var(--pico-form-element-spacing-vertical) var(--pico-form-element-spacing-horizontal);border:var(--pico-border-width) solid var(--pico-form-element-border-color);border-radius:var(--pico-border-radius);background-color:var(--pico-form-element-background-color);color:var(--pico-form-element-placeholder-color);line-height:inherit;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;transition:background-color var(--pico-transition),border-color var(--pico-transition),color var(--pico-transition),box-shadow var(--pico-transition)}details.dropdown>summary:not([role]):active,details.dropdown>summary:not([role]):focus{border-color:var(--pico-form-element-active-border-color);background-color:var(--pico-form-element-active-background-color)}details.dropdown>summary:not([role]):focus{box-shadow:0 0 0 var(--pico-outline-width) var(--pico-form-element-focus-color)}details.dropdown>summary:not([role]):focus-visible{outline:0}details.dropdown>summary:not([role])[aria-invalid=false]{--pico-form-element-border-color:var(--pico-form-element-valid-border-color);--pico-form-element-active-border-color:var(--pico-form-element-valid-focus-color);--pico-form-element-focus-color:var(--pico-form-element-valid-focus-color)}details.dropdown>summary:not([role])[aria-invalid=true]{--pico-form-element-border-color:var(--pico-form-element-invalid-border-color);--pico-form-element-active-border-color:var(--pico-form-element-invalid-focus-color);--pico-form-element-focus-color:var(--pico-form-element-invalid-focus-color)}nav details.dropdown{display:inline;margin:calc(var(--pico-nav-element-spacing-vertical) * -1) 0}nav details.dropdown>summary::after{transform:rotate(0) translateX(0)}nav details.dropdown>summary:not([role]){height:calc(1rem * var(--pico-line-height) + var(--pico-nav-link-spacing-vertical) * 2);padding:calc(var(--pico-nav-link-spacing-vertical) - var(--pico-border-width) * 2) var(--pico-nav-link-spacing-horizontal)}nav details.dropdown>summary:not([role]):focus-visible{box-shadow:0 0 0 var(--pico-outline-width) var(--pico-primary-focus)}details.dropdown>summary+ul{display:flex;z-index:99;position:absolute;left:0;flex-direction:column;width:100%;min-width:-moz-fit-content;min-width:fit-content;margin:0;margin-top:var(--pico-outline-width);padding:0;border:var(--pico-border-width) solid var(--pico-dropdown-border-color);border-radius:var(--pico-border-radius);background-color:var(--pico-dropdown-background-color);box-shadow:var(--pico-dropdown-box-shadow);color:var(--pico-dropdown-color);white-space:nowrap;opacity:0;transition:opacity var(--pico-transition),transform 0s ease-in-out 1s}details.dropdown>summary+ul[dir=rtl]{right:0;left:auto}details.dropdown>summary+ul li{width:100%;margin-bottom:0;padding:calc(var(--pico-form-element-spacing-vertical) * .5) var(--pico-form-element-spacing-horizontal);list-style:none}details.dropdown>summary+ul li:first-of-type{margin-top:calc(var(--pico-form-element-spacing-vertical) * .5)}details.dropdown>summary+ul li:last-of-type{margin-bottom:calc(var(--pico-form-element-spacing-vertical) * .5)}details.dropdown>summary+ul li a{display:block;margin:calc(var(--pico-form-element-spacing-vertical) * -.5) calc(var(--pico-form-element-spacing-horizontal) * -1);padding:calc(var(--pico-form-element-spacing-vertical) * .5) var(--pico-form-element-spacing-horizontal);overflow:hidden;border-radius:0;color:var(--pico-dropdown-color);text-decoration:none;text-overflow:ellipsis}details.dropdown>summary+ul li a:active,details.dropdown>summary+ul li a:focus,details.dropdown>summary+ul li a:focus-visible,details.dropdown>summary+ul li a:hover,details.dropdown>summary+ul li a[aria-current]:not([aria-current=false]){background-color:var(--pico-dropdown-hover-background-color)}details.dropdown>summary+ul li label{width:100%}details.dropdown>summary+ul li:has(label):hover{background-color:var(--pico-dropdown-hover-background-color)}details.dropdown[open]>summary{margin-bottom:0}details.dropdown[open]>summary+ul{transform:scaleY(1);opacity:1;transition:opacity var(--pico-transition),transform 0s ease-in-out 0s}details.dropdown[open]>summary::before{display:block;z-index:1;position:fixed;width:100vw;height:100vh;inset:0;background:0 0;content:"";cursor:default}label>details.dropdown{margin-top:calc(var(--pico-spacing) * .25)}[role=group],[role=search]{display:inline-flex;position:relative;width:100%;margin-bottom:var(--pico-spacing);border-radius:var(--pico-border-radius);box-shadow:var(--pico-group-box-shadow,0 0 0 transparent);vertical-align:middle;transition:box-shadow var(--pico-transition)}[role=group] input:not([type=checkbox],[type=radio]),[role=group] select,[role=group]>*,[role=search] input:not([type=checkbox],[type=radio]),[role=search] select,[role=search]>*{position:relative;flex:1 1 auto;margin-bottom:0}[role=group] input:not([type=checkbox],[type=radio]):not(:first-child),[role=group] select:not(:first-child),[role=group]>:not(:first-child),[role=search] input:not([type=checkbox],[type=radio]):not(:first-child),[role=search] select:not(:first-child),[role=search]>:not(:first-child){margin-left:0;border-top-left-radius:0;border-bottom-left-radius:0}[role=group] input:not([type=checkbox],[type=radio]):not(:last-child),[role=group] select:not(:last-child),[role=group]>:not(:last-child),[role=search] input:not([type=checkbox],[type=radio]):not(:last-child),[role=search] select:not(:last-child),[role=search]>:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}[role=group] input:not([type=checkbox],[type=radio]):focus,[role=group] select:focus,[role=group]>:focus,[role=search] input:not([type=checkbox],[type=radio]):focus,[role=search] select:focus,[role=search]>:focus{z-index:2}[role=group] [role=button]:not(:first-child),[role=group] [type=button]:not(:first-child),[role=group] [type=reset]:not(:first-child),[role=group] [type=submit]:not(:first-child),[role=group] button:not(:first-child),[role=group] input:not([type=checkbox],[type=radio]):not(:first-child),[role=group] select:not(:first-child),[role=search] [role=button]:not(:first-child),[role=search] [type=button]:not(:first-child),[role=search] [type=reset]:not(:first-child),[role=search] [type=submit]:not(:first-child),[role=search] button:not(:first-child),[role=search] input:not([type=checkbox],[type=radio]):not(:first-child),[role=search] select:not(:first-child){margin-left:calc(var(--pico-border-width) * -1)}[role=group] [role=button],[role=group] [type=button],[role=group] [type=reset],[role=group] [type=submit],[role=group] button,[role=search] [role=button],[role=search] [type=button],[role=search] [type=reset],[role=search] [type=submit],[role=search] button{width:auto}@supports selector(:has(*)){[role=group]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus),[role=search]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus){--pico-group-box-shadow:var(--pico-group-box-shadow-focus-with-button)}[role=group]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus) input:not([type=checkbox],[type=radio]),[role=group]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus) select,[role=search]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus) input:not([type=checkbox],[type=radio]),[role=search]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus) select{border-color:transparent}[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus),[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus){--pico-group-box-shadow:var(--pico-group-box-shadow-focus-with-input)}[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus) [role=button],[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus) [type=button],[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus) [type=submit],[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus) button,[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus) [role=button],[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus) [type=button],[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus) [type=submit],[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus) button{--pico-button-box-shadow:0 0 0 var(--pico-border-width) var(--pico-primary-border);--pico-button-hover-box-shadow:0 0 0 var(--pico-border-width) var(--pico-primary-hover-border)}[role=group] [role=button]:focus,[role=group] [type=button]:focus,[role=group] [type=reset]:focus,[role=group] [type=submit]:focus,[role=group] button:focus,[role=search] [role=button]:focus,[role=search] [type=button]:focus,[role=search] [type=reset]:focus,[role=search] [type=submit]:focus,[role=search] button:focus{box-shadow:none}}[role=search]>:first-child{border-top-left-radius:5rem;border-bottom-left-radius:5rem}[role=search]>:last-child{border-top-right-radius:5rem;border-bottom-right-radius:5rem}[aria-busy=true]:not(input,select,textarea,html,form){white-space:nowrap}[aria-busy=true]:not(input,select,textarea,html,form)::before{display:inline-block;width:1em;height:1em;background-image:var(--pico-icon-loading);background-size:1em auto;background-repeat:no-repeat;content:"";vertical-align:-.125em}[aria-busy=true]:not(input,select,textarea,html,form):not(:empty)::before{margin-inline-end:calc(var(--pico-spacing) * .5)}[aria-busy=true]:not(input,select,textarea,html,form):empty{text-align:center}[role=button][aria-busy=true],[type=button][aria-busy=true],[type=reset][aria-busy=true],[type=submit][aria-busy=true],a[aria-busy=true],button[aria-busy=true]{pointer-events:none}:host,:root{--pico-scrollbar-width:0px}dialog{display:flex;z-index:999;position:fixed;top:0;right:0;bottom:0;left:0;align-items:center;justify-content:center;width:inherit;min-width:100%;height:inherit;min-height:100%;padding:0;border:0;-webkit-backdrop-filter:var(--pico-modal-overlay-backdrop-filter);backdrop-filter:var(--pico-modal-overlay-backdrop-filter);background-color:var(--pico-modal-overlay-background-color);color:var(--pico-color)}dialog>article{width:100%;max-height:calc(100vh - var(--pico-spacing) * 2);margin:var(--pico-spacing);overflow:auto}@media (min-width:576px){dialog>article{max-width:510px}}@media (min-width:768px){dialog>article{max-width:700px}}dialog>article>header>*{margin-bottom:0}dialog>article>header .close,dialog>article>header :is(a,button)[rel=prev]{margin:0;margin-left:var(--pico-spacing);padding:0;float:right}dialog>article>footer{text-align:right}dialog>article>footer [role=button],dialog>article>footer button{margin-bottom:0}dialog>article>footer [role=button]:not(:first-of-type),dialog>article>footer button:not(:first-of-type){margin-left:calc(var(--pico-spacing) * .5)}dialog>article .close,dialog>article :is(a,button)[rel=prev]{display:block;width:1rem;height:1rem;margin-top:calc(var(--pico-spacing) * -1);margin-bottom:var(--pico-spacing);margin-left:auto;border:none;background-image:var(--pico-icon-close);background-position:center;background-size:auto 1rem;background-repeat:no-repeat;background-color:transparent;opacity:.5;transition:opacity var(--pico-transition)}dialog>article .close:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),dialog>article :is(a,button)[rel=prev]:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){opacity:1}dialog:not([open]),dialog[open=false]{display:none}.modal-is-open{padding-right:var(--pico-scrollbar-width,0);overflow:hidden;pointer-events:none;touch-action:none}.modal-is-open dialog{pointer-events:auto;touch-action:auto}:where(.modal-is-opening,.modal-is-closing) dialog,:where(.modal-is-opening,.modal-is-closing) dialog>article{animation-duration:.2s;animation-timing-function:ease-in-out;animation-fill-mode:both}:where(.modal-is-opening,.modal-is-closing) dialog{animation-duration:.8s;animation-name:modal-overlay}:where(.modal-is-opening,.modal-is-closing) dialog>article{animation-delay:.2s;animation-name:modal}.modal-is-closing dialog,.modal-is-closing dialog>article{animation-delay:0s;animation-direction:reverse}@keyframes modal-overlay{from{-webkit-backdrop-filter:none;backdrop-filter:none;background-color:transparent}}@keyframes modal{from{transform:translateY(-100%);opacity:0}}:where(nav li)::before{float:left;content:"​"}nav,nav ul{display:flex}nav{justify-content:space-between;overflow:visible}nav ol,nav ul{align-items:center;margin-bottom:0;padding:0;list-style:none}nav ol:first-of-type,nav ul:first-of-type{margin-left:calc(var(--pico-nav-element-spacing-horizontal) * -1)}nav ol:last-of-type,nav ul:last-of-type{margin-right:calc(var(--pico-nav-element-spacing-horizontal) * -1)}nav li{display:inline-block;margin:0;padding:var(--pico-nav-element-spacing-vertical) var(--pico-nav-element-spacing-horizontal)}nav li :where(a,[role=link]){display:inline-block;margin:calc(var(--pico-nav-link-spacing-vertical) * -1) calc(var(--pico-nav-link-spacing-horizontal) * -1);padding:var(--pico-nav-link-spacing-vertical) var(--pico-nav-link-spacing-horizontal);border-radius:var(--pico-border-radius)}nav li :where(a,[role=link]):not(:hover){text-decoration:none}nav li [role=button],nav li [type=button],nav li button,nav li input:not([type=checkbox],[type=radio],[type=range],[type=file]),nav li select{height:auto;margin-right:inherit;margin-bottom:0;margin-left:inherit;padding:calc(var(--pico-nav-link-spacing-vertical) - var(--pico-border-width) * 2) var(--pico-nav-link-spacing-horizontal)}nav[aria-label=breadcrumb]{align-items:center;justify-content:start}nav[aria-label=breadcrumb] ul li:not(:first-child){margin-inline-start:var(--pico-nav-link-spacing-horizontal)}nav[aria-label=breadcrumb] ul li a{margin:calc(var(--pico-nav-link-spacing-vertical) * -1) 0;margin-inline-start:calc(var(--pico-nav-link-spacing-horizontal) * -1)}nav[aria-label=breadcrumb] ul li:not(:last-child)::after{display:inline-block;position:absolute;width:calc(var(--pico-nav-link-spacing-horizontal) * 4);margin:0 calc(var(--pico-nav-link-spacing-horizontal) * -1);content:var(--pico-nav-breadcrumb-divider);color:var(--pico-muted-color);text-align:center;text-decoration:none;white-space:nowrap}nav[aria-label=breadcrumb] a[aria-current]:not([aria-current=false]){background-color:transparent;color:inherit;text-decoration:none;pointer-events:none}aside li,aside nav,aside ol,aside ul{display:block}aside li{padding:calc(var(--pico-nav-element-spacing-vertical) * .5) var(--pico-nav-element-spacing-horizontal)}aside li a{display:block}aside li [role=button]{margin:inherit}[dir=rtl] nav[aria-label=breadcrumb] ul li:not(:last-child) ::after{content:"\\"}progress{display:inline-block;vertical-align:baseline}progress{-webkit-appearance:none;-moz-appearance:none;display:inline-block;appearance:none;width:100%;height:.5rem;margin-bottom:calc(var(--pico-spacing) * .5);overflow:hidden;border:0;border-radius:var(--pico-border-radius);background-color:var(--pico-progress-background-color);color:var(--pico-progress-color)}progress::-webkit-progress-bar{border-radius:var(--pico-border-radius);background:0 0}progress[value]::-webkit-progress-value{background-color:var(--pico-progress-color);-webkit-transition:inline-size var(--pico-transition);transition:inline-size var(--pico-transition)}progress::-moz-progress-bar{background-color:var(--pico-progress-color)}@media (prefers-reduced-motion:no-preference){progress:indeterminate{background:var(--pico-progress-background-color) linear-gradient(to right,var(--pico-progress-color) 30%,var(--pico-progress-background-color) 30%) top left/150% 150% no-repeat;animation:progress-indeterminate 1s linear infinite}progress:indeterminate[value]::-webkit-progress-value{background-color:transparent}progress:indeterminate::-moz-progress-bar{background-color:transparent}}@media (prefers-reduced-motion:no-preference){[dir=rtl] progress:indeterminate{animation-direction:reverse}}@keyframes progress-indeterminate{0%{background-position:200% 0}100%{background-position:-200% 0}}[data-tooltip]{position:relative}[data-tooltip]:not(a,button,input,[role=button]){border-bottom:1px dotted;text-decoration:none;cursor:help}[data-tooltip]::after,[data-tooltip]::before,[data-tooltip][data-placement=top]::after,[data-tooltip][data-placement=top]::before{display:block;z-index:99;position:absolute;bottom:100%;left:50%;padding:.25rem .5rem;overflow:hidden;transform:translate(-50%,-.25rem);border-radius:var(--pico-border-radius);background:var(--pico-tooltip-background-color);content:attr(data-tooltip);color:var(--pico-tooltip-color);font-style:normal;font-weight:var(--pico-font-weight);font-size:.875rem;text-decoration:none;text-overflow:ellipsis;white-space:nowrap;opacity:0;pointer-events:none}[data-tooltip]::after,[data-tooltip][data-placement=top]::after{padding:0;transform:translate(-50%,0);border-top:.3rem solid;border-right:.3rem solid transparent;border-left:.3rem solid transparent;border-radius:0;background-color:transparent;content:"";color:var(--pico-tooltip-background-color)}[data-tooltip][data-placement=bottom]::after,[data-tooltip][data-placement=bottom]::before{top:100%;bottom:auto;transform:translate(-50%,.25rem)}[data-tooltip][data-placement=bottom]:after{transform:translate(-50%,-.3rem);border:.3rem solid transparent;border-bottom:.3rem solid}[data-tooltip][data-placement=left]::after,[data-tooltip][data-placement=left]::before{top:50%;right:100%;bottom:auto;left:auto;transform:translate(-.25rem,-50%)}[data-tooltip][data-placement=left]:after{transform:translate(.3rem,-50%);border:.3rem solid transparent;border-left:.3rem solid}[data-tooltip][data-placement=right]::after,[data-tooltip][data-placement=right]::before{top:50%;right:auto;bottom:auto;left:100%;transform:translate(.25rem,-50%)}[data-tooltip][data-placement=right]:after{transform:translate(-.3rem,-50%);border:.3rem solid transparent;border-right:.3rem solid}[data-tooltip]:focus::after,[data-tooltip]:focus::before,[data-tooltip]:hover::after,[data-tooltip]:hover::before{opacity:1}@media (hover:hover) and (pointer:fine){[data-tooltip]:focus::after,[data-tooltip]:focus::before,[data-tooltip]:hover::after,[data-tooltip]:hover::before{--pico-tooltip-slide-to:translate(-50%, -0.25rem);transform:translate(-50%,.75rem);animation-duration:.2s;animation-fill-mode:forwards;animation-name:tooltip-slide;opacity:0}[data-tooltip]:focus::after,[data-tooltip]:hover::after{--pico-tooltip-caret-slide-to:translate(-50%, 0rem);transform:translate(-50%,-.25rem);animation-name:tooltip-caret-slide}[data-tooltip][data-placement=bottom]:focus::after,[data-tooltip][data-placement=bottom]:focus::before,[data-tooltip][data-placement=bottom]:hover::after,[data-tooltip][data-placement=bottom]:hover::before{--pico-tooltip-slide-to:translate(-50%, 0.25rem);transform:translate(-50%,-.75rem);animation-name:tooltip-slide}[data-tooltip][data-placement=bottom]:focus::after,[data-tooltip][data-placement=bottom]:hover::after{--pico-tooltip-caret-slide-to:translate(-50%, -0.3rem);transform:translate(-50%,-.5rem);animation-name:tooltip-caret-slide}[data-tooltip][data-placement=left]:focus::after,[data-tooltip][data-placement=left]:focus::before,[data-tooltip][data-placement=left]:hover::after,[data-tooltip][data-placement=left]:hover::before{--pico-tooltip-slide-to:translate(-0.25rem, -50%);transform:translate(.75rem,-50%);animation-name:tooltip-slide}[data-tooltip][data-placement=left]:focus::after,[data-tooltip][data-placement=left]:hover::after{--pico-tooltip-caret-slide-to:translate(0.3rem, -50%);transform:translate(.05rem,-50%);animation-name:tooltip-caret-slide}[data-tooltip][data-placement=right]:focus::after,[data-tooltip][data-placement=right]:focus::before,[data-tooltip][data-placement=right]:hover::after,[data-tooltip][data-placement=right]:hover::before{--pico-tooltip-slide-to:translate(0.25rem, -50%);transform:translate(-.75rem,-50%);animation-name:tooltip-slide}[data-tooltip][data-placement=right]:focus::after,[data-tooltip][data-placement=right]:hover::after{--pico-tooltip-caret-slide-to:translate(-0.3rem, -50%);transform:translate(-.05rem,-50%);animation-name:tooltip-caret-slide}}@keyframes tooltip-slide{to{transform:var(--pico-tooltip-slide-to);opacity:1}}@keyframes tooltip-caret-slide{50%{opacity:0}to{transform:var(--pico-tooltip-caret-slide-to);opacity:1}}[aria-controls]{cursor:pointer}[aria-disabled=true],[disabled]{cursor:not-allowed}[aria-hidden=false][hidden]{display:initial}[aria-hidden=false][hidden]:not(:focus){clip:rect(0,0,0,0);position:absolute}[tabindex],a,area,button,input,label,select,summary,textarea{-ms-touch-action:manipulation}[dir=rtl]{direction:rtl}@media (prefers-reduced-motion:reduce){:not([aria-busy=true]),:not([aria-busy=true])::after,:not([aria-busy=true])::before{background-attachment:initial!important;animation-duration:1ms!important;animation-delay:-1ms!important;animation-iteration-count:1!important;scroll-behavior:auto!important;transition-delay:0s!important;transition-duration:0s!important}}
+83
server/static/style.css
··· 1 + :root { 2 + --zinc-700: rgb(66, 71, 81); 3 + --success: rgb(0, 166, 110); 4 + --danger: rgb(155, 35, 24); 5 + } 6 + 7 + body { 8 + display: flex; 9 + flex-direction: column; 10 + } 11 + 12 + main { 13 + } 14 + 15 + .margin-top-sm { 16 + margin-top: 2em; 17 + } 18 + 19 + .margin-top-md { 20 + margin-top: 2.5em; 21 + } 22 + 23 + .margin-bottom-xs { 24 + margin-bottom: 1.5em; 25 + } 26 + 27 + .centered-body { 28 + min-height: 100vh; 29 + justify-content: center; 30 + } 31 + 32 + .base-container { 33 + border: 1px solid var(--zinc-700); 34 + border-radius: 10px; 35 + padding: 1.75em 1.2em; 36 + } 37 + 38 + .box-shadow-container { 39 + box-shadow: 1px 1px 52px 2px rgba(0, 0, 0, 0.42); 40 + } 41 + 42 + .login-container { 43 + max-width: 50ch; 44 + form :last-child { 45 + margin-bottom: 0; 46 + } 47 + form button { 48 + float: right; 49 + } 50 + } 51 + 52 + .authorize-container { 53 + max-width: 100ch; 54 + } 55 + 56 + button { 57 + width: unset; 58 + min-width: 16ch; 59 + } 60 + 61 + .button-row { 62 + display: flex; 63 + gap: 1ch; 64 + justify-content: end; 65 + } 66 + 67 + .alert { 68 + border: 1px solid var(--zinc-700); 69 + border-radius: 10px; 70 + padding: 1em 1em; 71 + p { 72 + color: white; 73 + margin-bottom: unset; 74 + } 75 + } 76 + 77 + .alert-success { 78 + background-color: var(--success); 79 + } 80 + 81 + .alert-danger { 82 + background-color: var(--danger); 83 + }
+39
server/templates/account.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="utf-8" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1" /> 6 + <meta name="color-scheme" content="light dark" /> 7 + <link rel="stylesheet" href="/static/pico.css" /> 8 + <link rel="stylesheet" href="/static/style.css" /> 9 + <title>Your Account</title> 10 + </head> 11 + <body class="margin-top-md"> 12 + <main class="container base-container authorize-container margin-top-xl"> 13 + <h2>Welcome, {{ .Repo.Handle }}</h2> 14 + <ul> 15 + <li><a href="/account/signout">Sign Out</a></li> 16 + </ul> 17 + {{ if .flashes.successes }} 18 + <div class="alert alert-success margin-bottom-xs"> 19 + <p>{{ index .flashes.successes 0 }}</p> 20 + </div> 21 + {{ end }} {{ if eq (len .Tokens) 0 }} 22 + <div class="alert alert-success" role="alert"> 23 + <p class="alert-message">You do not have any active OAuth sessions!</p> 24 + </div> 25 + {{ else }} {{ range .Tokens }} 26 + <div class="base-container"> 27 + <h4>{{ .ClientId }}</h4> 28 + <p>Created: {{ .CreatedAt }}</p> 29 + <p>Updated: {{ .UpdatedAt }}</p> 30 + <p>Expires: {{ .ExpiresAt }}</p> 31 + <form action="/account/revoke" method="post"> 32 + <input type="hidden" name="token" value="{{ .Token }}" /> 33 + <button type="submit" value="">Revoke</button> 34 + </form> 35 + </div> 36 + {{ end }} {{ end }} 37 + </main> 38 + </body> 39 + </html>
+4
server/templates/alert.html
··· 1 + <!doctype html> 2 + <div class="alert alert-success" role="alert"> 3 + <p class="alert-message"></p> 4 + </div>
+44
server/templates/authorize.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="utf-8" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1" /> 6 + <meta name="color-scheme" content="light dark" /> 7 + <link rel="stylesheet" href="/static/pico.css" /> 8 + <link rel="stylesheet" href="/static/style.css" /> 9 + <title>Application Authorization</title> 10 + </head> 11 + <body class="centered-body"> 12 + <main 13 + class="container base-container box-shadow-container authorizer-container" 14 + > 15 + <h2>Authorizing with {{ .AppName }}</h2> 16 + <p> 17 + You are signed in as <b>{{ .Handle }}</b>. 18 + <a href="/account/signout?{{ .QueryParams }}">Switch Account</a> 19 + </p> 20 + <p><b>{{ .AppName }}</b> is asking for you to grant it these scopes:</p> 21 + <ul> 22 + {{ range .Scopes }} 23 + <li><b>{{.}}</b></li> 24 + {{ end }} 25 + </ul> 26 + <p> 27 + If you press Accept, the application will be granted permissions for 28 + these scopes with your account <b>{{ .Handle }}</b>. If you reject, you 29 + will be sent back to the application. 30 + </p> 31 + <form action="/oauth/authorize" method="post"> 32 + <div class="button-row"> 33 + <input type="hidden" name="request_uri" value="{{ .RequestUri }}" /> 34 + <button class="secondary" name="accept_or_reject" value="reject"> 35 + Reject 36 + </button> 37 + <button class="primary" name="accept_or_reject" value="accept"> 38 + Accept 39 + </button> 40 + </div> 41 + </form> 42 + </main> 43 + </body> 44 + </html>
+34
server/templates/signin.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="utf-8" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1" /> 6 + <meta name="color-scheme" content="light dark" /> 7 + <link rel="stylesheet" href="/static/pico.css" /> 8 + <link rel="stylesheet" href="/static/style.css" /> 9 + <title>PDS Authentication</title> 10 + </head> 11 + <body class="centered-body"> 12 + <main class="container base-container box-shadow-container login-container"> 13 + <h2>Sign into your account</h2> 14 + <p>Enter your handle and password below.</p> 15 + {{ if .flashes.errors }} 16 + <div class="alert alert-danger margin-bottom-xs"> 17 + <p>{{ index .flashes.errors 0 }}</p> 18 + </div> 19 + {{ end }} 20 + <form action="/account/signin" method="post"> 21 + <input name="username" id="username" placeholder="Handle" /> 22 + <br /> 23 + <input 24 + name="password" 25 + id="password" 26 + type="password" 27 + placeholder="Password" 28 + /> 29 + <input name="query_params" type="hidden" value="{{ .QueryParams }}" /> 30 + <button class="primary" type="submit" value="Login">Login</button> 31 + </form> 32 + </main> 33 + </body> 34 + </html>