···178178 | None -> None
179179 | Some session -> Store.find_user store session.user_id)
180180181181-(* ── Provider user profile fetch ─────────────────────────────────── *)
182182-183183-type provider_user = {
184184- uid : string; (* provider-specific unique ID *)
185185- login : string;
186186- email : string;
187187- name : string;
188188- avatar_url : string;
189189-}
181181+(* ── Userinfo fetch ──────────────────────────────────────────────── *)
190182191191-(* Generic userinfo: providers return different field names but all have
192192- some form of id, name, email, avatar. We try common field names. *)
193193-let provider_user_jsont =
194194- Jsont.Object.map ~kind:"provider_user" (fun id login email name avatar_url ->
195195- {
196196- uid = (match id with i when i > 0 -> string_of_int i | _ -> login);
197197- login;
198198- email;
199199- name;
200200- avatar_url;
201201- })
202202- |> Jsont.Object.mem "id" Jsont.int ~dec_absent:0 ~enc:(fun _ -> 0)
203203- |> Jsont.Object.mem "login" Jsont.string ~dec_absent:""
204204- ~enc:(fun (u : provider_user) -> u.login)
205205- |> Jsont.Object.mem "email" Jsont.string ~dec_absent:""
206206- ~enc:(fun (u : provider_user) -> u.email)
207207- |> Jsont.Object.mem "name" Jsont.string ~dec_absent:""
208208- ~enc:(fun (u : provider_user) -> u.name)
209209- |> Jsont.Object.mem "avatar_url" Jsont.string ~dec_absent:""
210210- ~enc:(fun (u : provider_user) -> u.avatar_url)
211211- |> Jsont.Object.skip_unknown |> Jsont.Object.finish
212212-213213-let fetch_provider_user http ~userinfo_url ~access_token =
183183+let fetch_userinfo http ~provider ~access_token =
214184 let headers =
215185 Headers.empty
216186 |> Headers.set `Authorization (Fmt.str "Bearer %s" access_token)
217187 |> Headers.set `Accept "application/json"
218188 in
219219- let resp = Requests.get http userinfo_url ~headers in
220220- let body = Requests.Response.text resp in
221221- match Jsont_bytesrw.decode_string provider_user_jsont body with
222222- | Ok user -> Ok user
223223- | Error e -> Error e
189189+ let url = Oauth.userinfo_url provider in
190190+ let resp = Requests.get http url ~headers in
191191+ let status = Requests.Response.status_code resp in
192192+ if status >= 400 then
193193+ Error (Fmt.str "userinfo endpoint returned HTTP %d" status)
194194+ else
195195+ let body = Requests.Response.text resp in
196196+ Oauth.parse_userinfo provider body
224197225198(* ── Routes ──────────────────────────────────────────────────────── *)
226199···230203 (* Sign the state for CSRF verification on callback *)
231204 let signed_state = Csrf.sign_state ~secret:config.cookie_secret state in
232205 let redirect_uri = config.base_url ^ "/auth/callback" in
233233- let scope =
234234- if config.oauth_provider.name = "github" then [ "user:email" ] else []
235235- in
206206+ let scope = Oauth.default_scope config.oauth_provider in
236207 let url =
237208 Oauth.authorization_url config.oauth_provider ~client_id:config.client_id
238209 ~redirect_uri ~state:signed_state ~scope
···260231 Oauth.exchange_form_body ~client_id:config.client_id
261232 ~client_secret:config.client_secret ~code ~redirect_uri
262233 in
263263- let token_url = config.oauth_provider.token_url in
234234+ let token_url = Oauth.token_url config.oauth_provider in
264235 let headers =
265236 Headers.of_list
266237 [
···280251 | Ok token_resp -> (
281252 (* Fetch user profile from provider *)
282253 match
283283- fetch_provider_user config.http
284284- ~userinfo_url:config.oauth_provider.userinfo_url
254254+ fetch_userinfo config.http ~provider:config.oauth_provider
285255 ~access_token:token_resp.access_token
286256 with
287257 | Error e ->
288258 Log.err (fun m -> m "callback: user fetch failed: %s" e);
289259 Respond.Response.internal_server_error "User fetch failed"
290290- | Ok pu ->
291291- let provider = config.oauth_provider.name in
292292- let provider_uid = pu.uid in
260260+ | Ok (ui : Oauth.userinfo) ->
261261+ let provider = Oauth.provider_name config.oauth_provider in
262262+ let provider_uid = ui.uid in
293263 let email =
294294- if pu.email <> "" then pu.email
295295- else pu.login ^ "@" ^ provider
264264+ if ui.email <> "" then ui.email
265265+ else ui.login ^ "@" ^ provider
296266 in
297267 (* Find or create user *)
298268 let user =
···301271 with
302272 | Some u -> u
303273 | None ->
304304- Store.create_user store ~email ~name:pu.name
305305- ~avatar_url:pu.avatar_url ~provider ~provider_uid
274274+ Store.create_user store ~email ~name:ui.name
275275+ ~avatar_url:ui.avatar_url ~provider ~provider_uid
306276 ~access_token:token_resp.access_token
307277 in
308278 (* Create session *)
+82-45
lib/auth.mli
···11-(** User authentication and session management for web applications.
11+(** User authentication and session management.
2233- Provides OAuth-based authentication, user management, and cookie-based
44- session handling. Ties together {!Oauth}, {!Sqlite}, and {!Csrf} into a
55- reusable auth flow.
33+ {b Auth} provides OAuth-based user authentication with server-side sessions.
44+ It handles the full sign-in lifecycle: redirect to provider, exchange
55+ authorization code, create or find user, issue session cookie.
6677- {2 Quick Start}
77+ Sessions are stored server-side in SQLite for revocability. Cookies are
88+ {e HttpOnly}, {e SameSite=Lax}, and {e Secure} (when base URL is HTTPS).
99+ CSRF protection on the OAuth callback uses signed state tokens via {!Csrf}.
1010+1111+ {2 Quick start}
812913 {[
1010- let store = Auth.Store.v ~sw db_path in
1111- let config =
1212- Auth.config ~oauth_provider:Oauth.Github.provider
1313- ~client_id:"xxx" ~client_secret:"yyy"
1414- ~base_url:"https://app.com" ~cookie_secret:"secret"
1414+ Eio_main.run @@ fun env ->
1515+ Eio.Switch.run @@ fun sw ->
1616+ let fs = Eio.Stdenv.fs env in
1717+ let http = Requests.v ~sw env in
1818+ let store = Auth.Store.v ~sw Eio.Path.(fs / "data" / "auth.db") in
1919+ let cfg =
2020+ Auth.config ~oauth_provider:Oauth.Github ~client_id:"Iv1.abc"
2121+ ~client_secret:"secret" ~base_url:"https://app.com"
2222+ ~cookie_secret:"32-char-min" ~http
1523 in
1616- let routes = Auth.routes config store in
1717- (* Add to your Respond routes *)
1818- ]} *)
2424+ let routes = Auth.routes cfg store in
2525+ Respond.run ~net:(Eio.Stdenv.net env) ~port:8080
2626+ ~root:Eio.Path.(fs / "static")
2727+ routes
2828+ ]}
2929+3030+ {2 Session lifecycle}
3131+3232+ + User visits [GET /auth/signin].
3333+ + Server redirects to the OAuth provider with a signed state parameter.
3434+ + Provider redirects back to [GET /auth/callback?code=...&state=...].
3535+ + Server verifies state (CSRF), exchanges code for access token, fetches
3636+ user profile, creates or finds the user, issues a session cookie, and
3737+ redirects to [/].
3838+ + On [POST /auth/signout], the server-side session is revoked and the cookie
3939+ is cleared. *)
19402041open Http
21422222-(** {1 Configuration} *)
4343+(** {1:config Configuration} *)
23442445type config
2525-(** Auth configuration: OAuth provider, client credentials, URLs, secrets. *)
4646+(** Authentication configuration. *)
26472748val config :
2849 oauth_provider:Oauth.provider ->
···3354 http:Requests.t ->
3455 config
3556(** [config ~oauth_provider ~client_id ~client_secret ~base_url ~cookie_secret
3636- ~http] creates auth configuration.
5757+ ~http] is an auth configuration.
37583838- @param base_url Public base URL (e.g. [https://run.space])
3939- @param cookie_secret Secret for signing session cookies (32+ chars) *)
5959+ - [base_url] is the public origin (e.g. [https://run.space]). It determines
6060+ the callback URL and whether cookies are marked {e Secure}.
6161+ - [cookie_secret] is used to sign CSRF state tokens. Must be at least 32
6262+ characters. *)
40634141-(** {1 Users} *)
6464+(** {1:user Users} *)
42654366type user = {
4467 id : int;
4568 email : string;
4669 name : string;
4770 avatar_url : string;
4848- created_at : float;
7171+ created_at : float; (** Unix timestamp. *)
4972}
5050-(** A user account. *)
7373+(** A user account. The [id] is the SQLite rowid, stable across sessions. *)
51745275val pp_user : user Fmt.t
7676+(** [pp_user] formats a user as [user(<id>, <email>, <name>)]. *)
53775454-(** {1 Sessions} *)
7878+(** {1:session Sessions} *)
55795656-type session = { token : string; user_id : int; expires_at : float }
5757-(** A server-side session. *)
8080+type session = {
8181+ token : string; (** 64-char hex, cryptographically random. *)
8282+ user_id : int;
8383+ expires_at : float; (** Unix timestamp. *)
8484+}
8585+(** A server-side session. Tokens are stored in the cookie as [sid=<token>].
8686+ Default lifetime is 30 days. *)
58875959-(** {1 Store} *)
8888+(** {1:store Store} *)
60896190module Store : sig
6291 type t
6363- (** User and session database. *)
9292+ (** User and session database backed by {!Sqlite}. Creates [users] and
9393+ [oauth_identities] tables on first open, plus a [sessions] KV table. *)
64946595 val v : sw:Eio.Switch.t -> Eio.Fs.dir_ty Eio.Path.t -> t
6696 (** [v ~sw path] opens or creates the auth database at [path]. *)
67976868- val find_user_by_provider :
6969- t -> provider:string -> provider_uid:string -> user option
7070- (** Find a user by OAuth provider identity. *)
9898+ (** {2 Users} *)
719972100 val find_user : t -> int -> user option
7373- (** Find a user by ID. *)
101101+ (** [find_user store id] is the user with rowid [id], or [None]. *)
102102+103103+ val find_user_by_provider :
104104+ t -> provider:string -> provider_uid:string -> user option
105105+ (** [find_user_by_provider store ~provider ~provider_uid] is the user linked
106106+ to the given OAuth identity, or [None]. *)
7410775108 val create_user :
76109 t ->
···81114 provider_uid:string ->
82115 access_token:string ->
83116 user
8484- (** Create a user and linked OAuth identity. Returns the new user. *)
117117+ (** [create_user store ~email ~name ~avatar_url ~provider ~provider_uid
118118+ ~access_token] inserts a user and an associated OAuth identity row.
119119+ Returns the new user with its assigned [id]. *)
120120+121121+ (** {2 Sessions} *)
8512286123 val create_session : t -> user_id:int -> session
8787- (** Create a session for a user. Returns the session with a random token.
8888- Sessions expire after 30 days. *)
124124+ (** [create_session store ~user_id] creates a session with a cryptographically
125125+ random token. Expires after 30 days. *)
8912690127 val find_session : t -> string -> session option
9191- (** [find_session store token] looks up a session by token. Returns [None] if
9292- not found or expired. *)
128128+ (** [find_session store token] is the session for [token], or [None] if the
129129+ token is unknown or expired. Expired sessions are deleted on lookup. *)
9313094131 val delete_session : t -> string -> unit
9595- (** [delete_session store token] revokes a session. *)
132132+ (** [delete_session store token] revokes [token]. No-op if absent. *)
96133end
971349898-(** {1 Middleware} *)
135135+(** {1:middleware Middleware} *)
99136100137val current_user : config -> Store.t -> Respond.post_request -> user option
101101-(** [current_user config store req] extracts the session cookie from the request
102102- and returns the authenticated user, or [None]. *)
138138+(** [current_user cfg store req] extracts the [sid] cookie from [req], validates
139139+ the session, and returns the authenticated user or [None]. *)
103140104141val current_user_from_params :
105142 config -> Store.t -> Respond.params -> Headers.t -> user option
106106-(** Same as {!current_user} but takes params and headers directly (for GET
107107- routes that need to check auth via cookie header). *)
143143+(** Same as {!current_user} but takes raw [params] and [headers]. Useful in GET
144144+ handlers where only query parameters are available. *)
108145109109-(** {1 Routes} *)
146146+(** {1:routes Routes} *)
110147111148val routes : config -> Store.t -> Respond.route list
112112-(** [routes config store] returns auth routes:
149149+(** [routes cfg store] is
113150 - [GET /auth/signin] — redirect to OAuth provider
114114- - [GET /auth/callback] — handle OAuth callback, create session
115115- - [GET /auth/signout] — clear session, redirect to [/] *)
151151+ - [GET /auth/callback] — handle provider callback, create session
152152+ - [POST /auth/signout] — revoke session, clear cookie, redirect to [/] *)