Auto-indexing service and GraphQL API for AT Protocol Records
quickslice.slices.network/
atproto
gleam
graphql
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}