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