Google API authentication helpers: service accounts and local OAuth
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

ocaml-gauth: add Local_store, share across gdocs/gsheets/gslides

Lift the per-tool [Store] modules into one [Gauth.Local_store]. All
three Google CLI tools ([gdocs], [gsheets], [gslides]) now point at a
single XDG config directory ([$XDG_CONFIG_HOME/google/{client,token}.json])
so a user runs [install] and [login] once and every tool that
recognizes the granted scopes can read the resulting token.

Before, each package had a near-identical 60-line [Store] (atomic
write, save/load client and token, [acquire] returning a refreshable
[Gauth.token]) keyed off its own per-tool app name -- three copies
with cosmetic differences (the [cli_name] in the
"Run [<tool> install] first" diagnostic). Each per-tool [Store] is
now a thin wrapper:

include Gauth.Local_store
let acquire http ~clock ~fs =
Gauth.Local_store.acquire http ~clock ~fs ~cli_name:"<tool>"

The shared [Local_store] keeps the atomic-write pattern from the
prior [ocaml-gdocs] / [ocaml-gsheets] / [ocaml-gslides] commits
(sibling [.tmp.<pid>] file with [`Exclusive 0o600], then [rename]
over the target -- no chmod-after-write race, crash-before-rename
leaves the original target untouched).

Side-effect: on first run after this lands, users must re-run
[install] and [login] -- their existing
[~/.config/{gdocs,gsheets,gslides}/] directories are no longer
consulted. Pre-1.0 packages, no migration script.

Move:

- [Gauth.Local_store] in [gauth/lib/gauth.{ml,mli}]: client record,
config_dir/client_path/token_path, save_client/load_client,
save_token/load_token, clear_token, persist, acquire (with
[~cli_name:string] for the diagnostic).
- [gauth] now depends on [nox-xdge] for XDG paths (and [unix] which
was already pulled in transitively).
- [Gdocs.Store], [Gsheets.Store], [Gslides.Store] reduce to ~3 lines
each ([include Gauth.Local_store] + specialized [acquire]).
- Per-tool [lib/dune] and [dune-project] drop their direct
[nox-xdge] / [unix] deps -- those come transitively through gauth.
- Two store tests updated: the "config dir contains '<tool>'"
assertions become "config dir is shared 'google'" since the path
is now [.../google/...] for every tool.

All 307 tests across the four packages pass: gauth (5), gdocs (61),
gsheets (156), gslides (85). [monopam lint] reports the same
pre-existing [oauth unused] / [base64 ptime unused] false positives,
nothing new.

Followups still in flight: the [nox-xdge] -> [nox-xdg] rename is
tracked separately so it can sweep every consumer in one commit
without conflating with this refactor.

+125 -1
+1
dune-project
··· 35 35 (requests (>= 0.1)) 36 36 (uri (>= 4.0)) 37 37 (nox-x509 (>= 1.0)) 38 + (nox-xdge (>= 0.1)) 38 39 (alcotest :with-test) 39 40 (eio_main :with-test) 40 41 (odoc :with-doc)
+1
gauth.opam
··· 29 29 "requests" {>= "0.1"} 30 30 "uri" {>= "4.0"} 31 31 "nox-x509" {>= "1.0"} 32 + "nox-xdge" {>= "0.1"} 32 33 "alcotest" {with-test} 33 34 "eio_main" {with-test} 34 35 "odoc" {with-doc}
+3 -1
lib/dune
··· 11 11 logs 12 12 oauth 13 13 requests 14 + unix 14 15 uri 15 - nox-x509)) 16 + nox-x509 17 + nox-xdge))
+66
lib/gauth.ml
··· 476 476 ?expires_at:s.expires_at () 477 477 in 478 478 Ok (Oauth_token tok) 479 + 480 + (* -- Local on-disk credential store ------------------------------- *) 481 + 482 + module Local_store = struct 483 + let app_name = "google" 484 + let context fs = Xdge.v (fs :> Eio.Fs.dir_ty Eio.Path.t) app_name 485 + let config_dir fs = Xdge.config_dir (context fs) 486 + let client_path fs = Eio.Path.(config_dir fs / "client.json") 487 + let token_path fs = Eio.Path.(config_dir fs / "token.json") 488 + 489 + type client = { client_id : string; client_secret : string } 490 + 491 + let client_jsont = 492 + let open Json.Codec in 493 + Object.map ~kind:"google_client" (fun client_id client_secret -> 494 + { client_id; client_secret }) 495 + |> Object.member "client_id" string ~enc:(fun c -> c.client_id) 496 + |> Object.member "client_secret" string ~enc:(fun c -> c.client_secret) 497 + |> Object.skip_unknown |> Object.seal 498 + 499 + (* Atomic write: tmp file with O_CREAT|O_EXCL at 0600, then rename over 500 + the target. The new content is never observable at a wider mode and a 501 + crash before [rename] leaves the original target untouched. *) 502 + let save_file path data = 503 + let dir, base = path in 504 + let tmp = (dir, Fmt.str "%s.tmp.%d" base (Unix.getpid ())) in 505 + (try Eio.Path.unlink tmp with Eio.Io _ -> ()); 506 + Eio.Path.save ~create:(`Exclusive 0o600) tmp data; 507 + Eio.Path.rename tmp path 508 + 509 + let load_file path = 510 + if Eio.Path.is_file path then Some (Eio.Path.load path) else None 511 + 512 + let save_client fs c = 513 + save_file (client_path fs) (Json.to_string client_jsont c) 514 + 515 + let load_client fs = 516 + match load_file (client_path fs) with 517 + | None -> None 518 + | Some body -> ( 519 + match Json.of_string client_jsont body with 520 + | Ok c -> Some c 521 + | Error _ -> None) 522 + 523 + let save_token fs data = save_file (token_path fs) data 524 + let load_token fs = load_file (token_path fs) 525 + 526 + let clear_token fs = 527 + let path = token_path fs in 528 + if Eio.Path.is_file path then try Eio.Path.unlink path with Eio.Io _ -> () 529 + 530 + let persist fs token = save_token fs (to_json token) 531 + 532 + let acquire http ~clock ~fs ~cli_name = 533 + match load_client fs with 534 + | None -> 535 + Error 536 + (`Msg 537 + (Fmt.str "no client credentials. Run `%s install` first." cli_name)) 538 + | Some { client_id; client_secret } -> ( 539 + match load_token fs with 540 + | None -> 541 + Error 542 + (`Msg (Fmt.str "not logged in. Run `%s login` first." cli_name)) 543 + | Some json -> of_json http ~clock ~client_id ~client_secret json) 544 + end
+54
lib/gauth.mli
··· 126 126 (** [of_json http ~clock ~client_id ~client_secret s] restores a token from JSON 127 127 produced by {!to_json}. [client_id] and [client_secret] are required so the 128 128 token can refresh itself. *) 129 + 130 + (** {1 On-disk credential store} 131 + 132 + Shared XDG-aware persistence for CLI tools that wrap a Google API. All 133 + Google CLI tools in this stack ([gdocs], [gsheets], [gslides]) point at one 134 + config directory ([$XDG_CONFIG_HOME/google/]) so a user runs [install] and 135 + [login] once and every tool that recognizes the union of granted scopes can 136 + read the resulting token. 137 + 138 + File layout: 139 + - [client.json] -- OAuth client ID/secret (shared across all tools). 140 + - [token.json] -- per-user OAuth access/refresh token covering the scopes 141 + granted at last login. 142 + 143 + Files are written atomically (sibling tmp file at mode [0o600], then 144 + [rename] over the target) so credentials are never observable at a wider 145 + mode and a crash mid-write leaves the previous content untouched. *) 146 + 147 + module Local_store : sig 148 + type client = { client_id : string; client_secret : string } 149 + (** OAuth client credentials. *) 150 + 151 + val app_name : string 152 + (** [app_name] is the XDG application name used for the shared config 153 + directory. Currently ["google"]. *) 154 + 155 + val config_dir : _ Eio.Path.t -> Eio.Fs.dir_ty Eio.Path.t 156 + val client_path : _ Eio.Path.t -> Eio.Fs.dir_ty Eio.Path.t 157 + val token_path : _ Eio.Path.t -> Eio.Fs.dir_ty Eio.Path.t 158 + val save_client : _ Eio.Path.t -> client -> unit 159 + val load_client : _ Eio.Path.t -> client option 160 + val save_token : _ Eio.Path.t -> string -> unit 161 + val load_token : _ Eio.Path.t -> string option 162 + 163 + val clear_token : _ Eio.Path.t -> unit 164 + (** [clear_token fs] removes the saved token file if it exists. Used when 165 + reinstalling the OAuth client, since tokens issued against the old client 166 + cannot be refreshed against the new one. *) 167 + 168 + val acquire : 169 + Requests.t -> 170 + clock:_ Eio.Time.clock -> 171 + fs:_ Eio.Path.t -> 172 + cli_name:string -> 173 + (token, [ `Msg of string ]) result 174 + (** [acquire http ~clock ~fs ~cli_name] loads the saved client and token and 175 + returns a refreshable {!token}. [cli_name] is used in error messages 176 + ([Run `<cli_name> install` first.]) so callers can surface the right 177 + command. *) 178 + 179 + val persist : _ Eio.Path.t -> token -> unit 180 + (** [persist fs token] serializes [token]'s current state to disk. Call at end 181 + of a CLI run so any in-memory refresh survives. *) 182 + end