Skip to content

feat: token exchange as part of VSC login #1240

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ config :cadet, Cadet.Jobs.Scheduler,
# Compute rolling leaderboard every 2 hours
{"0 */2 * * *", {Cadet.Assessments, :update_rolling_contest_leaderboards, []}},
# Collate contest entries that close in the previous day at 00:01
{"1 0 * * *", {Cadet.Assessments, :update_final_contest_entries, []}}
{"1 0 * * *", {Cadet.Assessments, :update_final_contest_entries, []}},
# Clean up expired exchange tokens at 00:01
{"1 0 * * *", {Cadet.TokenExchange, :delete_expired, []}}
]

# Configures the endpoint
Expand Down
2 changes: 2 additions & 0 deletions config/dev.secrets.exs.example
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ config :cadet,
# %{
# assertion_extractor: Cadet.Auth.Providers.NusstuAssertionExtractor,
# client_redirect_url: "http://cadet.frontend:8000/login/callback"
# vscode_redirect_url_prefix: "vscode://source-academy.source-academy/sso",
# client_post_exchange_redirect_url: "http://cadet.frontend:8000/login/vscode_callback",
# }},

"test" =>
Expand Down
60 changes: 60 additions & 0 deletions lib/cadet/code_exchange.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
defmodule Cadet.TokenExchange do
@moduledoc """
The TokenExchange entity stores short-lived codes to be exchanged for long-lived auth tokens.
"""
use Cadet, :model

import Ecto.Query

alias Cadet.Repo
alias Cadet.Accounts.User

@primary_key {:code, :string, []}
schema "token_exchange" do
field(:generated_at, :utc_datetime_usec)
field(:expires_at, :utc_datetime_usec)

belongs_to(:user, User)

timestamps()
end

@required_fields ~w(code generated_at expires_at user_id)a

def get_by_code(code) do
case Repo.get_by(__MODULE__, code: code) do
nil ->
{:error, "Not found"}

struct ->
if Timex.before?(struct.expires_at, Timex.now()) do
{:error, "Expired"}
else
struct = Repo.preload(struct, :user)
Repo.delete(struct)
{:ok, struct}
end
end
end

def delete_expired do
now = Timex.now()

Repo.delete_all(from(c in __MODULE__, where: c.expires_at < ^now))
end

def changeset(struct, attrs) do
struct
|> cast(attrs, @required_fields)
|> validate_required(@required_fields)
end

def insert(attrs) do
changeset =
%__MODULE__{}
|> changeset(attrs)

changeset
|> Repo.insert()
end
end
110 changes: 104 additions & 6 deletions lib/cadet_web/controllers/auth_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ defmodule CadetWeb.AuthController do
use CadetWeb, :controller
use PhoenixSwagger

alias Cadet.Accounts
alias Cadet.Accounts.User
alias Cadet.{Accounts, Accounts.User}
alias Cadet.Auth.{Guardian, Provider}
alias Cadet.TokenExchange

@doc """
Receives a /login request with valid attributes.
Expand Down Expand Up @@ -85,9 +85,87 @@ defmodule CadetWeb.AuthController do
send_resp(conn, :bad_request, "Missing parameter")
end

@spec create_user_and_tokens(Provider.authorise_params()) ::
{:ok, %{access_token: String.t(), refresh_token: String.t()}} | Plug.Conn.t()
defp create_user_and_tokens(
@doc """
Exchanges a short-lived code for access and refresh tokens.
"""
def exchange(
conn,
%{
"code" => code,
"provider" => provider
}
) do
case TokenExchange.get_by_code(code) do
{:error, _message} ->
conn
|> put_status(:forbidden)
|> text("Invalid code")

{:ok, struct} ->
tokens = generate_tokens(struct.user)

{_provider, %{client_post_exchange_redirect_url: client_post_exchange_redirect_url}} =
Application.get_env(:cadet, :identity_providers, %{})[provider]

conn
|> put_resp_header(
"location",
URI.encode(
client_post_exchange_redirect_url <>
"?access_token=" <> tokens.access_token <> "&refresh_token=" <> tokens.refresh_token
)
)
|> send_resp(302, "")
|> halt()
end
end

@doc """
Alternate callback URL which redirect to VSCode via deeplinking.
"""
def saml_redirect_vscode(
conn,
%{
"provider" => provider
}
) do
code_ttl = 60

case create_user(%{
conn: conn,
provider_instance: provider,
code: nil,
client_id: nil,
redirect_uri: nil
}) do
{:ok, user} ->
code = generate_code()

TokenExchange.insert(%{
code: code,
generated_at: Timex.now(),
expires_at: Timex.add(Timex.now(), Timex.Duration.from_seconds(code_ttl)),
user_id: user.id
})

{_provider, %{vscode_redirect_url_prefix: vscode_redirect_url_prefix}} =
Application.get_env(:cadet, :identity_providers, %{})[provider]

conn
|> put_resp_header(
"location",
vscode_redirect_url_prefix <> "?provider=" <> provider <> "&code=" <> code
)
|> send_resp(302, "")
|> halt()

conn ->
conn
end
end

@spec create_user(Provider.authorise_params()) :: {:ok, User.t()} | Plug.Conn.t()
defp create_user(
params = %{
conn: conn,
provider_instance: provider
Expand All @@ -96,7 +174,7 @@ defmodule CadetWeb.AuthController do
with {:authorise, {:ok, %{token: token, username: username}}} <-
{:authorise, Provider.authorise(params)},
{:signin, {:ok, user}} <- {:signin, Accounts.sign_in(username, token, provider)} do
{:ok, generate_tokens(user)}
{:ok, user}
else
{:authorise, {:error, :upstream, reason}} ->
conn
Expand All @@ -121,6 +199,18 @@ defmodule CadetWeb.AuthController do
end
end

@spec create_user_and_tokens(Provider.authorise_params()) ::
{:ok, %{access_token: String.t(), refresh_token: String.t()}} | Plug.Conn.t()
defp create_user_and_tokens(params) do
case create_user(params) do
{:ok, user} ->
{:ok, generate_tokens(user)}

conn ->
conn
end
end

@doc """
Receives a /refresh request with valid attribute.

Expand Down Expand Up @@ -170,6 +260,14 @@ defmodule CadetWeb.AuthController do
%{access_token: access_token, refresh_token: refresh_token}
end

@spec generate_code :: String.t()
defp generate_code do
16
|> :crypto.strong_rand_bytes()
|> Base.url_encode64(padding: false)
|> String.slice(0, 22)
end

swagger_path :create do
post("/auth/login")

Expand Down
2 changes: 2 additions & 0 deletions lib/cadet_web/router.ex
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ defmodule CadetWeb.Router do
post("/auth/login", AuthController, :create)
post("/auth/logout", AuthController, :logout)
get("/auth/saml_redirect", AuthController, :saml_redirect)
get("/auth/saml_redirect_vscode", AuthController, :saml_redirect_vscode)
get("/auth/exchange", AuthController, :exchange)
end

scope "/v2", CadetWeb do
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
defmodule Cadet.Repo.Migrations.CreateTokenExchangeTable do
use Ecto.Migration

def change do
create table(:token_exchange) do
add(:code, :string, null: false)
add(:generated_at, :utc_datetime_usec, null: false)
add(:expires_at, :utc_datetime_usec, null: false)
add(:user_id, references(:users), null: false)
timestamps()
end
end
end
Loading