• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

source-academy / backend / ad975fd01013a530bdf20eb6afc4fb564b4c7a99

01 Aug 2026 04:43AM UTC coverage: 87.459% (+0.01%) from 87.445%
ad975fd01013a530bdf20eb6afc4fb564b4c7a99

push

github

web-flow
Upgrade most dependencies and modernize codebase (#1363)

* Initial implementation for upgrading most dependencies

* Fix format

* Fix credo raw value pipe chain

* Fix more credo issues

* Fix dialyzer issues

* Fix tests

* Fix bugs as identified by Codex

* Fix credo issues

* Fix invalid migration

* Fix dialyzer

* Read CORS origins from config at compile time

Remove the dead `Code.ensure_loaded?(__MODULE__)` guard, which always
evaluated to false during the endpoint's own compilation and forced
`origins` to "*". Read the config directly with compile_env.

Addresses PR review comments.

* Use :day/:hour units in DateTime.add calls

DateTime.add/4 supports :day and :hour units directly (Elixir >= 1.14),
so drop the manual second multiplications for readability.

Addresses PR review comments.

* Remove unused variables and redundant computation

- teams.ex: drop the unused unique-id count (pure computation, no effect)
- ai_comments_helpers.ex: return the encrypted string directly
- generate_ai_comments.ex: drop the unused api-key parameter and its caller arg

Addresses PR review comments.

* Report failed autograder jobs as failures to Oban

handle_failure/4 returned :ok, which made Oban mark the job as completed
even though the Lambda invocation failed. Return {:error, message} so the
job is recorded as discarded (max_attempts: 1) and stays visible in Oban
telemetry. The failed result is still enqueued beforehand, so the answer
is updated as before.

Addresses PR review comments.

* Verify course ownership for contest score/XP endpoints

calculate_contest_score/2 and dispatch_contest_xp/2 looked up the voting
question by assessment id only, ignoring the course id in the path. A staff
member of one course could therefore trigger score calculation or XP
dispatch on another course's assessment. Guard both with the existing
is_same_course/2 check (returning 403 on mismatch), matching delete/2, and
extract the shared lookup into a helper.

Addresses ... (continued)

81 of 103 new or added lines in 17 files covered. (78.64%)

5 existing lines in 4 files now uncovered.

3996 of 4569 relevant lines covered (87.46%)

6891.59 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

72.29
/lib/cadet_web/controllers/auth_controller.ex
1
defmodule CadetWeb.AuthController do
2
  @moduledoc """
3
  Handles user login and authentication.
4
  """
5
  use CadetWeb, :controller
6
  use PhoenixSwagger
7
  require Logger
8

9
  alias Cadet.{Accounts, Accounts.User}
10
  alias Cadet.Auth.{Guardian, Provider}
11
  alias Cadet.TokenExchange
12

13
  @doc """
14
  Receives a /login request with valid attributes.
15

16
  If the user is already registered in our database, simply return `Tokens`. If
17
  the user has not been registered before, register the user, then return the
18
  `Tokens`.
19
  """
20
  def create(
21
        conn,
22
        params = %{
23
          "code" => code,
24
          "provider" => provider
25
        }
26
      ) do
27
    client_id = Map.get(params, "client_id")
5✔
28
    redirect_uri = Map.get(params, "redirect_uri")
5✔
29

30
    Logger.info(
5✔
31
      "Starting login process for provider '#{provider}' with client ID '#{client_id}'."
×
32
    )
33

34
    case create_user_and_tokens(%{
5✔
35
           conn: conn,
36
           provider_instance: provider,
37
           code: code,
38
           client_id: client_id,
39
           redirect_uri: redirect_uri
40
         }) do
41
      {:ok, tokens} ->
42
        Logger.info("Login successful for provider '#{provider}'. Tokens generated.")
1✔
43
        render(conn, "token.json", tokens)
1✔
44

45
      conn ->
46
        Logger.error("Login failed for provider '#{provider}'.")
4✔
47
        conn
4✔
48
    end
49
  end
50

51
  def create(conn, _params) do
52
    Logger.error("Login request failed due to missing parameters.")
1✔
53
    send_resp(conn, :bad_request, "Missing parameter")
1✔
54
  end
55

56
  @doc """
57
  Callback URL which processes a SAML redirect from the Assertion Consumer Service (ACS).
58
  """
59
  def saml_redirect(
60
        conn,
61
        %{
62
          "provider" => provider
63
        }
64
      ) do
65
    Logger.info("Processing SAML redirect for provider '#{provider}'.")
4✔
66

67
    case create_user_and_tokens(%{
4✔
68
           conn: conn,
69
           provider_instance: provider,
70
           code: nil,
71
           client_id: nil,
72
           redirect_uri: nil
73
         }) do
74
      {:ok, tokens} ->
75
        {_provider, %{client_redirect_url: client_redirect_url}} =
1✔
76
          Application.get_env(:cadet, :identity_providers, %{})[provider]
77

78
        encoded_tokens = tokens |> Jason.encode!()
1✔
79

80
        Logger.info("SAML redirect successful for provider '#{provider}'. Redirecting to client.")
1✔
81

82
        conn
83
        |> put_resp_cookie("jwts", encoded_tokens,
84
          domain: URI.new!(client_redirect_url).host,
1✔
85
          http_only: false
86
        )
87
        |> put_resp_header("location", URI.encode(client_redirect_url))
88
        |> send_resp(302, "")
89
        |> halt()
1✔
90

91
      conn ->
92
        Logger.error("SAML redirect failed for provider '#{provider}'.")
3✔
93
        conn
3✔
94
    end
95
  end
96

97
  def saml_redirect(conn, _params) do
98
    Logger.error("SAML redirect request failed due to missing parameters.")
1✔
99
    send_resp(conn, :bad_request, "Missing parameter")
1✔
100
  end
101

102
  @doc """
103
  Exchanges a short-lived code for access and refresh tokens.
104
  """
105
  def exchange(
106
        conn,
107
        %{
108
          "code" => code,
109
          "provider" => provider
110
        }
111
      ) do
112
    Logger.info("Exchanging code for tokens for provider '#{provider}'.")
×
113

114
    case TokenExchange.get_by_code(code) do
×
115
      {:error, _message} ->
116
        Logger.error("Code exchange failed. Invalid code provided.")
×
117

118
        conn
119
        |> put_status(:forbidden)
120
        |> text("Invalid code")
×
121

122
      {:ok, struct} ->
123
        tokens = generate_tokens(struct.user)
×
124

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

128
        Logger.info("Code exchange successful for provider '#{provider}'. Redirecting to client.")
×
129

130
        conn
131
        |> put_resp_header(
132
          "location",
133
          URI.encode(
134
            client_post_exchange_redirect_url <>
135
              "?access_token=" <> tokens.access_token <> "&refresh_token=" <> tokens.refresh_token
×
136
          )
137
        )
138
        |> send_resp(302, "")
139
        |> halt()
×
140
    end
141
  end
142

143
  @doc """
144
  Alternate callback URL which redirect to VSCode via deeplinking.
145
  """
146
  def saml_redirect_vscode(
147
        conn,
148
        %{
149
          "provider" => provider
150
        }
151
      ) do
152
    Logger.info("Processing SAML redirect for VSCode with provider '#{provider}'.")
×
153

154
    code_ttl = 60
×
155

156
    case create_user(%{
×
157
           conn: conn,
158
           provider_instance: provider,
159
           code: nil,
160
           client_id: nil,
161
           redirect_uri: nil
162
         }) do
163
      {:ok, user} ->
164
        code = generate_code()
×
165

166
        TokenExchange.insert(%{
×
167
          code: code,
168
          generated_at: DateTime.utc_now(),
169
          expires_at: DateTime.add(DateTime.utc_now(), code_ttl, :second),
UNCOV
170
          user_id: user.id
×
171
        })
172

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

176
        Logger.info("SAML redirect for VSCode successful. Redirecting with generated code.")
×
177

178
        conn
179
        |> put_resp_header(
180
          "location",
181
          vscode_redirect_url_prefix <> "?provider=" <> provider <> "&code=" <> code
182
        )
183
        |> send_resp(302, "")
184
        |> halt()
×
185

186
      conn ->
187
        Logger.error("SAML redirect for VSCode failed for provider '#{provider}'.")
×
188
        conn
×
189
    end
190
  end
191

192
  @spec create_user(Provider.authorise_params()) :: {:ok, User.t()} | Plug.Conn.t()
193
  defp create_user(
194
         params = %{
195
           conn: conn,
196
           provider_instance: provider
197
         }
198
       ) do
199
    with {:authorise, {:ok, %{token: token, username: username}}} <-
9✔
200
           {:authorise, Provider.authorise(params)},
201
         {:signin, {:ok, user}} <- {:signin, Accounts.sign_in(username, token, provider)} do
3✔
202
      {:ok, user}
203
    else
204
      {:authorise, {:error, :upstream, reason}} ->
205
        conn
206
        |> put_status(:bad_request)
207
        |> text("Unable to retrieve token from authentication provider: #{reason}")
1✔
208

209
      {:authorise, {:error, :invalid_credentials, reason}} ->
210
        conn
211
        |> put_status(:bad_request)
212
        |> text("Unable to validate token: #{reason}")
4✔
213

214
      {:authorise, {:error, _, reason}} ->
215
        conn
216
        |> put_status(:internal_server_error)
217
        |> text("Unknown error: #{reason}")
1✔
218

219
      {:signin, {:error, status, reason}} ->
220
        # status can be :bad_request or :internal_server_error
221
        conn
222
        |> put_status(status)
223
        |> text("Unable to retrieve user: #{reason}")
1✔
224
    end
225
  end
226

227
  @spec create_user_and_tokens(Provider.authorise_params()) ::
228
          {:ok, %{access_token: String.t(), refresh_token: String.t()}} | Plug.Conn.t()
229
  defp create_user_and_tokens(params) do
230
    case create_user(params) do
9✔
231
      {:ok, user} ->
2✔
232
        {:ok, generate_tokens(user)}
233

234
      conn ->
235
        conn
7✔
236
    end
237
  end
238

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

242
  Exchanges the refresh_token with a new access_token.
243
  """
244
  def refresh(conn, %{"refresh_token" => refresh_token}) do
245
    Logger.info("Attempting to refresh tokens for the provided refresh token.")
4✔
246

247
    # TODO: Refactor to use refresh after guardian_db > v1.1.0 is released.
248
    case Guardian.resource_from_token(refresh_token) do
4✔
249
      {:ok, user, %{"typ" => "refresh"}} ->
250
        Logger.info("Successfully refreshed tokens for user with ID #{user.id}.")
1✔
251
        render(conn, "token.json", generate_tokens(user))
1✔
252

253
      _ ->
254
        Logger.error("Invalid refresh token provided.")
3✔
255
        send_resp(conn, :unauthorized, "Invalid refresh token")
3✔
256
    end
257
  end
258

259
  def refresh(conn, _params) do
260
    Logger.error("Refresh request failed due to missing parameters.")
1✔
261
    send_resp(conn, :bad_request, "Missing parameter")
1✔
262
  end
263

264
  @doc """
265
  Receives a /logout request with valid attribute.
266
  """
267
  def logout(conn, %{"refresh_token" => refresh_token}) do
268
    Logger.info("Attempting to log out using the provided refresh token.")
2✔
269

270
    case Guardian.decode_and_verify(refresh_token) do
2✔
271
      {:ok, _} ->
272
        Guardian.revoke(refresh_token)
1✔
273
        Logger.info("Successfully logged out and invalidated the refresh token.")
1✔
274
        text(conn, "OK")
1✔
275

276
      {:error, _} ->
277
        Logger.error("Invalid token provided for logout.")
1✔
278
        send_resp(conn, :unauthorized, "Invalid token")
1✔
279
    end
280
  end
281

282
  def logout(conn, _params) do
283
    Logger.error("Logout request failed due to missing parameters.")
1✔
284
    send_resp(conn, :bad_request, "Missing parameter")
1✔
285
  end
286

287
  @spec generate_tokens(User.t()) :: %{access_token: String.t(), refresh_token: String.t()}
288
  defp generate_tokens(user) do
289
    {:ok, access_token, _} =
3✔
290
      Guardian.encode_and_sign(user, %{}, token_type: "access", ttl: {1, :hour})
291

292
    {:ok, refresh_token, _} =
3✔
293
      Guardian.encode_and_sign(user, %{}, token_type: "refresh", ttl: {1, :week})
294

295
    %{access_token: access_token, refresh_token: refresh_token}
3✔
296
  end
297

298
  @spec generate_code :: String.t()
299
  defp generate_code do
300
    16
301
    |> :crypto.strong_rand_bytes()
302
    |> Base.url_encode64(padding: false)
303
    |> String.slice(0, 22)
×
304
  end
305

306
  swagger_path :create do
1✔
307
    post("/auth/login")
308

309
    summary("Obtain access and refresh tokens to authenticate user")
310

311
    description(
312
      "Get a set of access and refresh tokens, using the authentication code " <>
313
        "from the OAuth2 provider. When accessing resources, pass the access " <>
314
        "token in the Authorization HTTP header using the Bearer schema: " <>
315
        "`Authorization: Bearer <token>`."
316
    )
317

318
    consumes("application/json")
319
    produces("application/json")
320

321
    parameters do
322
      code(:query, :string, "OAuth2 code", required: true)
323
      provider(:query, :string, "OAuth2 provider ID", required: true)
324
      client_id(:query, :string, "OAuth2 client ID", required: false)
325
      redirect_uri(:query, :string, "OAuth2 redirect URI", required: false)
326
    end
327

328
    response(200, "OK", Schema.ref(:Tokens))
329
    response(400, "Missing or invalid parameters or credentials, or upstream error")
330
    response(500, "Internal server error")
331
  end
332

333
  swagger_path :refresh do
1✔
334
    post("/auth/refresh")
335
    summary("Obtain a new access token using a refresh token")
336
    consumes("application/json")
337
    produces("application/json")
338

339
    parameters do
340
      refresh_token(
341
        :body,
342
        Schema.ref(:RefreshToken),
343
        "Refresh token obtained from /auth/login",
344
        required: true
345
      )
346
    end
347

348
    response(200, "OK", Schema.ref(:Tokens))
349
    response(400, "Missing parameter(s)")
350
    response(401, "Invalid refresh token")
351
  end
352

353
  swagger_path :logout do
1✔
354
    post("/auth/logout")
355
    summary("Logout and invalidate the tokens")
356
    consumes("application/json")
357

358
    parameters do
359
      tokens(:body, Schema.ref(:RefreshToken), "Refresh token to be invalidated", required: true)
360
    end
361

362
    response(200, "OK")
363
    response(400, "Missing parameter(s)")
364
    response(401, "Invalid token")
365
  end
366

367
  swagger_path :saml_redirect do
×
368
    get("/auth/saml_redirect")
369

370
    summary(
371
      "SAML redirect endpoint after Assertion Consumer Service validation. Generates JWT tokens before redirecting again to the frontend."
372
    )
373

374
    response(302, "Found")
375
  end
376

377
  def swagger_definitions do
378
    %{
1✔
379
      Tokens:
380
        swagger_schema do
1✔
381
          title("Tokens")
382

383
          properties do
1✔
384
            access_token(:string, "Access token with TTL of 1 hour", required: true)
385
            refresh_token(:string, "Refresh token with TTL of 1 week", required: true)
1✔
386
          end
387
        end,
388
      RefreshToken:
389
        swagger_schema do
1✔
390
          title("Refresh Token")
391

392
          properties do
1✔
393
            refresh_token(:string, "Refresh token", required: true)
1✔
394
          end
395
        end
396
    }
397
  end
398
end
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc