Skip to content

Commit

Permalink
Add GitHub user information
Browse files Browse the repository at this point in the history
Add bypass for GitHub
  • Loading branch information
joshsmith committed Jun 15, 2017
1 parent 7b776ea commit 2b10312
Show file tree
Hide file tree
Showing 29 changed files with 582 additions and 183 deletions.
5 changes: 3 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ export CLOUDEX_API_KEY=
export CLOUDEX_CLOUD_NAME=
export CLOUDEX_SECRET=
export CLOUDFRONT_DOMAIN=
export GITHUB_OAUTH_CLIENT_ID=
export GITHUB_OAUTH_CLIENT_SECRET=
export GITHUB_APP_CLIENT_ID=
export GITHUB_APP_CLIENT_SECRET=
export GITHUB_APP_PEM=
export POSTMARK_API_KEY=
export S3_BUCKET=
export SEGMENT_WRITE_KEY=
Expand Down
8 changes: 8 additions & 0 deletions config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,14 @@ config :code_corps, :icon_color_generator, CodeCorps.RandomIconColor.Generator
# Set Corsica logging to output a console warning when rejecting a request
config :code_corps, :corsica_log_level, [rejected: :warn]

{:ok, pem} = System.get_env("GITHUB_APP_PEM") |> Base.decode64()

config :code_corps,
github_app_id: System.get_env("GITHUB_APP_ID"),
github_app_client_id: System.get_env("GITHUB_APP_CLIENT_ID"),
github_app_client_secret: System.get_env("GITHUB_APP_CLIENT_SECRET"),
github_app_pem: pem

config :stripity_stripe,
api_key: System.get_env("STRIPE_SECRET_KEY"),
connect_client_id: System.get_env("STRIPE_PLATFORM_CLIENT_ID")
Expand Down
6 changes: 0 additions & 6 deletions config/dev.exs
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,6 @@ config :guardian, Guardian,

config :code_corps, :analytics, CodeCorps.Analytics.InMemoryAPI

config :code_corps, :github_api, CodeCorps.GitHub.API

# Configures stripe for dev mode
config :code_corps, :stripe, Stripe
config :code_corps, :stripe_env, :dev
Expand All @@ -73,7 +71,3 @@ if System.get_env("CLOUDEX_API_KEY") == nil do
config :code_corps, :cloudex, CloudexTest
config :cloudex, api_key: "test_key", secret: "test_secret", cloud_name: "test_cloud_name"
end

config :code_corps,
github_oauth_client_id: System.get_env("GITHUB_OAUTH_CLIENT_ID"),
github_oauth_client_secret: System.get_env("GITHUB_OAUTH_CLIENT_SECRET")
4 changes: 0 additions & 4 deletions config/prod.exs
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,6 @@ config :code_corps,
postmark_project_acceptance_template: "1447041",
postmark_receipt_template: "1255222"

config :code_corps,
github_oauth_client_id: System.get_env("GITHUB_OAUTH_CLIENT_ID"),
github_oauth_client_secret: System.get_env("GITHUB_OAUTH_CLIENT_SECRET")

# ## SSL Support
#
# To get SSL working, you will need to add the `https` key
Expand Down
4 changes: 0 additions & 4 deletions config/remote-development.exs
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,6 @@ config :code_corps,
postmark_project_acceptance_template: "123",
postmark_receipt_template: "123"

config :code_corps,
github_oauth_client_id: System.get_env("GITHUB_OAUTH_CLIENT_ID"),
github_oauth_client_secret: System.get_env("GITHUB_OAUTH_CLIENT_SECRET")

# ## SSL Support
#
# To get SSL working, you will need to add the `https` key
Expand Down
4 changes: 0 additions & 4 deletions config/staging.exs
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,6 @@ config :code_corps,
postmark_project_acceptance_template: "1447022",
postmark_receipt_template: "1252361"

config :code_corps,
github_oauth_client_id: System.get_env("GITHUB_OAUTH_CLIENT_ID"),
github_oauth_client_secret: System.get_env("GITHUB_OAUTH_CLIENT_SECRET")

# ## SSL Support
#
# To get SSL working, you will need to add the `https` key
Expand Down
2 changes: 0 additions & 2 deletions config/test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,6 @@ config :guardian, Guardian,

config :code_corps, :analytics, CodeCorps.Analytics.TestAPI

config :code_corps, :github_api, CodeCorps.GitHub.TestAPI

# Configures stripe for test mode
config :code_corps, :stripe, CodeCorps.StripeTesting
config :code_corps, :stripe_env, :test
Expand Down
271 changes: 246 additions & 25 deletions lib/code_corps/github.ex
Original file line number Diff line number Diff line change
@@ -1,41 +1,262 @@
defmodule CodeCorps.GitHub do
alias CodeCorps.{User, Repo}

@api Application.get_env(:code_corps, :github_api)
@app_id Application.get_env(:code_corps, :github_app_id)
@client_id Application.get_env(:code_corps, :github_app_client_id)
@client_secret Application.get_env(:code_corps, :github_app_client_secret)

@base_access_token_params %{
client_id: @client_id,
client_secret: @client_secret
}

defmodule APIErrorObject do
@moduledoc """
Represents an error object from the GitHub API.
Used in some `APIError`s when the API's JSON response contains an
`errors` key.
The full details of error objects can be found in the
[GitHub API documentation](https://developer.github.com/v3/#client-errors).
"""

defstruct [:code, :field, :resource]

def new(opts) do
struct(__MODULE__, opts)
end
end

defmodule APIError do
@moduledoc """
Represents a client error from the GitHub API.
You can read more about client errors in the
[GitHub API documentation](https://developer.github.com/v3/#client-errors).
"""

defstruct [:documentation_url, :errors, :message, :status_code]

@type t :: %__MODULE__{
documentation_url: String.t | nil,
errors: List.t | nil,
message: String.t | nil,
status_code: pos_integer | nil
}

@spec new({integer, map}) :: t
def new({status_code, %{"message" => message, "errors" => errors}}) do
errors = Enum.into(errors, [], fn error -> convert_error(error) end)

%__MODULE__{
errors: errors,
message: message,
status_code: status_code
}
end

@spec new({integer, map}) :: t
def new({status_code, %{"message" => message, "documentation_url" => documentation_url}}) do
%__MODULE__{
documentation_url: documentation_url,
message: message,
status_code: status_code
}
end

@spec new({integer, map}) :: t
def new({status_code, %{"message" => message}}) do
%__MODULE__{
message: message,
status_code: status_code
}
end

defp convert_error(%{"code" => code, "field" => field, "resource" => resource}) do
APIErrorObject.new([code: code, field: field, resource: resource])
end
end

defmodule HTTPClientError do
defstruct [:reason, message: """
The GitHub HTTP client encountered an error while communicating with
the GitHub API.
"""]

def new(opts) do
struct(__MODULE__, opts)
end
end

@type method :: :get | :post | :put | :delete | :patch
@type headers :: %{String.t => String.t} | %{}
@type body :: {:multipart, list} | map
@typep http_success :: {:ok, integer, [{String.t, String.t}], String.t}
@typep http_failure :: {:error, term}

@type api_error_struct ::
%APIError{} |
%HTTPClientError{}

@spec get_base_url() :: String.t
defp get_base_url() do
Application.get_env(:code_corps, :github_base_url) || "https://api.github.com/"
end

#
@spec get_token_url() :: String.t
defp get_token_url() do
Application.get_env(:code_corps, :github_oauth_url) || "https://github.com/login/oauth/access_token"
end

@spec use_pool?() :: boolean
defp use_pool?() do
Application.get_env(:code_corps, :github_api_use_connection_pool)
end

@spec add_default_headers(headers) :: headers
defp add_default_headers(existing_headers) do
Map.merge(%{"Accept" => "application/vnd.github.machine-man-preview+json"}, existing_headers)
end

@spec add_access_token_header(headers, String.t | nil) :: headers
defp add_access_token_header(existing_headers, nil), do: existing_headers
defp add_access_token_header(existing_headers, access_token) do
Map.put(existing_headers, "Authorization", "token #{access_token}")
end

@doc """
POSTs `code` to GitHub to receive an OAuth token, then associates the user
with that OAuth token.
Generates a JWT from the GitHub App's generated RSA private key using the
RS256 algo, where the issuer is the GitHub App's ID.
Accepts a third parameter – a custom API module – for the purposes of
explicit dependency injection during testing.
Used to exchange the JWT for an access token for a given integration, or
for the GitHub App itself.
Returns one of the following:
Expires in 10 minutes.
"""
def generate_jwt do
signer = rsa_key() |> Joken.rs256()

%{}
|> Joken.token
|> Joken.with_exp(Timex.now |> Timex.shift(minutes: 10) |> Timex.to_unix)
|> Joken.with_iss(@app_id |> String.to_integer())
|> Joken.with_iat(Timex.now |> Timex.to_unix)
|> Joken.with_signer(signer)
|> Joken.sign
|> Joken.get_compact
end

defp rsa_key do
Application.get_env(:code_corps, :github_app_pem)
|> JOSE.JWK.from_pem()
end

- `{:ok, %CodeCorps.User{}}`
- `{:error, %Ecto.Changeset{}}`
- `{:error, "some_github_error"}`
@spec add_jwt_header(headers) :: headers
defp add_jwt_header(existing_headers) do
jwt = generate_jwt()
Map.put(existing_headers, "Authorization", "Bearer #{jwt}")
end

@spec add_default_options(list) :: list
defp add_default_options(opts) do
[:with_body | opts]
end

@doc """
A low level utility function to make a direct request to the GitHub API.
"""
@spec connect(User.t, String.t, module) :: {:ok, User.t} | {:error, String.t}
def connect(%User{} = user, code, api \\ @api) do
case code |> api.connect do
{:ok, github_auth_token} -> user |> associate(%{github_auth_token: github_auth_token})
{:error, error} -> {:error, error}
end
@spec request(body, method, String.t, headers, list) :: {:ok, map} | {:error, api_error_struct}
def request(body, method, endpoint, headers, opts) do
{access_token, opts} = Keyword.pop(opts, :access_token)

base_url = get_base_url()
req_url = base_url <> endpoint
req_body = body |> Poison.encode!
req_headers =
headers
|> add_default_headers()
|> add_access_token_header(access_token)
|> Map.to_list()

req_opts =
opts
|> add_default_options()

method
|> :hackney.request(req_url, req_headers, req_body, req_opts)
|> handle_response()
end

@spec build_access_token_params(String.t, String.t) :: map
defp build_access_token_params(code, state) do
@base_access_token_params
|> Map.put(:code, code)
|> Map.put(:state, state)
end

@doc """
Associates user with the GitHub OAuth token.
A low level utility function to fetch a GitHub user's OAuth access token
"""
@spec user_access_token_request(String.t, String.t) :: {:ok, map} | {:error, api_error_struct}
def user_access_token_request(code, state) do
req_url = get_token_url()
req_body = code |> build_access_token_params(state) |> Poison.encode!
req_headers =
%{"Accept" => "application/json", "Content-Type" => "application/json"}
|> add_default_headers()
|> Map.to_list()

Returns one of the following:
req_opts =
[]
|> add_default_options()

- {:ok, %CodeCorps.User{}}
- {:error, %Ecto.Changeset{}}
:hackney.request(:post, req_url, req_headers, req_body, req_opts)
|> handle_response()
end

@doc """
A low level utility function to make an authenticated request to the
GitHub API on behalf of a GitHub App or integration
"""
@spec associate(User.t, map) :: {:ok, User.t} | {:error, Ecto.Changeset.t}
def associate(user, params) do
user
|> User.github_association_changeset(params)
|> Repo.update()
@spec authenticated_integration_request(body, method, String.t, headers, list) :: {:ok, map} | {:error, api_error_struct}
def authenticated_integration_request(body, method, endpoint, headers, opts) do
base_url = get_base_url()
req_url = base_url <> endpoint
req_body = body |> Poison.encode!
req_headers =
headers
|> add_default_headers()
|> add_jwt_header()
|> Map.to_list()

req_opts =
opts
|> add_default_options()

:hackney.request(method, req_url, req_headers, req_body, req_opts)
|> handle_response()
end

@spec handle_response(http_success | http_failure) :: {:ok, map} | {:error, api_error_struct}
defp handle_response({:ok, status, _headers, body}) when status in 200..299 do
decoded_body = body |> Poison.decode!
{:ok, decoded_body}
end

defp handle_response({:ok, 404, _headers, body}) do
error = APIError.new({404, %{"message" => body}})
{:error, error}
end

defp handle_response({:ok, status, _headers, body}) when status in 400..599 do
json = body |> Poison.decode!
error = APIError.new({status, json})
{:error, error}
end

defp handle_response({:error, reason}) do
error = HTTPClientError.new(reason: reason)
{:error, error}
end
end
Loading

0 comments on commit 2b10312

Please sign in to comment.