Auto-indexing service and GraphQL API for AT Protocol Records
quickslice.slices.network/
atproto
gleam
graphql
1import database/executor.{type Executor}
2import database/repositories/oauth_access_tokens
3import database/repositories/oauth_atp_sessions
4import gleam/erlang/process.{type Subject}
5import gleam/option.{type Option, None, Some}
6import gleam/result
7import gleam/string
8import lib/oauth/atproto/bridge
9import lib/oauth/atproto/did_resolver
10import lib/oauth/did_cache
11import lib/oauth/token_generator
12
13/// UserInfo response from OAuth provider
14pub type UserInfo {
15 UserInfo(sub: String, did: String)
16}
17
18/// ATProto session data from AIP
19pub type AtprotoSession {
20 AtprotoSession(pds_endpoint: String, access_token: String, dpop_jwk: String)
21}
22
23/// Error type for authentication operations
24pub type AuthError {
25 MissingAuthHeader
26 InvalidAuthHeader
27 UnauthorizedToken
28 TokenExpired
29 SessionNotFound
30 SessionNotReady
31 RefreshFailed(String)
32 DIDResolutionFailed(String)
33 NetworkError
34 ParseError
35}
36
37/// Extract bearer token from Authorization header
38///
39/// # Example
40/// ```gleam
41/// extract_bearer_token(request.headers)
42/// // Ok("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...")
43/// ```
44pub fn extract_bearer_token(
45 from headers: List(#(String, String)),
46) -> Result(String, AuthError) {
47 headers
48 |> list_find(one_that: fn(header) {
49 string.lowercase(header.0) == "authorization"
50 })
51 |> result.map(fn(header) { header.1 })
52 |> result.replace_error(MissingAuthHeader)
53 |> result.try(fn(auth_value) {
54 case string.starts_with(auth_value, "Bearer ") {
55 True -> {
56 auth_value
57 |> string.drop_start(7)
58 |> Ok
59 }
60 False -> Error(InvalidAuthHeader)
61 }
62 })
63}
64
65/// Helper function to find in list
66fn list_find(
67 in list: List(a),
68 one_that predicate: fn(a) -> Bool,
69) -> Result(a, Nil) {
70 case list {
71 [] -> Error(Nil)
72 [first, ..rest] ->
73 case predicate(first) {
74 True -> Ok(first)
75 False -> list_find(rest, predicate)
76 }
77 }
78}
79
80/// Verify token from local database and return user info
81pub fn verify_token(
82 conn: Executor,
83 token: String,
84) -> Result(UserInfo, AuthError) {
85 // Look up token in database
86 case oauth_access_tokens.get(conn, token) {
87 Error(_) -> Error(UnauthorizedToken)
88 Ok(None) -> Error(UnauthorizedToken)
89 Ok(Some(access_token)) -> {
90 // Check if revoked
91 case access_token.revoked {
92 True -> Error(UnauthorizedToken)
93 False -> {
94 // Check if expired
95 let now = token_generator.current_timestamp()
96 case access_token.expires_at < now {
97 True -> Error(TokenExpired)
98 False -> {
99 // Check user_id is present
100 case access_token.user_id {
101 None -> Error(UnauthorizedToken)
102 Some(did) -> Ok(UserInfo(sub: did, did: did))
103 }
104 }
105 }
106 }
107 }
108 }
109 }
110}
111
112/// Get ATP session from local database, refreshing if needed
113pub fn get_atp_session(
114 conn: Executor,
115 did_cache: Subject(did_cache.Message),
116 token: String,
117 signing_key: Option(String),
118 atp_client_id: String,
119) -> Result(AtprotoSession, AuthError) {
120 // Look up access token to get session_id and iteration
121 use access_token <- result.try(case oauth_access_tokens.get(conn, token) {
122 Error(_) -> Error(UnauthorizedToken)
123 Ok(None) -> Error(UnauthorizedToken)
124 Ok(Some(t)) -> Ok(t)
125 })
126
127 // Get session_id and iteration
128 use #(session_id, iteration) <- result.try(
129 case access_token.session_id, access_token.session_iteration {
130 Some(sid), Some(iter) -> Ok(#(sid, iter))
131 _, _ -> Error(SessionNotFound)
132 },
133 )
134
135 // Look up ATP session
136 use atp_session <- result.try(
137 case oauth_atp_sessions.get(conn, session_id, iteration) {
138 Error(_) -> Error(SessionNotFound)
139 Ok(None) -> Error(SessionNotFound)
140 Ok(Some(s)) -> Ok(s)
141 },
142 )
143
144 // Validate session is ready (exchanged, no error, has access token)
145 use _ <- result.try(case atp_session.exchange_error {
146 Some(_) -> Error(SessionNotReady)
147 None -> Ok(Nil)
148 })
149
150 use _ <- result.try(case atp_session.session_exchanged_at {
151 None -> Error(SessionNotReady)
152 Some(_) -> Ok(Nil)
153 })
154
155 use atp_access_token <- result.try(case atp_session.access_token {
156 None -> Error(SessionNotReady)
157 Some(t) -> Ok(t)
158 })
159
160 // Check if ATP token is expired and refresh if needed
161 let now = token_generator.current_timestamp()
162 use current_session <- result.try(case atp_session.access_token_expires_at {
163 Some(expires_at) if expires_at < now -> {
164 // Token expired, try to refresh
165 case
166 bridge.refresh_tokens(
167 conn,
168 did_cache,
169 atp_session,
170 atp_client_id,
171 signing_key,
172 )
173 {
174 Ok(refreshed) -> Ok(refreshed)
175 Error(err) -> Error(RefreshFailed(string.inspect(err)))
176 }
177 }
178 _ -> Ok(atp_session)
179 })
180
181 // Get the (possibly refreshed) access token
182 use final_access_token <- result.try(case current_session.access_token {
183 None -> Error(SessionNotReady)
184 Some(t) -> Ok(t)
185 })
186
187 // Get DID from session
188 use did <- result.try(case current_session.did {
189 None -> Error(SessionNotFound)
190 Some(d) -> Ok(d)
191 })
192
193 // Resolve DID to get PDS endpoint
194 use did_doc <- result.try(
195 did_resolver.resolve_did_with_cache(did_cache, did, False)
196 |> result.map_error(fn(err) { DIDResolutionFailed(string.inspect(err)) }),
197 )
198
199 use pds_endpoint <- result.try(case did_resolver.get_pds_endpoint(did_doc) {
200 None -> Error(DIDResolutionFailed("No PDS endpoint in DID document"))
201 Some(endpoint) -> Ok(endpoint)
202 })
203
204 // Suppress unused variable warning for atp_access_token
205 let _ = atp_access_token
206 let _ = final_access_token
207
208 Ok(AtprotoSession(
209 pds_endpoint: pds_endpoint,
210 access_token: case current_session.access_token {
211 Some(t) -> t
212 None -> ""
213 },
214 dpop_jwk: current_session.dpop_key,
215 ))
216}