Auto-indexing service and GraphQL API for AT Protocol Records quickslice.slices.network/
atproto gleam graphql
at main 293 lines 9.8 kB view raw
1/// Admin OAuth callback handler 2/// GET /admin/oauth/callback - Handles ATP OAuth callback for admin login 3import database/executor.{type Executor} 4import database/repositories/admin_session 5import database/repositories/config as config_repo 6import database/repositories/oauth_access_tokens 7import database/repositories/oauth_atp_requests 8import database/repositories/oauth_atp_sessions 9import database/repositories/oauth_refresh_tokens 10import database/types.{Bearer, OAuthAccessToken, OAuthRefreshToken} 11import gleam/crypto 12import gleam/erlang/process.{type Subject} 13import gleam/http/cookie 14import gleam/http/response 15import gleam/json 16import gleam/list 17import gleam/option.{type Option, None, Some} 18import gleam/result 19import gleam/string 20import gleam/uri 21import lib/oauth/atproto/bridge 22import lib/oauth/did_cache 23import lib/oauth/token_generator 24import wisp 25 26/// Handle GET /admin/oauth/callback 27pub fn handle( 28 req: wisp.Request, 29 conn: Executor, 30 did_cache: Subject(did_cache.Message), 31 redirect_uri: String, 32 client_id: String, 33 signing_key: Option(String), 34) -> wisp.Response { 35 // Parse query parameters 36 let query = wisp.get_query(req) 37 38 // Check for OAuth error FIRST (user denied, etc.) 39 case list.key_find(query, "error") { 40 Ok(error) -> { 41 let error_description = 42 list.key_find(query, "error_description") 43 |> result.unwrap("") 44 45 // Redirect to / or /onboarding based on admin existence 46 let redirect_path = case config_repo.has_admins(conn) { 47 True -> "/" 48 False -> "/onboarding" 49 } 50 51 let redirect_url = 52 redirect_path 53 <> "?error=" 54 <> uri.percent_encode(error) 55 <> "&error_description=" 56 <> uri.percent_encode(error_description) 57 58 wisp.redirect(redirect_url) 59 } 60 Error(_) -> { 61 // Normal flow: check for code and state 62 let code_result = list.key_find(query, "code") 63 let state_result = list.key_find(query, "state") 64 65 case code_result, state_result { 66 Error(_), _ -> error_response(400, "Missing 'code' parameter") 67 _, Error(_) -> error_response(400, "Missing 'state' parameter") 68 Ok(code), Ok(state) -> { 69 process_callback( 70 req, 71 conn, 72 did_cache, 73 code, 74 state, 75 redirect_uri, 76 client_id, 77 signing_key, 78 ) 79 } 80 } 81 } 82 } 83} 84 85fn process_callback( 86 req: wisp.Request, 87 conn: Executor, 88 did_cache: Subject(did_cache.Message), 89 code: String, 90 state: String, 91 redirect_uri: String, 92 client_id: String, 93 signing_key: Option(String), 94) -> wisp.Response { 95 // Retrieve ATP session by state 96 case oauth_atp_sessions.get_by_state(conn, state) { 97 Error(err) -> error_response(500, "Database error: " <> string.inspect(err)) 98 Ok(None) -> error_response(400, "Invalid or expired state parameter") 99 Ok(Some(atp_session)) -> { 100 // Retrieve ATP request to get PKCE verifier 101 case oauth_atp_requests.get(conn, state) { 102 Error(err) -> 103 error_response(500, "Database error: " <> string.inspect(err)) 104 Ok(None) -> 105 error_response(400, "OAuth request not found - PKCE verifier missing") 106 Ok(Some(atp_request)) -> { 107 let code_verifier = atp_request.pkce_verifier 108 109 // Call bridge to exchange code for tokens 110 case 111 bridge.handle_callback( 112 conn, 113 did_cache, 114 atp_session, 115 code, 116 code_verifier, 117 redirect_uri, 118 client_id, 119 state, 120 signing_key, 121 ) 122 { 123 Error(bridge_err) -> { 124 error_response( 125 500, 126 "Token exchange failed: " <> bridge_error_to_string(bridge_err), 127 ) 128 } 129 Ok(updated_session) -> { 130 // Clean up one-time-use oauth request 131 let _ = oauth_atp_requests.delete(conn, state) 132 133 // Generate admin session ID (for cookie) 134 let admin_session_id = token_generator.generate_session_id() 135 136 // Create admin session linking to ATP session 137 case 138 admin_session.insert( 139 conn, 140 admin_session_id, 141 updated_session.session_id, 142 ) 143 { 144 Error(err) -> 145 error_response( 146 500, 147 "Failed to create admin session: " <> string.inspect(err), 148 ) 149 Ok(_) -> { 150 // Get DID from ATP session 151 let did = case updated_session.did { 152 Some(d) -> d 153 None -> "" 154 } 155 156 // If no admins exist, register this user as the first admin 157 case config_repo.has_admins(conn) { 158 False -> { 159 let _ = config_repo.add_admin_did(conn, did) 160 wisp.log_info( 161 "[onboarding] First admin registered: " <> did, 162 ) 163 } 164 True -> Nil 165 } 166 167 // Generate OAuth access token for GraphiQL/API use 168 let access_token_value = 169 token_generator.generate_access_token() 170 let refresh_token_value = 171 token_generator.generate_refresh_token() 172 let now = token_generator.current_timestamp() 173 174 let access_token = 175 OAuthAccessToken( 176 token: access_token_value, 177 token_type: Bearer, 178 client_id: "admin", 179 user_id: Some(did), 180 session_id: Some(updated_session.session_id), 181 session_iteration: Some(updated_session.iteration), 182 scope: None, 183 created_at: now, 184 expires_at: token_generator.expiration_timestamp( 185 3600 * 24 * 7, 186 ), 187 revoked: False, 188 dpop_jkt: None, 189 ) 190 191 let refresh_token = 192 OAuthRefreshToken( 193 token: refresh_token_value, 194 access_token: access_token_value, 195 client_id: "admin", 196 user_id: did, 197 session_id: Some(updated_session.session_id), 198 session_iteration: Some(updated_session.iteration), 199 scope: None, 200 created_at: now, 201 expires_at: None, 202 revoked: False, 203 ) 204 205 // Insert OAuth tokens 206 case oauth_access_tokens.insert(conn, access_token) { 207 Ok(_) -> 208 wisp.log_info( 209 "OAuth access token created for session: " 210 <> updated_session.session_id, 211 ) 212 Error(err) -> 213 wisp.log_error( 214 "Failed to create OAuth access token: " 215 <> string.inspect(err), 216 ) 217 } 218 case oauth_refresh_tokens.insert(conn, refresh_token) { 219 Ok(_) -> 220 wisp.log_info( 221 "OAuth refresh token created for session: " 222 <> updated_session.session_id, 223 ) 224 Error(err) -> 225 wisp.log_error( 226 "Failed to create OAuth refresh token: " 227 <> string.inspect(err), 228 ) 229 } 230 231 // Set session cookie and redirect to home 232 wisp.redirect("/") 233 |> set_session_cookie(req, admin_session_id) 234 } 235 } 236 } 237 } 238 } 239 } 240 } 241 } 242} 243 244fn error_response(status: Int, message: String) -> wisp.Response { 245 wisp.log_error("Admin OAuth callback error: " <> message) 246 let json_body = 247 json.object([ 248 #("error", json.string("server_error")), 249 #("error_description", json.string(message)), 250 ]) 251 252 wisp.response(status) 253 |> wisp.set_header("content-type", "application/json") 254 |> wisp.set_body(wisp.Text(json.to_string(json_body))) 255} 256 257fn bridge_error_to_string(err: bridge.BridgeError) -> String { 258 case err { 259 bridge.DIDResolutionError(_) -> "DID resolution failed" 260 bridge.PDSNotFound(msg) -> "PDS not found: " <> msg 261 bridge.TokenExchangeError(msg) -> "Token exchange failed: " <> msg 262 bridge.HTTPError(msg) -> "HTTP error: " <> msg 263 bridge.InvalidResponse(msg) -> "Invalid response: " <> msg 264 bridge.StorageError(msg) -> "Storage error: " <> msg 265 bridge.MetadataFetchError(msg) -> "Metadata fetch failed: " <> msg 266 bridge.PARError(msg) -> "PAR error: " <> msg 267 } 268} 269 270/// Set session cookie on response 271fn set_session_cookie( 272 resp: wisp.Response, 273 req: wisp.Request, 274 session_id: String, 275) -> wisp.Response { 276 // Sign the session ID the same way wisp does 277 let signed_value = wisp.sign_message(req, <<session_id:utf8>>, crypto.Sha512) 278 279 // Create cookie attributes 280 let attributes = 281 cookie.Attributes( 282 max_age: option.Some(60 * 60 * 24 * 14), 283 // 14 days 284 domain: option.None, 285 path: option.Some("/"), 286 secure: False, 287 // False for localhost HTTP 288 http_only: True, 289 same_site: option.None, 290 ) 291 292 response.set_cookie(resp, "quickslice_session", signed_value, attributes) 293}