Skip to content

Commit 3a1d89b

Browse files
Add support for auth with GH Apps
1 parent e0e6ccf commit 3a1d89b

File tree

9 files changed

+96
-11
lines changed

9 files changed

+96
-11
lines changed

lib/api_remote.ml

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,41 @@ open Devkit
33
open Common
44
open Util
55

6+
module Github_app = struct
7+
let jwt_token ({ client_id; pem; _ } : Config_t.app_installation_cfg) =
8+
let b64enc s = Base64.encode_string ~alphabet:Base64.uri_safe_alphabet ~pad:false s in
9+
let now = Unix.gettimeofday () in
10+
(* Issues 60 seconds in the past *)
11+
let iat = int_of_float (now -. 60.0) in
12+
(* Expires 1 minute from now *)
13+
let exp = int_of_float (now +. 60.0) in
14+
let header_json = {|{"typ":"JWT","alg":"RS256"}|} in
15+
let payload_json = sprintf {|{"iat":%d,"exp":%d,"iss":"%s"}|} iat exp client_id in
16+
let header_payload = sprintf "%s.%s" (b64enc header_json) (b64enc payload_json) in
17+
let key =
18+
match X509.Private_key.decode_pem pem with
19+
| Ok (`RSA k) -> k
20+
| Ok _ -> failwith "Expected RSA key for app installation auth"
21+
| Error (`Message e) -> failwith @@ "Failed to parse app installation private key: " ^ e
22+
| _ -> failwith "Failed to parse app installation private key"
23+
in
24+
let signature = Mirage_crypto_pk.Rsa.PKCS1.sign ~hash:`SHA256 ~key (`Message header_payload) |> b64enc in
25+
sprintf "%s.%s" header_payload signature
26+
27+
let get_installation_token (app : Config_t.app_installation_cfg) =
28+
let headers = [ "Accept: application/vnd.github.v3+json"; sprintf "Authorization: Bearer %s" (jwt_token app) ] in
29+
let url = sprintf "https://api.github.com/app/installations/%s/access_tokens" app.installation_id in
30+
let%lwt res =
31+
http_request ~headers `POST url ~body:(`Raw ("application/json", ""))
32+
|> Lwt_result.map_error (fun e -> sprintf "Error while authenticating with GitHub app: %s" e)
33+
in
34+
match res with
35+
| Ok res ->
36+
let { Github_t.token; _ } = Github_j.installation_token_response_of_string res in
37+
Lwt.return token
38+
| Error e -> failwith e
39+
end
40+
641
module Github : Api.Github = struct
742
let commits_url ~(repo : Github_t.repository) ~sha =
843
let _, url = ExtLib.String.replace ~sub:"{/sha}" ~by:("/" ^ sha) ~str:repo.commits_url in
@@ -29,7 +64,14 @@ module Github : Api.Github = struct
2964
Option.map_default (fun v -> sprintf "Authorization: token %s" v :: headers) headers token
3065

3166
let prepare_request ~secrets ~repo_url url =
32-
let token = Context.gh_token_of_secrets secrets repo_url in
67+
let%lwt token =
68+
match Context.gh_auth_of_secrets secrets repo_url with
69+
| None -> Lwt.return_none
70+
| Some (GH_token token) -> Lwt.return_some token
71+
| Some (AppInstallation gh_app) ->
72+
let%lwt token = Github_app.get_installation_token gh_app in
73+
Lwt.return_some token
74+
in
3375
let headers = build_headers ?token () in
3476
let url =
3577
match Context.gh_repo_of_secrets secrets repo_url with
@@ -40,15 +82,15 @@ module Github : Api.Github = struct
4082
let repo_config_url_scheme = repo_config.url |> Uri.of_string |> Uri.scheme in
4183
url |> Uri.of_string |> flip Uri.with_scheme repo_config_url_scheme |> Uri.to_string
4284
in
43-
headers, url
85+
Lwt.return (headers, url)
4486

4587
let get_resource ~secrets ~repo_url url =
46-
let headers, url = prepare_request ~secrets ~repo_url url in
88+
let%lwt headers, url = prepare_request ~secrets ~repo_url url in
4789
http_request ~headers `GET url
4890
|> Lwt_result.map_error (fun e -> sprintf "error while querying remote: %s\nfailed to get resource from %s" e url)
4991

5092
let post_resource ~secrets ~repo_url body url =
51-
let headers, url = prepare_request ~secrets ~repo_url url in
93+
let%lwt headers, url = prepare_request ~secrets ~repo_url url in
5294
http_request ~headers ~body:(`Raw ("application/json; charset=utf-8", body)) `POST url
5395
|> Lwt_result.map_error (sprintf "POST to %s failed : %s" url)
5496

lib/atd_adapters.ml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,3 +82,21 @@ module Strings_to_pipelines_adapter : Atdgen_runtime.Json_adapter.S = struct
8282
| `Assoc [ ("name", `String s) ] -> `String s
8383
| _ -> x
8484
end
85+
86+
(* This adapter is meant to avoid breaking changes in the config because the type for
87+
[repo_config] was changed and [gh_token] was removed and [auth] was added. *)
88+
module GH_token_to_auth_adapter : Atdgen_runtime.Json_adapter.S = struct
89+
let normalize (x : Yojson.Safe.t) =
90+
match x with
91+
| `Assoc ks ->
92+
`Assoc
93+
(List.map
94+
(fun (k, v) ->
95+
match k with
96+
| "gh_token" -> "auth", `List [ `String "GH_token"; v ]
97+
| _ -> k, v)
98+
ks)
99+
| _ -> x
100+
101+
let restore (x : Yojson.Safe.t) = x
102+
end

lib/config.atd

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,14 +68,22 @@ type webhook = {
6868
channel : any_channel; (* name of the Slack channel to post the message *)
6969
}
7070

71+
type app_installation_cfg = {
72+
installation_id: string;
73+
client_id: string;
74+
pem: string;
75+
}
76+
77+
type repo_auth = [ GH_token of string | AppInstallation of app_installation_cfg ] <ocaml repr="classic">
78+
7179
type repo_config = {
7280
(* Repository url. Fully qualified (include protocol), without trailing slash. e.g. https://github.com/ahrefs/monorobot *)
7381
url : string;
7482
(* GitHub personal access token, if repo access requires it *)
75-
?gh_token : string nullable;
83+
?auth : repo_auth nullable;
7684
(* GitHub webhook secret token to secure the webhook *)
7785
?gh_hook_secret : string nullable;
78-
}
86+
} <json adapter.ocaml="Atd_adapters.GH_token_to_auth_adapter">
7987

8088
(* This is the structure of the secrets file which stores sensitive information, and
8189
shouldn't be checked into version control. *)

lib/context.ml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,10 @@ let gh_repo_of_secrets (secrets : Config_t.secrets) repo_url =
5353
| None -> None
5454
| Some repo -> Some repo
5555

56-
let gh_token_of_secrets (secrets : Config_t.secrets) repo_url =
56+
let gh_auth_of_secrets (secrets : Config_t.secrets) repo_url =
5757
match gh_repo_of_secrets secrets repo_url with
58-
| None -> None
59-
| Some repo -> repo.gh_token
58+
| None | Some { auth = None; _ } -> None
59+
| Some { auth; _ } -> auth
6060

6161
let gh_hook_secret_token_of_secrets (secrets : Config_t.secrets) repo_url =
6262
match gh_repo_of_secrets secrets repo_url with

lib/dune

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
extlib
1313
lwt
1414
lwt.unix
15+
mirage-crypto-pk
1516
ocamldiff
1617
omd
1718
ptime
@@ -22,6 +23,7 @@
2223
sqlgg.traits
2324
sqlite3
2425
unix
26+
x509
2527
uri
2628
yojson
2729
text_cleanup)

lib/github.atd

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,3 +312,8 @@ type content_api_response = {
312312
encoding : string;
313313
content : string;
314314
}
315+
316+
type installation_token_response = {
317+
token: string;
318+
expires_at: string;
319+
}

src/dune

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
(executable
2-
(libraries monorobotlib cmdliner devkit devkit.core extlib lwt.unix uri unix)
2+
(libraries
3+
monorobotlib
4+
cmdliner
5+
devkit
6+
devkit.core
7+
extlib
8+
lwt.unix
9+
mirage-crypto-rng.unix
10+
uri
11+
unix)
312
(preprocess
413
(pps lwt_ppx))
514
(public_name monorobot))

src/monorobot.ml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,7 @@ let default, info =
200200
Term.(ret (const (`Help (`Pager, None)))), Cmd.info "monorobot" ~doc ~version:Version.current
201201

202202
let () =
203+
Mirage_crypto_rng_unix.use_default ();
203204
let cmds = [ run; check_gh; check_slack; debug_db ] in
204205
let group = Cmd.group ~default info cmds in
205206
exit @@ Cmd.eval group

test/test.ml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ let process_gh_payload ~(secrets : Config_t.secrets) ~config (kind, path, state_
3737
let%lwt n = Github.parse_exn headers event ~get_build_branch in
3838
let repo = Github.repo_of_notification n in
3939
(* overwrite repo url in secrets with that of notification for this test case *)
40-
let secrets = { secrets with repos = [ { url = repo.url; gh_token = None; gh_hook_secret = None } ] } in
40+
let secrets = { secrets with repos = [ { url = repo.url; auth = None; gh_hook_secret = None } ] } in
4141
ctx.secrets <- Some secrets;
4242
let (_ : State_t.repo_state) = State.find_or_add_repo ctx.state repo.url in
4343
let () =

0 commit comments

Comments
 (0)