Skip to content

Commit f5f8890

Browse files
authored
feat: token exchange as part of VSC login (#1240)
* create auth flow for token exchange * clean up create_user_and_tokens * update example env file * schedule deletion job * rename CodeExchange to TokenExchange * fix formatting * fix credo issues * make "code" PK in token_exchange table * Rename migration file to maintain total order
1 parent dbee0dd commit f5f8890

File tree

6 files changed

+184
-7
lines changed

6 files changed

+184
-7
lines changed

config/config.exs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,9 @@ config :cadet, Cadet.Jobs.Scheduler,
2525
# Compute rolling leaderboard every 2 hours
2626
{"0 */2 * * *", {Cadet.Assessments, :update_rolling_contest_leaderboards, []}},
2727
# Collate contest entries that close in the previous day at 00:01
28-
{"1 0 * * *", {Cadet.Assessments, :update_final_contest_entries, []}}
28+
{"1 0 * * *", {Cadet.Assessments, :update_final_contest_entries, []}},
29+
# Clean up expired exchange tokens at 00:01
30+
{"1 0 * * *", {Cadet.TokenExchange, :delete_expired, []}}
2931
]
3032

3133
# Configures the endpoint

config/dev.secrets.exs.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ config :cadet,
3636
# %{
3737
# assertion_extractor: Cadet.Auth.Providers.NusstuAssertionExtractor,
3838
# client_redirect_url: "http://cadet.frontend:8000/login/callback"
39+
# vscode_redirect_url_prefix: "vscode://source-academy.source-academy/sso",
40+
# client_post_exchange_redirect_url: "http://cadet.frontend:8000/login/vscode_callback",
3941
# }},
4042

4143
"test" =>

lib/cadet/code_exchange.ex

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
defmodule Cadet.TokenExchange do
2+
@moduledoc """
3+
The TokenExchange entity stores short-lived codes to be exchanged for long-lived auth tokens.
4+
"""
5+
use Cadet, :model
6+
7+
import Ecto.Query
8+
9+
alias Cadet.Repo
10+
alias Cadet.Accounts.User
11+
12+
@primary_key {:code, :string, []}
13+
schema "token_exchange" do
14+
field(:generated_at, :utc_datetime_usec)
15+
field(:expires_at, :utc_datetime_usec)
16+
17+
belongs_to(:user, User)
18+
19+
timestamps()
20+
end
21+
22+
@required_fields ~w(code generated_at expires_at user_id)a
23+
24+
def get_by_code(code) do
25+
case Repo.get_by(__MODULE__, code: code) do
26+
nil ->
27+
{:error, "Not found"}
28+
29+
struct ->
30+
if Timex.before?(struct.expires_at, Timex.now()) do
31+
{:error, "Expired"}
32+
else
33+
struct = Repo.preload(struct, :user)
34+
Repo.delete(struct)
35+
{:ok, struct}
36+
end
37+
end
38+
end
39+
40+
def delete_expired do
41+
now = Timex.now()
42+
43+
Repo.delete_all(from(c in __MODULE__, where: c.expires_at < ^now))
44+
end
45+
46+
def changeset(struct, attrs) do
47+
struct
48+
|> cast(attrs, @required_fields)
49+
|> validate_required(@required_fields)
50+
end
51+
52+
def insert(attrs) do
53+
changeset =
54+
%__MODULE__{}
55+
|> changeset(attrs)
56+
57+
changeset
58+
|> Repo.insert()
59+
end
60+
end

lib/cadet_web/controllers/auth_controller.ex

Lines changed: 104 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ defmodule CadetWeb.AuthController do
55
use CadetWeb, :controller
66
use PhoenixSwagger
77

8-
alias Cadet.Accounts
9-
alias Cadet.Accounts.User
8+
alias Cadet.{Accounts, Accounts.User}
109
alias Cadet.Auth.{Guardian, Provider}
10+
alias Cadet.TokenExchange
1111

1212
@doc """
1313
Receives a /login request with valid attributes.
@@ -85,9 +85,87 @@ defmodule CadetWeb.AuthController do
8585
send_resp(conn, :bad_request, "Missing parameter")
8686
end
8787

88-
@spec create_user_and_tokens(Provider.authorise_params()) ::
89-
{:ok, %{access_token: String.t(), refresh_token: String.t()}} | Plug.Conn.t()
90-
defp create_user_and_tokens(
88+
@doc """
89+
Exchanges a short-lived code for access and refresh tokens.
90+
"""
91+
def exchange(
92+
conn,
93+
%{
94+
"code" => code,
95+
"provider" => provider
96+
}
97+
) do
98+
case TokenExchange.get_by_code(code) do
99+
{:error, _message} ->
100+
conn
101+
|> put_status(:forbidden)
102+
|> text("Invalid code")
103+
104+
{:ok, struct} ->
105+
tokens = generate_tokens(struct.user)
106+
107+
{_provider, %{client_post_exchange_redirect_url: client_post_exchange_redirect_url}} =
108+
Application.get_env(:cadet, :identity_providers, %{})[provider]
109+
110+
conn
111+
|> put_resp_header(
112+
"location",
113+
URI.encode(
114+
client_post_exchange_redirect_url <>
115+
"?access_token=" <> tokens.access_token <> "&refresh_token=" <> tokens.refresh_token
116+
)
117+
)
118+
|> send_resp(302, "")
119+
|> halt()
120+
end
121+
end
122+
123+
@doc """
124+
Alternate callback URL which redirect to VSCode via deeplinking.
125+
"""
126+
def saml_redirect_vscode(
127+
conn,
128+
%{
129+
"provider" => provider
130+
}
131+
) do
132+
code_ttl = 60
133+
134+
case create_user(%{
135+
conn: conn,
136+
provider_instance: provider,
137+
code: nil,
138+
client_id: nil,
139+
redirect_uri: nil
140+
}) do
141+
{:ok, user} ->
142+
code = generate_code()
143+
144+
TokenExchange.insert(%{
145+
code: code,
146+
generated_at: Timex.now(),
147+
expires_at: Timex.add(Timex.now(), Timex.Duration.from_seconds(code_ttl)),
148+
user_id: user.id
149+
})
150+
151+
{_provider, %{vscode_redirect_url_prefix: vscode_redirect_url_prefix}} =
152+
Application.get_env(:cadet, :identity_providers, %{})[provider]
153+
154+
conn
155+
|> put_resp_header(
156+
"location",
157+
vscode_redirect_url_prefix <> "?provider=" <> provider <> "&code=" <> code
158+
)
159+
|> send_resp(302, "")
160+
|> halt()
161+
162+
conn ->
163+
conn
164+
end
165+
end
166+
167+
@spec create_user(Provider.authorise_params()) :: {:ok, User.t()} | Plug.Conn.t()
168+
defp create_user(
91169
params = %{
92170
conn: conn,
93171
provider_instance: provider
@@ -96,7 +174,7 @@ defmodule CadetWeb.AuthController do
96174
with {:authorise, {:ok, %{token: token, username: username}}} <-
97175
{:authorise, Provider.authorise(params)},
98176
{:signin, {:ok, user}} <- {:signin, Accounts.sign_in(username, token, provider)} do
99-
{:ok, generate_tokens(user)}
177+
{:ok, user}
100178
else
101179
{:authorise, {:error, :upstream, reason}} ->
102180
conn
@@ -121,6 +199,18 @@ defmodule CadetWeb.AuthController do
121199
end
122200
end
123201

202+
@spec create_user_and_tokens(Provider.authorise_params()) ::
203+
{:ok, %{access_token: String.t(), refresh_token: String.t()}} | Plug.Conn.t()
204+
defp create_user_and_tokens(params) do
205+
case create_user(params) do
206+
{:ok, user} ->
207+
{:ok, generate_tokens(user)}
208+
209+
conn ->
210+
conn
211+
end
212+
end
213+
124214
@doc """
125215
Receives a /refresh request with valid attribute.
126216
@@ -170,6 +260,14 @@ defmodule CadetWeb.AuthController do
170260
%{access_token: access_token, refresh_token: refresh_token}
171261
end
172262

263+
@spec generate_code :: String.t()
264+
defp generate_code do
265+
16
266+
|> :crypto.strong_rand_bytes()
267+
|> Base.url_encode64(padding: false)
268+
|> String.slice(0, 22)
269+
end
270+
173271
swagger_path :create do
174272
post("/auth/login")
175273

lib/cadet_web/router.ex

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ defmodule CadetWeb.Router do
5151
post("/auth/login", AuthController, :create)
5252
post("/auth/logout", AuthController, :logout)
5353
get("/auth/saml_redirect", AuthController, :saml_redirect)
54+
get("/auth/saml_redirect_vscode", AuthController, :saml_redirect_vscode)
55+
get("/auth/exchange", AuthController, :exchange)
5456
end
5557

5658
scope "/v2", CadetWeb do
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
defmodule Cadet.Repo.Migrations.CreateTokenExchangeTable do
2+
use Ecto.Migration
3+
4+
def change do
5+
create table(:token_exchange) do
6+
add(:code, :string, null: false)
7+
add(:generated_at, :utc_datetime_usec, null: false)
8+
add(:expires_at, :utc_datetime_usec, null: false)
9+
add(:user_id, references(:users), null: false)
10+
timestamps()
11+
end
12+
end
13+
end

0 commit comments

Comments
 (0)