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