Auto-indexing service and GraphQL API for AT Protocol Records quickslice.slices.network/
atproto gleam graphql
at main 190 lines 5.7 kB view raw
1import database/executor.{type Executor} 2import database/repositories/admin_session 3import database/repositories/oauth_access_tokens 4import database/repositories/oauth_atp_sessions 5import database/repositories/oauth_refresh_tokens 6import gleam/bit_array 7import gleam/crypto 8import gleam/dynamic/decode 9import gleam/erlang/process.{type Subject} 10import gleam/http/cookie 11import gleam/http/response 12import gleam/option.{type Option, None, Some} 13import gleam/result 14import lib/oauth/atproto/did_resolver 15import lib/oauth/did_cache 16import wisp.{type Request, type Response} 17 18/// OAuth session data stored server-side 19pub type OAuthSession { 20 OAuthSession( 21 session_id: String, 22 access_token: String, 23 refresh_token: Option(String), 24 did: String, 25 handle: String, 26 expires_at: Option(Int), 27 ) 28} 29 30const session_cookie_name = "quickslice_session" 31 32/// Generate a new session ID 33pub fn generate_session_id() -> String { 34 let random_bytes = crypto.strong_random_bytes(32) 35 bit_array.base64_url_encode(random_bytes, False) 36} 37 38/// Set session cookie on response with SameSite=None for fetch with credentials 39pub fn set_session_cookie( 40 response: Response, 41 req: Request, 42 session_id: String, 43) -> Response { 44 // Sign the session ID the same way wisp does 45 let signed_value = wisp.sign_message(req, <<session_id:utf8>>, crypto.Sha512) 46 47 // Create cookie attributes without SameSite restriction 48 let attributes = 49 cookie.Attributes( 50 max_age: option.Some(60 * 60 * 24 * 14), 51 domain: option.None, 52 path: option.Some("/"), 53 secure: False, 54 // False for localhost HTTP 55 http_only: True, 56 same_site: option.None, 57 // No SameSite restriction for JavaScript fetch 58 ) 59 60 response.set_cookie(response, session_cookie_name, signed_value, attributes) 61} 62 63/// Get session ID from request cookies 64pub fn get_session_id(req: Request) -> Result(String, Nil) { 65 wisp.get_cookie(req, session_cookie_name, wisp.Signed) 66} 67 68/// Clear session cookie on response 69pub fn clear_session_cookie(response: Response, req: Request) -> Response { 70 wisp.set_cookie(response, req, session_cookie_name, "", wisp.Signed, 0) 71} 72 73/// Get the current user from session 74/// Returns (did, handle, access_token) 75pub fn get_current_user( 76 req: Request, 77 db: Executor, 78 did_cache: Subject(did_cache.Message), 79) -> Result(#(String, String, String), Nil) { 80 use sess <- result.try(get_current_session(req, db, did_cache)) 81 Ok(#(sess.did, sess.handle, sess.access_token)) 82} 83 84/// Get the current user session from admin_session + ATP session tables 85/// Returns OAuthSession for compatibility with existing callers 86pub fn get_current_session( 87 req: Request, 88 db: Executor, 89 did_cache: Subject(did_cache.Message), 90) -> Result(OAuthSession, Nil) { 91 use session_id <- result.try(get_session_id(req)) 92 93 // Look up admin session 94 use admin_sess_opt <- result.try( 95 admin_session.get(db, session_id) |> result.replace_error(Nil), 96 ) 97 use admin_sess <- result.try(case admin_sess_opt { 98 Some(s) -> Ok(s) 99 None -> Error(Nil) 100 }) 101 102 // Look up ATP session (get latest iteration) 103 use atp_sess_opt <- result.try( 104 oauth_atp_sessions.get_latest(db, admin_sess.atp_session_id) 105 |> result.replace_error(Nil), 106 ) 107 use atp_sess <- result.try(case atp_sess_opt { 108 Some(s) -> Ok(s) 109 None -> Error(Nil) 110 }) 111 112 // Get the DID 113 let did = option.unwrap(atp_sess.did, "") 114 115 // Look up OAuth access token by session_id (atp_session_id) 116 // This is our OAuth token (e.g., tok-xxx), not the ATP token from PDS 117 let oauth_access_token_opt = 118 oauth_access_tokens.get_by_session_id(db, admin_sess.atp_session_id) 119 |> result.unwrap(None) 120 121 // Look up OAuth refresh token by session_id (atp_session_id) 122 let oauth_refresh_token_opt = 123 oauth_refresh_tokens.get_by_session_id(db, admin_sess.atp_session_id) 124 |> result.unwrap(None) 125 126 // Resolve handle from DID document (falls back to DID if resolution fails) 127 let handle = case did { 128 "" -> "" 129 _ -> { 130 case did_resolver.resolve_did_with_cache(did_cache, did, False) { 131 Ok(doc) -> option.unwrap(did_resolver.get_handle(doc), did) 132 Error(_) -> did 133 } 134 } 135 } 136 137 // Get OAuth token values (or empty string if not found) 138 let access_token = case oauth_access_token_opt { 139 Some(t) -> t.token 140 None -> "" 141 } 142 143 let refresh_token = case oauth_refresh_token_opt { 144 Some(t) -> Some(t.token) 145 None -> None 146 } 147 148 // Get expiration from OAuth access token 149 let expires_at = case oauth_access_token_opt { 150 Some(t) -> Some(t.expires_at) 151 None -> None 152 } 153 154 // Convert to OAuthSession format 155 Ok(OAuthSession( 156 session_id: session_id, 157 access_token: access_token, 158 refresh_token: refresh_token, 159 did: did, 160 handle: handle, 161 expires_at: expires_at, 162 )) 163} 164 165/// Check if a session token is expired or will expire soon (within 5 minutes) 166/// Returns True if token should be refreshed 167pub fn should_refresh_token(db: Executor, session: OAuthSession) -> Bool { 168 case session.expires_at { 169 option.None -> False 170 option.Some(expires_at) -> { 171 // Check if token expires within 5 minutes (300 seconds) 172 // Use dialect-aware time function 173 let sql = case executor.dialect(db) { 174 executor.SQLite -> "SELECT (? - unixepoch()) < 300" 175 executor.PostgreSQL -> 176 "SELECT ($1::bigint - EXTRACT(EPOCH FROM NOW())::bigint) < 300" 177 } 178 179 let decoder = { 180 use should_refresh <- decode.field(0, decode.int) 181 decode.success(should_refresh != 0) 182 } 183 184 case executor.query(db, sql, [executor.Int(expires_at)], decoder) { 185 Ok([True]) -> True 186 _ -> False 187 } 188 } 189 } 190}