1
+# frozen_string_literal: true
2
+
3
+module Api
4
+ module V1
5
+ module Oauth
6
+ # "Continue with GitHub" for browser SPA clients (code.sigit.si).
7
+ #
8
+ # The web app (Oauth::GithubController) keeps the whole flow on sigit.si and
9
+ # establishes a cookie session. A client-only SPA can't do that: it has no
10
+ # server to hold the smbCloud app secret, and it needs a *token* back, not a
11
+ # cookie. So this endpoint plays the server half of the dance:
12
+ #
13
+ # 1. #start (here) — the SPA points the browser at
14
+ # GET /api/v1/auth/github?redirect_uri=<spa>/auth/github/callback.
15
+ # We inject the app secret server-side and 302 to smbCloud's authorize
16
+ # URL, carrying the SPA's callback as the redirect_uri.
17
+ # 2. smbCloud brokers GitHub, then redirects the browser straight back to
18
+ # the SPA callback with ?access_token=… (or ?error=…).
19
+ # 3. The SPA stores that access_token as its bearer — it's the very token
20
+ # Api::BaseController#authenticate_token! validates against smbCloud,
21
+ # so no extra exchange step is needed.
22
+ #
23
+ # The redirect_uri is validated against an allowlist so this can't be turned
24
+ # into an open redirector that leaks tokens to an attacker-controlled origin.
25
+ class GithubController < Api::BaseController
26
+ # GET /api/v1/auth/github?redirect_uri=…
27
+ def start
28
+ redirect_uri = allowed_redirect_uri(params[:redirect_uri])
29
+ unless redirect_uri
30
+ return render_error(ERR_INVALID, "Unsupported redirect_uri.", status: :unprocessable_entity)
31
+ end
32
+
33
+ redirect_to SmbcloudAuthService.github_authorize_url(redirect_uri: redirect_uri),
34
+ allow_other_host: true
35
+ rescue KeyError => e
36
+ Rails.logger.error("smbCloud configuration error during GitHub sign-in: #{e.message}")
37
+ render_error(ERR_UNKNOWN, "Authentication service is not configured.", status: :internal_server_error)
38
+ end
39
+
40
+ private
41
+
42
+ # Returns the requested redirect_uri only when its origin is allowlisted
43
+ # and its path is the SPA callback, otherwise nil. Keeps the brokered
44
+ # access_token from ever being redirected somewhere we don't control.
45
+ def allowed_redirect_uri(raw)
46
+ return nil if raw.blank?
47
+
48
+ uri = URI.parse(raw)
49
+ return nil unless %w[http https].include?(uri.scheme)
50
+ return nil if uri.host.blank?
51
+ return nil unless uri.path.to_s.end_with?("/auth/github/callback")
52
+
53
+ allowed_origins.include?(origin_of(uri)) ? raw : nil
54
+ rescue URI::InvalidURIError
55
+ nil
56
+ end
57
+
58
+ # scheme://host[:port], dropping the port when it's the scheme default so
59
+ # "https://code.sigit.si:443" matches an allowlisted "https://code.sigit.si".
60
+ def origin_of(uri)
61
+ default = (uri.scheme == "http" && uri.port == 80) || (uri.scheme == "https" && uri.port == 443)
62
+ default ? "#{uri.scheme}://#{uri.host}" : "#{uri.scheme}://#{uri.host}:#{uri.port}"
63
+ end
64
+
65
+ # Origins permitted to receive the brokered access_token. Configurable via
66
+ # CODE_CLOUD_ALLOWED_ORIGINS (comma-separated); defaults cover the
67
+ # production SPA and the local dev server (vite, port 5180).
68
+ def allowed_origins
69
+ ENV.fetch("CODE_CLOUD_ALLOWED_ORIGINS", "https://code.sigit.si,http://localhost:5180")
70
+ .split(",").map(&:strip).reject(&:blank?)
71
+ end
72
+ end
73
+ end
74
+ end
75
+end