···
48
48
)
49
49
let request =
50
50
request
51
51
-
|> request.set_header(
52
52
-
"authorization",
53
53
-
"DPoP " <> credential.access_token,
54
54
-
)
51
51
+
|> request.set_header("authorization", "DPoP " <> credential.access_token)
55
52
|> request.set_header("dpop", dpop_proof)
56
53
57
54
case httpc.send(request) {
···
13
13
import tangled
14
14
15
15
const max_concurrent_profile_checks = 40
16
16
+
16
17
const oauth_scope = "atproto repo:sh.tangled.graph.follow"
18
18
+
17
19
const oauth_redirect_uri = "http://127.0.0.1:8080/callback"
18
20
19
21
@external(erlang, "entwine_ffi", "prompt")
···
27
29
#("scope", oauth_scope),
28
30
])
29
31
30
30
-
oauth.OAuthClient(client_id:, redirect_uri: oauth_redirect_uri, scope: oauth_scope)
32
32
+
oauth.OAuthClient(
33
33
+
client_id:,
34
34
+
redirect_uri: oauth_redirect_uri,
35
35
+
scope: oauth_scope,
36
36
+
)
31
37
}
32
38
33
39
pub fn find_accounts_to_follow(
···
135
141
io.println("Open the authorization URL in your browser to continue.")
136
142
137
143
let credential =
138
138
-
oauth.authenticate(
139
139
-
"https://" <> pds,
140
140
-
at_did.to_string(did),
141
141
-
oauth_client(),
142
142
-
)
144
144
+
oauth.authenticate("https://" <> pds, at_did.to_string(did), oauth_client())
143
145
io.println("Authenticated successfully as " <> credential.subject <> ".")
144
146
145
147
let bsky_follows = bsky.get_bsky_follows(did, pds)
···
1
1
+
import gleam/bit_array
1
2
import gleam/dict
2
3
import gleam/dynamic/decode
3
4
import gleam/erlang/process
5
5
+
import gleam/http
4
6
import gleam/http/request
5
7
import gleam/http/response
6
6
-
import gleam/http
7
8
import gleam/httpc
8
9
import gleam/json
9
10
import gleam/list
10
11
import gleam/option.{type Option}
11
12
import gleam/string
12
12
-
import gleam/uri
13
13
import gleam/time/timestamp
14
14
-
import gleam/bit_array
14
14
+
import gleam/uri
15
15
16
16
+
import gose
16
17
import gose/jose/jwk
17
18
import gose/jose/jws
18
18
-
import gose
19
19
20
20
import kryptos/crypto
21
21
import kryptos/ec
···
48
48
}
49
49
50
50
pub type ParResponse {
51
51
-
ParResponse(
52
52
-
request_uri: String,
53
53
-
expires_in: Int,
54
54
-
dpop_nonce: Option(String),
55
55
-
)
51
51
+
ParResponse(request_uri: String, expires_in: Int, dpop_nonce: Option(String))
56
52
}
57
53
58
54
pub type TokenResponse {
···
80
76
)
81
77
}
82
78
83
83
-
84
84
-
85
79
pub fn with_dpop_nonce(
86
80
credential: OAuthCredential,
87
81
dpop_nonce: Option(String),
···
91
85
92
86
fn random_string() -> String {
93
87
crypto.random_bytes(32)
94
94
-
|> bit_array.base64_url_encode(False)
88
88
+
|> bit_array.base64_url_encode(False)
95
89
}
96
90
97
91
pub fn get_auth_server(pds: String) -> String {
···
142
136
decode.list(decode.string),
143
137
)
144
138
145
145
-
decode.success(
146
146
-
OAuthServerMetadata(
147
147
-
issuer: issuer,
148
148
-
authorization_endpoint: authoriization_endpoint,
149
149
-
token_endpoint: token_endpoint,
150
150
-
pushed_authorization_request_endpoint: par_endpoint,
151
151
-
dpop_signing_alg_values_supported: dpop_algorithms,
152
152
-
)
153
153
-
)
139
139
+
decode.success(OAuthServerMetadata(
140
140
+
issuer: issuer,
141
141
+
authorization_endpoint: authoriization_endpoint,
142
142
+
token_endpoint: token_endpoint,
143
143
+
pushed_authorization_request_endpoint: par_endpoint,
144
144
+
dpop_signing_alg_values_supported: dpop_algorithms,
145
145
+
))
154
146
}
155
147
156
148
let assert Ok(metadata) = json.parse(metadata_response.body, metadata_decoder)
···
162
154
let assert Ok(h) = hash.new(hash.Sha256)
163
155
164
156
let challenge =
165
165
-
h
166
166
-
|> hash.update(<<verifier:utf8>>)
167
167
-
|> hash.final()
168
168
-
|> bit_array.base64_url_encode(False)
157
157
+
h
158
158
+
|> hash.update(<<verifier:utf8>>)
159
159
+
|> hash.final()
160
160
+
|> bit_array.base64_url_encode(False)
169
161
170
162
#(verifier, challenge)
171
163
}
···
174
166
gose.generate_ec(ec.P256)
175
167
}
176
168
177
177
-
pub fn create_dpop_proof(key: gose.Key(String), method: String, url: String, nonce: Option(String), access_token: Option(String)) -> String {
169
169
+
pub fn create_dpop_proof(
170
170
+
key: gose.Key(String),
171
171
+
method: String,
172
172
+
url: String,
173
173
+
nonce: Option(String),
174
174
+
access_token: Option(String),
175
175
+
) -> String {
178
176
let jti = random_string()
179
179
-
let #(iat, _) = timestamp.system_time()
177
177
+
let #(iat, _) =
178
178
+
timestamp.system_time()
180
179
|> timestamp.to_unix_seconds_and_nanoseconds()
181
180
182
182
-
let payload =
183
183
-
[
181
181
+
let payload = [
184
182
#("jti", json.string(jti)),
185
183
#("htm", json.string(method)),
186
184
#("htu", json.string(url)),
···
190
188
let payload = case nonce {
191
189
option.None -> payload
192
190
193
193
-
option.Some(nonce_value) ->
194
194
-
[#("nonce", json.string(nonce_value)), ..payload]
191
191
+
option.Some(nonce_value) -> [
192
192
+
#("nonce", json.string(nonce_value)),
193
193
+
..payload
194
194
+
]
195
195
}
196
196
197
197
let payload = case access_token {
···
224
224
|> jws.with_typ("dpop+jwt")
225
225
|> jws.with_header("jwk", public_jwk)
226
226
227
227
-
let assert Ok(signed) =
228
228
-
jws.sign(unsigned, key: key, payload: payload_bits)
227
227
+
let assert Ok(signed) = jws.sign(unsigned, key: key, payload: payload_bits)
229
228
230
229
let assert Ok(dpop_proof) = jws.serialize_compact(signed)
231
230
dpop_proof
232
231
}
233
232
234
234
-
pub fn push_authorization_request(par_endpoint: String, client_id: String, redirect_uri: String, scope: String, state: String, code_challenge: String, dpop_key: gose.Key(String)) -> ParResponse {
235
235
-
let dpop_proof = create_dpop_proof(
236
236
-
dpop_key,
237
237
-
"POST",
238
238
-
par_endpoint,
239
239
-
option.None,
240
240
-
option.None,
241
241
-
)
233
233
+
pub fn push_authorization_request(
234
234
+
par_endpoint: String,
235
235
+
client_id: String,
236
236
+
redirect_uri: String,
237
237
+
scope: String,
238
238
+
state: String,
239
239
+
code_challenge: String,
240
240
+
dpop_key: gose.Key(String),
241
241
+
) -> ParResponse {
242
242
+
let dpop_proof =
243
243
+
create_dpop_proof(dpop_key, "POST", par_endpoint, option.None, option.None)
242
244
243
245
let form_body =
244
246
uri.query_to_string([
···
255
257
let assert Ok(par_request) = request.from_uri(par_uri)
256
258
257
259
let par_request =
258
258
-
request.Request(
259
259
-
..par_request,
260
260
-
method: http.Post,
261
261
-
body: form_body,
262
262
-
)
260
260
+
request.Request(..par_request, method: http.Post, body: form_body)
263
261
264
262
let par_request =
265
263
par_request
266
266
-
|> request.set_header(
267
267
-
"content-type",
268
268
-
"application/x-www-form-urlencoded",
269
269
-
)
264
264
+
|> request.set_header("content-type", "application/x-www-form-urlencoded")
270
265
|> request.set_header("dpop", dpop_proof)
271
266
272
267
let assert Ok(par_response) = httpc.send(par_request)
···
278
273
decode.success(#(request_uri, expires_in))
279
274
}
280
275
281
281
-
let assert Ok(#(request_uri, expires_in)) = json.parse(par_response.body, par_response_decoder)
276
276
+
let assert Ok(#(request_uri, expires_in)) =
277
277
+
json.parse(par_response.body, par_response_decoder)
282
278
283
279
let dpop_nonce =
284
280
response.get_header(par_response, "dpop-nonce")
···
286
282
287
283
ParResponse(request_uri:, expires_in:, dpop_nonce:)
288
284
}
289
289
-
290
290
-
291
285
292
286
pub fn build_authorization_url(
293
287
authorization_endpoint: String,
···
316
310
state: String,
317
311
) -> Option(OAuthSession) {
318
312
dict.get(sessions, state)
319
319
-
|> option.from_result
313
313
+
|> option.from_result
320
314
}
321
315
322
316
pub fn parse_callback_url(callback_url: String) -> #(String, String, String) {
···
341
335
dpop_nonce: Option(String),
342
336
) -> TokenResponse {
343
337
let dpop_proof =
344
344
-
create_dpop_proof(
345
345
-
dpop_key,
346
346
-
"POST",
347
347
-
token_endpoint,
348
348
-
dpop_nonce,
349
349
-
option.None,
350
350
-
)
338
338
+
create_dpop_proof(dpop_key, "POST", token_endpoint, dpop_nonce, option.None)
351
339
352
340
let form_body =
353
341
uri.query_to_string([
···
362
350
let assert Ok(token_request) = request.from_uri(token_uri)
363
351
364
352
let token_request =
365
365
-
request.Request(
366
366
-
..token_request,
367
367
-
method: http.Post,
368
368
-
body: form_body,
369
369
-
)
353
353
+
request.Request(..token_request, method: http.Post, body: form_body)
370
354
371
355
let token_request =
372
372
-
token_request
373
373
-
|> request.set_header(
374
374
-
"content-type",
375
375
-
"application/x-www-form-urlencoded",
376
376
-
)
356
356
+
token_request
357
357
+
|> request.set_header("content-type", "application/x-www-form-urlencoded")
377
358
|> request.set_header("dpop", dpop_proof)
378
359
379
360
let assert Ok(token_response) = httpc.send(token_request)
···
381
362
let dpop_nonce =
382
363
response.get_header(token_response, "dpop-nonce")
383
364
|> option.from_result
384
384
-
385
365
386
366
let token_response_decoder = {
387
367
use access_token <- decode.field("access_token", decode.string)
···
395
375
use scope <- decode.field("scope", decode.string)
396
376
use sub <- decode.field("sub", decode.string)
397
377
398
398
-
decode.success(
399
399
-
TokenResponse(
400
400
-
access_token:,
401
401
-
token_type:,
402
402
-
expires_in:,
403
403
-
refresh_token:,
404
404
-
scope:,
405
405
-
sub:,
406
406
-
dpop_nonce:,
407
407
-
),
408
408
-
)
378
378
+
decode.success(TokenResponse(
379
379
+
access_token:,
380
380
+
token_type:,
381
381
+
expires_in:,
382
382
+
refresh_token:,
383
383
+
scope:,
384
384
+
sub:,
385
385
+
dpop_nonce:,
386
386
+
))
409
387
}
410
388
411
389
let assert Ok(tokens) =
···
461
439
462
440
let oauth_callback.Callback(code:, state: returned_state, issuer:) =
463
441
process.receive_forever(callback_messages)
464
464
-
let assert option.Some(session) =
465
465
-
get_oauth_session(sessions, returned_state)
442
442
+
let assert option.Some(session) = get_oauth_session(sessions, returned_state)
466
443
let assert True = issuer == session.issuer
467
444
468
445
let tokens =
···
57
57
}
58
58
}
59
59
60
60
-
fn html_response(status: Int, body: String) -> response.Response(mist.ResponseData) {
60
60
+
fn html_response(
61
61
+
status: Int,
62
62
+
body: String,
63
63
+
) -> response.Response(mist.ResponseData) {
61
64
response.new(status)
62
65
|> response.set_header("content-type", "text/html; charset=utf-8")
63
66
|> response.set_body(mist.Bytes(bytes_tree.from_string(body)))
···
30
30
"urn:example:request-123",
31
31
)
32
32
33
33
-
assert authorization_url ==
34
34
-
"https://pds.cute.haus/oauth/authorize?client_id=http%3A%2F%2Flocalhost%3Fscope%3Datproto&request_uri=urn%3Aexample%3Arequest-123"
33
33
+
assert authorization_url
34
34
+
== "https://pds.cute.haus/oauth/authorize?client_id=http%3A%2F%2Flocalhost%3Fscope%3Datproto&request_uri=urn%3Aexample%3Arequest-123"
35
35
}
36
36
37
37
pub fn create_pkce_returns_matching_challenge_test() {