An atproto PDS written in Go

oauth (#15)

+2
.env.example
···
COCOON_RELAYS=https://bsky.network
# Generate with `openssl rand -hex 16`
COCOON_ADMIN_PASSWORD=
+
# openssl rand -hex 32
+
COCOON_SESSION_SECRET=
+1
.gitignore
···
.env
/cocoon
*.key
+
*.secret
+21
LICENSE
···
+
MIT License
+
+
Copyright (c) 2025 me@haileyok.com
+
+
Permission is hereby granted, free of charge, to any person obtaining a copy
+
of this software and associated documentation files (the "Software"), to deal
+
in the Software without restriction, including without limitation the rights
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+
copies of the Software, and to permit persons to whom the Software is
+
furnished to do so, subject to the following conditions:
+
+
The above copyright notice and this permission notice shall be included in all
+
copies or substantial portions of the Software.
+
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+
SOFTWARE.
+5
README.md
···
- [ ] com.atproto.moderation.createReport
- [x] app.bsky.actor.getPreferences
- [x] app.bsky.actor.putPreferences
+
+
+
## License
+
+
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
···
Name: "s3-secret-key",
EnvVars: []string{"COCOON_S3_SECRET_KEY"},
},
+
&cli.StringFlag{
+
Name: "session-secret",
+
EnvVars: []string{"COCOON_SESSION_SECRET"},
+
},
},
Commands: []*cli.Command{
run,
···
AccessKey: cmd.String("s3-access-key"),
SecretKey: cmd.String("s3-secret-key"),
},
+
SessionSecret: cmd.String("session-secret"),
})
if err != nil {
fmt.Printf("error creating cocoon: %v", err)
+19 -14
go.mod
···
github.com/bluesky-social/indigo v0.0.0-20250414202759-826fcdeaa36b
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792
github.com/domodwyer/mailyak/v3 v3.6.2
+
github.com/go-pkgz/expirable-cache/v3 v3.0.0
github.com/go-playground/validator v9.31.0+incompatible
github.com/golang-jwt/jwt/v4 v4.5.2
github.com/google/uuid v1.4.0
+
github.com/gorilla/sessions v1.4.0
github.com/gorilla/websocket v1.5.1
github.com/hashicorp/golang-lru/v2 v2.0.7
github.com/ipfs/go-block-format v0.2.0
···
github.com/ipfs/go-ipld-cbor v0.1.0
github.com/ipld/go-car v0.6.1-0.20230509095817-92d28eb23ba4
github.com/joho/godotenv v1.5.1
+
github.com/labstack/echo-contrib v0.17.4
github.com/labstack/echo/v4 v4.13.3
github.com/lestrrat-go/jwx/v2 v2.0.12
github.com/multiformats/go-multihash v0.2.3
···
github.com/urfave/cli/v2 v2.27.6
github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b
-
golang.org/x/crypto v0.36.0
+
golang.org/x/crypto v0.38.0
gorm.io/driver/sqlite v1.5.7
gorm.io/gorm v1.25.12
)
···
github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/carlmjohnson/versioninfo v0.22.5 // indirect
-
github.com/cespare/xxhash/v2 v2.2.0 // indirect
+
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
···
github.com/gocql/gocql v1.7.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/snappy v0.0.4 // indirect
+
github.com/gorilla/context v1.1.2 // indirect
+
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.5 // indirect
···
github.com/lestrrat-go/httprc v1.0.4 // indirect
github.com/lestrrat-go/iter v1.0.2 // indirect
github.com/lestrrat-go/option v1.0.1 // indirect
-
github.com/mattn/go-colorable v0.1.13 // indirect
+
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
-
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect
github.com/minio/sha256-simd v1.0.1 // indirect
github.com/mr-tron/base58 v1.2.0 // indirect
github.com/multiformats/go-base32 v0.1.0 // indirect
github.com/multiformats/go-base36 v0.2.0 // indirect
github.com/multiformats/go-multibase v0.2.0 // indirect
github.com/multiformats/go-varint v0.0.7 // indirect
+
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/opentracing/opentracing-go v1.2.0 // indirect
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect
-
github.com/prometheus/client_golang v1.17.0 // indirect
-
github.com/prometheus/client_model v0.5.0 // indirect
-
github.com/prometheus/common v0.45.0 // indirect
-
github.com/prometheus/procfs v0.12.0 // indirect
+
github.com/prometheus/client_golang v1.22.0 // indirect
+
github.com/prometheus/client_model v0.6.2 // indirect
+
github.com/prometheus/common v0.63.0 // indirect
+
github.com/prometheus/procfs v0.16.1 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/samber/lo v1.49.1 // indirect
github.com/segmentio/asm v1.2.0 // indirect
···
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.26.0 // indirect
-
golang.org/x/net v0.33.0 // indirect
-
golang.org/x/sync v0.12.0 // indirect
-
golang.org/x/sys v0.31.0 // indirect
-
golang.org/x/text v0.23.0 // indirect
-
golang.org/x/time v0.8.0 // indirect
+
golang.org/x/net v0.40.0 // indirect
+
golang.org/x/sync v0.14.0 // indirect
+
golang.org/x/sys v0.33.0 // indirect
+
golang.org/x/text v0.25.0 // indirect
+
golang.org/x/time v0.11.0 // indirect
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect
-
google.golang.org/protobuf v1.33.0 // indirect
+
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/go-playground/assert.v1 v1.2.1 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gorm.io/driver/postgres v1.5.7 // indirect
+42 -32
go.sum
···
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY=
github.com/carlmjohnson/versioninfo v0.22.5 h1:O00sjOLUAFxYQjlN/bzYTuZiS0y6fWDQjMRvwtKgwwc=
github.com/carlmjohnson/versioninfo v0.22.5/go.mod h1:QT9mph3wcVfISUKd0i9sZfVrPviHuSF+cUtLjm2WSf8=
-
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
-
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc=
github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
···
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
+
github.com/go-pkgz/expirable-cache/v3 v3.0.0 h1:u3/gcu3sabLYiTCevoRKv+WzjIn5oo7P8XtiXBeRDLw=
+
github.com/go-pkgz/expirable-cache/v3 v3.0.0/go.mod h1:2OQiDyEGQalYecLWmXprm3maPXeVb5/6/X7yRPYTzec=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
···
github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
-
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
-
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
+
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8=
github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
···
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
+
github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o=
+
github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM=
+
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
+
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
+
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
+
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed h1:5upAirOpQc1Q53c0bnx2ufif5kANL7bfZWcc6VJWJd8=
···
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+
github.com/labstack/echo-contrib v0.17.4 h1:g5mfsrJfJTKv+F5uNKCyrjLK7js+ZW6HTjg4FnDxxgk=
+
github.com/labstack/echo-contrib v0.17.4/go.mod h1:9O7ZPAHUeMGTOAfg80YqQduHzt0CzLak36PZRldYrZ0=
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
···
github.com/libp2p/go-nat v0.1.0/go.mod h1:X7teVkwRHNInVNWQiO/tAiAVRwSr5zoRz4YSTC3uRBM=
github.com/libp2p/go-netroute v0.2.1 h1:V8kVrpD8GK0Riv15/7VN6RbUQ3URNZVosw7H2v9tksU=
github.com/libp2p/go-netroute v0.2.1/go.mod h1:hraioZr0fhBjG0ZRXJJ6Zj2IVEVNx6tDTFQfSmcq7mQ=
-
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
-
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
+
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
+
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
-
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
-
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg=
-
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k=
github.com/miekg/dns v1.1.50 h1:DQUfb9uc6smULcREF09Uc+/Gd46YWqJd5DbpPE9xkcA=
github.com/miekg/dns v1.1.50/go.mod h1:e3IlAVfNqAllflbibAZEWOXOQ+Ynzk/dDozDxY7XnME=
github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM=
···
github.com/multiformats/go-multistream v0.4.1/go.mod h1:Mz5eykRVAjJWckE2U78c6xqdtyNUEhKSM0Lwar2p77Q=
github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8=
github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU=
+
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
+
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs=
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
github.com/petar/GoLLRB v0.0.0-20210522233825-ae3b015fd3e9 h1:1/WtZae0yGtPq+TI6+Tv1WTxkukpXeMlviSxvL7SRgk=
···
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f h1:VXTQfuJj9vKR4TCkEuWIckKvdHFeJH/huIFJ9/cXOB0=
github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f/go.mod h1:/zvteZs/GwLtCgZ4BL6CBsk9IKIlexP43ObX9AxTqTw=
-
github.com/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q=
-
github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY=
-
github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
-
github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
-
github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lneoxM=
-
github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY=
-
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
-
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
+
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
+
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
+
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
+
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
+
github.com/prometheus/common v0.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k=
+
github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18=
+
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
+
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
···
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
-
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
-
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
+
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
+
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ=
golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
···
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
-
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
-
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
+
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
+
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
-
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
+
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
+
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
···
golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
-
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
+
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
···
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
-
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
-
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
-
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
-
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
+
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
+
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
+
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
+
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
···
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU=
golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
-
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
-
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
+
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
+
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+47
internal/helpers/helpers.go
···
package helpers
import (
+
crand "crypto/rand"
+
"encoding/hex"
+
"errors"
"math/rand"
+
"net/url"
"github.com/labstack/echo/v4"
+
"github.com/lestrrat-go/jwx/v2/jwk"
)
// This will confirm to the regex in the application if 5 chars are used for each side of the -
···
}
return string(b)
}
+
+
func RandomHex(n int) (string, error) {
+
bytes := make([]byte, n)
+
if _, err := crand.Read(bytes); err != nil {
+
return "", err
+
}
+
return hex.EncodeToString(bytes), nil
+
}
+
+
func RandomBytes(n int) []byte {
+
bs := make([]byte, n)
+
crand.Read(bs)
+
return bs
+
}
+
+
func ParseJWKFromBytes(b []byte) (jwk.Key, error) {
+
return jwk.ParseKey(b)
+
}
+
+
func OauthParseHtu(htu string) (string, error) {
+
u, err := url.Parse(htu)
+
if err != nil {
+
return "", errors.New("`htu` is not a valid URL")
+
}
+
+
if u.User != nil {
+
_, containsPass := u.User.Password()
+
if u.User.Username() != "" || containsPass {
+
return "", errors.New("`htu` must not contain credentials")
+
}
+
}
+
+
if u.Scheme != "http" && u.Scheme != "https" {
+
return "", errors.New("`htu` must be http or https")
+
}
+
+
return OauthNormalizeHtu(u), nil
+
}
+
+
func OauthNormalizeHtu(u *url.URL) string {
+
return u.Scheme + "://" + u.Host + u.RawPath
+
}
+8
oauth/client.go
···
+
package oauth
+
+
import "github.com/lestrrat-go/jwx/v2/jwk"
+
+
type Client struct {
+
Metadata *ClientMetadata
+
JWKS jwk.Key
+
}
+390
oauth/client_manager/client_manager.go
···
+
package client_manager
+
+
import (
+
"context"
+
"encoding/json"
+
"errors"
+
"fmt"
+
"io"
+
"log/slog"
+
"net/http"
+
"net/url"
+
"slices"
+
"strings"
+
"time"
+
+
cache "github.com/go-pkgz/expirable-cache/v3"
+
"github.com/haileyok/cocoon/internal/helpers"
+
"github.com/haileyok/cocoon/oauth"
+
"github.com/lestrrat-go/jwx/v2/jwk"
+
)
+
+
type ClientManager struct {
+
cli *http.Client
+
logger *slog.Logger
+
jwksCache cache.Cache[string, jwk.Key]
+
metadataCache cache.Cache[string, oauth.ClientMetadata]
+
}
+
+
type Args struct {
+
Cli *http.Client
+
Logger *slog.Logger
+
}
+
+
func New(args Args) *ClientManager {
+
if args.Logger == nil {
+
args.Logger = slog.Default()
+
}
+
+
if args.Cli == nil {
+
args.Cli = http.DefaultClient
+
}
+
+
jwksCache := cache.NewCache[string, jwk.Key]().WithLRU().WithMaxKeys(500).WithTTL(5 * time.Minute)
+
metadataCache := cache.NewCache[string, oauth.ClientMetadata]().WithLRU().WithMaxKeys(500).WithTTL(5 * time.Minute)
+
+
return &ClientManager{
+
cli: args.Cli,
+
logger: args.Logger,
+
jwksCache: jwksCache,
+
metadataCache: metadataCache,
+
}
+
}
+
+
func (cm *ClientManager) GetClient(ctx context.Context, clientId string) (*oauth.Client, error) {
+
metadata, err := cm.getClientMetadata(ctx, clientId)
+
if err != nil {
+
return nil, err
+
}
+
+
var jwks jwk.Key
+
if metadata.JWKS != nil {
+
// TODO: this is kinda bad but whatever for now. there could obviously be more than one jwk, and we need to
+
// make sure we use the right one
+
k, err := helpers.ParseJWKFromBytes((*metadata.JWKS)[0])
+
if err != nil {
+
return nil, err
+
}
+
jwks = k
+
} else if metadata.JWKSURI != nil {
+
maybeJwks, err := cm.getClientJwks(ctx, clientId, *metadata.JWKSURI)
+
if err != nil {
+
return nil, err
+
}
+
+
jwks = maybeJwks
+
}
+
+
return &oauth.Client{
+
Metadata: metadata,
+
JWKS: jwks,
+
}, nil
+
}
+
+
func (cm *ClientManager) getClientMetadata(ctx context.Context, clientId string) (*oauth.ClientMetadata, error) {
+
metadataCached, ok := cm.metadataCache.Get(clientId)
+
if !ok {
+
req, err := http.NewRequestWithContext(ctx, "GET", clientId, nil)
+
if err != nil {
+
return nil, err
+
}
+
+
resp, err := cm.cli.Do(req)
+
if err != nil {
+
return nil, err
+
}
+
defer resp.Body.Close()
+
+
if resp.StatusCode != http.StatusOK {
+
io.Copy(io.Discard, resp.Body)
+
return nil, fmt.Errorf("fetching client metadata returned response code %d", resp.StatusCode)
+
}
+
+
b, err := io.ReadAll(resp.Body)
+
if err != nil {
+
return nil, fmt.Errorf("error reading bytes from client response: %w", err)
+
}
+
+
validated, err := validateAndParseMetadata(clientId, b)
+
if err != nil {
+
return nil, err
+
}
+
+
return validated, nil
+
} else {
+
return &metadataCached, nil
+
}
+
}
+
+
func (cm *ClientManager) getClientJwks(ctx context.Context, clientId, jwksUri string) (jwk.Key, error) {
+
jwks, ok := cm.jwksCache.Get(clientId)
+
if !ok {
+
req, err := http.NewRequestWithContext(ctx, "GET", jwksUri, nil)
+
if err != nil {
+
return nil, err
+
}
+
+
resp, err := cm.cli.Do(req)
+
if err != nil {
+
return nil, err
+
}
+
defer resp.Body.Close()
+
+
if resp.StatusCode != http.StatusOK {
+
io.Copy(io.Discard, resp.Body)
+
return nil, fmt.Errorf("fetching client jwks returned response code %d", resp.StatusCode)
+
}
+
+
type Keys struct {
+
Keys []map[string]any `json:"keys"`
+
}
+
+
var keys Keys
+
if err := json.NewDecoder(resp.Body).Decode(&keys); err != nil {
+
return nil, fmt.Errorf("error unmarshaling keys response: %w", err)
+
}
+
+
if len(keys.Keys) == 0 {
+
return nil, errors.New("no keys in jwks response")
+
}
+
+
// TODO: this is again bad, we should be figuring out which one we need to use...
+
b, err := json.Marshal(keys.Keys[0])
+
if err != nil {
+
return nil, fmt.Errorf("could not marshal key: %w", err)
+
}
+
+
k, err := helpers.ParseJWKFromBytes(b)
+
if err != nil {
+
return nil, err
+
}
+
+
jwks = k
+
}
+
+
return jwks, nil
+
}
+
+
func validateAndParseMetadata(clientId string, b []byte) (*oauth.ClientMetadata, error) {
+
var metadataMap map[string]any
+
if err := json.Unmarshal(b, &metadataMap); err != nil {
+
return nil, fmt.Errorf("error unmarshaling metadata: %w", err)
+
}
+
+
_, jwksOk := metadataMap["jwks"].(string)
+
_, jwksUriOk := metadataMap["jwks_uri"].(string)
+
if jwksOk && jwksUriOk {
+
return nil, errors.New("jwks_uri and jwks are mutually exclusive")
+
}
+
+
for _, k := range []string{
+
"default_max_age",
+
"userinfo_signed_response_alg",
+
"id_token_signed_response_alg",
+
"userinfo_encryhpted_response_alg",
+
"authorization_encrypted_response_enc",
+
"authorization_encrypted_response_alg",
+
"tls_client_certificate_bound_access_tokens",
+
} {
+
_, kOk := metadataMap[k]
+
if kOk {
+
return nil, fmt.Errorf("unsupported `%s` parameter", k)
+
}
+
}
+
+
var metadata oauth.ClientMetadata
+
if err := json.Unmarshal(b, &metadata); err != nil {
+
return nil, fmt.Errorf("error unmarshaling metadata: %w", err)
+
}
+
+
u, err := url.Parse(metadata.ClientURI)
+
if err != nil {
+
return nil, fmt.Errorf("unable to parse client uri: %w", err)
+
}
+
+
if isLocalHostname(u.Hostname()) {
+
return nil, errors.New("`client_uri` hostname is invalid")
+
}
+
+
if metadata.Scope == "" {
+
return nil, errors.New("missing `scopes` scope")
+
}
+
+
scopes := strings.Split(metadata.Scope, " ")
+
if !slices.Contains(scopes, "atproto") {
+
return nil, errors.New("missing `atproto` scope")
+
}
+
+
scopesMap := map[string]bool{}
+
for _, scope := range scopes {
+
if scopesMap[scope] {
+
return nil, fmt.Errorf("duplicate scope `%s`", scope)
+
}
+
+
// TODO: check for unsupported scopes
+
+
scopesMap[scope] = true
+
}
+
+
grantTypesMap := map[string]bool{}
+
for _, gt := range metadata.GrantTypes {
+
if grantTypesMap[gt] {
+
return nil, fmt.Errorf("duplicate grant type `%s`", gt)
+
}
+
+
switch gt {
+
case "implicit":
+
return nil, errors.New("grantg type `implicit` is not allowed")
+
case "authorization_code", "refresh_token":
+
// TODO check if this grant type is supported
+
default:
+
return nil, fmt.Errorf("grant tyhpe `%s` is not supported", gt)
+
}
+
+
grantTypesMap[gt] = true
+
}
+
+
if metadata.ClientID != clientId {
+
return nil, errors.New("`client_id` does not match")
+
}
+
+
subjectType, subjectTypeOk := metadataMap["subject_type"].(string)
+
if subjectTypeOk && subjectType != "public" {
+
return nil, errors.New("only public `subject_type` is supported")
+
}
+
+
switch metadata.TokenEndpointAuthMethod {
+
case "none":
+
if metadata.TokenEndpointAuthSigningAlg != "" {
+
return nil, errors.New("token_endpoint_auth_method `none` must not have token_endpoint_auth_signing_alg")
+
}
+
case "private_key_jwt":
+
if metadata.JWKS == nil && metadata.JWKSURI == nil {
+
return nil, errors.New("private_key_jwt auth method requires jwks or jwks_uri")
+
}
+
+
if metadata.JWKS != nil && len(*metadata.JWKS) == 0 {
+
return nil, errors.New("private_key_jwt auth method requires atleast one key in jwks")
+
}
+
+
if metadata.TokenEndpointAuthSigningAlg == "" {
+
return nil, errors.New("missing token_endpoint_auth_signing_alg in client metadata")
+
}
+
default:
+
return nil, fmt.Errorf("unsupported client authentication method `%s`", metadata.TokenEndpointAuthMethod)
+
}
+
+
if !metadata.DpopBoundAccessTokens {
+
return nil, errors.New("dpop_bound_access_tokens must be true")
+
}
+
+
if !slices.Contains(metadata.ResponseTypes, "code") {
+
return nil, errors.New("response_types must inclue `code`")
+
}
+
+
if !slices.Contains(metadata.GrantTypes, "authorization_code") {
+
return nil, errors.New("the `code` response type requires that `grant_types` contains `authorization_code`")
+
}
+
+
if len(metadata.RedirectURIs) == 0 {
+
return nil, errors.New("at least one `redirect_uri` is required")
+
}
+
+
if metadata.ApplicationType == "native" && metadata.TokenEndpointAuthMethod == "none" {
+
return nil, errors.New("native clients must authenticate using `none` method")
+
}
+
+
if metadata.ApplicationType == "web" && slices.Contains(metadata.GrantTypes, "implicit") {
+
for _, ruri := range metadata.RedirectURIs {
+
u, err := url.Parse(ruri)
+
if err != nil {
+
return nil, fmt.Errorf("error parsing redirect uri: %w", err)
+
}
+
+
if u.Scheme != "https" {
+
return nil, errors.New("web clients must use https redirect uris")
+
}
+
+
if u.Hostname() == "localhost" {
+
return nil, errors.New("web clients must not use localhost as the hostname")
+
}
+
}
+
}
+
+
for _, ruri := range metadata.RedirectURIs {
+
u, err := url.Parse(ruri)
+
if err != nil {
+
return nil, fmt.Errorf("error parsing redirect uri: %w", err)
+
}
+
+
if u.User != nil {
+
if u.User.Username() != "" {
+
return nil, fmt.Errorf("redirect uri %s must not contain credentials", ruri)
+
}
+
+
if _, hasPass := u.User.Password(); hasPass {
+
return nil, fmt.Errorf("redirect uri %s must not contain credentials", ruri)
+
}
+
}
+
+
switch true {
+
case u.Hostname() == "localhost":
+
return nil, errors.New("loopback redirect uri is not allowed (use explicit ips instead)")
+
case u.Hostname() == "127.0.0.1", u.Hostname() == "[::1]":
+
if metadata.ApplicationType != "native" {
+
return nil, errors.New("loopback redirect uris are only allowed for native apps")
+
}
+
+
if u.Port() != "" {
+
// reference impl doesn't do anything with this?
+
}
+
+
if u.Scheme != "http" {
+
return nil, fmt.Errorf("loopback redirect uri %s must use http", ruri)
+
}
+
+
break
+
case u.Scheme == "http":
+
return nil, errors.New("only loopbvack redirect uris are allowed to use the `http` scheme")
+
case u.Scheme == "https":
+
if isLocalHostname(u.Hostname()) {
+
return nil, fmt.Errorf("redirect uri %s's domain must not be a local hostname", ruri)
+
}
+
break
+
case strings.Contains(u.Scheme, "."):
+
if metadata.ApplicationType != "native" {
+
return nil, errors.New("private-use uri scheme redirect uris are only allowed for native apps")
+
}
+
+
revdomain := reverseDomain(u.Scheme)
+
+
if isLocalHostname(revdomain) {
+
return nil, errors.New("private use uri scheme redirect uris must not be local hostnames")
+
}
+
+
if strings.HasPrefix(u.String(), fmt.Sprintf("%s://", u.Scheme)) || u.Hostname() != "" || u.Port() != "" {
+
return nil, fmt.Errorf("private use uri scheme must be in the form ")
+
}
+
default:
+
return nil, fmt.Errorf("invalid redirect uri scheme `%s`", u.Scheme)
+
}
+
}
+
+
return &metadata, nil
+
}
+
+
func isLocalHostname(hostname string) bool {
+
pts := strings.Split(hostname, ".")
+
if len(pts) < 2 {
+
return true
+
}
+
+
tld := strings.ToLower(pts[len(pts)-1])
+
return tld == "test" || tld == "local" || tld == "localhost" || tld == "invalid" || tld == "example"
+
}
+
+
func reverseDomain(domain string) string {
+
pts := strings.Split(domain, ".")
+
slices.Reverse(pts)
+
return strings.Join(pts, ".")
+
}
+20
oauth/client_metadata.go
···
+
package oauth
+
+
type ClientMetadata struct {
+
ClientID string `json:"client_id"`
+
ClientName string `json:"client_name"`
+
ClientURI string `json:"client_uri"`
+
LogoURI string `json:"logo_uri"`
+
TOSURI string `json:"tos_uri"`
+
PolicyURI string `json:"policy_uri"`
+
RedirectURIs []string `json:"redirect_uris"`
+
GrantTypes []string `json:"grant_types"`
+
ResponseTypes []string `json:"response_types"`
+
ApplicationType string `json:"application_type"`
+
DpopBoundAccessTokens bool `json:"dpop_bound_access_tokens"`
+
JWKSURI *string `json:"jwks_uri,omitempty"`
+
JWKS *[][]byte `json:"jwks,omitempty"`
+
Scope string `json:"scope"`
+
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"`
+
TokenEndpointAuthSigningAlg string `json:"token_endpoint_auth_signing_alg"`
+
}
+52
oauth/constants/constants.go
···
+
package constants
+
+
import "time"
+
+
const (
+
MaxDpopAge = 10 * time.Second
+
DpopCheckTolerance = 5 * time.Second
+
+
NonceSecretByteLength = 32
+
+
NonceMaxRotationInterval = DpopNonceMaxAge / 3
+
NonceMinRotationInterval = 1 * time.Second
+
+
JTICacheSize = 100_000
+
JTITtl = 24 * time.Hour
+
+
ClientAssertionTypeJwtBearer = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
+
ParExpiresIn = 5 * time.Minute
+
+
ClientAssertionMaxAge = 1 * time.Minute
+
+
DeviceIdPrefix = "dev-"
+
DeviceIdBytesLength = 16
+
+
SessionIdPrefix = "ses-"
+
SessionIdBytesLength = 16
+
+
RefreshTokenPrefix = "ref-"
+
RefreshTokenBytesLength = 32
+
+
RequestIdPrefix = "req-"
+
RequestIdBytesLength = 16
+
RequestUriPrefix = "urn:ietf:params:oauth:request_uri:"
+
+
CodePrefix = "cod-"
+
CodeBytesLength = 32
+
+
TokenIdPrefix = "tok-"
+
TokenIdBytesLength = 16
+
+
TokenMaxAge = 60 * time.Minute
+
+
AuthorizationInactivityTimeout = 5 * time.Minute
+
+
DpopNonceMaxAge = 3 * time.Minute
+
+
ConfidentialClientSessionLifetime = 2 * 365 * 24 * time.Hour // 2 years
+
ConfidentialClientRefreshLifetime = 3 * 30 * 24 * time.Hour // 3 months
+
+
PublicClientSessionLifetime = 2 * 7 * 24 * time.Hour // 2 weeks
+
PublicClientRefreshLifetime = PublicClientSessionLifetime
+
)
+251
oauth/dpop/dpop_manager/dpop_manager.go
···
+
package dpop_manager
+
+
import (
+
"crypto"
+
"crypto/sha256"
+
"encoding/base64"
+
"encoding/json"
+
"errors"
+
"fmt"
+
"log/slog"
+
"net/http"
+
"net/url"
+
"strings"
+
"time"
+
+
"github.com/golang-jwt/jwt/v4"
+
"github.com/haileyok/cocoon/internal/helpers"
+
"github.com/haileyok/cocoon/oauth/constants"
+
"github.com/haileyok/cocoon/oauth/dpop"
+
"github.com/haileyok/cocoon/oauth/dpop/nonce"
+
"github.com/lestrrat-go/jwx/v2/jwa"
+
"github.com/lestrrat-go/jwx/v2/jwk"
+
)
+
+
type DpopManager struct {
+
nonce *nonce.Nonce
+
jtiCache *jtiCache
+
logger *slog.Logger
+
hostname string
+
}
+
+
type Args struct {
+
NonceSecret []byte
+
NonceRotationInterval time.Duration
+
OnNonceSecretCreated func([]byte)
+
JTICacheSize int
+
Logger *slog.Logger
+
Hostname string
+
}
+
+
func New(args Args) *DpopManager {
+
if args.Logger == nil {
+
args.Logger = slog.Default()
+
}
+
+
if args.JTICacheSize == 0 {
+
args.JTICacheSize = 100_000
+
}
+
+
if args.NonceSecret == nil {
+
args.Logger.Warn("nonce secret passed to dpop manager was nil. existing sessions may break. consider saving and restoring your nonce.")
+
}
+
+
return &DpopManager{
+
nonce: nonce.NewNonce(nonce.Args{
+
RotationInterval: args.NonceRotationInterval,
+
Secret: args.NonceSecret,
+
OnSecretCreated: args.OnNonceSecretCreated,
+
}),
+
jtiCache: newJTICache(args.JTICacheSize),
+
logger: args.Logger,
+
hostname: args.Hostname,
+
}
+
}
+
+
func (dm *DpopManager) CheckProof(reqMethod, reqUrl string, headers http.Header, accessToken *string) (*dpop.Proof, error) {
+
if reqMethod == "" {
+
return nil, errors.New("HTTP method is required")
+
}
+
+
if !strings.HasPrefix(reqUrl, "https://") {
+
reqUrl = "https://" + dm.hostname + reqUrl
+
}
+
+
proof := extractProof(headers)
+
+
if proof == "" {
+
return nil, nil
+
}
+
+
parser := jwt.NewParser(jwt.WithoutClaimsValidation())
+
var token *jwt.Token
+
+
token, _, err := parser.ParseUnverified(proof, jwt.MapClaims{})
+
if err != nil {
+
return nil, fmt.Errorf("could not parse dpop proof jwt: %w", err)
+
}
+
+
typ, _ := token.Header["typ"].(string)
+
if typ != "dpop+jwt" {
+
return nil, errors.New(`invalid dpop proof jwt: "typ" must be 'dpop+jwt'`)
+
}
+
+
dpopJwk, jwkOk := token.Header["jwk"].(map[string]any)
+
if !jwkOk {
+
return nil, errors.New(`invalid dpop proof jwt: "jwk" is missing in header`)
+
}
+
+
jwkb, err := json.Marshal(dpopJwk)
+
if err != nil {
+
return nil, fmt.Errorf("failed to marshal jwk: %w", err)
+
}
+
+
key, err := jwk.ParseKey(jwkb)
+
if err != nil {
+
return nil, fmt.Errorf("failed to parse jwk: %w", err)
+
}
+
+
var pubKey any
+
if err := key.Raw(&pubKey); err != nil {
+
return nil, fmt.Errorf("failed to get raw public key: %w", err)
+
}
+
+
token, err = jwt.Parse(proof, func(t *jwt.Token) (any, error) {
+
alg := t.Header["alg"].(string)
+
+
switch key.KeyType() {
+
case jwa.EC:
+
if !strings.HasPrefix(alg, "ES") {
+
return nil, fmt.Errorf("algorithm %s doesn't match EC key type", alg)
+
}
+
case jwa.RSA:
+
if !strings.HasPrefix(alg, "RS") && !strings.HasPrefix(alg, "PS") {
+
return nil, fmt.Errorf("algorithm %s doesn't match RSA key type", alg)
+
}
+
case jwa.OKP:
+
if alg != "EdDSA" {
+
return nil, fmt.Errorf("algorithm %s doesn't match OKP key type", alg)
+
}
+
}
+
+
return pubKey, nil
+
}, jwt.WithValidMethods([]string{"ES256", "ES384", "ES512", "RS256", "RS384", "RS512", "PS256", "PS384", "PS512", "EdDSA"}))
+
if err != nil {
+
return nil, fmt.Errorf("could not verify dpop proof jwt: %w", err)
+
}
+
+
if !token.Valid {
+
return nil, errors.New("dpop proof jwt is invalid")
+
}
+
+
claims, ok := token.Claims.(jwt.MapClaims)
+
if !ok {
+
return nil, errors.New("no claims in dpop proof jwt")
+
}
+
+
iat, iatOk := claims["iat"].(float64)
+
if !iatOk {
+
return nil, errors.New(`invalid dpop proof jwt: "iat" is missing`)
+
}
+
+
iatTime := time.Unix(int64(iat), 0)
+
now := time.Now()
+
+
if now.Sub(iatTime) > constants.DpopNonceMaxAge+constants.DpopCheckTolerance {
+
return nil, errors.New("dpop proof too old")
+
}
+
+
if iatTime.Sub(now) > constants.DpopCheckTolerance {
+
return nil, errors.New("dpop proof iat is in the future")
+
}
+
+
jti, _ := claims["jti"].(string)
+
if jti == "" {
+
return nil, errors.New(`invalid dpop proof jwt: "jti" is missing`)
+
}
+
+
if dm.jtiCache.add(jti) {
+
return nil, errors.New("dpop proof replay detected")
+
}
+
+
htm, _ := claims["htm"].(string)
+
if htm == "" {
+
return nil, errors.New(`invalid dpop proof jwt: "htm" is missing`)
+
}
+
+
if htm != reqMethod {
+
return nil, errors.New(`invalid dpop proof jwt: "htm" mismatch`)
+
}
+
+
htu, _ := claims["htu"].(string)
+
if htu == "" {
+
return nil, errors.New(`invalid dpop proof jwt: "htu" is missing`)
+
}
+
+
parsedHtu, err := helpers.OauthParseHtu(htu)
+
if err != nil {
+
return nil, errors.New(`invalid dpop proof jwt: "htu" could not be parsed`)
+
}
+
+
u, _ := url.Parse(reqUrl)
+
if parsedHtu != helpers.OauthNormalizeHtu(u) {
+
return nil, fmt.Errorf(`invalid dpop proof jwt: "htu" mismatch. reqUrl: %s, parsed: %s, normalized: %s`, reqUrl, parsedHtu, helpers.OauthNormalizeHtu(u))
+
}
+
+
nonce, _ := claims["nonce"].(string)
+
if nonce == "" {
+
// WARN: this _must_ be `use_dpop_nonce` for clients know they should make another request
+
return nil, errors.New("use_dpop_nonce")
+
}
+
+
if nonce != "" && !dm.nonce.Check(nonce) {
+
// WARN: this _must_ be `use_dpop_nonce` so that clients will fetch a new nonce
+
return nil, errors.New("use_dpop_nonce")
+
}
+
+
ath, _ := claims["ath"].(string)
+
+
if accessToken != nil && *accessToken != "" {
+
if ath == "" {
+
return nil, errors.New(`invalid dpop proof jwt: "ath" is required with access token`)
+
}
+
+
hash := sha256.Sum256([]byte(*accessToken))
+
if ath != base64.RawURLEncoding.EncodeToString(hash[:]) {
+
return nil, errors.New(`invalid dpop proof jwt: "ath" mismatch`)
+
}
+
} else if ath != "" {
+
return nil, errors.New(`invalid dpop proof jwt: "ath" claim not allowed`)
+
}
+
+
thumbBytes, err := key.Thumbprint(crypto.SHA256)
+
if err != nil {
+
return nil, fmt.Errorf("failed to calculate thumbprint: %w", err)
+
}
+
+
thumb := base64.RawURLEncoding.EncodeToString(thumbBytes)
+
+
return &dpop.Proof{
+
JTI: jti,
+
JKT: thumb,
+
HTM: htm,
+
HTU: htu,
+
}, nil
+
}
+
+
func extractProof(headers http.Header) string {
+
dpopHeaders := headers["Dpop"]
+
switch len(dpopHeaders) {
+
case 0:
+
return ""
+
case 1:
+
return dpopHeaders[0]
+
default:
+
return ""
+
}
+
}
+
+
func (dm *DpopManager) NextNonce() string {
+
return dm.nonce.NextNonce()
+
}
+28
oauth/dpop/dpop_manager/jti_cache.go
···
+
package dpop_manager
+
+
import (
+
"sync"
+
"time"
+
+
cache "github.com/go-pkgz/expirable-cache/v3"
+
"github.com/haileyok/cocoon/oauth/constants"
+
)
+
+
type jtiCache struct {
+
mu sync.Mutex
+
cache cache.Cache[string, bool]
+
}
+
+
func newJTICache(size int) *jtiCache {
+
cache := cache.NewCache[string, bool]().WithTTL(24 * time.Hour).WithLRU().WithTTL(constants.JTITtl)
+
return &jtiCache{
+
cache: cache,
+
mu: sync.Mutex{},
+
}
+
}
+
+
func (c *jtiCache) add(jti string) bool {
+
c.mu.Lock()
+
defer c.mu.Unlock()
+
return c.cache.Add(jti, true)
+
}
+108
oauth/dpop/nonce/nonce.go
···
+
package nonce
+
+
import (
+
"crypto/hmac"
+
"crypto/sha256"
+
"encoding/base64"
+
"encoding/binary"
+
"sync"
+
"time"
+
+
"github.com/haileyok/cocoon/internal/helpers"
+
"github.com/haileyok/cocoon/oauth/constants"
+
)
+
+
type Nonce struct {
+
rotationInterval time.Duration
+
secret []byte
+
+
mu sync.RWMutex
+
+
counter int64
+
prev string
+
curr string
+
next string
+
}
+
+
type Args struct {
+
RotationInterval time.Duration
+
Secret []byte
+
OnSecretCreated func([]byte)
+
}
+
+
func NewNonce(args Args) *Nonce {
+
if args.RotationInterval == 0 {
+
args.RotationInterval = constants.NonceMaxRotationInterval / 3
+
}
+
+
if args.RotationInterval > constants.NonceMaxRotationInterval {
+
args.RotationInterval = constants.NonceMaxRotationInterval
+
}
+
+
if args.Secret == nil {
+
args.Secret = helpers.RandomBytes(constants.NonceSecretByteLength)
+
args.OnSecretCreated(args.Secret)
+
}
+
+
n := &Nonce{
+
rotationInterval: args.RotationInterval,
+
secret: args.Secret,
+
mu: sync.RWMutex{},
+
}
+
+
n.counter = n.currentCounter()
+
n.prev = n.compute(n.counter - 1)
+
n.curr = n.compute(n.counter)
+
n.next = n.compute(n.counter + 1)
+
+
return n
+
}
+
+
func (n *Nonce) currentCounter() int64 {
+
return time.Now().UnixNano() / int64(n.rotationInterval)
+
}
+
+
func (n *Nonce) compute(counter int64) string {
+
h := hmac.New(sha256.New, n.secret)
+
counterBytes := make([]byte, 8)
+
binary.BigEndian.PutUint64(counterBytes, uint64(counter))
+
h.Write(counterBytes)
+
return base64.RawURLEncoding.EncodeToString(h.Sum(nil))
+
}
+
+
func (n *Nonce) rotate() {
+
counter := n.currentCounter()
+
diff := counter - n.counter
+
+
switch diff {
+
case 0:
+
// counter == n.counter, do nothing
+
case 1:
+
n.prev = n.curr
+
n.curr = n.next
+
n.next = n.compute(counter + 1)
+
case 2:
+
n.prev = n.next
+
n.curr = n.compute(counter)
+
n.next = n.compute(counter + 1)
+
default:
+
n.prev = n.compute(counter - 1)
+
n.curr = n.compute(counter)
+
n.next = n.compute(counter + 1)
+
}
+
+
n.counter = counter
+
}
+
+
func (n *Nonce) NextNonce() string {
+
n.mu.Lock()
+
defer n.mu.Unlock()
+
n.rotate()
+
return n.next
+
}
+
+
func (n *Nonce) Check(nonce string) bool {
+
n.mu.RLock()
+
defer n.mu.RUnlock()
+
return nonce == n.prev || nonce == n.curr || nonce == n.next
+
}
+8
oauth/dpop/proof.go
···
+
package dpop
+
+
type Proof struct {
+
JTI string
+
JKT string
+
HTM string
+
HTU string
+
}
+48
oauth/helpers.go
···
+
package oauth
+
+
import (
+
"errors"
+
"fmt"
+
"net/url"
+
+
"github.com/haileyok/cocoon/internal/helpers"
+
"github.com/haileyok/cocoon/oauth/constants"
+
)
+
+
func GenerateCode() string {
+
h, _ := helpers.RandomHex(constants.CodeBytesLength)
+
return constants.CodePrefix + h
+
}
+
+
func GenerateTokenId() string {
+
h, _ := helpers.RandomHex(constants.TokenIdBytesLength)
+
return constants.TokenIdPrefix + h
+
}
+
+
func GenerateRefreshToken() string {
+
h, _ := helpers.RandomHex(constants.RefreshTokenBytesLength)
+
return constants.RefreshTokenPrefix + h
+
}
+
+
func GenerateRequestId() string {
+
h, _ := helpers.RandomHex(constants.RequestIdBytesLength)
+
return constants.RequestIdPrefix + h
+
}
+
+
func EncodeRequestUri(reqId string) string {
+
return constants.RequestUriPrefix + url.QueryEscape(reqId)
+
}
+
+
func DecodeRequestUri(reqUri string) (string, error) {
+
if len(reqUri) < len(constants.RequestUriPrefix) {
+
return "", errors.New("invalid request uri")
+
}
+
+
reqIdEnc := reqUri[len(constants.RequestUriPrefix):]
+
reqId, err := url.QueryUnescape(reqIdEnc)
+
if err != nil {
+
return "", fmt.Errorf("could not unescape request id: %w", err)
+
}
+
+
return reqId, nil
+
}
+175
oauth/provider/client_auth.go
···
+
package provider
+
+
import (
+
"context"
+
"crypto"
+
"database/sql/driver"
+
"encoding/base64"
+
"encoding/json"
+
"errors"
+
"fmt"
+
"time"
+
+
"github.com/golang-jwt/jwt/v4"
+
"github.com/haileyok/cocoon/oauth"
+
"github.com/haileyok/cocoon/oauth/constants"
+
"github.com/haileyok/cocoon/oauth/dpop"
+
)
+
+
type ClientAuth struct {
+
Method string
+
Alg string
+
Kid string
+
Jkt string
+
Jti string
+
Exp *float64
+
}
+
+
func (ca *ClientAuth) Scan(value any) error {
+
b, ok := value.([]byte)
+
if !ok {
+
return fmt.Errorf("failed to unmarshal OauthParRequest value")
+
}
+
return json.Unmarshal(b, ca)
+
}
+
+
func (ca ClientAuth) Value() (driver.Value, error) {
+
return json.Marshal(ca)
+
}
+
+
type AuthenticateClientOptions struct {
+
AllowMissingDpopProof bool
+
}
+
+
type AuthenticateClientRequestBase struct {
+
ClientID string `form:"client_id" json:"client_id" validate:"required"`
+
ClientAssertionType *string `form:"client_assertion_type" json:"client_assertion_type,omitempty"`
+
ClientAssertion *string `form:"client_assertion" json:"client_assertion,omitempty"`
+
}
+
+
func (p *Provider) AuthenticateClient(ctx context.Context, req AuthenticateClientRequestBase, proof *dpop.Proof, opts *AuthenticateClientOptions) (*oauth.Client, *ClientAuth, error) {
+
client, err := p.ClientManager.GetClient(ctx, req.ClientID)
+
if err != nil {
+
return nil, nil, fmt.Errorf("failed to get client: %w", err)
+
}
+
+
if client.Metadata.DpopBoundAccessTokens && proof == nil && (opts == nil || !opts.AllowMissingDpopProof) {
+
return nil, nil, errors.New("dpop proof required")
+
}
+
+
if proof != nil && !client.Metadata.DpopBoundAccessTokens {
+
return nil, nil, errors.New("dpop proof not allowed for this client")
+
}
+
+
clientAuth, err := p.Authenticate(ctx, req, client)
+
if err != nil {
+
return nil, nil, err
+
}
+
+
return client, clientAuth, nil
+
}
+
+
func (p *Provider) Authenticate(_ context.Context, req AuthenticateClientRequestBase, client *oauth.Client) (*ClientAuth, error) {
+
metadata := client.Metadata
+
+
if metadata.TokenEndpointAuthMethod == "none" {
+
return &ClientAuth{
+
Method: "none",
+
}, nil
+
}
+
+
if metadata.TokenEndpointAuthMethod == "private_key_jwt" {
+
if req.ClientAssertion == nil {
+
return nil, errors.New(`client authentication method "private_key_jwt" requires a "client_assertion`)
+
}
+
+
if req.ClientAssertionType == nil || *req.ClientAssertionType != constants.ClientAssertionTypeJwtBearer {
+
return nil, fmt.Errorf("unsupported client_assertion_type %s", *req.ClientAssertionType)
+
}
+
+
token, _, err := jwt.NewParser().ParseUnverified(*req.ClientAssertion, jwt.MapClaims{})
+
if err != nil {
+
return nil, fmt.Errorf("error parsing client assertion: %w", err)
+
}
+
+
kid, ok := token.Header["kid"].(string)
+
if !ok || kid == "" {
+
return nil, errors.New(`"kid" required in client_assertion`)
+
}
+
+
var rawKey any
+
if err := client.JWKS.Raw(&rawKey); err != nil {
+
return nil, fmt.Errorf("failed to extract raw key: %w", err)
+
}
+
+
token, err = jwt.Parse(*req.ClientAssertion, func(token *jwt.Token) (any, error) {
+
if token.Method.Alg() != jwt.SigningMethodES256.Alg() {
+
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
+
}
+
+
return rawKey, nil
+
})
+
if err != nil {
+
return nil, fmt.Errorf(`unable to verify "client_assertion" jwt: %w`, err)
+
}
+
+
if !token.Valid {
+
return nil, errors.New("client_assertion jwt is invalid")
+
}
+
+
claims, ok := token.Claims.(jwt.MapClaims)
+
if !ok {
+
return nil, errors.New("no claims in client_assertion jwt")
+
}
+
+
sub, _ := claims["sub"].(string)
+
if sub != metadata.ClientID {
+
return nil, errors.New("subject must be client_id")
+
}
+
+
aud, _ := claims["aud"].(string)
+
if aud != "" && aud != "https://"+p.hostname {
+
return nil, fmt.Errorf("audience must be %s, got %s", "https://"+p.hostname, aud)
+
}
+
+
iat, iatOk := claims["iat"].(float64)
+
if !iatOk {
+
return nil, errors.New(`invalid client_assertion jwt: "iat" is missing`)
+
}
+
+
iatTime := time.Unix(int64(iat), 0)
+
if time.Since(iatTime) > constants.ClientAssertionMaxAge {
+
return nil, errors.New("client_assertion jwt too old")
+
}
+
+
jti, _ := claims["jti"].(string)
+
if jti == "" {
+
return nil, errors.New(`invalid client_assertion jwt: "jti" is missing`)
+
}
+
+
var exp *float64
+
if maybeExp, ok := claims["exp"].(float64); ok {
+
exp = &maybeExp
+
}
+
+
alg := token.Header["alg"].(string)
+
+
thumbBytes, err := client.JWKS.Thumbprint(crypto.SHA256)
+
if err != nil {
+
return nil, fmt.Errorf("failed to calculate thumbprint: %w", err)
+
}
+
+
thumb := base64.RawURLEncoding.EncodeToString(thumbBytes)
+
+
return &ClientAuth{
+
Method: "private_key_jwt",
+
Jti: jti,
+
Exp: exp,
+
Jkt: thumb,
+
Alg: alg,
+
Kid: kid,
+
}, nil
+
}
+
+
return nil, fmt.Errorf("auth method %s is not implemented in this pds", metadata.TokenEndpointAuthMethod)
+
}
+20
oauth/provider/middleware.go
···
+
package provider
+
+
import (
+
"github.com/labstack/echo/v4"
+
)
+
+
func (p *Provider) BaseMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
+
return func(e echo.Context) error {
+
e.Response().Header().Set("cache-control", "no-store")
+
e.Response().Header().Set("pragma", "no-cache")
+
+
nonce := p.NextNonce()
+
if nonce != "" {
+
e.Response().Header().Set("DPoP-Nonce", nonce)
+
e.Response().Header().Add("access-control-expose-headers", "DPoP-Nonce")
+
}
+
+
return next(e)
+
}
+
}
+87
oauth/provider/provider.go
···
+
package provider
+
+
import (
+
"database/sql/driver"
+
"encoding/json"
+
"fmt"
+
"time"
+
+
"github.com/haileyok/cocoon/oauth/client_manager"
+
"github.com/haileyok/cocoon/oauth/dpop/dpop_manager"
+
"gorm.io/gorm"
+
)
+
+
type Provider struct {
+
ClientManager *client_manager.ClientManager
+
DpopManager *dpop_manager.DpopManager
+
+
hostname string
+
}
+
+
type Args struct {
+
Hostname string
+
ClientManagerArgs client_manager.Args
+
DpopManagerArgs dpop_manager.Args
+
}
+
+
func NewProvider(args Args) *Provider {
+
return &Provider{
+
ClientManager: client_manager.New(args.ClientManagerArgs),
+
DpopManager: dpop_manager.New(args.DpopManagerArgs),
+
hostname: args.Hostname,
+
}
+
}
+
+
func (p *Provider) NextNonce() string {
+
return p.DpopManager.NextNonce()
+
}
+
+
type ParRequest struct {
+
AuthenticateClientRequestBase
+
ResponseType string `form:"response_type" json:"response_type" validate:"required"`
+
CodeChallenge *string `form:"code_challenge" json:"code_challenge" validate:"required"`
+
CodeChallengeMethod string `form:"code_challenge_method" json:"code_challenge_method" validate:"required"`
+
State string `form:"state" json:"state" validate:"required"`
+
RedirectURI string `form:"redirect_uri" json:"redirect_uri" validate:"required"`
+
Scope string `form:"scope" json:"scope" validate:"required"`
+
LoginHint *string `form:"login_hint" json:"login_hint,omitempty"`
+
DpopJkt *string `form:"dpop_jkt" json:"dpop_jkt,omitempty"`
+
}
+
+
func (opr *ParRequest) Scan(value any) error {
+
b, ok := value.([]byte)
+
if !ok {
+
return fmt.Errorf("failed to unmarshal OauthParRequest value")
+
}
+
return json.Unmarshal(b, opr)
+
}
+
+
func (opr ParRequest) Value() (driver.Value, error) {
+
return json.Marshal(opr)
+
}
+
+
type OauthToken struct {
+
gorm.Model
+
ClientId string `gorm:"index"`
+
ClientAuth ClientAuth `gorm:"type:json"`
+
Parameters ParRequest `gorm:"type:json"`
+
ExpiresAt time.Time `gorm:"index"`
+
DeviceId string
+
Sub string `gorm:"index"`
+
Code string `gorm:"index"`
+
Token string `gorm:"uniqueIndex"`
+
RefreshToken string `gorm:"uniqueIndex"`
+
}
+
+
type OauthAuthorizationRequest struct {
+
gorm.Model
+
RequestId string `gorm:"primaryKey"`
+
ClientId string `gorm:"index"`
+
ClientAuth ClientAuth `gorm:"type:json"`
+
Parameters ParRequest `gorm:"type:json"`
+
ExpiresAt time.Time `gorm:"index"`
+
DeviceId *string
+
Sub *string
+
Code *string
+
Accepted *bool
+
}
+44
server/handle_account.go
···
+
package server
+
+
import (
+
"time"
+
+
"github.com/haileyok/cocoon/oauth/provider"
+
"github.com/labstack/echo/v4"
+
)
+
+
func (s *Server) handleAccount(e echo.Context) error {
+
repo, sess, err := s.getSessionRepoOrErr(e)
+
if err != nil {
+
return e.Redirect(303, "/account/signin")
+
}
+
+
now := time.Now()
+
+
var tokens []provider.OauthToken
+
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 {
+
s.logger.Error("couldnt fetch oauth sessions for account", "did", repo.Repo.Did, "error", err)
+
sess.AddFlash("Unable to fetch sessions. See server logs for more details.", "error")
+
sess.Save(e.Request(), e.Response())
+
return e.Render(200, "account.html", map[string]any{
+
"flashes": getFlashesFromSession(e, sess),
+
})
+
}
+
+
tokenInfo := []map[string]string{}
+
for _, t := range tokens {
+
tokenInfo = append(tokenInfo, map[string]string{
+
"ClientId": t.ClientId,
+
"CreatedAt": t.CreatedAt.Format("02 Jan 06 15:04 MST"),
+
"UpdatedAt": t.CreatedAt.Format("02 Jan 06 15:04 MST"),
+
"ExpiresAt": t.CreatedAt.Format("02 Jan 06 15:04 MST"),
+
"Token": t.Token,
+
})
+
}
+
+
return e.Render(200, "account.html", map[string]any{
+
"Repo": repo,
+
"Tokens": tokenInfo,
+
"flashes": getFlashesFromSession(e, sess),
+
})
+
}
+34
server/handle_account_revoke.go
···
+
package server
+
+
import (
+
"github.com/haileyok/cocoon/internal/helpers"
+
"github.com/labstack/echo/v4"
+
)
+
+
type AccountRevokeRequest struct {
+
Token string `form:"token"`
+
}
+
+
func (s *Server) handleAccountRevoke(e echo.Context) error {
+
var req AccountRevokeRequest
+
if err := e.Bind(&req); err != nil {
+
s.logger.Error("could not bind account revoke request", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
repo, sess, err := s.getSessionRepoOrErr(e)
+
if err != nil {
+
return e.Redirect(303, "/account/signin")
+
}
+
+
if err := s.db.Exec("DELETE FROM oauth_tokens WHERE sub = ? AND token = ?", nil, repo.Repo.Did, req.Token).Error; err != nil {
+
s.logger.Error("couldnt delete oauth session for account", "did", repo.Repo.Did, "token", req.Token, "error", err)
+
sess.AddFlash("Unable to revoke session. See server logs for more details.", "error")
+
sess.Save(e.Request(), e.Response())
+
return e.Redirect(303, "/account")
+
}
+
+
sess.AddFlash("Session successfully revoked!", "success")
+
sess.Save(e.Request(), e.Response())
+
return e.Redirect(303, "/account")
+
}
+130
server/handle_account_signin.go
···
+
package server
+
+
import (
+
"errors"
+
"strings"
+
+
"github.com/bluesky-social/indigo/atproto/syntax"
+
"github.com/gorilla/sessions"
+
"github.com/haileyok/cocoon/internal/helpers"
+
"github.com/haileyok/cocoon/models"
+
"github.com/labstack/echo-contrib/session"
+
"github.com/labstack/echo/v4"
+
"golang.org/x/crypto/bcrypt"
+
"gorm.io/gorm"
+
)
+
+
type OauthSigninRequest struct {
+
Username string `form:"username"`
+
Password string `form:"password"`
+
QueryParams string `form:"query_params"`
+
}
+
+
func (s *Server) getSessionRepoOrErr(e echo.Context) (*models.RepoActor, *sessions.Session, error) {
+
sess, err := session.Get("session", e)
+
if err != nil {
+
return nil, nil, err
+
}
+
+
did, ok := sess.Values["did"].(string)
+
if !ok {
+
return nil, sess, errors.New("did was not set in session")
+
}
+
+
repo, err := s.getRepoActorByDid(did)
+
if err != nil {
+
return nil, sess, err
+
}
+
+
return repo, sess, nil
+
}
+
+
func getFlashesFromSession(e echo.Context, sess *sessions.Session) map[string]any {
+
defer sess.Save(e.Request(), e.Response())
+
return map[string]any{
+
"errors": sess.Flashes("error"),
+
"successes": sess.Flashes("success"),
+
}
+
}
+
+
func (s *Server) handleAccountSigninGet(e echo.Context) error {
+
_, sess, err := s.getSessionRepoOrErr(e)
+
if err == nil {
+
return e.Redirect(303, "/account")
+
}
+
+
return e.Render(200, "signin.html", map[string]any{
+
"flashes": getFlashesFromSession(e, sess),
+
"QueryParams": e.QueryParams().Encode(),
+
})
+
}
+
+
func (s *Server) handleAccountSigninPost(e echo.Context) error {
+
var req OauthSigninRequest
+
if err := e.Bind(&req); err != nil {
+
s.logger.Error("error binding sign in req", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
sess, _ := session.Get("session", e)
+
+
req.Username = strings.ToLower(req.Username)
+
var idtype string
+
if _, err := syntax.ParseDID(req.Username); err == nil {
+
idtype = "did"
+
} else if _, err := syntax.ParseHandle(req.Username); err == nil {
+
idtype = "handle"
+
} else {
+
idtype = "email"
+
}
+
+
// TODO: we should make this a helper since we do it for the base create_session as well
+
var repo models.RepoActor
+
var err error
+
switch idtype {
+
case "did":
+
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
+
case "handle":
+
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
+
case "email":
+
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
+
}
+
if err != nil {
+
if err == gorm.ErrRecordNotFound {
+
sess.AddFlash("Handle or password is incorrect", "error")
+
} else {
+
sess.AddFlash("Something went wrong!", "error")
+
}
+
sess.Save(e.Request(), e.Response())
+
return e.Redirect(303, "/account/signin")
+
}
+
+
if err := bcrypt.CompareHashAndPassword([]byte(repo.Password), []byte(req.Password)); err != nil {
+
if err != bcrypt.ErrMismatchedHashAndPassword {
+
sess.AddFlash("Handle or password is incorrect", "error")
+
} else {
+
sess.AddFlash("Something went wrong!", "error")
+
}
+
sess.Save(e.Request(), e.Response())
+
return e.Redirect(303, "/account/signin")
+
}
+
+
sess.Options = &sessions.Options{
+
Path: "/",
+
MaxAge: int(AccountSessionMaxAge.Seconds()),
+
HttpOnly: true,
+
}
+
+
sess.Values = map[any]any{}
+
sess.Values["did"] = repo.Repo.Did
+
+
if err := sess.Save(e.Request(), e.Response()); err != nil {
+
return err
+
}
+
+
if req.QueryParams != "" {
+
return e.Redirect(303, "/oauth/authorize?"+req.QueryParams)
+
} else {
+
return e.Redirect(303, "/account")
+
}
+
}
+35
server/handle_account_signout.go
···
+
package server
+
+
import (
+
"github.com/gorilla/sessions"
+
"github.com/labstack/echo-contrib/session"
+
"github.com/labstack/echo/v4"
+
)
+
+
func (s *Server) handleAccountSignout(e echo.Context) error {
+
sess, err := session.Get("session", e)
+
if err != nil {
+
return err
+
}
+
+
sess.Options = &sessions.Options{
+
Path: "/",
+
MaxAge: -1,
+
HttpOnly: true,
+
}
+
+
sess.Values = map[any]any{}
+
+
if err := sess.Save(e.Request(), e.Response()); err != nil {
+
return err
+
}
+
+
reqUri := e.QueryParam("request_uri")
+
+
redirect := "/account/signin"
+
if reqUri != "" {
+
redirect += "?" + e.QueryParams().Encode()
+
}
+
+
return e.Redirect(303, redirect)
+
}
+132
server/handle_oauth_authorize.go
···
+
package server
+
+
import (
+
"net/url"
+
"strings"
+
"time"
+
+
"github.com/Azure/go-autorest/autorest/to"
+
"github.com/haileyok/cocoon/internal/helpers"
+
"github.com/haileyok/cocoon/oauth"
+
"github.com/haileyok/cocoon/oauth/provider"
+
"github.com/labstack/echo/v4"
+
)
+
+
func (s *Server) handleOauthAuthorizeGet(e echo.Context) error {
+
reqUri := e.QueryParam("request_uri")
+
if reqUri == "" {
+
// render page for logged out dev
+
if s.config.Version == "dev" {
+
return e.Render(200, "authorize.html", map[string]any{
+
"Scopes": []string{"atproto", "transition:generic"},
+
"AppName": "DEV MODE AUTHORIZATION PAGE",
+
"Handle": "paula.cocoon.social",
+
"RequestUri": "",
+
})
+
}
+
return helpers.InputError(e, to.StringPtr("no request uri"))
+
}
+
+
repo, _, err := s.getSessionRepoOrErr(e)
+
if err != nil {
+
return e.Redirect(303, "/account/signin?"+e.QueryParams().Encode())
+
}
+
+
reqId, err := oauth.DecodeRequestUri(reqUri)
+
if err != nil {
+
return helpers.InputError(e, to.StringPtr(err.Error()))
+
}
+
+
var req provider.OauthAuthorizationRequest
+
if err := s.db.Raw("SELECT * FROM oauth_authorization_requests WHERE request_id = ?", nil, reqId).Scan(&req).Error; err != nil {
+
return helpers.ServerError(e, to.StringPtr(err.Error()))
+
}
+
+
clientId := e.QueryParam("client_id")
+
if clientId != req.ClientId {
+
return helpers.InputError(e, to.StringPtr("client id does not match the client id for the supplied request"))
+
}
+
+
client, err := s.oauthProvider.ClientManager.GetClient(e.Request().Context(), req.ClientId)
+
if err != nil {
+
return helpers.ServerError(e, to.StringPtr(err.Error()))
+
}
+
+
scopes := strings.Split(req.Parameters.Scope, " ")
+
appName := client.Metadata.ClientName
+
+
data := map[string]any{
+
"Scopes": scopes,
+
"AppName": appName,
+
"RequestUri": reqUri,
+
"QueryParams": e.QueryParams().Encode(),
+
"Handle": repo.Actor.Handle,
+
}
+
+
return e.Render(200, "authorize.html", data)
+
}
+
+
type OauthAuthorizePostRequest struct {
+
RequestUri string `form:"request_uri"`
+
AcceptOrRejct string `form:"accept_or_reject"`
+
}
+
+
func (s *Server) handleOauthAuthorizePost(e echo.Context) error {
+
repo, _, err := s.getSessionRepoOrErr(e)
+
if err != nil {
+
return e.Redirect(303, "/account/signin")
+
}
+
+
var req OauthAuthorizePostRequest
+
if err := e.Bind(&req); err != nil {
+
s.logger.Error("error binding authorize post request", "error", err)
+
return helpers.InputError(e, nil)
+
}
+
+
reqId, err := oauth.DecodeRequestUri(req.RequestUri)
+
if err != nil {
+
return helpers.InputError(e, to.StringPtr(err.Error()))
+
}
+
+
var authReq provider.OauthAuthorizationRequest
+
if err := s.db.Raw("SELECT * FROM oauth_authorization_requests WHERE request_id = ?", nil, reqId).Scan(&authReq).Error; err != nil {
+
return helpers.ServerError(e, to.StringPtr(err.Error()))
+
}
+
+
client, err := s.oauthProvider.ClientManager.GetClient(e.Request().Context(), authReq.ClientId)
+
if err != nil {
+
return helpers.ServerError(e, to.StringPtr(err.Error()))
+
}
+
+
// TODO: figure out how im supposed to actually redirect
+
if req.AcceptOrRejct == "reject" {
+
return e.Redirect(303, client.Metadata.ClientURI)
+
}
+
+
if time.Now().After(authReq.ExpiresAt) {
+
return helpers.InputError(e, to.StringPtr("the request has expired"))
+
}
+
+
if authReq.Sub != nil || authReq.Code != nil {
+
return helpers.InputError(e, to.StringPtr("this request was already authorized"))
+
}
+
+
code := oauth.GenerateCode()
+
+
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 {
+
s.logger.Error("error updating authorization request", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
q := url.Values{}
+
q.Set("state", authReq.Parameters.State)
+
q.Set("iss", "https://"+s.config.Hostname)
+
q.Set("code", code)
+
+
hashOrQuestion := "?"
+
if authReq.ClientAuth.Method != "private_key_jwt" {
+
hashOrQuestion = "#"
+
}
+
+
return e.Redirect(303, authReq.Parameters.RedirectURI+hashOrQuestion+q.Encode())
+
}
+12
server/handle_oauth_jwks.go
···
+
package server
+
+
import "github.com/labstack/echo/v4"
+
+
type OauthJwksResponse struct {
+
Keys []any `json:"keys"`
+
}
+
+
// TODO: ?
+
func (s *Server) handleOauthJwks(e echo.Context) error {
+
return e.JSON(200, OauthJwksResponse{Keys: []any{}})
+
}
+88
server/handle_oauth_par.go
···
+
package server
+
+
import (
+
"time"
+
+
"github.com/Azure/go-autorest/autorest/to"
+
"github.com/haileyok/cocoon/internal/helpers"
+
"github.com/haileyok/cocoon/oauth"
+
"github.com/haileyok/cocoon/oauth/constants"
+
"github.com/haileyok/cocoon/oauth/provider"
+
"github.com/labstack/echo/v4"
+
)
+
+
type OauthParResponse struct {
+
ExpiresIn int64 `json:"expires_in"`
+
RequestURI string `json:"request_uri"`
+
}
+
+
func (s *Server) handleOauthPar(e echo.Context) error {
+
var parRequest provider.ParRequest
+
if err := e.Bind(&parRequest); err != nil {
+
s.logger.Error("error binding for par request", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
if err := e.Validate(parRequest); err != nil {
+
s.logger.Error("missing parameters for par request", "error", err)
+
return helpers.InputError(e, nil)
+
}
+
+
// TODO: this seems wrong. should be a way to get the entire request url i believe, but this will work for now
+
dpopProof, err := s.oauthProvider.DpopManager.CheckProof(e.Request().Method, "https://"+s.config.Hostname+e.Request().URL.String(), e.Request().Header, nil)
+
if err != nil {
+
s.logger.Error("error getting dpop proof", "error", err)
+
return helpers.InputError(e, to.StringPtr(err.Error()))
+
}
+
+
client, clientAuth, err := s.oauthProvider.AuthenticateClient(e.Request().Context(), parRequest.AuthenticateClientRequestBase, dpopProof, &provider.AuthenticateClientOptions{
+
// rfc9449
+
// https://github.com/bluesky-social/atproto/blob/main/packages/oauth/oauth-provider/src/oauth-provider.ts#L473
+
AllowMissingDpopProof: true,
+
})
+
if err != nil {
+
s.logger.Error("error authenticating client", "error", err)
+
return helpers.InputError(e, to.StringPtr(err.Error()))
+
}
+
+
if parRequest.DpopJkt == nil {
+
if client.Metadata.DpopBoundAccessTokens {
+
parRequest.DpopJkt = to.StringPtr(dpopProof.JKT)
+
}
+
} else {
+
if !client.Metadata.DpopBoundAccessTokens {
+
msg := "dpop bound access tokens are not enabled for this client"
+
s.logger.Error(msg)
+
return helpers.InputError(e, &msg)
+
}
+
+
if dpopProof.JKT != *parRequest.DpopJkt {
+
msg := "supplied dpop jkt does not match header dpop jkt"
+
s.logger.Error(msg)
+
return helpers.InputError(e, &msg)
+
}
+
}
+
+
eat := time.Now().Add(constants.ParExpiresIn)
+
id := oauth.GenerateRequestId()
+
+
authRequest := &provider.OauthAuthorizationRequest{
+
RequestId: id,
+
ClientId: client.Metadata.ClientID,
+
ClientAuth: *clientAuth,
+
Parameters: parRequest,
+
ExpiresAt: eat,
+
}
+
+
if err := s.db.Create(authRequest, nil).Error; err != nil {
+
s.logger.Error("error creating auth request in db", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
uri := oauth.EncodeRequestUri(id)
+
+
return e.JSON(201, OauthParResponse{
+
ExpiresIn: int64(constants.ParExpiresIn.Seconds()),
+
RequestURI: uri,
+
})
+
}
+276
server/handle_oauth_token.go
···
+
package server
+
+
import (
+
"bytes"
+
"crypto/sha256"
+
"encoding/base64"
+
"fmt"
+
"slices"
+
"time"
+
+
"github.com/Azure/go-autorest/autorest/to"
+
"github.com/golang-jwt/jwt/v4"
+
"github.com/haileyok/cocoon/internal/helpers"
+
"github.com/haileyok/cocoon/oauth"
+
"github.com/haileyok/cocoon/oauth/constants"
+
"github.com/haileyok/cocoon/oauth/provider"
+
"github.com/labstack/echo/v4"
+
)
+
+
type OauthTokenRequest struct {
+
provider.AuthenticateClientRequestBase
+
GrantType string `form:"grant_type" json:"grant_type"`
+
Code *string `form:"code" json:"code,omitempty"`
+
CodeVerifier *string `form:"code_verifier" json:"code_verifier,omitempty"`
+
RedirectURI *string `form:"redirect_uri" json:"redirect_uri,omitempty"`
+
RefreshToken *string `form:"refresh_token" json:"refresh_token,omitempty"`
+
}
+
+
type OauthTokenResponse struct {
+
AccessToken string `json:"access_token"`
+
TokenType string `json:"token_type"`
+
RefreshToken string `json:"refresh_token"`
+
Scope string `json:"scope"`
+
ExpiresIn int64 `json:"expires_in"`
+
Sub string `json:"sub"`
+
}
+
+
func (s *Server) handleOauthToken(e echo.Context) error {
+
var req OauthTokenRequest
+
if err := e.Bind(&req); err != nil {
+
s.logger.Error("error binding token request", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
proof, err := s.oauthProvider.DpopManager.CheckProof(e.Request().Method, e.Request().URL.String(), e.Request().Header, nil)
+
if err != nil {
+
s.logger.Error("error getting dpop proof", "error", err)
+
return helpers.InputError(e, to.StringPtr(err.Error()))
+
}
+
+
client, clientAuth, err := s.oauthProvider.AuthenticateClient(e.Request().Context(), req.AuthenticateClientRequestBase, proof, &provider.AuthenticateClientOptions{
+
AllowMissingDpopProof: true,
+
})
+
if err != nil {
+
s.logger.Error("error authenticating client", "error", err)
+
return helpers.InputError(e, to.StringPtr(err.Error()))
+
}
+
+
// TODO: this should come from an oauth provier config
+
if !slices.Contains([]string{"authorization_code", "refresh_token"}, req.GrantType) {
+
return helpers.InputError(e, to.StringPtr(fmt.Sprintf(`"%s" grant type is not supported by the server`, req.GrantType)))
+
}
+
+
if !slices.Contains(client.Metadata.GrantTypes, req.GrantType) {
+
return helpers.InputError(e, to.StringPtr(fmt.Sprintf(`"%s" grant type is not supported by the client`, req.GrantType)))
+
}
+
+
if req.GrantType == "authorization_code" {
+
if req.Code == nil {
+
return helpers.InputError(e, to.StringPtr(`"code" is required"`))
+
}
+
+
var authReq provider.OauthAuthorizationRequest
+
// get the lil guy and delete him
+
if err := s.db.Raw("DELETE FROM oauth_authorization_requests WHERE code = ? RETURNING *", nil, *req.Code).Scan(&authReq).Error; err != nil {
+
s.logger.Error("error finding authorization request", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
if req.RedirectURI == nil || *req.RedirectURI != authReq.Parameters.RedirectURI {
+
return helpers.InputError(e, to.StringPtr(`"redirect_uri" mismatch`))
+
}
+
+
if authReq.Parameters.CodeChallenge != nil {
+
if req.CodeVerifier == nil {
+
return helpers.InputError(e, to.StringPtr(`"code_verifier" is required`))
+
}
+
+
if len(*req.CodeVerifier) < 43 {
+
return helpers.InputError(e, to.StringPtr(`"code_verifier" is too short`))
+
}
+
+
switch *&authReq.Parameters.CodeChallengeMethod {
+
case "", "plain":
+
if authReq.Parameters.CodeChallenge != req.CodeVerifier {
+
return helpers.InputError(e, to.StringPtr("invalid code_verifier"))
+
}
+
case "S256":
+
inputChal, err := base64.RawURLEncoding.DecodeString(*authReq.Parameters.CodeChallenge)
+
if err != nil {
+
s.logger.Error("error decoding code challenge", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
h := sha256.New()
+
h.Write([]byte(*req.CodeVerifier))
+
compdChal := h.Sum(nil)
+
+
if !bytes.Equal(inputChal, compdChal) {
+
return helpers.InputError(e, to.StringPtr("invalid code_verifier"))
+
}
+
default:
+
return helpers.InputError(e, to.StringPtr("unsupported code_challenge_method "+*&authReq.Parameters.CodeChallengeMethod))
+
}
+
} else if req.CodeVerifier != nil {
+
return helpers.InputError(e, to.StringPtr("code_challenge parameter wasn't provided"))
+
}
+
+
repo, err := s.getRepoActorByDid(*authReq.Sub)
+
if err != nil {
+
helpers.InputError(e, to.StringPtr("unable to find actor"))
+
}
+
+
now := time.Now()
+
eat := now.Add(constants.TokenMaxAge)
+
id := oauth.GenerateTokenId()
+
+
refreshToken := oauth.GenerateRefreshToken()
+
+
accessClaims := jwt.MapClaims{
+
"scope": authReq.Parameters.Scope,
+
"aud": s.config.Did,
+
"sub": repo.Repo.Did,
+
"iat": now.Unix(),
+
"exp": eat.Unix(),
+
"jti": id,
+
"client_id": authReq.ClientId,
+
}
+
+
if authReq.Parameters.DpopJkt != nil {
+
accessClaims["cnf"] = *authReq.Parameters.DpopJkt
+
}
+
+
accessToken := jwt.NewWithClaims(jwt.SigningMethodES256, accessClaims)
+
accessString, err := accessToken.SignedString(s.privateKey)
+
if err != nil {
+
return err
+
}
+
+
if err := s.db.Create(&provider.OauthToken{
+
ClientId: authReq.ClientId,
+
ClientAuth: *clientAuth,
+
Parameters: authReq.Parameters,
+
ExpiresAt: eat,
+
DeviceId: "",
+
Sub: repo.Repo.Did,
+
Code: *authReq.Code,
+
Token: accessString,
+
RefreshToken: refreshToken,
+
}, nil).Error; err != nil {
+
s.logger.Error("error creating token in db", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
// prob not needed
+
tokenType := "Bearer"
+
if authReq.Parameters.DpopJkt != nil {
+
tokenType = "DPoP"
+
}
+
+
e.Response().Header().Set("content-type", "application/json")
+
+
return e.JSON(200, OauthTokenResponse{
+
AccessToken: accessString,
+
RefreshToken: refreshToken,
+
TokenType: tokenType,
+
Scope: authReq.Parameters.Scope,
+
ExpiresIn: int64(eat.Sub(time.Now()).Seconds()),
+
Sub: repo.Repo.Did,
+
})
+
}
+
+
if req.GrantType == "refresh_token" {
+
if req.RefreshToken == nil {
+
return helpers.InputError(e, to.StringPtr(`"refresh_token" is required`))
+
}
+
+
var oauthToken provider.OauthToken
+
if err := s.db.Raw("SELECT * FROM oauth_tokens WHERE refresh_token = ?", nil, req.RefreshToken).Scan(&oauthToken).Error; err != nil {
+
s.logger.Error("error finding oauth token by refresh token", "error", err, "refresh_token", req.RefreshToken)
+
return helpers.ServerError(e, nil)
+
}
+
+
if client.Metadata.ClientID != oauthToken.ClientId {
+
return helpers.InputError(e, to.StringPtr(`"client_id" mismatch`))
+
}
+
+
if clientAuth.Method != oauthToken.ClientAuth.Method {
+
return helpers.InputError(e, to.StringPtr(`"client authentication method mismatch`))
+
}
+
+
if *oauthToken.Parameters.DpopJkt != proof.JKT {
+
return helpers.InputError(e, to.StringPtr("dpop proof does not match expected jkt"))
+
}
+
+
sessionLifetime := constants.PublicClientSessionLifetime
+
refreshLifetime := constants.PublicClientRefreshLifetime
+
if clientAuth.Method != "none" {
+
sessionLifetime = constants.ConfidentialClientSessionLifetime
+
refreshLifetime = constants.ConfidentialClientRefreshLifetime
+
}
+
+
sessionAge := time.Since(oauthToken.CreatedAt)
+
if sessionAge > sessionLifetime {
+
return helpers.InputError(e, to.StringPtr("Session expired"))
+
}
+
+
refreshAge := time.Since(oauthToken.UpdatedAt)
+
if refreshAge > refreshLifetime {
+
return helpers.InputError(e, to.StringPtr("Refresh token expired"))
+
}
+
+
if client.Metadata.DpopBoundAccessTokens && oauthToken.Parameters.DpopJkt == nil {
+
// why? ref impl
+
return helpers.InputError(e, to.StringPtr("dpop jkt is required for dpop bound access tokens"))
+
}
+
+
nextTokenId := oauth.GenerateTokenId()
+
nextRefreshToken := oauth.GenerateRefreshToken()
+
+
now := time.Now()
+
eat := now.Add(constants.TokenMaxAge)
+
+
accessClaims := jwt.MapClaims{
+
"scope": oauthToken.Parameters.Scope,
+
"aud": s.config.Did,
+
"sub": oauthToken.Sub,
+
"iat": now.Unix(),
+
"exp": eat.Unix(),
+
"jti": nextTokenId,
+
"client_id": oauthToken.ClientId,
+
}
+
+
if oauthToken.Parameters.DpopJkt != nil {
+
accessClaims["cnf"] = *&oauthToken.Parameters.DpopJkt
+
}
+
+
accessToken := jwt.NewWithClaims(jwt.SigningMethodES256, accessClaims)
+
accessString, err := accessToken.SignedString(s.privateKey)
+
if err != nil {
+
return err
+
}
+
+
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 {
+
s.logger.Error("error updating token", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
// prob not needed
+
tokenType := "Bearer"
+
if oauthToken.Parameters.DpopJkt != nil {
+
tokenType = "DPoP"
+
}
+
+
return e.JSON(200, OauthTokenResponse{
+
AccessToken: accessString,
+
RefreshToken: nextRefreshToken,
+
TokenType: tokenType,
+
Scope: oauthToken.Parameters.Scope,
+
ExpiresIn: int64(eat.Sub(time.Now()).Seconds()),
+
Sub: oauthToken.Sub,
+
})
+
}
+
+
return helpers.InputError(e, to.StringPtr(fmt.Sprintf(`grant type "%s" is not supported`, req.GrantType)))
+
}
+112
server/handle_server_get_service_auth.go
···
+
package server
+
+
import (
+
"crypto/rand"
+
"crypto/sha256"
+
"encoding/base64"
+
"encoding/json"
+
"fmt"
+
"strings"
+
"time"
+
+
"github.com/Azure/go-autorest/autorest/to"
+
"github.com/google/uuid"
+
"github.com/haileyok/cocoon/internal/helpers"
+
"github.com/haileyok/cocoon/models"
+
"github.com/labstack/echo/v4"
+
secp256k1secec "gitlab.com/yawning/secp256k1-voi/secec"
+
)
+
+
type ServerGetServiceAuthRequest struct {
+
Aud string `query:"aud" validate:"required,atproto-did"`
+
Exp int64 `query:"exp"`
+
Lxm string `query:"lxm" validate:"required,atproto-nsid"`
+
}
+
+
func (s *Server) handleServerGetServiceAuth(e echo.Context) error {
+
var req ServerGetServiceAuthRequest
+
if err := e.Bind(&req); err != nil {
+
s.logger.Error("could not bind service auth request", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
if err := e.Validate(req); err != nil {
+
return helpers.InputError(e, nil)
+
}
+
+
now := time.Now().Unix()
+
if req.Exp == 0 {
+
req.Exp = now + 60 // default
+
}
+
+
if req.Lxm == "com.atproto.server.getServiceAuth" {
+
return helpers.InputError(e, to.StringPtr("may not generate auth tokens recursively"))
+
}
+
+
maxExp := now + (60 * 30)
+
if req.Exp > maxExp {
+
return helpers.InputError(e, to.StringPtr("expiration too big. smoller please"))
+
}
+
+
repo := e.Get("repo").(*models.RepoActor)
+
+
header := map[string]string{
+
"alg": "ES256K",
+
"crv": "secp256k1",
+
"typ": "JWT",
+
}
+
hj, err := json.Marshal(header)
+
if err != nil {
+
s.logger.Error("error marshaling header", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
encheader := strings.TrimRight(base64.RawURLEncoding.EncodeToString(hj), "=")
+
+
payload := map[string]any{
+
"iss": repo.Repo.Did,
+
"aud": req.Aud,
+
"lxm": req.Lxm,
+
"jti": uuid.NewString(),
+
"exp": req.Exp,
+
"iat": now,
+
}
+
pj, err := json.Marshal(payload)
+
if err != nil {
+
s.logger.Error("error marashaling payload", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
encpayload := strings.TrimRight(base64.RawURLEncoding.EncodeToString(pj), "=")
+
+
input := fmt.Sprintf("%s.%s", encheader, encpayload)
+
hash := sha256.Sum256([]byte(input))
+
+
sk, err := secp256k1secec.NewPrivateKey(repo.SigningKey)
+
if err != nil {
+
s.logger.Error("can't load private key", "error", err)
+
return err
+
}
+
+
R, S, _, err := sk.SignRaw(rand.Reader, hash[:])
+
if err != nil {
+
s.logger.Error("error signing", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
rBytes := R.Bytes()
+
sBytes := S.Bytes()
+
+
rPadded := make([]byte, 32)
+
sPadded := make([]byte, 32)
+
copy(rPadded[32-len(rBytes):], rBytes)
+
copy(sPadded[32-len(sBytes):], sBytes)
+
+
rawsig := append(rPadded, sPadded...)
+
encsig := strings.TrimRight(base64.RawURLEncoding.EncodeToString(rawsig), "=")
+
token := fmt.Sprintf("%s.%s", input, encsig)
+
+
return e.JSON(200, map[string]string{
+
"token": token,
+
})
+
}
+88
server/handle_well_known.go
···
package server
import (
+
"fmt"
+
+
"github.com/Azure/go-autorest/autorest/to"
"github.com/labstack/echo/v4"
)
+
var (
+
CocoonSupportedScopes = []string{
+
"atproto",
+
"transition:email",
+
"transition:generic",
+
"transition:chat.bsky",
+
}
+
)
+
+
type OauthAuthorizationMetadata struct {
+
Issuer string `json:"issuer"`
+
RequestParameterSupported bool `json:"request_parameter_supported"`
+
RequestUriParameterSupported bool `json:"request_uri_parameter_supported"`
+
RequireRequestUriRegistration *bool `json:"require_request_uri_registration,omitempty"`
+
ScopesSupported []string `json:"scopes_supported"`
+
SubjectTypesSupported []string `json:"subject_types_supported"`
+
ResponseTypesSupported []string `json:"response_types_supported"`
+
ResponseModesSupported []string `json:"response_modes_supported"`
+
GrantTypesSupported []string `json:"grant_types_supported"`
+
CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"`
+
UILocalesSupported []string `json:"ui_locales_supported"`
+
DisplayValuesSupported []string `json:"display_values_supported"`
+
RequestObjectSigningAlgValuesSupported []string `json:"request_object_signing_alg_values_supported"`
+
AuthorizationResponseISSParameterSupported bool `json:"authorization_response_iss_parameter_supported"`
+
RequestObjectEncryptionAlgValuesSupported []string `json:"request_object_encryption_alg_values_supported"`
+
RequestObjectEncryptionEncValuesSupported []string `json:"request_object_encryption_enc_values_supported"`
+
JwksUri string `json:"jwks_uri"`
+
AuthorizationEndpoint string `json:"authorization_endpoint"`
+
TokenEndpoint string `json:"token_endpoint"`
+
TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported"`
+
TokenEndpointAuthSigningAlgValuesSupported []string `json:"token_endpoint_auth_signing_alg_values_supported"`
+
RevocationEndpoint string `json:"revocation_endpoint"`
+
IntrospectionEndpoint string `json:"introspection_endpoint"`
+
PushedAuthorizationRequestEndpoint string `json:"pushed_authorization_request_endpoint"`
+
RequirePushedAuthorizationRequests bool `json:"require_pushed_authorization_requests"`
+
DpopSigningAlgValuesSupported []string `json:"dpop_signing_alg_values_supported"`
+
ProtectedResources []string `json:"protected_resources"`
+
ClientIDMetadataDocumentSupported bool `json:"client_id_metadata_document_supported"`
+
}
+
func (s *Server) handleWellKnown(e echo.Context) error {
return e.JSON(200, map[string]any{
"@context": []string{
···
},
})
}
+
+
func (s *Server) handleOauthProtectedResource(e echo.Context) error {
+
return e.JSON(200, map[string]any{
+
"resource": "https://" + s.config.Hostname,
+
"authorization_servers": []string{
+
"https://" + s.config.Hostname,
+
},
+
"scopes_supported": []string{},
+
"bearer_methods_supported": []string{"header"},
+
"resource_documentation": "https://atproto.com",
+
})
+
}
+
+
func (s *Server) handleOauthAuthorizationServer(e echo.Context) error {
+
return e.JSON(200, OauthAuthorizationMetadata{
+
Issuer: "https://" + s.config.Hostname,
+
RequestParameterSupported: true,
+
RequestUriParameterSupported: true,
+
RequireRequestUriRegistration: to.BoolPtr(true),
+
ScopesSupported: CocoonSupportedScopes,
+
SubjectTypesSupported: []string{"public"},
+
ResponseTypesSupported: []string{"code"},
+
ResponseModesSupported: []string{"query", "fragment", "form_post"},
+
GrantTypesSupported: []string{"authorization_code", "refresh_token"},
+
CodeChallengeMethodsSupported: []string{"S256"},
+
UILocalesSupported: []string{"en-US"},
+
DisplayValuesSupported: []string{"page", "popup", "touch"},
+
RequestObjectSigningAlgValuesSupported: []string{"ES256"}, // only es256 for now...
+
AuthorizationResponseISSParameterSupported: true,
+
RequestObjectEncryptionAlgValuesSupported: []string{},
+
RequestObjectEncryptionEncValuesSupported: []string{},
+
JwksUri: fmt.Sprintf("https://%s/oauth/jwks", s.config.Hostname),
+
AuthorizationEndpoint: fmt.Sprintf("https://%s/oauth/authorize", s.config.Hostname),
+
TokenEndpoint: fmt.Sprintf("https://%s/oauth/token", s.config.Hostname),
+
TokenEndpointAuthMethodsSupported: []string{"none", "private_key_jwt"},
+
TokenEndpointAuthSigningAlgValuesSupported: []string{"ES256"}, // Same as above, just es256
+
RevocationEndpoint: fmt.Sprintf("https://%s/oauth/revoke", s.config.Hostname),
+
IntrospectionEndpoint: fmt.Sprintf("https://%s/oauth/introspect", s.config.Hostname),
+
PushedAuthorizationRequestEndpoint: fmt.Sprintf("https://%s/oauth/par", s.config.Hostname),
+
RequirePushedAuthorizationRequests: true,
+
DpopSigningAlgValuesSupported: []string{"ES256"}, // again same as above
+
ProtectedResources: []string{"https://" + s.config.Hostname},
+
ClientIDMetadataDocumentSupported: true,
+
})
+
}
+16
server/mail.go
···
import "fmt"
func (s *Server) sendWelcomeMail(email, handle string) error {
+
if s.mail == nil {
+
return nil
+
}
+
s.mailLk.Lock()
defer s.mailLk.Unlock()
···
}
func (s *Server) sendPasswordReset(email, handle, code string) error {
+
if s.mail == nil {
+
return nil
+
}
+
s.mailLk.Lock()
defer s.mailLk.Unlock()
···
}
func (s *Server) sendEmailUpdate(email, handle, code string) error {
+
if s.mail == nil {
+
return nil
+
}
+
s.mailLk.Lock()
defer s.mailLk.Unlock()
···
}
func (s *Server) sendEmailVerification(email, handle, code string) error {
+
if s.mail == nil {
+
return nil
+
}
+
s.mailLk.Lock()
defer s.mailLk.Unlock()
+230 -35
server/server.go
···
"bytes"
"context"
"crypto/ecdsa"
+
"embed"
"errors"
"fmt"
"io"
···
"net/http"
"net/smtp"
"os"
+
"path/filepath"
"strings"
"sync"
+
"text/template"
"time"
"github.com/Azure/go-autorest/autorest/to"
···
"github.com/domodwyer/mailyak/v3"
"github.com/go-playground/validator"
"github.com/golang-jwt/jwt/v4"
+
"github.com/gorilla/sessions"
"github.com/haileyok/cocoon/identity"
"github.com/haileyok/cocoon/internal/db"
"github.com/haileyok/cocoon/internal/helpers"
"github.com/haileyok/cocoon/models"
+
"github.com/haileyok/cocoon/oauth/client_manager"
+
"github.com/haileyok/cocoon/oauth/constants"
+
"github.com/haileyok/cocoon/oauth/dpop/dpop_manager"
+
"github.com/haileyok/cocoon/oauth/provider"
"github.com/haileyok/cocoon/plc"
+
echo_session "github.com/labstack/echo-contrib/session"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
-
"github.com/lestrrat-go/jwx/v2/jwk"
slogecho "github.com/samber/slog-echo"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
+
)
+
+
const (
+
AccountSessionMaxAge = 30 * 24 * time.Hour // one week
)
type S3Config struct {
···
}
type Server struct {
-
http *http.Client
-
httpd *http.Server
-
mail *mailyak.MailYak
-
mailLk *sync.Mutex
-
echo *echo.Echo
-
db *db.DB
-
plcClient *plc.Client
-
logger *slog.Logger
-
config *config
-
privateKey *ecdsa.PrivateKey
-
repoman *RepoMan
-
evtman *events.EventManager
-
passport *identity.Passport
+
http *http.Client
+
httpd *http.Server
+
mail *mailyak.MailYak
+
mailLk *sync.Mutex
+
echo *echo.Echo
+
db *db.DB
+
plcClient *plc.Client
+
logger *slog.Logger
+
config *config
+
privateKey *ecdsa.PrivateKey
+
repoman *RepoMan
+
oauthProvider *provider.Provider
+
evtman *events.EventManager
+
passport *identity.Passport
dbName string
s3Config *S3Config
···
SmtpName string
S3Config *S3Config
+
+
SessionSecret string
}
type config struct {
···
return nil
}
+
//go:embed templates/*
+
var templateFS embed.FS
+
+
//go:embed static/*
+
var staticFS embed.FS
+
+
type TemplateRenderer struct {
+
templates *template.Template
+
isDev bool
+
templatePath string
+
}
+
+
func (s *Server) loadTemplates() {
+
absPath, _ := filepath.Abs("server/templates/*.html")
+
if s.config.Version == "dev" {
+
tmpl := template.Must(template.ParseGlob(absPath))
+
s.echo.Renderer = &TemplateRenderer{
+
templates: tmpl,
+
isDev: true,
+
templatePath: absPath,
+
}
+
} else {
+
tmpl := template.Must(template.ParseFS(templateFS, "templates/*.html"))
+
s.echo.Renderer = &TemplateRenderer{
+
templates: tmpl,
+
isDev: false,
+
}
+
}
+
}
+
+
func (t *TemplateRenderer) Render(w io.Writer, name string, data any, c echo.Context) error {
+
if t.isDev {
+
tmpl, err := template.ParseGlob(t.templatePath)
+
if err != nil {
+
return err
+
}
+
t.templates = tmpl
+
}
+
+
if viewContext, isMap := data.(map[string]any); isMap {
+
viewContext["reverse"] = c.Echo().Reverse
+
}
+
+
return t.templates.ExecuteTemplate(w, name, data)
+
}
+
func (s *Server) handleAdminMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
return func(e echo.Context) error {
username, password, ok := e.Request().BasicAuth()
···
}
}
-
func (s *Server) handleSessionMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
+
func (s *Server) handleLegacySessionMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
return func(e echo.Context) error {
authheader := e.Request().Header.Get("authorization")
if authheader == "" {
···
pts := strings.Split(authheader, " ")
if len(pts) != 2 {
return helpers.ServerError(e, nil)
+
}
+
+
if pts[0] == "DPoP" {
+
return next(e)
}
tokenstr := pts[1]
···
}
}
+
func (s *Server) handleOauthSessionMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
+
return func(e echo.Context) error {
+
authheader := e.Request().Header.Get("authorization")
+
if authheader == "" {
+
return e.JSON(401, map[string]string{"error": "Unauthorized"})
+
}
+
+
pts := strings.Split(authheader, " ")
+
if len(pts) != 2 {
+
return helpers.ServerError(e, nil)
+
}
+
+
if pts[0] != "DPoP" {
+
return next(e)
+
}
+
+
accessToken := pts[1]
+
+
proof, err := s.oauthProvider.DpopManager.CheckProof(e.Request().Method, "https://"+s.config.Hostname+e.Request().URL.String(), e.Request().Header, to.StringPtr(accessToken))
+
if err != nil {
+
s.logger.Error("invalid dpop proof", "error", err)
+
return helpers.InputError(e, to.StringPtr(err.Error()))
+
}
+
+
var oauthToken provider.OauthToken
+
if err := s.db.Raw("SELECT * FROM oauth_tokens WHERE token = ?", nil, accessToken).Scan(&oauthToken).Error; err != nil {
+
s.logger.Error("error finding access token in db", "error", err)
+
return helpers.InputError(e, nil)
+
}
+
+
if oauthToken.Token == "" {
+
return helpers.InputError(e, to.StringPtr("InvalidToken"))
+
}
+
+
if *oauthToken.Parameters.DpopJkt != proof.JKT {
+
s.logger.Error("jkt mismatch", "token", oauthToken.Parameters.DpopJkt, "proof", proof.JKT)
+
return helpers.InputError(e, to.StringPtr("dpop jkt mismatch"))
+
}
+
+
if time.Now().After(oauthToken.ExpiresAt) {
+
return e.JSON(400, map[string]string{"error": "ExpiredToken", "message": "token has expired"})
+
}
+
+
repo, err := s.getRepoActorByDid(oauthToken.Sub)
+
if err != nil {
+
s.logger.Error("could not find actor in db", "error", err)
+
return helpers.ServerError(e, nil)
+
}
+
+
nonce := s.oauthProvider.NextNonce()
+
if nonce != "" {
+
e.Response().Header().Set("DPoP-Nonce", nonce)
+
e.Response().Header().Add("access-control-expose-headers", "DPoP-Nonce")
+
}
+
+
e.Set("repo", repo)
+
e.Set("did", repo.Repo.Did)
+
e.Set("token", accessToken)
+
e.Set("scopes", strings.Split(oauthToken.Parameters.Scope, " "))
+
+
return next(e)
+
}
+
}
+
func New(args *Args) (*Server, error) {
if args.Addr == "" {
return nil, fmt.Errorf("addr must be set")
···
args.Logger = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{}))
}
+
if args.SessionSecret == "" {
+
panic("SESSION SECRET WAS NOT SET. THIS IS REQUIRED. ")
+
}
+
e := echo.New()
e.Pre(middleware.RemoveTrailingSlash())
e.Pre(slogecho.New(args.Logger))
+
e.Use(echo_session.Middleware(sessions.NewCookieStore([]byte(args.SessionSecret))))
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
AllowOrigins: []string{"*"},
AllowHeaders: []string{"*"},
···
return nil, err
}
-
key, err := jwk.ParseKey(jwkbytes)
+
key, err := helpers.ParseJWKFromBytes(jwkbytes)
if err != nil {
return nil, err
}
···
return nil, err
}
+
oauthCli := &http.Client{
+
Timeout: 10 * time.Second,
+
}
+
+
var nonceSecret []byte
+
maybeSecret, err := os.ReadFile("nonce.secret")
+
if err != nil && !os.IsNotExist(err) {
+
args.Logger.Error("error attempting to read nonce secret", "error", err)
+
} else {
+
nonceSecret = maybeSecret
+
}
+
s := &Server{
http: h,
httpd: httpd,
···
dbName: args.DbName,
s3Config: args.S3Config,
+
+
oauthProvider: provider.NewProvider(provider.Args{
+
Hostname: args.Hostname,
+
ClientManagerArgs: client_manager.Args{
+
Cli: oauthCli,
+
Logger: args.Logger,
+
},
+
DpopManagerArgs: dpop_manager.Args{
+
NonceSecret: nonceSecret,
+
NonceRotationInterval: constants.NonceMaxRotationInterval / 3,
+
OnNonceSecretCreated: func(newNonce []byte) {
+
if err := os.WriteFile("nonce.secret", newNonce, 0644); err != nil {
+
args.Logger.Error("error writing new nonce secret", "error", err)
+
}
+
},
+
Logger: args.Logger,
+
Hostname: args.Hostname,
+
},
+
}),
}
+
+
s.loadTemplates()
s.repoman = NewRepoMan(s) // TODO: this is way too lazy, stop it
···
}
func (s *Server) addRoutes() {
+
// static
+
if s.config.Version == "dev" {
+
s.echo.Static("/static", "server/static")
+
} else {
+
s.echo.GET("/static/*", echo.WrapHandler(http.FileServer(http.FS(staticFS))))
+
}
+
// random stuff
s.echo.GET("/", s.handleRoot)
s.echo.GET("/xrpc/_health", s.handleHealth)
s.echo.GET("/.well-known/did.json", s.handleWellKnown)
+
s.echo.GET("/.well-known/oauth-protected-resource", s.handleOauthProtectedResource)
+
s.echo.GET("/.well-known/oauth-authorization-server", s.handleOauthAuthorizationServer)
s.echo.GET("/robots.txt", s.handleRobots)
// public
···
s.echo.GET("/xrpc/com.atproto.sync.listBlobs", s.handleSyncListBlobs)
s.echo.GET("/xrpc/com.atproto.sync.getBlob", s.handleSyncGetBlob)
+
// account
+
s.echo.GET("/account", s.handleAccount)
+
s.echo.POST("/account/revoke", s.handleAccountRevoke)
+
s.echo.GET("/account/signin", s.handleAccountSigninGet)
+
s.echo.POST("/account/signin", s.handleAccountSigninPost)
+
s.echo.GET("/account/signout", s.handleAccountSignout)
+
+
// oauth account
+
s.echo.GET("/oauth/jwks", s.handleOauthJwks)
+
s.echo.GET("/oauth/authorize", s.handleOauthAuthorizeGet)
+
s.echo.POST("/oauth/authorize", s.handleOauthAuthorizePost)
+
+
// oauth authorization
+
s.echo.POST("/oauth/par", s.handleOauthPar, s.oauthProvider.BaseMiddleware)
+
s.echo.POST("/oauth/token", s.handleOauthToken, s.oauthProvider.BaseMiddleware)
+
// authed
-
s.echo.GET("/xrpc/com.atproto.server.getSession", s.handleGetSession, s.handleSessionMiddleware)
-
s.echo.POST("/xrpc/com.atproto.server.refreshSession", s.handleRefreshSession, s.handleSessionMiddleware)
-
s.echo.POST("/xrpc/com.atproto.server.deleteSession", s.handleDeleteSession, s.handleSessionMiddleware)
-
s.echo.POST("/xrpc/com.atproto.identity.updateHandle", s.handleIdentityUpdateHandle, s.handleSessionMiddleware)
-
s.echo.POST("/xrpc/com.atproto.server.confirmEmail", s.handleServerConfirmEmail, s.handleSessionMiddleware)
-
s.echo.POST("/xrpc/com.atproto.server.requestEmailConfirmation", s.handleServerRequestEmailConfirmation, s.handleSessionMiddleware)
+
s.echo.GET("/xrpc/com.atproto.server.getSession", s.handleGetSession, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
+
s.echo.POST("/xrpc/com.atproto.server.refreshSession", s.handleRefreshSession, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
+
s.echo.POST("/xrpc/com.atproto.server.deleteSession", s.handleDeleteSession, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
+
s.echo.POST("/xrpc/com.atproto.identity.updateHandle", s.handleIdentityUpdateHandle, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
+
s.echo.POST("/xrpc/com.atproto.server.confirmEmail", s.handleServerConfirmEmail, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
+
s.echo.POST("/xrpc/com.atproto.server.requestEmailConfirmation", s.handleServerRequestEmailConfirmation, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
s.echo.POST("/xrpc/com.atproto.server.requestPasswordReset", s.handleServerRequestPasswordReset) // AUTH NOT REQUIRED FOR THIS ONE
-
s.echo.POST("/xrpc/com.atproto.server.requestEmailUpdate", s.handleServerRequestEmailUpdate, s.handleSessionMiddleware)
-
s.echo.POST("/xrpc/com.atproto.server.resetPassword", s.handleServerResetPassword, s.handleSessionMiddleware)
-
s.echo.POST("/xrpc/com.atproto.server.updateEmail", s.handleServerUpdateEmail, s.handleSessionMiddleware)
+
s.echo.POST("/xrpc/com.atproto.server.requestEmailUpdate", s.handleServerRequestEmailUpdate, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
+
s.echo.POST("/xrpc/com.atproto.server.resetPassword", s.handleServerResetPassword, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
+
s.echo.POST("/xrpc/com.atproto.server.updateEmail", s.handleServerUpdateEmail, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
+
s.echo.GET("/xrpc/com.atproto.server.getServiceAuth", s.handleServerGetServiceAuth, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
// repo
-
s.echo.POST("/xrpc/com.atproto.repo.createRecord", s.handleCreateRecord, s.handleSessionMiddleware)
-
s.echo.POST("/xrpc/com.atproto.repo.putRecord", s.handlePutRecord, s.handleSessionMiddleware)
-
s.echo.POST("/xrpc/com.atproto.repo.deleteRecord", s.handleDeleteRecord, s.handleSessionMiddleware)
-
s.echo.POST("/xrpc/com.atproto.repo.applyWrites", s.handleApplyWrites, s.handleSessionMiddleware)
-
s.echo.POST("/xrpc/com.atproto.repo.uploadBlob", s.handleRepoUploadBlob, s.handleSessionMiddleware)
-
s.echo.POST("/xrpc/com.atproto.repo.importRepo", s.handleRepoImportRepo, s.handleSessionMiddleware)
+
s.echo.POST("/xrpc/com.atproto.repo.createRecord", s.handleCreateRecord, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
+
s.echo.POST("/xrpc/com.atproto.repo.putRecord", s.handlePutRecord, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
+
s.echo.POST("/xrpc/com.atproto.repo.deleteRecord", s.handleDeleteRecord, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
+
s.echo.POST("/xrpc/com.atproto.repo.applyWrites", s.handleApplyWrites, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
+
s.echo.POST("/xrpc/com.atproto.repo.uploadBlob", s.handleRepoUploadBlob, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
+
s.echo.POST("/xrpc/com.atproto.repo.importRepo", s.handleRepoImportRepo, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
// stupid silly endpoints
-
s.echo.GET("/xrpc/app.bsky.actor.getPreferences", s.handleActorGetPreferences, s.handleSessionMiddleware)
-
s.echo.POST("/xrpc/app.bsky.actor.putPreferences", s.handleActorPutPreferences, s.handleSessionMiddleware)
+
s.echo.GET("/xrpc/app.bsky.actor.getPreferences", s.handleActorGetPreferences, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
+
s.echo.POST("/xrpc/app.bsky.actor.putPreferences", s.handleActorPutPreferences, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
// are there any routes that we should be allowing without auth? i dont think so but idk
-
s.echo.GET("/xrpc/*", s.handleProxy, s.handleSessionMiddleware)
-
s.echo.POST("/xrpc/*", s.handleProxy, s.handleSessionMiddleware)
+
s.echo.GET("/xrpc/*", s.handleProxy, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
+
s.echo.POST("/xrpc/*", s.handleProxy, s.handleLegacySessionMiddleware, s.handleOauthSessionMiddleware)
// admin routes
s.echo.POST("/xrpc/com.atproto.server.createInviteCode", s.handleCreateInviteCode, s.handleAdminMiddleware)
···
&models.Record{},
&models.Blob{},
&models.BlobPart{},
+
&provider.OauthToken{},
+
&provider.OauthAuthorizationRequest{},
)
s.logger.Info("starting cocoon")
+4
server/static/pico.css
···
+
@charset "UTF-8";/*!
+
* Pico CSS ✨ v2.1.1 (https://picocss.com)
+
* Copyright 2019-2025 - Licensed under MIT
+
*/: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
···
+
:root {
+
--zinc-700: rgb(66, 71, 81);
+
--success: rgb(0, 166, 110);
+
--danger: rgb(155, 35, 24);
+
}
+
+
body {
+
display: flex;
+
flex-direction: column;
+
}
+
+
main {
+
}
+
+
.margin-top-sm {
+
margin-top: 2em;
+
}
+
+
.margin-top-md {
+
margin-top: 2.5em;
+
}
+
+
.margin-bottom-xs {
+
margin-bottom: 1.5em;
+
}
+
+
.centered-body {
+
min-height: 100vh;
+
justify-content: center;
+
}
+
+
.base-container {
+
border: 1px solid var(--zinc-700);
+
border-radius: 10px;
+
padding: 1.75em 1.2em;
+
}
+
+
.box-shadow-container {
+
box-shadow: 1px 1px 52px 2px rgba(0, 0, 0, 0.42);
+
}
+
+
.login-container {
+
max-width: 50ch;
+
form :last-child {
+
margin-bottom: 0;
+
}
+
form button {
+
float: right;
+
}
+
}
+
+
.authorize-container {
+
max-width: 100ch;
+
}
+
+
button {
+
width: unset;
+
min-width: 16ch;
+
}
+
+
.button-row {
+
display: flex;
+
gap: 1ch;
+
justify-content: end;
+
}
+
+
.alert {
+
border: 1px solid var(--zinc-700);
+
border-radius: 10px;
+
padding: 1em 1em;
+
p {
+
color: white;
+
margin-bottom: unset;
+
}
+
}
+
+
.alert-success {
+
background-color: var(--success);
+
}
+
+
.alert-danger {
+
background-color: var(--danger);
+
}
+39
server/templates/account.html
···
+
<!doctype html>
+
<html lang="en">
+
<head>
+
<meta charset="utf-8" />
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
+
<meta name="color-scheme" content="light dark" />
+
<link rel="stylesheet" href="/static/pico.css" />
+
<link rel="stylesheet" href="/static/style.css" />
+
<title>Your Account</title>
+
</head>
+
<body class="margin-top-md">
+
<main class="container base-container authorize-container margin-top-xl">
+
<h2>Welcome, {{ .Repo.Handle }}</h2>
+
<ul>
+
<li><a href="/account/signout">Sign Out</a></li>
+
</ul>
+
{{ if .flashes.successes }}
+
<div class="alert alert-success margin-bottom-xs">
+
<p>{{ index .flashes.successes 0 }}</p>
+
</div>
+
{{ end }} {{ if eq (len .Tokens) 0 }}
+
<div class="alert alert-success" role="alert">
+
<p class="alert-message">You do not have any active OAuth sessions!</p>
+
</div>
+
{{ else }} {{ range .Tokens }}
+
<div class="base-container">
+
<h4>{{ .ClientId }}</h4>
+
<p>Created: {{ .CreatedAt }}</p>
+
<p>Updated: {{ .UpdatedAt }}</p>
+
<p>Expires: {{ .ExpiresAt }}</p>
+
<form action="/account/revoke" method="post">
+
<input type="hidden" name="token" value="{{ .Token }}" />
+
<button type="submit" value="">Revoke</button>
+
</form>
+
</div>
+
{{ end }} {{ end }}
+
</main>
+
</body>
+
</html>
+4
server/templates/alert.html
···
+
<!doctype html>
+
<div class="alert alert-success" role="alert">
+
<p class="alert-message"></p>
+
</div>
+44
server/templates/authorize.html
···
+
<!doctype html>
+
<html lang="en">
+
<head>
+
<meta charset="utf-8" />
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
+
<meta name="color-scheme" content="light dark" />
+
<link rel="stylesheet" href="/static/pico.css" />
+
<link rel="stylesheet" href="/static/style.css" />
+
<title>Application Authorization</title>
+
</head>
+
<body class="centered-body">
+
<main
+
class="container base-container box-shadow-container authorizer-container"
+
>
+
<h2>Authorizing with {{ .AppName }}</h2>
+
<p>
+
You are signed in as <b>{{ .Handle }}</b>.
+
<a href="/account/signout?{{ .QueryParams }}">Switch Account</a>
+
</p>
+
<p><b>{{ .AppName }}</b> is asking for you to grant it these scopes:</p>
+
<ul>
+
{{ range .Scopes }}
+
<li><b>{{.}}</b></li>
+
{{ end }}
+
</ul>
+
<p>
+
If you press Accept, the application will be granted permissions for
+
these scopes with your account <b>{{ .Handle }}</b>. If you reject, you
+
will be sent back to the application.
+
</p>
+
<form action="/oauth/authorize" method="post">
+
<div class="button-row">
+
<input type="hidden" name="request_uri" value="{{ .RequestUri }}" />
+
<button class="secondary" name="accept_or_reject" value="reject">
+
Reject
+
</button>
+
<button class="primary" name="accept_or_reject" value="accept">
+
Accept
+
</button>
+
</div>
+
</form>
+
</main>
+
</body>
+
</html>
+34
server/templates/signin.html
···
+
<!doctype html>
+
<html lang="en">
+
<head>
+
<meta charset="utf-8" />
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
+
<meta name="color-scheme" content="light dark" />
+
<link rel="stylesheet" href="/static/pico.css" />
+
<link rel="stylesheet" href="/static/style.css" />
+
<title>PDS Authentication</title>
+
</head>
+
<body class="centered-body">
+
<main class="container base-container box-shadow-container login-container">
+
<h2>Sign into your account</h2>
+
<p>Enter your handle and password below.</p>
+
{{ if .flashes.errors }}
+
<div class="alert alert-danger margin-bottom-xs">
+
<p>{{ index .flashes.errors 0 }}</p>
+
</div>
+
{{ end }}
+
<form action="/account/signin" method="post">
+
<input name="username" id="username" placeholder="Handle" />
+
<br />
+
<input
+
name="password"
+
id="password"
+
type="password"
+
placeholder="Password"
+
/>
+
<input name="query_params" type="hidden" value="{{ .QueryParams }}" />
+
<button class="primary" type="submit" value="Login">Login</button>
+
</form>
+
</main>
+
</body>
+
</html>