main
rb 115 lines 3.74 KB
Raw
1 # frozen_string_literal: true
2
3 require "net/http"
4 require "json"
5 require "securerandom"
6
7 # The GitHub OAuth app siGit uses to import repositories.
8 #
9 # This is a *separate* OAuth app from smbCloud "Continue with GitHub" sign-in.
10 # Sign-in authenticates the user to siGit (via smbCloud) and gives us no GitHub
11 # API access. Importing needs a GitHub token that can read the user's repos and
12 # clone private ones, so we run our own minimal OAuth app:
13 #
14 # 1. #authorize_url — send the user to GitHub to grant access.
15 # 2. GitHub redirects back to our callback with ?code=…&state=…
16 # 3. #exchange_code — trade the code for an access token, stored encrypted in
17 # GithubConnection.
18 #
19 # Requires:
20 # GITHUB_IMPORT_CLIENT_ID
21 # GITHUB_IMPORT_CLIENT_SECRET
22 #
23 # The token never reaches the browser; only the server holds it.
24 class GithubOauthService
25 class ConfigurationError < StandardError; end
26 class ExchangeError < StandardError; end
27
28 AUTHORIZE_URL = "https://github.com/login/oauth/authorize"
29 TOKEN_URL = "https://github.com/login/oauth/access_token"
30
31 # Full read/write to private repos vs. public repos only. We ask for the
32 # narrowest scope that covers what the user chose to import.
33 SCOPE_PRIVATE = "repo"
34 SCOPE_PUBLIC = "public_repo"
35
36 OPEN_TIMEOUT = 5
37 READ_TIMEOUT = 15
38
39 class << self
40 def configured?
41 client_id.present? && client_secret.present?
42 end
43
44 # A CSRF token to round-trip through the `state` param. Stored in the session
45 # before redirecting and compared on callback.
46 def generate_state
47 SecureRandom.urlsafe_base64(24)
48 end
49
50 # URL to send the user's browser to. +include_private+ picks the scope.
51 def authorize_url(redirect_uri:, state:, include_private: true)
52 raise ConfigurationError, "GitHub import OAuth app is not configured." unless configured?
53
54 params = {
55 client_id: client_id,
56 redirect_uri: redirect_uri,
57 scope: include_private ? SCOPE_PRIVATE : SCOPE_PUBLIC,
58 state: state,
59 allow_signup: "false"
60 }
61 "#{AUTHORIZE_URL}?#{URI.encode_www_form(params)}"
62 end
63
64 # Exchanges the OAuth +code+ for an access token. Returns a hash:
65 # { access_token:, scope:, token_type: }
66 # Raises ExchangeError on any failure (never leaks the secret or the raw
67 # GitHub error to callers/logs).
68 def exchange_code(code:, redirect_uri:)
69 raise ConfigurationError, "GitHub import OAuth app is not configured." unless configured?
70
71 uri = URI.parse(TOKEN_URL)
72 req = Net::HTTP::Post.new(uri)
73 req["Accept"] = "application/json"
74 req.set_form_data(
75 client_id: client_id,
76 client_secret: client_secret,
77 code: code,
78 redirect_uri: redirect_uri
79 )
80
81 res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true,
82 open_timeout: OPEN_TIMEOUT, read_timeout: READ_TIMEOUT) do |http|
83 http.request(req)
84 end
85
86 unless res.is_a?(Net::HTTPSuccess)
87 raise ExchangeError, "GitHub rejected the authorization (HTTP #{res.code})."
88 end
89
90 body = JSON.parse(res.body)
91 token = body["access_token"]
92 if token.blank?
93 # body["error"] is safe to surface (e.g. "bad_verification_code"); it
94 # contains no secret.
95 raise ExchangeError, "GitHub did not return an access token (#{body['error'] || 'unknown error'})."
96 end
97
98 {
99 access_token: token,
100 scope: body["scope"].to_s,
101 token_type: body["token_type"].presence || "bearer"
102 }
103 rescue JSON::ParserError
104 raise ExchangeError, "GitHub returned an unreadable token response."
105 end
106
107 def client_id
108 ENV["GITHUB_IMPORT_CLIENT_ID"].presence
109 end
110
111 def client_secret
112 ENV["GITHUB_IMPORT_CLIENT_SECRET"].presence
113 end
114 end
115 end