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