| 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 |