𝒴𝑜𝓊 𝒸𝒶𝓃 𝓃𝑜𝓌 use pdsls
+25
-2
bin/main.ml
+25
-2
bin/main.ml
···
7
7
; (get, "/robots.txt", Api.Robots.handler)
8
8
; (get, "/xrpc/_health", Api.Health.handler)
9
9
; (get, "/.well-known/did.json", Api.Well_known.did_json)
10
+
; ( get
11
+
, "/.well-known/oauth-protected-resource"
12
+
, Api.Well_known.oauth_protected_resource )
13
+
; ( get
14
+
, "/.well-known/oauth-authorization-server"
15
+
, Api.Well_known.oauth_authorization_server )
16
+
; (* oauth *)
17
+
(options, "/oauth/par", Api.Oauth_.Par.options_handler)
18
+
; (post, "/oauth/par", Api.Oauth_.Par.post_handler)
19
+
; (get, "/oauth/authorize", Api.Oauth_.Authorize.get_handler)
20
+
; (post, "/oauth/authorize", Api.Oauth_.Authorize.post_handler)
21
+
; (options, "/oauth/token", Api.Oauth_.Token.options_handler)
22
+
; (post, "/oauth/token", Api.Oauth_.Token.post_handler)
23
+
; (* account *)
24
+
(get, "/account/login", Api.Account_.Login.get_handler)
25
+
; (post, "/account/login", Api.Account_.Login.post_handler)
26
+
; (get, "/account/logout", Api.Account_.Logout.handler)
10
27
; (* unauthed *)
11
28
( get
12
29
, "/xrpc/com.atproto.server.describeServer"
···
15
32
; ( get
16
33
, "/xrpc/com.atproto.identity.resolveHandle"
17
34
, Api.Identity.ResolveHandle.handler )
18
-
; (* account *)
35
+
; (* account management *)
19
36
( post
20
37
, "/xrpc/com.atproto.server.createInviteCode"
21
38
, Api.Server.CreateInviteCode.handler )
···
65
82
, "/xrpc/com.atproto.actor.putPreferences"
66
83
, Api.Actor.PutPreferences.handler ) ]
67
84
85
+
let static_routes =
86
+
[Dream.get "/public/**" (Dream.static "_build/default/public")]
87
+
68
88
let main =
69
89
let%lwt db = Data_store.connect ~create:true () in
70
90
let%lwt () = Data_store.init db in
71
91
Dream.serve ~interface:"0.0.0.0" ~port:8008
72
92
@@ Dream.logger
93
+
@@ Dream.set_secret (Env.jwt_key |> Kleidos.privkey_to_multikey)
94
+
@@ Dream.cookie_sessions
73
95
@@ Xrpc.service_proxy_middleware db
74
-
@@ Dream.router
96
+
@@ Xrpc.dpop_middleware @@ Xrpc.cors_middleware @@ Dream.router
75
97
@@ List.map
76
98
(fun (fn, path, handler) ->
77
99
fn path (fun req -> handler ({req; db} : Xrpc.init)) )
78
100
handlers
101
+
@ static_routes
79
102
80
103
let () = Lwt_main.run main
+21
dune
+21
dune
···
1
+
(subdir
2
+
public/
3
+
(rule
4
+
(target index.css)
5
+
(deps
6
+
%{workspace_root}/tools/tailwindcss/tailwindcss
7
+
(:input %{workspace_root}/public/main.css)
8
+
(source_tree %{workspace_root}/public)
9
+
(source_tree %{workspace_root}/pegasus/lib/templates))
10
+
(action
11
+
(chdir
12
+
%{workspace_root}
13
+
(run
14
+
%{workspace_root}/tools/tailwindcss/tailwindcss
15
+
-m
16
+
-i
17
+
%{input}
18
+
-o
19
+
%{target})))))
20
+
21
+
(copy_files public/*)
+17
-1
dune-project
+17
-1
dune-project
···
46
47
(cohttp-lwt-unix (>= 6.1.1))
47
48
(dns-client (>= 10.2.0))
48
49
dream
50
+
html_of_jsx
51
+
mlx
49
52
(re (>= 1.13.2))
50
53
(safepass (>= 3.1))
51
54
(timedesc (>= 3.1.0))
55
+
(uri (>= 4.4.0))
52
56
(uuidm (>= 0.9.10))
53
57
(yojson (>= 3.0.0))
54
58
(lwt_ppx (>= 5.9.1))
55
59
(ppx_deriving_yojson (>= 3.9.1))
56
60
ppx_rapper
57
61
ppx_rapper_lwt
58
-
(alcotest :with-test)))
62
+
(alcotest :with-test)
63
+
(ocamlformat-mlx :with-dev-setup)
64
+
(ocamlmerlin-mlx :with-dev-setup)))
59
65
60
66
(package
61
67
(name mist)
···
97
103
(hacl-star (>= 0.7.2))
98
104
(mirage-crypto-ec (>= 2.0.1))
99
105
(multibase (>= 0.1.0))))
106
+
107
+
(package
108
+
(name tailwindcss) (allow_empty))
109
+
110
+
(dialect
111
+
(name mlx)
112
+
(implementation
113
+
(extension mlx)
114
+
(merlin_reader mlx)
115
+
(preprocess
116
+
(run mlx-pp %{input-file}))))
+4
kleidos/kleidos.ml
+4
kleidos/kleidos.ml
+4
-3
mist/lib/mst.ml
+4
-3
mist/lib/mst.ml
···
498
498
match Util.at_index index seq with
499
499
| Some (Leaf (k, v, _)) when k = key -> (
500
500
(* include the found leaf block to prove existence *)
501
-
match%lwt Store.get_bytes t.blockstore v with
501
+
match%lwt
502
+
Store.get_bytes t.blockstore v
503
+
with
502
504
| Some leaf_bytes ->
503
505
Lwt.return (Block_map.set v leaf_bytes Block_map.empty)
504
506
| None ->
···
531
533
| Some cid_left -> (
532
534
match%lwt Store.get_bytes t.blockstore cid_left with
533
535
| Some b ->
534
-
Lwt.return
535
-
(Block_map.set cid_left b Block_map.empty)
536
+
Lwt.return (Block_map.set cid_left b Block_map.empty)
536
537
| None ->
537
538
Lwt.return Block_map.empty )
538
539
| None ->
+5
pegasus.opam
+5
pegasus.opam
···
18
18
"cohttp-lwt-unix" {>= "6.1.1"}
19
19
"dns-client" {>= "10.2.0"}
20
20
"dream"
21
+
"html_of_jsx"
22
+
"mlx"
21
23
"re" {>= "1.13.2"}
22
24
"safepass" {>= "3.1"}
23
25
"timedesc" {>= "3.1.0"}
26
+
"uri" {>= "4.4.0"}
24
27
"uuidm" {>= "0.9.10"}
25
28
"yojson" {>= "3.0.0"}
26
29
"lwt_ppx" {>= "5.9.1"}
···
28
31
"ppx_rapper"
29
32
"ppx_rapper_lwt"
30
33
"alcotest" {with-test}
34
+
"ocamlformat-mlx" {with-dev-setup}
35
+
"ocamlmerlin-mlx" {with-dev-setup}
31
36
"odoc" {with-doc}
32
37
]
33
38
build: [
+49
pegasus/lib/api/account_/login.ml
+49
pegasus/lib/api/account_/login.ml
···
1
+
let get_handler =
2
+
Xrpc.handler (fun ctx ->
3
+
let redirect_url =
4
+
if List.length @@ Dream.all_queries ctx.req > 0 then
5
+
Uri.make ~path:"/oauth/authorize" ~query:(Util.copy_query ctx.req) ()
6
+
|> Uri.to_string
7
+
else "/account"
8
+
in
9
+
let csrf_token = Dream.csrf_token ctx.req in
10
+
let html =
11
+
JSX.render (Templates.Login.make ~redirect_url ~csrf_token ())
12
+
in
13
+
Dream.html html )
14
+
15
+
let post_handler =
16
+
Xrpc.handler (fun ctx ->
17
+
let csrf_token = Dream.csrf_token ctx.req in
18
+
match%lwt Dream.form ctx.req with
19
+
| `Ok fields -> (
20
+
let identifier = List.assoc "identifier" fields in
21
+
let password = List.assoc "password" fields in
22
+
let redirect_url =
23
+
List.assoc_opt "redirect_url" fields
24
+
|> Option.value ~default:"/account"
25
+
in
26
+
let%lwt actor =
27
+
Data_store.try_login ~id:identifier ~password ctx.db
28
+
in
29
+
match actor with
30
+
| None ->
31
+
let html =
32
+
JSX.render
33
+
(Templates.Login.make ~redirect_url
34
+
~error:"Invalid username or password. Please try again."
35
+
~csrf_token () )
36
+
in
37
+
Dream.html ~status:`Unauthorized html
38
+
| Some {did; _} ->
39
+
let%lwt () = Dream.invalidate_session ctx.req in
40
+
let%lwt () = Dream.set_session_field ctx.req "did" did in
41
+
Dream.redirect ctx.req redirect_url )
42
+
| _ ->
43
+
let html =
44
+
JSX.render
45
+
(Templates.Login.make ~redirect_url:"/account"
46
+
~error:"Invalid credentials provided. Please try again."
47
+
~csrf_token () )
48
+
in
49
+
Dream.html ~status:`Unauthorized html )
+4
pegasus/lib/api/account_/logout.ml
+4
pegasus/lib/api/account_/logout.ml
+1
-1
pegasus/lib/api/actor/putPreferences.ml
+1
-1
pegasus/lib/api/actor/putPreferences.ml
+3
-6
pegasus/lib/api/identity/updateHandle.ml
+3
-6
pegasus/lib/api/identity/updateHandle.ml
···
1
1
type request = {handle: string} [@@deriving yojson]
2
2
3
3
let handler =
4
-
Xrpc.handler ~auth:Authorization (fun {req; auth; db} ->
4
+
Xrpc.handler ~auth:Authorization (fun {req; auth; db; _} ->
5
5
let did = Auth.get_authed_did_exn auth in
6
6
let%lwt body = Dream.body req in
7
7
let handle =
···
17
17
| Ok () -> (
18
18
match%lwt Data_store.get_actor_by_identifier handle db with
19
19
| Some _ ->
20
-
Errors.invalid_request ~name:"InvalidHandle"
21
-
"handle already in use"
20
+
Errors.invalid_request ~name:"InvalidHandle" "handle already in use"
22
21
| None ->
23
22
let%lwt () = Data_store.update_actor_handle ~did ~handle db in
24
23
let%lwt _ =
···
67
66
~msg:"failed to submit plc operation" () )
68
67
else Lwt.return_unit
69
68
in
70
-
let () =
71
-
Ttl_cache.String_cache.remove Id_resolver.Did.cache did
72
-
in
69
+
let () = Ttl_cache.String_cache.remove Id_resolver.Did.cache did in
73
70
let%lwt _ = Sequencer.sequence_identity db ~did ~handle () in
74
71
Dream.empty `OK ) )
+41
pegasus/lib/api/oauth_/par.ml
+41
pegasus/lib/api/oauth_/par.ml
···
1
+
open Oauth
2
+
open Oauth.Types
3
+
4
+
let options_handler = Xrpc.handler (fun _ -> Dream.empty `No_Content)
5
+
6
+
let post_handler =
7
+
Xrpc.handler ~auth:DPoP (fun ctx ->
8
+
let proof = Auth.get_dpop_proof_exn ctx.auth in
9
+
let%lwt req = Xrpc.parse_body ctx.req par_request_of_yojson in
10
+
let%lwt client =
11
+
try%lwt Client.fetch_client_metadata req.client_id
12
+
with e ->
13
+
Errors.log_exn ~req:ctx.req e ;
14
+
Errors.invalid_request "failed to fetch client metadata"
15
+
in
16
+
if req.response_type <> "code" then
17
+
Errors.invalid_request "only response_type=code supported"
18
+
else if req.code_challenge_method <> "S256" then
19
+
Errors.invalid_request "only code_challenge_method=S256 supported"
20
+
else if not (List.mem req.redirect_uri client.redirect_uris) then
21
+
Errors.invalid_request "invalid redirect_uri"
22
+
else
23
+
let request_id =
24
+
"req-"
25
+
^ Uuidm.to_string (Uuidm.v4_gen (Random.State.make_self_init ()) ())
26
+
in
27
+
let request_uri = Constants.request_uri_prefix ^ request_id in
28
+
let expires_at = Util.now_ms () + Constants.par_request_ttl_ms in
29
+
let request : oauth_request =
30
+
{ request_id
31
+
; client_id= req.client_id
32
+
; request_data= Yojson.Safe.to_string (par_request_to_yojson req)
33
+
; dpop_jkt= Some proof.jkt
34
+
; expires_at
35
+
; created_at= Util.now_ms () }
36
+
in
37
+
let%lwt () = Queries.insert_par_request ctx.db request in
38
+
Dream.json ~status:`Created
39
+
@@ Yojson.Safe.to_string
40
+
@@ `Assoc
41
+
[("request_uri", `String request_uri); ("expires_in", `Int 300)] )
+179
pegasus/lib/api/oauth_/token.ml
+179
pegasus/lib/api/oauth_/token.ml
···
1
+
open Oauth
2
+
3
+
let options_handler = Xrpc.handler (fun _ -> Dream.empty `No_Content)
4
+
5
+
let post_handler =
6
+
Xrpc.handler ~auth:DPoP (fun ctx ->
7
+
let%lwt req = Xrpc.parse_body ctx.req Types.token_request_of_yojson in
8
+
let proof = Auth.get_dpop_proof_exn ctx.auth in
9
+
match req.grant_type with
10
+
| "authorization_code" -> (
11
+
match req.code with
12
+
| None ->
13
+
Errors.invalid_request "code required"
14
+
| Some code -> (
15
+
let%lwt code_record = Queries.consume_auth_code ctx.db code in
16
+
match code_record with
17
+
| None ->
18
+
Errors.invalid_request "invalid code"
19
+
| Some code_rec -> (
20
+
if Util.now_ms () > code_rec.expires_at then
21
+
Errors.invalid_request "code expired"
22
+
else
23
+
match code_rec.authorized_by with
24
+
| None ->
25
+
Errors.invalid_request "code not authorized"
26
+
| Some did -> (
27
+
let%lwt par_req =
28
+
Queries.get_par_request ctx.db code_rec.request_id
29
+
in
30
+
match par_req with
31
+
| None ->
32
+
Errors.internal_error ~msg:"request not found" ()
33
+
| Some par_record ->
34
+
let orig_req =
35
+
Yojson.Safe.from_string par_record.request_data
36
+
|> Types.par_request_of_yojson |> Result.get_ok
37
+
in
38
+
( match req.redirect_uri with
39
+
| None ->
40
+
Errors.invalid_request "redirect_uri required"
41
+
| Some uri when uri <> orig_req.redirect_uri ->
42
+
Errors.invalid_request "redirect_uri mismatch"
43
+
| _ ->
44
+
() ) ;
45
+
( match req.code_verifier with
46
+
| None ->
47
+
Errors.invalid_request "code_verifier required"
48
+
| Some verifier ->
49
+
let computed =
50
+
Digestif.SHA256.digest_string verifier
51
+
|> Digestif.SHA256.to_raw_string
52
+
|> Base64.(
53
+
encode_exn ~pad:false
54
+
~alphabet:uri_safe_alphabet )
55
+
in
56
+
if orig_req.code_challenge <> computed then
57
+
Errors.invalid_request "invalid code_verifier"
58
+
) ;
59
+
( match par_record.dpop_jkt with
60
+
| Some stored when stored <> proof.jkt ->
61
+
Errors.invalid_request "DPoP key mismatch"
62
+
| _ ->
63
+
() ) ;
64
+
let token_id =
65
+
"tok-"
66
+
^ Uuidm.to_string
67
+
(Uuidm.v4_gen
68
+
(Random.State.make_self_init ())
69
+
() )
70
+
in
71
+
let refresh_token =
72
+
"ref-"
73
+
^ Uuidm.to_string
74
+
(Uuidm.v4_gen
75
+
(Random.State.make_self_init ())
76
+
() )
77
+
in
78
+
let now_sec = int_of_float (Unix.gettimeofday ()) in
79
+
let expires_in =
80
+
Constants.access_token_expiry_ms / 1000
81
+
in
82
+
let exp_sec = now_sec + expires_in in
83
+
let expires_at = exp_sec * 1000 in
84
+
let claims =
85
+
`Assoc
86
+
[ ("jti", `String token_id)
87
+
; ("sub", `String did)
88
+
; ("iat", `Int now_sec)
89
+
; ("exp", `Int exp_sec)
90
+
; ("scope", `String orig_req.scope)
91
+
; ("aud", `String ("https://" ^ Env.hostname))
92
+
; ("cnf", `Assoc [("jkt", `String proof.jkt)]) ]
93
+
in
94
+
let access_token =
95
+
Jwt.sign_jwt claims ~typ:"at+jwt" Env.jwt_key
96
+
in
97
+
let%lwt () =
98
+
Queries.insert_oauth_token ctx.db
99
+
{ refresh_token
100
+
; client_id= req.client_id
101
+
; did
102
+
; dpop_jkt= proof.jkt
103
+
; scope= orig_req.scope
104
+
; expires_at }
105
+
in
106
+
let nonce = Dpop.next_nonce () in
107
+
Dream.json
108
+
~headers:
109
+
[ ("DPoP-Nonce", nonce)
110
+
; ("Access-Control-Expose-Headers", "DPoP-Nonce")
111
+
; ("Cache-Control", "no-store") ]
112
+
@@ Yojson.Safe.to_string
113
+
@@ `Assoc
114
+
[ ("access_token", `String access_token)
115
+
; ("token_type", `String "DPoP")
116
+
; ("refresh_token", `String refresh_token)
117
+
; ("expires_in", `Int expires_in)
118
+
; ("scope", `String orig_req.scope)
119
+
; ("sub", `String did) ] ) ) ) )
120
+
| "refresh_token" -> (
121
+
match req.refresh_token with
122
+
| None ->
123
+
Errors.invalid_request "refresh_token required"
124
+
| Some refresh_token -> (
125
+
let%lwt token_record =
126
+
Queries.get_oauth_token_by_refresh ctx.db refresh_token
127
+
in
128
+
match token_record with
129
+
| None ->
130
+
Errors.invalid_request "invalid refresh token"
131
+
| Some session ->
132
+
if session.client_id <> req.client_id then
133
+
Errors.invalid_request "client_id mismatch"
134
+
else if session.dpop_jkt <> proof.jkt then
135
+
Errors.invalid_request "DPoP key mismatch"
136
+
else
137
+
let new_token_id =
138
+
"tok-"
139
+
^ Uuidm.to_string
140
+
(Uuidm.v4_gen (Random.State.make_self_init ()) ())
141
+
in
142
+
let new_refresh =
143
+
"ref-"
144
+
^ Uuidm.to_string
145
+
(Uuidm.v4_gen (Random.State.make_self_init ()) ())
146
+
in
147
+
let now_sec = int_of_float (Unix.gettimeofday ()) in
148
+
let expires_in = Constants.access_token_expiry_ms / 1000 in
149
+
let exp_sec = now_sec + expires_in in
150
+
let new_expires_at = exp_sec * 1000 in
151
+
let claims =
152
+
`Assoc
153
+
[ ("jti", `String new_token_id)
154
+
; ("sub", `String session.did)
155
+
; ("iat", `Int now_sec)
156
+
; ("exp", `Int exp_sec)
157
+
; ("scope", `String session.scope)
158
+
; ("aud", `String ("https://" ^ Env.hostname))
159
+
; ("cnf", `Assoc [("jkt", `String proof.jkt)]) ]
160
+
in
161
+
let new_access_token =
162
+
Jwt.sign_jwt claims ~typ:"at+jwt" Env.jwt_key
163
+
in
164
+
let%lwt () =
165
+
Queries.update_oauth_token ctx.db
166
+
~old_refresh_token:refresh_token
167
+
~new_refresh_token:new_refresh ~expires_at:new_expires_at
168
+
in
169
+
Dream.json ~headers:[("Cache-Control", "no-store")]
170
+
@@ Yojson.Safe.to_string
171
+
@@ `Assoc
172
+
[ ("access_token", `String new_access_token)
173
+
; ("token_type", `String "DPoP")
174
+
; ("refresh_token", `String new_refresh)
175
+
; ("expires_in", `Int expires_in)
176
+
; ("scope", `String session.scope)
177
+
; ("sub", `String session.did) ] ) )
178
+
| _ ->
179
+
Errors.invalid_request ("unsupported grant_type: " ^ req.grant_type) )
+1
-1
pegasus/lib/api/server/createSession.ml
+1
-1
pegasus/lib/api/server/createSession.ml
+1
-1
pegasus/lib/api/server/getServiceAuth.ml
+1
-1
pegasus/lib/api/server/getServiceAuth.ml
···
1
1
type response = {token: string} [@@deriving yojson {strict= false}]
2
2
3
3
let handler =
4
-
Xrpc.handler ~auth:Authorization (fun {req; auth; db} ->
4
+
Xrpc.handler ~auth:Authorization (fun {req; auth; db; _} ->
5
5
let did = Auth.get_authed_did_exn auth in
6
6
let aud, lxm =
7
7
match (Dream.query req "aud", Dream.query req "lxm") with
+57
-2
pegasus/lib/api/well_known.ml
+57
-2
pegasus/lib/api/well_known.ml
···
1
+
open struct
2
+
let make_url pth =
3
+
Uri.(make ~scheme:"https" ~host:Env.hostname ~path:pth () |> to_string)
4
+
5
+
let pds_url = `String (make_url "")
6
+
end
7
+
1
8
let did_json =
2
9
Xrpc.handler (fun _ ->
3
10
Dream.json @@ Yojson.Safe.to_string
···
8
15
, `Assoc
9
16
[ ("id", `String "#atproto_pds")
10
17
; ("type", `String "AtprotoPersonalDataServer")
11
-
; ("serviceEndpoint", `String ("https://" ^ Env.hostname)) ] )
12
-
] )
18
+
; ("serviceEndpoint", pds_url) ] ) ] )
19
+
20
+
let oauth_protected_resource =
21
+
Xrpc.handler (fun _ ->
22
+
Dream.json @@ Yojson.Safe.to_string
23
+
@@ `Assoc
24
+
[ ("authorization_servers", `List [pds_url])
25
+
; ("bearer_methods_supported", `List [`String "header"])
26
+
; ("resource", pds_url)
27
+
; ("resource_documentation", `String "https://atproto.com")
28
+
; ("scopes_supported", `List []) ] )
29
+
30
+
let oauth_authorization_server =
31
+
Xrpc.handler (fun _ ->
32
+
Dream.json @@ Yojson.Safe.to_string
33
+
@@ `Assoc
34
+
[ ("issuer", pds_url)
35
+
; ("authorization_endpoint", `String (make_url "/oauth/authorize"))
36
+
; ("token_endpoint", `String (make_url "/oauth/token"))
37
+
; ( "pushed_authorization_request_endpoint"
38
+
, `String (make_url "/oauth/par") )
39
+
; ("require_pushed_authorization_requests", `Bool true)
40
+
; ( "scopes_supported"
41
+
, `List
42
+
[ `String "atproto"
43
+
; `String "transition:email"
44
+
; `String "transition:generic"
45
+
; `String "transition:chat.bsky" ] )
46
+
; ("subject_types_supported", `List [`String "public"])
47
+
; ("response_types_supported", `List [`String "code"])
48
+
; ( "response_modes_supported"
49
+
, `List [`String "query"; `String "fragment"] )
50
+
; ( "grant_types_supported"
51
+
, `List [`String "authorization_code"; `String "refresh_token"] )
52
+
; ("code_challenge_methods_supported", `List [`String "S256"])
53
+
; ("ui_locales_supported", `List [`String "en-US"])
54
+
; ( "display_values_supported"
55
+
, `List [`String "page"; `String "popup"; `String "touch"] )
56
+
; ("authorization_response_iss_parameter_supported", `Bool true)
57
+
; ( "request_object_signing_alg_values_supported"
58
+
, `List [`String "ES256"; `String "ES256K"] )
59
+
; ("request_object_encryption_alg_values_supported", `List [])
60
+
; ("request_object_encryption_enc_values_supported", `List [])
61
+
; ( "token_endpoint_auth_methods_supported"
62
+
, `List [`String "none"; `String "private_key_jwt"] )
63
+
; ( "token_endpoint_auth_signing_alg_values_supported"
64
+
, `List [`String "ES256"; `String "ES256K"] )
65
+
; ( "dpop_signing_alg_values_supported"
66
+
, `List [`String "ES256"; `String "ES256K"] )
67
+
; ("client_id_metadata_document_supported", `Bool true) ] )
+112
-22
pegasus/lib/auth.ml
+112
-22
pegasus/lib/auth.ml
···
15
15
| Admin
16
16
| Access of {did: string}
17
17
| Refresh of {did: string; jti: string}
18
+
| OAuth of {did: string; proof: Oauth.Dpop.proof}
19
+
| DPoP of {proof: Oauth.Dpop.proof}
18
20
19
21
let verify_bearer_jwt t token expected_scope =
20
22
match Jwt.verify_jwt token Env.jwt_key with
···
42
44
match credentials with
43
45
| Admin ->
44
46
true
45
-
| Access {did= creds} when creds = did ->
47
+
| (Access {did= creds} | OAuth {did= creds; _}) when creds = did ->
46
48
true
47
49
| Refresh {did= creds; _} when creds = did && refresh ->
48
50
true
···
50
52
false
51
53
52
54
let get_authed_did_exn = function
53
-
| Access {did} ->
55
+
| Access {did} | OAuth {did; _} ->
54
56
did
55
57
| Refresh {did; _} ->
56
58
did
57
59
| _ ->
58
-
Errors.auth_required "Invalid authorization header"
60
+
Errors.auth_required "invalid authorization header"
61
+
62
+
let get_dpop_proof_exn = function
63
+
| OAuth {proof; _} | DPoP {proof} ->
64
+
proof
65
+
| _ ->
66
+
Errors.invalid_request "invalid DPoP header"
59
67
60
68
let get_session_info identifier db =
61
69
let%lwt actor =
···
84
92
module Verifiers = struct
85
93
open struct
86
94
let parse_header req expected_type =
87
-
match Dream.header req "authorization" with
95
+
match Dream.header req "Authorization" with
88
96
| Some header -> (
89
97
match String.split_on_char ' ' header with
90
98
| [typ; token]
···
95
103
Error "invalid authorization header" )
96
104
| None ->
97
105
Error "missing authorization header"
106
+
end
98
107
99
108
let parse_basic req =
100
109
match parse_header req "Basic" with
···
112
121
Error "invalid basic authorization header"
113
122
114
123
let parse_bearer req = parse_header req "Bearer"
115
-
end
124
+
125
+
let parse_dpop req = parse_header req "DPoP"
116
126
117
127
type ctx = {req: Dream.request; db: Data_store.t}
118
128
···
122
132
fun {req; _} ->
123
133
match Dream.header req "authorization" with
124
134
| Some _ ->
125
-
Lwt.return_error @@ Errors.auth_required "Invalid authorization header"
135
+
Lwt.return_error @@ Errors.auth_required "invalid authorization header"
126
136
| None ->
127
137
Lwt.return_ok Unauthenticated
128
138
···
134
144
| "admin", p when p = Env.admin_password ->
135
145
Lwt.return_ok Admin
136
146
| _ ->
137
-
Lwt.return_error @@ Errors.auth_required "Invalid credentials" )
147
+
Lwt.return_error @@ Errors.auth_required "invalid credentials" )
138
148
| Error _ ->
139
-
Lwt.return_error @@ Errors.auth_required "Invalid authorization header"
149
+
Lwt.return_error @@ Errors.auth_required "invalid authorization header"
140
150
141
-
let access : verifier =
151
+
let bearer : verifier =
142
152
fun {req; db} ->
143
153
match parse_bearer req with
144
154
| Ok jwt -> (
···
150
160
| Some {deactivated_at= Some _; _} ->
151
161
Lwt.return_error
152
162
@@ Errors.auth_required ~name:"AccountDeactivated"
153
-
"Account is deactivated"
163
+
"account is deactivated"
154
164
| None ->
155
-
Lwt.return_error @@ Errors.auth_required "Invalid credentials" )
165
+
Lwt.return_error @@ Errors.auth_required "invalid credentials" )
156
166
| Error _ ->
157
-
Lwt.return_error @@ Errors.auth_required "Invalid credentials" )
167
+
Lwt.return_error @@ Errors.auth_required "invalid credentials" )
158
168
| Error _ ->
159
-
Lwt.return_error @@ Errors.auth_required "Invalid authorization header"
169
+
Lwt.return_error @@ Errors.auth_required "invalid authorization header"
170
+
171
+
let dpop : verifier =
172
+
fun {req; _} ->
173
+
let dpop_header = Dream.header req "DPoP" in
174
+
match
175
+
Oauth.Dpop.verify_dpop_proof
176
+
~mthd:(Dream.method_to_string @@ Dream.method_ req)
177
+
~url:(Dream.target req) ~dpop_header ()
178
+
with
179
+
| Error "use_dpop_nonce" ->
180
+
Lwt.return_error @@ Errors.use_dpop_nonce ()
181
+
| Error e ->
182
+
Lwt.return_error @@ Errors.invalid_request ("dpop error: " ^ e)
183
+
| Ok proof ->
184
+
Lwt.return_ok (DPoP {proof})
185
+
186
+
let oauth : verifier =
187
+
fun {req; db} ->
188
+
match parse_dpop req with
189
+
| Error e ->
190
+
Lwt.return_error @@ Errors.invalid_request ("dpop error: " ^ e)
191
+
| Ok token -> (
192
+
match%lwt dpop {req; db} with
193
+
| Error e ->
194
+
Lwt.return_error e
195
+
| Ok (DPoP {proof}) -> (
196
+
match Jwt.verify_jwt token Env.jwt_key with
197
+
| Error e ->
198
+
Lwt.return_error @@ Errors.auth_required e
199
+
| Ok (_header, claims) -> (
200
+
let open Yojson.Safe.Util in
201
+
try
202
+
let did = claims |> member "sub" |> to_string in
203
+
let exp = claims |> member "exp" |> to_int in
204
+
let jkt_claim =
205
+
claims |> member "cnf" |> member "jkt" |> to_string
206
+
in
207
+
let now = int_of_float (Unix.gettimeofday ()) in
208
+
if jkt_claim <> proof.jkt then
209
+
Lwt.return_error @@ Errors.auth_required "dpop key mismatch"
210
+
else if exp < now then
211
+
Lwt.return_error @@ Errors.auth_required "token expired"
212
+
else
213
+
let%lwt session =
214
+
try%lwt
215
+
let%lwt sess = get_session_info did db in
216
+
Lwt.return_ok sess
217
+
with _ ->
218
+
Lwt.return_error
219
+
@@ Errors.auth_required "invalid credentials"
220
+
in
221
+
match session with
222
+
| Ok {active= Some true; _} ->
223
+
Lwt.return_ok (OAuth {did; proof})
224
+
| Ok _ ->
225
+
Lwt.return_error
226
+
@@ Errors.auth_required ~name:"AccountDeactivated"
227
+
"account is deactivated"
228
+
| Error _ ->
229
+
Lwt.return_error
230
+
@@ Errors.auth_required "invalid credentials"
231
+
with _ ->
232
+
Lwt.return_error @@ Errors.auth_required "malformed JWT claims" )
233
+
)
234
+
| Ok _ ->
235
+
Lwt.return_error @@ Errors.auth_required "invalid credentials" )
160
236
161
237
let refresh : verifier =
162
238
fun {req; db} ->
···
170
246
| Some {deactivated_at= Some _; _} ->
171
247
Lwt.return_error
172
248
@@ Errors.auth_required ~name:"AccountDeactivated"
173
-
"Account is deactivated"
249
+
"account is deactivated"
174
250
| None ->
175
-
Lwt.return_error @@ Errors.auth_required "Invalid credentials" )
251
+
Lwt.return_error @@ Errors.auth_required "invalid credentials" )
176
252
| Error "" | Error _ ->
177
-
Lwt.return_error @@ Errors.auth_required "Invalid credentials" )
253
+
Lwt.return_error @@ Errors.auth_required "invalid credentials" )
178
254
| Error _ ->
179
-
Lwt.return_error @@ Errors.auth_required "Invalid authorization header"
255
+
Lwt.return_error @@ Errors.auth_required "invalid authorization header"
180
256
181
257
let authorization : verifier =
182
258
fun ctx ->
···
187
263
| Some ("Basic" :: _) ->
188
264
admin ctx
189
265
| Some ("Bearer" :: _) ->
190
-
access ctx
266
+
bearer ctx
267
+
| Some ("DPoP" :: _) ->
268
+
oauth ctx
191
269
| _ ->
192
270
Lwt.return_error
193
271
@@ Errors.auth_required ~name:"InvalidToken"
194
-
"Unexpected authorization type"
272
+
"unexpected authorization type"
195
273
196
274
let any : verifier =
197
275
fun ctx -> try authorization ctx with _ -> unauthenticated ctx
198
276
199
-
type t = Unauthenticated | Admin | Access | Refresh | Authorization | Any
277
+
type t =
278
+
| Unauthenticated
279
+
| Admin
280
+
| Bearer
281
+
| DPoP
282
+
| OAuth
283
+
| Refresh
284
+
| Authorization
285
+
| Any
200
286
201
287
let of_t = function
202
288
| Unauthenticated ->
203
289
unauthenticated
204
290
| Admin ->
205
291
admin
206
-
| Access ->
207
-
access
292
+
| Bearer ->
293
+
bearer
294
+
| DPoP ->
295
+
dpop
296
+
| OAuth ->
297
+
oauth
208
298
| Refresh ->
209
299
refresh
210
300
| Authorization ->
+92
pegasus/lib/data_store.ml
+92
pegasus/lib/data_store.ml
···
71
71
|sql}]
72
72
() conn
73
73
in
74
+
let$! () =
74
75
[%rapper
75
76
execute
76
77
(* no need to store issued tokens, just revoked ones; stolen from millipds https://github.com/DavidBuchanan314/millipds/blob/8f89a01e7d367a2a46f379960e9ca50347dcce71/src/millipds/database.py#L253 *)
···
82
83
)
83
84
|sql}]
84
85
() conn
86
+
in
87
+
let$! () =
88
+
[%rapper
89
+
execute
90
+
{sql| CREATE TABLE IF NOT EXISTS oauth_requests (
91
+
request_id TEXT PRIMARY KEY,
92
+
client_id TEXT NOT NULL,
93
+
request_data TEXT NOT NULL,
94
+
dpop_jkt TEXT,
95
+
expires_at INTEGER NOT NULL,
96
+
created_at INTEGER NOT NULL
97
+
)
98
+
|sql}]
99
+
() conn
100
+
in
101
+
let$! () =
102
+
[%rapper
103
+
execute
104
+
{sql| CREATE TABLE IF NOT EXISTS oauth_codes (
105
+
code TEXT PRIMARY KEY,
106
+
request_id TEXT NOT NULL REFERENCES oauth_requests(request_id) ON DELETE CASCADE,
107
+
authorized_by TEXT,
108
+
authorized_at INTEGER,
109
+
expires_at INTEGER NOT NULL,
110
+
used BOOLEAN DEFAULT FALSE
111
+
)
112
+
|sql}]
113
+
() conn
114
+
in
115
+
let$! () =
116
+
[%rapper
117
+
execute
118
+
{sql| CREATE TABLE IF NOT EXISTS oauth_tokens (
119
+
refresh_token TEXT UNIQUE NOT NULL,
120
+
client_id TEXT NOT NULL,
121
+
did TEXT NOT NULL,
122
+
dpop_jkt TEXT,
123
+
scope TEXT NOT NULL,
124
+
expires_at INTEGER NOT NULL
125
+
)
126
+
|sql}]
127
+
() conn
128
+
in
129
+
let$! () =
130
+
[%rapper
131
+
execute
132
+
{sql| CREATE INDEX IF NOT EXISTS oauth_requests_expires_idx ON oauth_requests(expires_at);
133
+
CREATE INDEX IF NOT EXISTS oauth_codes_expires_idx ON oauth_codes(expires_at);
134
+
CREATE INDEX IF NOT EXISTS oauth_tokens_refresh_idx ON oauth_tokens(refresh_token);
135
+
|sql}]
136
+
() conn
137
+
in
138
+
let$! () =
139
+
[%rapper
140
+
execute
141
+
{sql| CREATE TRIGGER IF NOT EXISTS cleanup_expired_oauth_requests
142
+
AFTER INSERT ON oauth_requests
143
+
BEGIN
144
+
DELETE FROM oauth_requests WHERE expires_at < unixepoch() * 1000;
145
+
END
146
+
|sql}
147
+
syntax_off]
148
+
() conn
149
+
in
150
+
let$! () =
151
+
[%rapper
152
+
execute
153
+
{sql| CREATE TRIGGER IF NOT EXISTS cleanup_expired_oauth_codes
154
+
AFTER INSERT ON oauth_codes
155
+
BEGIN
156
+
DELETE FROM oauth_codes WHERE expires_at < unixepoch() * 1000 OR used = 1;
157
+
END
158
+
|sql}
159
+
syntax_off]
160
+
() conn
161
+
in
162
+
let$! () =
163
+
[%rapper
164
+
execute
165
+
{sql| CREATE TRIGGER IF NOT EXISTS cleanup_expired_oauth_tokens
166
+
AFTER INSERT ON oauth_tokens
167
+
BEGIN
168
+
DELETE FROM oauth_tokens WHERE expires_at < unixepoch() * 1000;
169
+
END
170
+
|sql}
171
+
syntax_off]
172
+
() conn
173
+
in
174
+
Lwt.return_ok ()
85
175
86
176
let create_actor =
87
177
[%rapper
···
221
311
type t = Util.caqti_pool
222
312
223
313
let connect ?create ?write () : t Lwt.t =
314
+
if create = Some true then
315
+
Util.mkfile_p Util.Constants.pegasus_db_filepath ~perm:0o644 ;
224
316
Util.connect_sqlite ?create ?write Util.Constants.pegasus_db_location
225
317
226
318
let init conn : unit Lwt.t = Util.use_pool conn Queries.create_tables
+3
-1
pegasus/lib/dune
+3
-1
pegasus/lib/dune
···
9
9
cohttp-lwt-unix
10
10
dns-client.unix
11
11
dream
12
+
html_of_jsx
12
13
ipld
13
14
kleidos
14
15
lwt
···
18
19
safepass
19
20
str
20
21
timedesc
22
+
uri
21
23
uuidm
22
24
yojson
23
25
lwt_ppx
24
26
ppx_deriving_yojson.runtime
25
27
ppx_rapper_lwt)
26
28
(preprocess
27
-
(pps lwt_ppx ppx_deriving_yojson ppx_rapper)))
29
+
(pps html_of_jsx.ppx lwt_ppx ppx_deriving_yojson ppx_rapper)))
28
30
29
31
(include_subdirs qualified)
+26
-6
pegasus/lib/env.ml
+26
-6
pegasus/lib/env.ml
···
1
+
let getenv name =
2
+
try Sys.getenv name
3
+
with Not_found -> failwith ("Missing environment variable " ^ name)
4
+
1
5
let data_dir = Option.value ~default:"./data" @@ Sys.getenv_opt "DATA_DIR"
2
6
3
-
let hostname = Sys.getenv "PDS_HOSTNAME"
7
+
let hostname = getenv "PDS_HOSTNAME"
4
8
5
9
let did =
6
10
Option.value ~default:("did:web:" ^ hostname) @@ Sys.getenv_opt "PDS_DID"
7
11
8
-
let invite_required = Sys.getenv "INVITE_CODE_REQUIRED" = "true"
12
+
let invite_required = getenv "INVITE_CODE_REQUIRED" = "true"
13
+
14
+
let rotation_key = getenv "ROTATION_KEY_MULTIBASE" |> Kleidos.parse_multikey_str
9
15
10
-
let rotation_key =
11
-
Sys.getenv "ROTATION_KEY_MULTIBASE" |> Kleidos.parse_multikey_str
16
+
let jwt_key = getenv "JWK_MULTIBASE" |> Kleidos.parse_multikey_str
12
17
13
-
let jwt_key = Sys.getenv "JWK_MULTIBASE" |> Kleidos.parse_multikey_str
18
+
let admin_password = getenv "ADMIN_PASSWORD"
14
19
15
-
let admin_password = Sys.getenv "ADMIN_PASSWORD"
20
+
let dpop_nonce_secret =
21
+
match Sys.getenv_opt "DPOP_NONCE_SECRET" with
22
+
| Some sec ->
23
+
let secret =
24
+
Base64.(decode_exn ~alphabet:uri_safe_alphabet ~pad:false) sec
25
+
|> Bytes.of_string
26
+
in
27
+
if Bytes.length secret = 32 then secret
28
+
else failwith "DPOP_NONCE_SECRET must be 32 bytes in base64uri"
29
+
| None ->
30
+
let secret = Mirage_crypto_rng_unix.getrandom 32 in
31
+
Dream.warning (fun log ->
32
+
log "DPOP_NONCE_SECRET not set; using DPOP_NONCE_SECRET=%s"
33
+
( Base64.(encode ~alphabet:uri_safe_alphabet ~pad:false) secret
34
+
|> Result.get_ok ) ) ;
35
+
Bytes.of_string secret
+6
pegasus/lib/errors.ml
+6
pegasus/lib/errors.ml
···
4
4
5
5
exception AuthError of (string * string)
6
6
7
+
exception UseDpopNonceError
8
+
7
9
let is_xrpc_error = function
8
10
| InvalidRequestError _ | InternalServerError _ | AuthError _ ->
9
11
true
···
19
21
20
22
let auth_required ?(name = "AuthRequired") msg = raise (AuthError (name, msg))
21
23
24
+
let use_dpop_nonce () = raise UseDpopNonceError
25
+
22
26
let exn_to_response exn =
23
27
let format_response error msg status =
24
28
Dream.json ~status @@ Yojson.Safe.to_string
···
31
35
format_response error message `Internal_Server_Error
32
36
| AuthError (error, message) ->
33
37
format_response error message `Unauthorized
38
+
| UseDpopNonceError ->
39
+
Dream.json ~status:`Bad_Request {|{ "error": "use_dpop_nonce" }|}
34
40
| _ ->
35
41
format_response "InternalServerError" "Internal server error"
36
42
`Internal_Server_Error
+3
-4
pegasus/lib/id_resolver.ml
+3
-4
pegasus/lib/id_resolver.ml
···
1
1
open Cohttp_lwt
2
-
open Cohttp_lwt_unix
3
2
4
3
let did_regex =
5
4
Str.regexp {|^did:([a-z]+):([a-zA-Z0-9._:%\-]*[a-zA-Z0-9._\-])$|}
···
12
11
let uri =
13
12
Uri.of_string ("https://" ^ handle ^ "/.well-known/atproto-did")
14
13
in
15
-
let%lwt {status; _}, body = Client.get uri in
14
+
let%lwt {status; _}, body = Util.http_get uri in
16
15
match status with
17
16
| `OK ->
18
17
let%lwt did = Body.to_string body in
···
164
163
~path:(Uri.pct_encode did) ()
165
164
in
166
165
let%lwt {status; _}, body =
167
-
Client.get uri
166
+
Util.http_get uri
168
167
~headers:(Cohttp.Header.of_list [("Accept", "application/json")])
169
168
in
170
169
match status with
···
186
185
~path:"/.well-known/did.json" ()
187
186
in
188
187
let%lwt {status; _}, body =
189
-
Client.get uri
188
+
Util.http_get uri
190
189
~headers:(Cohttp.Header.of_list [("Accept", "application/json")])
191
190
in
192
191
match status with
+13
-19
pegasus/lib/jwt.ml
+13
-19
pegasus/lib/jwt.ml
···
19
19
let b64_decode str =
20
20
match Base64.decode ~pad:false ~alphabet:Base64.uri_safe_alphabet str with
21
21
| Ok s ->
22
-
Ok s
22
+
s
23
23
| Error (`Msg e) ->
24
-
Error e
24
+
failwith e
25
25
26
26
let extract_signature_components signature =
27
27
if Bytes.length signature <> 64 then failwith "expected 64 byte jwt signature"
···
30
30
let s = Bytes.sub signature 32 32 in
31
31
(r, s)
32
32
33
-
let sign_jwt payload signing_key =
33
+
let sign_jwt payload ?(typ = "JWT") signing_key =
34
34
let _, (module Curve : Kleidos.CURVE) = signing_key in
35
35
let alg =
36
36
match Curve.name with
···
51
51
failwith "invalid curve"
52
52
in
53
53
let header_json =
54
-
`Assoc [("alg", `String alg); ("crv", `String crv); ("typ", `String "JWT")]
54
+
`Assoc [("alg", `String alg); ("crv", `String crv); ("typ", `String typ)]
55
55
in
56
56
let encoded_header = header_json |> Yojson.Safe.to_string |> b64_encode in
57
57
let encoded_payload = payload |> Yojson.Safe.to_string |> b64_encode in
···
65
65
let decode_jwt jwt =
66
66
match String.split_on_char '.' jwt with
67
67
| [header_b64; payload_b64; _] -> (
68
-
match (b64_decode header_b64, b64_decode payload_b64) with
69
-
| Ok header_str, Ok payload_str -> (
70
68
try
71
-
let header = Yojson.Safe.from_string header_str in
72
-
let payload = Yojson.Safe.from_string payload_str in
69
+
let header = Yojson.Safe.from_string (b64_decode header_b64) in
70
+
let payload = Yojson.Safe.from_string (b64_decode payload_b64) in
73
71
Ok (header, payload)
74
-
with _ -> Error "invalid json in jwt" )
75
-
| Error e, _ | _, Error e ->
76
-
Error e )
72
+
with _ -> Error "invalid jwt" )
77
73
| _ ->
78
74
Error "invalid jwt format"
79
75
80
76
let verify_jwt jwt pubkey =
81
77
match String.split_on_char '.' jwt with
82
-
| [header_b64; payload_b64; signature_b64] -> (
83
-
match b64_decode signature_b64 with
84
-
| Error e ->
85
-
Error e
86
-
| Ok signature_str ->
87
-
let signature = Bytes.of_string signature_str in
78
+
| [header_b64; payload_b64; signature_b64] ->
79
+
let signature = Bytes.of_string (b64_decode signature_b64) in
88
80
let signing_input = header_b64 ^ "." ^ payload_b64 in
89
81
let verified =
90
82
Kleidos.verify ~pubkey ~msg:(Bytes.of_string signing_input) ~signature
91
83
in
92
84
if verified then decode_jwt jwt
93
-
else Error "jwt signature verification failed" )
85
+
else Error "jwt signature verification failed"
94
86
| _ ->
95
87
Error "invalid jwt format"
96
88
···
98
90
let now_s = int_of_float (Unix.gettimeofday ()) in
99
91
let access_exp = now_s + Defaults.access_token_exp in
100
92
let refresh_exp = now_s + Defaults.refresh_token_exp in
101
-
let jti = Uuidm.v4_gen (Random.get_state ()) () |> Uuidm.to_string in
93
+
let jti =
94
+
Uuidm.v4_gen (Random.State.make_self_init ()) () |> Uuidm.to_string
95
+
in
102
96
let access_payload =
103
97
symmetric_jwt_to_yojson
104
98
{ scope= "com.atproto.access"
+45
pegasus/lib/oauth/client.ml
+45
pegasus/lib/oauth/client.ml
···
1
+
open Types
2
+
3
+
let fetch_client_metadata client_id : client_metadata Lwt.t =
4
+
let%lwt {status; _}, res = Util.http_get (Uri.of_string client_id) in
5
+
if status <> `OK then
6
+
let%lwt () = Cohttp_lwt.Body.drain_body res in
7
+
failwith
8
+
(Printf.sprintf "client metadata not found; http %d"
9
+
(Cohttp.Code.code_of_status status) )
10
+
else
11
+
let%lwt body = Cohttp_lwt.Body.to_string res in
12
+
let json = Yojson.Safe.from_string body in
13
+
let metadata =
14
+
match client_metadata_of_yojson json with
15
+
| Ok metadata ->
16
+
metadata
17
+
| Error err ->
18
+
failwith err
19
+
in
20
+
if metadata.client_id <> client_id then failwith "client_id mismatch"
21
+
else
22
+
let scopes = String.split_on_char ' ' metadata.scope in
23
+
if not (List.mem "atproto" scopes) then
24
+
failwith "scope must include 'atproto'"
25
+
else
26
+
List.iter
27
+
(function
28
+
| "authorization_code" | "refresh_token" ->
29
+
()
30
+
| grant ->
31
+
failwith ("invalid grant type: " ^ grant) )
32
+
metadata.grant_types ;
33
+
List.iter
34
+
(fun uri ->
35
+
let u = Uri.of_string uri in
36
+
let host = Uri.host u in
37
+
match Uri.scheme u with
38
+
| Some "https" when host <> Some "localhost" ->
39
+
()
40
+
| Some "http" when host = Some "127.0.0.1" || host = Some "[::1]" ->
41
+
()
42
+
| _ ->
43
+
failwith ("invalid redirect_uri: " ^ uri) )
44
+
metadata.redirect_uris ;
45
+
Lwt.return metadata
+15
pegasus/lib/oauth/constants.ml
+15
pegasus/lib/oauth/constants.ml
···
1
+
let max_dpop_age_s = 60
2
+
3
+
let dpop_rotation_interval_ms = 60_000L
4
+
5
+
let jti_ttl_s = 3600
6
+
7
+
let jti_cache_size = 10_000
8
+
9
+
let par_request_ttl_ms = 300_000
10
+
11
+
let code_expiry_ms = 300_000
12
+
13
+
let access_token_expiry_ms = 60 * 60 * 1000
14
+
15
+
let request_uri_prefix = "urn:ietf:params:oauth:request_uri:"
+204
pegasus/lib/oauth/dpop.ml
+204
pegasus/lib/oauth/dpop.ml
···
1
+
type nonce_state =
2
+
{ secret: bytes
3
+
; mutable counter: int64
4
+
; mutable prev: string
5
+
; mutable curr: string
6
+
; mutable next: string
7
+
; rotation_interval_ms: int64 }
8
+
9
+
type ec_jwk = {crv: string; kty: string; x: string; y: string}
10
+
[@@deriving yojson]
11
+
12
+
type proof = {jti: string; jkt: string; htm: string; htu: string}
13
+
[@@deriving yojson]
14
+
15
+
let jti_cache : (string, int) Hashtbl.t =
16
+
Hashtbl.create Constants.jti_cache_size
17
+
18
+
let cleanup_jti_cache () =
19
+
let now = int_of_float (Unix.gettimeofday ()) in
20
+
Hashtbl.filter_map_inplace
21
+
(fun _ expires_at -> if expires_at > now then Some expires_at else None)
22
+
jti_cache
23
+
24
+
let compute_nonce secret counter =
25
+
let data = Bytes.create 8 in
26
+
Bytes.set_int64_be data 0 counter ;
27
+
Digestif.SHA256.(
28
+
hmac_bytes ~key:(Bytes.to_string secret) data
29
+
|> to_raw_string |> Jwt.b64_encode )
30
+
31
+
let create_nonce_state secret =
32
+
let counter =
33
+
Int64.div
34
+
(Int64.of_float (Unix.gettimeofday () *. 1000.))
35
+
Constants.dpop_rotation_interval_ms
36
+
in
37
+
{ secret
38
+
; counter
39
+
; prev= compute_nonce secret (Int64.pred counter)
40
+
; curr= compute_nonce secret counter
41
+
; next= compute_nonce secret (Int64.succ counter)
42
+
; rotation_interval_ms= Constants.dpop_rotation_interval_ms }
43
+
44
+
let nonce_state = ref (create_nonce_state Env.dpop_nonce_secret)
45
+
46
+
let next_nonce () =
47
+
let now_counter =
48
+
Int64.div
49
+
(Int64.of_float (Unix.gettimeofday () *. 1000.))
50
+
!nonce_state.rotation_interval_ms
51
+
in
52
+
if now_counter <> !nonce_state.counter then (
53
+
!nonce_state.prev <- !nonce_state.curr ;
54
+
!nonce_state.curr <- !nonce_state.next ;
55
+
!nonce_state.next <-
56
+
compute_nonce !nonce_state.secret (Int64.succ now_counter) ;
57
+
!nonce_state.counter <- now_counter ) ;
58
+
!nonce_state.next
59
+
60
+
let verify_nonce nonce =
61
+
let valid =
62
+
nonce = !nonce_state.prev || nonce = !nonce_state.curr
63
+
|| nonce = !nonce_state.next
64
+
in
65
+
ignore next_nonce ; valid
66
+
67
+
let add_jti jti =
68
+
let expires_at = int_of_float (Unix.gettimeofday ()) + Constants.jti_ttl_s in
69
+
if Hashtbl.mem jti_cache jti then false (* replay *)
70
+
else (
71
+
Hashtbl.add jti_cache jti expires_at ;
72
+
(* clean up every once in a while *)
73
+
if Hashtbl.length jti_cache mod 100 = 0 then cleanup_jti_cache () ;
74
+
true )
75
+
76
+
let normalize_url url =
77
+
let uri = Uri.of_string url in
78
+
Uri.make ~scheme:"https"
79
+
~host:(Uri.host uri |> Option.value ~default:Env.hostname)
80
+
~path:(Uri.path uri) ()
81
+
|> Uri.to_string
82
+
83
+
let compute_jwk_thumbprint jwk =
84
+
let {crv; kty; x; y} = jwk in
85
+
let tp =
86
+
(* keys must be in lexicographic order *)
87
+
Printf.sprintf {|{"crv":"%s","kty":"%s","x":"%s","y":"%s"}|} crv kty x y
88
+
in
89
+
Digestif.SHA256.(digest_string tp |> to_raw_string |> Jwt.b64_encode)
90
+
91
+
let verify_signature jwt jwk =
92
+
let parts = String.split_on_char '.' jwt in
93
+
match parts with
94
+
| [header_b64; payload_b64; sig_b64] ->
95
+
let signing_input = header_b64 ^ "." ^ payload_b64 in
96
+
let msg = Bytes.of_string signing_input in
97
+
let {x; y; crv; _} = jwk in
98
+
let x = x |> Jwt.b64_decode |> Bytes.of_string in
99
+
let y = y |> Jwt.b64_decode |> Bytes.of_string in
100
+
let pubkey = Bytes.cat (Bytes.of_string "\x04") (Bytes.cat x y) in
101
+
let pubkey =
102
+
( pubkey
103
+
, match crv with
104
+
| "secp256k1" ->
105
+
(module Kleidos.K256 : Kleidos.CURVE)
106
+
| "P-256" ->
107
+
(module Kleidos.P256 : Kleidos.CURVE)
108
+
| _ ->
109
+
failwith "unsupported algorithm" )
110
+
in
111
+
let sig_bytes = Jwt.b64_decode sig_b64 |> Bytes.of_string in
112
+
let r = Bytes.sub sig_bytes 0 32 in
113
+
let s = Bytes.sub sig_bytes 32 32 in
114
+
let signature = Bytes.cat r s in
115
+
Kleidos.verify ~pubkey ~msg ~signature
116
+
| _ ->
117
+
false
118
+
119
+
let verify_dpop_proof ~mthd ~url ~dpop_header ?access_token () =
120
+
match dpop_header with
121
+
| None ->
122
+
Error "missing dpop header"
123
+
| Some jwt -> (
124
+
let open Yojson.Safe.Util in
125
+
match String.split_on_char '.' jwt with
126
+
| [header_b64; payload_b64; _] -> (
127
+
let header = Yojson.Safe.from_string (Jwt.b64_decode header_b64) in
128
+
let payload = Yojson.Safe.from_string (Jwt.b64_decode payload_b64) in
129
+
let typ = header |> member "typ" |> to_string in
130
+
if typ <> "dpop+jwt" then Error "invalid typ in dpop proof"
131
+
else
132
+
let alg = header |> member "alg" |> to_string in
133
+
if alg <> "ES256" && alg <> "ES256K" then
134
+
Error "only es256 and es256k supported for dpop"
135
+
else
136
+
let jwk =
137
+
header |> member "jwk" |> ec_jwk_of_yojson |> Result.get_ok
138
+
in
139
+
if
140
+
not
141
+
( match (alg, jwk.crv) with
142
+
| "ES256", "P-256" ->
143
+
true
144
+
| "ES256K", "secp256k1" ->
145
+
true
146
+
| _ ->
147
+
false )
148
+
then
149
+
Error
150
+
(Printf.sprintf "algorithm %s doesn't match curve %s" alg
151
+
jwk.crv )
152
+
else
153
+
let jti = payload |> member "jti" |> to_string in
154
+
let htm = payload |> member "htm" |> to_string in
155
+
let htu = payload |> member "htu" |> to_string in
156
+
let iat = payload |> member "iat" |> to_int in
157
+
let nonce_claim =
158
+
payload |> member "nonce" |> to_string_option
159
+
in
160
+
match nonce_claim with
161
+
(* error must be this string; see https://datatracker.ietf.org/doc/html/rfc9449#section-8 *)
162
+
| None ->
163
+
Error "use_dpop_nonce"
164
+
| Some n when not (verify_nonce n) ->
165
+
Error "use_dpop_nonce"
166
+
| Some _ -> (
167
+
if htm <> mthd then Error "htm mismatch"
168
+
else if
169
+
not (String.equal (normalize_url htu) (normalize_url url))
170
+
then Error "htu mismatch"
171
+
else
172
+
let now = int_of_float (Unix.gettimeofday ()) in
173
+
if now - iat > Constants.max_dpop_age_s then
174
+
Error "dpop proof too old"
175
+
else if iat - now > 5 then Error "dpop proof in future"
176
+
else if not (add_jti jti) then
177
+
Error "dpop proof replay detected"
178
+
else if not (verify_signature jwt jwk) then
179
+
Error "invalid dpop signature"
180
+
else
181
+
let jkt = compute_jwk_thumbprint jwk in
182
+
(* verify ath if access token is provided *)
183
+
match access_token with
184
+
| Some token ->
185
+
let ath_claim =
186
+
payload |> member "ath" |> to_string_option
187
+
in
188
+
let expected_ath =
189
+
Digestif.SHA256.(
190
+
digest_string token |> to_raw_string
191
+
|> Jwt.b64_encode )
192
+
in
193
+
if Some expected_ath <> ath_claim then
194
+
Error "ath mismatch"
195
+
else Ok {jti; jkt; htm; htu}
196
+
| None ->
197
+
let ath_claim =
198
+
payload |> member "ath" |> to_string_option
199
+
in
200
+
if ath_claim <> None then
201
+
Error "ath claim not allowed without access token"
202
+
else Ok {jti; jkt; htm; htu} ) )
203
+
| _ ->
204
+
Error "invalid dpop jwt" )
+138
pegasus/lib/oauth/queries.ml
+138
pegasus/lib/oauth/queries.ml
···
1
+
[@@@warning "-missing-record-field-pattern"]
2
+
3
+
open Types
4
+
5
+
let insert_par_request conn req =
6
+
Util.use_pool conn
7
+
@@ [%rapper
8
+
execute
9
+
{sql|
10
+
INSERT INTO oauth_requests (request_id, client_id, request_data, dpop_jkt, expires_at, created_at)
11
+
VALUES (%string{request_id}, %string{client_id}, %string{request_data}, %string?{dpop_jkt}, %int{expires_at}, %int{created_at})
12
+
|sql}
13
+
record_in]
14
+
req
15
+
16
+
let get_par_request conn request_id =
17
+
Util.use_pool conn
18
+
@@ [%rapper
19
+
get_opt
20
+
{sql|
21
+
SELECT @string{request_id}, @string{client_id}, @string{request_data},
22
+
@string?{dpop_jkt}, @int{expires_at}, @int{created_at}
23
+
FROM oauth_requests
24
+
WHERE request_id = %string{request_id}
25
+
AND expires_at > %int{now}
26
+
|sql}
27
+
record_out]
28
+
~request_id ~now:(Util.now_ms ())
29
+
30
+
let insert_auth_code conn code =
31
+
Util.use_pool conn
32
+
@@ [%rapper
33
+
execute
34
+
{sql|
35
+
INSERT INTO oauth_codes (code, request_id, authorized_by, authorized_at, expires_at, used)
36
+
VALUES (%string{code}, %string{request_id}, %string?{authorized_by}, %int?{authorized_at}, %int{expires_at}, 0)
37
+
|sql}
38
+
record_in]
39
+
code
40
+
41
+
let get_auth_code conn code =
42
+
Util.use_pool conn
43
+
@@ [%rapper
44
+
get_opt
45
+
{sql|
46
+
SELECT @string{code}, @string{request_id}, @string?{authorized_by},
47
+
@int?{authorized_at}, @int{expires_at}, @bool{used}
48
+
FROM oauth_codes
49
+
WHERE code = %string{code}
50
+
|sql}
51
+
record_out]
52
+
~code
53
+
54
+
let activate_auth_code conn code did =
55
+
let authorized_at = Util.now_ms () in
56
+
Util.use_pool conn
57
+
@@ [%rapper
58
+
execute
59
+
{sql|
60
+
UPDATE oauth_codes
61
+
SET authorized_by = %string{did},
62
+
authorized_at = %int{authorized_at}
63
+
WHERE code = %string{code}
64
+
|sql}]
65
+
~did ~authorized_at ~code
66
+
67
+
let consume_auth_code conn code =
68
+
Util.use_pool conn
69
+
@@ [%rapper
70
+
get_opt
71
+
{sql|
72
+
UPDATE oauth_codes
73
+
SET used = 1
74
+
WHERE code = %string{code} AND used = 0
75
+
RETURNING @string{code}, @string{request_id}, @string?{authorized_by},
76
+
@int?{authorized_at}, @int{expires_at}, @bool{used}
77
+
|sql}
78
+
record_out]
79
+
~code
80
+
81
+
let insert_oauth_token conn token =
82
+
Util.use_pool conn
83
+
@@ [%rapper
84
+
execute
85
+
{sql|
86
+
INSERT INTO oauth_tokens (refresh_token, client_id, did, dpop_jkt, scope, expires_at)
87
+
VALUES (%string{refresh_token}, %string{client_id}, %string{did}, %string{dpop_jkt}, %string{scope}, %int{expires_at})
88
+
|sql}
89
+
record_in]
90
+
token
91
+
92
+
let get_oauth_token_by_refresh conn refresh_token =
93
+
Util.use_pool conn
94
+
@@ [%rapper
95
+
get_opt
96
+
{sql|
97
+
SELECT @string{refresh_token}, @string{client_id}, @string{did},
98
+
@string{dpop_jkt}, @string{scope}, @int{expires_at}
99
+
FROM oauth_tokens
100
+
WHERE refresh_token = %string{refresh_token}
101
+
|sql}
102
+
record_out]
103
+
~refresh_token
104
+
105
+
let update_oauth_token conn ~old_refresh_token ~new_refresh_token ~expires_at =
106
+
Util.use_pool conn
107
+
@@ [%rapper
108
+
execute
109
+
{sql|
110
+
UPDATE oauth_tokens
111
+
SET refresh_token = %string{new_refresh_token},
112
+
expires_at = %int{expires_at}
113
+
WHERE refresh_token = %string{old_refresh_token}
114
+
|sql}]
115
+
~new_refresh_token ~expires_at ~old_refresh_token
116
+
117
+
let delete_oauth_token_by_refresh conn refresh_token =
118
+
Util.use_pool conn
119
+
@@ [%rapper
120
+
execute
121
+
{sql|
122
+
DELETE FROM oauth_tokens WHERE refresh_token = %string{refresh_token}
123
+
|sql}]
124
+
~refresh_token
125
+
126
+
let get_oauth_tokens_by_did conn did =
127
+
Util.use_pool conn
128
+
@@ [%rapper
129
+
get_many
130
+
{sql|
131
+
SELECT @string{refresh_token}, @string{client_id}, @string{did},
132
+
@string{dpop_jkt}, @string{scope}, @int{expires_at}
133
+
FROM oauth_tokens
134
+
WHERE did = %string{did}
135
+
ORDER BY expires_at ASC
136
+
|sql}
137
+
record_out]
138
+
~did
+71
pegasus/lib/oauth/types.ml
+71
pegasus/lib/oauth/types.ml
···
1
+
type par_request =
2
+
{ client_id: string
3
+
; response_type: string
4
+
; response_mode: string option [@default None]
5
+
; redirect_uri: string
6
+
; scope: string
7
+
; state: string
8
+
; code_challenge: string
9
+
; code_challenge_method: string
10
+
; login_hint: string option [@default None]
11
+
; dpop_jkt: string option [@default None]
12
+
; client_assertion_type: string option [@default None]
13
+
; client_assertion: string option [@default None] }
14
+
[@@deriving yojson {strict= false}]
15
+
16
+
type token_request =
17
+
{ grant_type: string
18
+
; code: string option [@default None]
19
+
; redirect_uri: string option [@default None]
20
+
; code_verifier: string option [@default None]
21
+
; refresh_token: string option [@default None]
22
+
; client_id: string
23
+
; client_assertion_type: string option [@default None]
24
+
; client_assertion: string option [@default None] }
25
+
[@@deriving yojson {strict= false}]
26
+
27
+
type client_metadata =
28
+
{ client_id: string
29
+
; client_name: string option [@default None]
30
+
; client_uri: string
31
+
; redirect_uris: string list
32
+
; grant_types: string list
33
+
; response_types: string list
34
+
; scope: string
35
+
; token_endpoint_auth_method: string
36
+
; token_endpoint_auth_signing_alg: string option [@default None]
37
+
; application_type: string
38
+
; dpop_bound_access_tokens: bool
39
+
; jwks_uri: string option [@default None]
40
+
; jwks: Yojson.Safe.t option [@default None] }
41
+
[@@deriving yojson {strict= false}]
42
+
43
+
type dpop_proof = {jti: string; jkt: string; htm: string; htu: string}
44
+
[@@deriving yojson {strict= false}]
45
+
46
+
type oauth_request =
47
+
{ request_id: string
48
+
; client_id: string
49
+
; request_data: string
50
+
; dpop_jkt: string option [@default None]
51
+
; expires_at: int
52
+
; created_at: int }
53
+
[@@deriving yojson {strict= false}]
54
+
55
+
type oauth_code =
56
+
{ code: string
57
+
; request_id: string
58
+
; authorized_by: string option [@default None]
59
+
; authorized_at: int option [@default None]
60
+
; expires_at: int
61
+
; used: bool }
62
+
[@@deriving yojson {strict= false}]
63
+
64
+
type oauth_token =
65
+
{ refresh_token: string
66
+
; client_id: string
67
+
; did: string
68
+
; dpop_jkt: string
69
+
; scope: string
70
+
; expires_at: int }
71
+
[@@deriving yojson {strict= false}]
+1
-1
pegasus/lib/plc.ml
+1
-1
pegasus/lib/plc.ml
···
302
302
did
303
303
in
304
304
let headers = Http.Header.init_with "Accept" "application/json" in
305
-
let%lwt res, body = Client.get ~headers uri in
305
+
let%lwt res, body = Util.http_get ~headers uri in
306
306
match res.status with
307
307
| `OK ->
308
308
let%lwt body = Body.to_string body in
+56
pegasus/lib/templates/components/input.mlx
+56
pegasus/lib/templates/components/input.mlx
···
1
+
open JSX
2
+
3
+
(* putting this inline messes with ocamlformat-mlx *)
4
+
let req_marker = " *"
5
+
6
+
let make ?id ~name ?(class_ = "") ?(type_ = "text") ?label ?(sr_only = false)
7
+
?value ?placeholder ?(required = false) ?(disabled = false) ?trailing () =
8
+
let id = Option.value id ~default:name in
9
+
let placeholder = if label <> None && sr_only then label else placeholder in
10
+
let input =
11
+
<input
12
+
id
13
+
type_
14
+
name
15
+
?placeholder
16
+
required
17
+
disabled
18
+
?value
19
+
class_="block min-w-0 grow text-mist-100 placeholder:text-mist-80 \
20
+
placeholder:font-medium focus-visible:outline-none"
21
+
/>
22
+
in
23
+
<div>
24
+
( match label with
25
+
| Some label ->
26
+
<div
27
+
class_=( "flex justify-between text-sm"
28
+
^ if sr_only then " sr-only" else "" )>
29
+
<label for_=id class_="text-mist-100">
30
+
( if required then
31
+
list
32
+
[ string label
33
+
; <span class_="text-phoenix-100">(string req_marker)</span>
34
+
]
35
+
else string label )
36
+
</label>
37
+
( if required then null
38
+
else <span class_="text-mist-80">"optional"</span> )
39
+
</div>
40
+
| None ->
41
+
null )
42
+
( if type_ = "hidden" then input
43
+
else
44
+
<div
45
+
class_=( "flex items-center rounded-lg py-1.5 px-3 outline-1 \
46
+
outline-mana-40 disabled:outline-mana-40/20 \
47
+
disabled:bg-mana-40/20 focus-within:outline-2 \
48
+
focus-within:outline-mana-100" ^ class_ )>
49
+
input
50
+
( match trailing with
51
+
| Some trailing ->
52
+
<div class_="shrink-0 text-mist-100 select-none">trailing</div>
53
+
| None ->
54
+
null )
55
+
</div> )
56
+
</div>
+11
pegasus/lib/templates/icons/circle_alert.mlx
+11
pegasus/lib/templates/icons/circle_alert.mlx
+16
pegasus/lib/templates/layout.mlx
+16
pegasus/lib/templates/layout.mlx
···
1
+
open JSX
2
+
3
+
let make ?(title = "Pegasus") ~children () =
4
+
<html lang="en">
5
+
<head>
6
+
<meta charset="utf-8" />
7
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
8
+
<link rel="stylesheet" href="/public/index.css" />
9
+
<title>(string title)</title>
10
+
</head>
11
+
<body
12
+
class_="bg-feather-100 font-sans font-normal text-base tracking-normal \
13
+
flex items-center justify-center min-h-screen">
14
+
children
15
+
</body>
16
+
</html>
+35
pegasus/lib/templates/login.mlx
+35
pegasus/lib/templates/login.mlx
···
1
+
open JSX
2
+
open Components
3
+
4
+
let make ~redirect_url ?error ~csrf_token () =
5
+
<Layout title="Login">
6
+
<main class_="w-full h-auto max-w-xs px-4 sm:px-0">
7
+
<h1 class_="text-2xl font-serif text-mana-200 mb-2">"sign in"</h1>
8
+
<span class_="w-full text-balance text-mist-100">
9
+
"Enter your handle, email address, or DID, and your password."
10
+
</span>
11
+
<form method_="post" class_="w-full flex flex-col mt-4 mb-2 gap-y-2">
12
+
<input type_="hidden" name="dream.csrf" value=csrf_token />
13
+
<Input sr_only=true name="identifier" type_="text" label="identifier" />
14
+
<Input sr_only=true name="password" type_="password" label="password" />
15
+
<input type_="hidden" name="redirect_url" value=redirect_url />
16
+
( match error with
17
+
| Some error ->
18
+
<span class_="inline-flex items-center text-phoenix-100 text-sm">
19
+
<Icons.Circle_alert class_="w-4 h-4 mr-2" /> (string error)
20
+
</span>
21
+
| None ->
22
+
null )
23
+
<Button type_="submit" class_="mt-2">"sign in"</Button>
24
+
</form>
25
+
<span class_="text-sm text-mist-100">
26
+
"Or "
27
+
<a
28
+
href="/account/signup"
29
+
class_="text-mana-100 underline hover:text-mana-200">
30
+
"create an account"
31
+
</a>
32
+
"."
33
+
</span>
34
+
</main>
35
+
</Layout>
+47
-7
pegasus/lib/util.ml
+47
-7
pegasus/lib/util.ml
···
287
287
let is_none = function None -> true | _ -> false
288
288
289
289
let validate_handle handle =
290
-
if not @@ String.ends_with ~suffix:("." ^ Env.hostname) handle then
291
-
Error (Errors.InvalidRequestError ("InvalidHandle", "invalid handle suffix"))
292
-
else
293
290
let front =
294
-
String.sub handle 0
295
-
(String.length handle - (String.length Env.hostname + 1))
291
+
String.sub handle 0 (String.length handle - (String.length Env.hostname + 1))
296
292
in
297
293
if String.contains front '.' then
298
294
Error
···
301
297
else
302
298
match String.length front with
303
299
| l when l < 3 ->
304
-
Error
305
-
(Errors.InvalidRequestError ("InvalidHandle", "handle too short"))
300
+
Error (Errors.InvalidRequestError ("InvalidHandle", "handle too short"))
306
301
| l when l > 18 ->
307
302
Error (Errors.InvalidRequestError ("InvalidHandle", "handle too long"))
308
303
| _ ->
···
324
319
valid )
325
320
did_keys
326
321
<> None
322
+
323
+
let rec http_get ?(max_redirects = 5) ?headers uri =
324
+
let%lwt ans = Cohttp_lwt_unix.Client.get ?headers uri in
325
+
follow_redirect ~max_redirects uri ans
326
+
327
+
and follow_redirect ~max_redirects request_uri (response, body) =
328
+
let status = Http.Response.status response in
329
+
(* the unconsumed body would otherwise leak memory *)
330
+
let%lwt () =
331
+
if status <> `OK then Cohttp_lwt.Body.drain_body body else Lwt.return_unit
332
+
in
333
+
match status with
334
+
| `OK ->
335
+
Lwt.return (response, body)
336
+
| `Permanent_redirect | `Moved_permanently ->
337
+
handle_redirect ~permanent:true ~max_redirects request_uri response
338
+
| `Found | `Temporary_redirect ->
339
+
handle_redirect ~permanent:false ~max_redirects request_uri response
340
+
| `Not_found | `Gone ->
341
+
failwith "not found"
342
+
| status ->
343
+
Printf.ksprintf failwith "unhandled status: %s"
344
+
(Cohttp.Code.string_of_status status)
345
+
346
+
and handle_redirect ~permanent ~max_redirects request_uri response =
347
+
if max_redirects <= 0 then failwith "too many redirects"
348
+
else
349
+
let headers = Http.Response.headers response in
350
+
let location = Http.Header.get headers "location" in
351
+
match location with
352
+
| None ->
353
+
failwith "redirection without Location header"
354
+
| Some url ->
355
+
let uri = Uri.of_string url in
356
+
let%lwt () =
357
+
if permanent then
358
+
Logs_lwt.warn (fun m ->
359
+
m "Permanent redirection from %s to %s"
360
+
(Uri.to_string request_uri)
361
+
url )
362
+
else Lwt.return_unit
363
+
in
364
+
http_get uri ~max_redirects:(max_redirects - 1)
365
+
366
+
let copy_query req = Dream.all_queries req |> List.map (fun (k, v) -> (k, [v]))
+29
-11
pegasus/lib/xrpc.ml
+29
-11
pegasus/lib/xrpc.ml
···
10
10
let handler ?(auth : Auth.Verifiers.t = Any) (hdlr : handler) (init : init) =
11
11
let open Errors in
12
12
let auth = Auth.Verifiers.of_t auth in
13
+
try%lwt
13
14
match%lwt auth init with
14
15
| Ok creds -> (
15
16
try%lwt hdlr {req= init.req; db= init.db; auth= creds}
16
17
with e ->
17
-
( match is_xrpc_error e with
18
-
| true ->
19
-
()
20
-
| false ->
21
-
log_exn ~req:init.req e ) ;
18
+
if not (is_xrpc_error e) then log_exn ~req:init.req e ;
22
19
exn_to_response e )
23
20
| Error e ->
24
21
exn_to_response e
22
+
with e ->
23
+
if not (is_xrpc_error e) then log_exn ~req:init.req e ;
24
+
exn_to_response e
25
25
26
26
let parse_query (req : Dream.request)
27
27
(of_yojson : Yojson.Safe.t -> ('a, string) result) : 'a =
···
29
29
let queries = Dream.all_queries req in
30
30
let query_json = `Assoc (List.map (fun (k, v) -> (k, `String v)) queries) in
31
31
query_json |> of_yojson |> Result.get_ok
32
-
with _ -> Errors.invalid_request "Invalid query string"
32
+
with _ -> Errors.invalid_request "invalid query string"
33
33
34
34
let parse_body (req : Dream.request)
35
35
(of_yojson : Yojson.Safe.t -> ('a, string) result) : 'a Lwt.t =
36
36
try%lwt
37
37
let%lwt body = Dream.body req in
38
38
body |> Yojson.Safe.from_string |> of_yojson |> Result.get_ok |> Lwt.return
39
-
with e ->
40
-
Errors.log_exn e ;
41
-
Errors.invalid_request "Invalid request body"
39
+
with _ -> Errors.invalid_request "invalid request body"
42
40
43
41
let service_proxy (ctx : context) (proxy_header : string) =
44
42
let did = Auth.get_authed_did_exn ctx.auth in
···
87
85
let headers = Http.Header.of_list [("Authorization", "Bearer " ^ jwt)] in
88
86
match Dream.method_ ctx.req with
89
87
| `GET -> (
90
-
let%lwt res, body = Client.get uri ~headers in
88
+
let%lwt res, body = Util.http_get uri ~headers in
91
89
match res.status with
92
90
| `OK ->
93
91
let%lwt body = Body.to_string body in
···
121
119
let service_proxy_middleware db inner_handler req =
122
120
match Dream.header req "atproto-proxy" with
123
121
| Some header ->
124
-
handler ~auth:Access (fun ctx -> service_proxy ctx header) {req; db}
122
+
handler ~auth:Authorization (fun ctx -> service_proxy ctx header) {req; db}
125
123
| None ->
126
124
inner_handler req
127
125
126
+
let dpop_middleware inner_handler req =
127
+
let%lwt res = inner_handler req in
128
+
match Dream.header req "DPoP" with
129
+
| Some _ ->
130
+
Dream.add_header res "DPoP-Nonce" (Oauth.Dpop.next_nonce ()) ;
131
+
Dream.add_header res "Access-Control-Expose-Headers" "DPoP-Nonce" ;
132
+
Lwt.return res
133
+
| None ->
134
+
Lwt.return res
135
+
136
+
let cors_middleware inner_handler req =
137
+
let%lwt res = inner_handler req in
138
+
Dream.add_header res "Access-Control-Allow-Origin" "*" ;
139
+
Dream.add_header res "Access-Control-Allow-Methods"
140
+
"GET, POST, PUT, DELETE, OPTIONS" ;
141
+
Dream.add_header res "Access-Control-Allow-Headers"
142
+
"Content-Type, Authorization, DPoP" ;
143
+
Dream.add_header res "Access-Control-Max-Age" "86400" ;
144
+
Lwt.return res
145
+
128
146
let resolve_repo_did ctx repo =
129
147
if String.starts_with ~prefix:"did:" repo then Lwt.return repo
130
148
else
public/fonts/Fragment.woff
public/fonts/Fragment.woff
This is a binary file and will not be displayed.
public/fonts/Fragment.woff2
public/fonts/Fragment.woff2
This is a binary file and will not be displayed.
+48
public/main.css
+48
public/main.css
···
1
+
@import "tailwindcss" source("../pegasus/lib/templates");
2
+
3
+
@font-face {
4
+
font-family: "Fragment";
5
+
src:
6
+
url("fonts/Fragment.woff2") format("woff2"),
7
+
url("fonts/Fragment.woff") format("woff");
8
+
font-weight: normal;
9
+
font-style: normal;
10
+
font-display: swap;
11
+
}
12
+
13
+
@font-face {
14
+
font-family: "Geist";
15
+
src: url("https://fonts.gstatic.com/s/geist/v4/gyByhwUxId8gMEwcGFWNOITd.woff2")
16
+
format("woff2");
17
+
font-weight: 300 400;
18
+
font-style: normal;
19
+
font-display: swap;
20
+
}
21
+
22
+
@theme {
23
+
--font-serif: Fragment, Georgia, "Times New Roman", Times, serif;
24
+
--font-sans: Geist, Helvetica, -apple-system, system-ui, sans-serif;
25
+
--font-weight-normal: 300;
26
+
--font-weight-medium: 400;
27
+
28
+
--tracking-normal: 0.01em;
29
+
30
+
--color-*: initial;
31
+
--color-white: #fff;
32
+
--color-feather-100: #c8cfd2;
33
+
--color-phoenix-40: #e499a6;
34
+
--color-phoenix-100: #db4c64;
35
+
--color-mana-40: #9b9eaa;
36
+
--color-mana-100: #6558a1;
37
+
--color-mana-200: #312b4d;
38
+
--color-mist-20: #ecedf8;
39
+
--color-mist-40: #dee1e3;
40
+
--color-mist-60: #a4a9ac;
41
+
--color-mist-80: #737579;
42
+
--color-mist-100: #4f4f53;
43
+
44
+
--shadow-whisper: inset 0 0 1em #97baff8c;
45
+
--shadow-shimmer: inset 0 0 1em #79a7ed99;
46
+
--shadow-glow: inset 0 0 2em #2d37ba73;
47
+
--shadow-bleed: inset 0 0 2em #db4c6466;
48
+
}
-5
kleidos.opam
tailwindcss.opam
-5
kleidos.opam
tailwindcss.opam
···
1
1
# This file is generated by dune, edit dune-project instead
2
2
opam-version: "2.0"
3
-
synopsis: "Atproto-flavour k256 and p256 signing and verification"
4
3
maintainer: ["futurGH"]
5
4
authors: ["futurGH"]
6
5
license: "MPL-2.0"
7
6
homepage: "https://github.com/futurGH/pegasus"
8
7
bug-reports: "https://github.com/futurGH/pegasus/issues"
9
8
depends: [
10
-
"ocaml" {= "5.2.1"}
11
9
"dune" {>= "3.20"}
12
-
"hacl-star" {>= "0.7.2"}
13
-
"mirage-crypto-ec" {>= "2.0.1"}
14
-
"multibase" {>= "0.1.0"}
15
10
"odoc" {with-doc}
16
11
]
17
12
build: [
+88
tools/tailwindcss/dune
+88
tools/tailwindcss/dune
···
1
+
(rule
2
+
(target tailwindcss-linux-x64)
3
+
(action
4
+
(progn
5
+
(with-stdout-to
6
+
%{target}
7
+
(bash
8
+
"cat 2> /dev/null < $(which tailwindcss) || curl -#fSL https://github.com/tailwindlabs/tailwindcss/releases/download/v4.1.7/%{target}"))
9
+
(run chmod +x %{target}))))
10
+
11
+
(rule
12
+
(target tailwindcss-linux-arm64)
13
+
(action
14
+
(progn
15
+
(with-stdout-to
16
+
%{target}
17
+
(bash
18
+
"cat 2> /dev/null < $(which tailwindcss) || curl -#fSL https://github.com/tailwindlabs/tailwindcss/releases/download/v4.1.7/%{target}"))
19
+
(run chmod +x %{target}))))
20
+
21
+
(rule
22
+
(target tailwindcss-macos-x64)
23
+
(action
24
+
(progn
25
+
(with-stdout-to
26
+
%{target}
27
+
(bash
28
+
"cat 2> /dev/null < $(which tailwindcss) || curl -#fSL https://github.com/tailwindlabs/tailwindcss/releases/download/v4.1.7/%{target}"))
29
+
(run chmod +x %{target}))))
30
+
31
+
(rule
32
+
(target tailwindcss-macos-arm64)
33
+
(action
34
+
(progn
35
+
(with-stdout-to
36
+
%{target}
37
+
(bash
38
+
"cat 2> /dev/null < $(which tailwindcss) || curl -#fSL https://github.com/tailwindlabs/tailwindcss/releases/download/v4.1.7/%{target}"))
39
+
(run chmod +x %{target}))))
40
+
41
+
(rule
42
+
(enabled_if
43
+
(and
44
+
(= %{architecture} amd64)
45
+
(= %{system} linux)))
46
+
(target tailwindcss)
47
+
(deps tailwindcss-linux-x64)
48
+
(action
49
+
(copy %{deps} tailwindcss)))
50
+
51
+
(rule
52
+
(enabled_if
53
+
(and
54
+
(= %{architecture} arm64)
55
+
(= %{system} linux)))
56
+
(target tailwindcss)
57
+
(deps tailwindcss-linux-arm64)
58
+
(action
59
+
(copy %{deps} tailwindcss)))
60
+
61
+
(rule
62
+
(enabled_if
63
+
(and
64
+
(= %{architecture} amd64)
65
+
(= %{system} macosx)))
66
+
(target tailwindcss)
67
+
(deps tailwindcss-macos-x64)
68
+
(action
69
+
(copy %{deps} tailwindcss)))
70
+
71
+
(rule
72
+
(enabled_if
73
+
(and
74
+
(= %{architecture} arm64)
75
+
(= %{system} macosx)))
76
+
(target tailwindcss)
77
+
(deps tailwindcss-macos-arm64)
78
+
(action
79
+
(copy %{deps} tailwindcss)))
80
+
81
+
(alias
82
+
(name default)
83
+
(deps tailwindcss))
84
+
85
+
(install
86
+
(section bin)
87
+
(package tailwindcss)
88
+
(files tailwindcss))