Auto-indexing service and GraphQL API for AT Protocol Records quickslice.slices.network/
atproto gleam graphql
at main 216 lines 5.9 kB view raw
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}