Skip to content

Commit 2bb5fa9

Browse files
committed
add SSO for management UI
1 parent 1039f19 commit 2bb5fa9

File tree

13 files changed

+450
-7
lines changed

13 files changed

+450
-7
lines changed

extras/lavinmq.ini

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
[main]
22
data_dir = /var/lib/lavinmq
3-
log_level = info
3+
log_level = debug
4+
auth_backends = local,oauth
45
;pidfile = /var/run/lavinmq.pid
56
;default_user_only_loopback = true
67
;tls_cert = /etc/lavinmq/cert.pem
@@ -28,3 +29,10 @@ bind = ::
2829
;port = 5679
2930
;advertised_uri = tcp://hostname.local:5679
3031
;etcd_endpoints = localhost:2379
32+
33+
[oauth]
34+
issuer = https://test-giant-beige-hawk.rmq7.cloudamqp.com/realms/lavinmq-dev/
35+
resource_server_id = kickster-lavin
36+
audience = account
37+
preferred_username_claims = sub,preferred_username,email
38+
client_id = kickster-lavin

spec/http/oauth_spec.cr

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
require "../spec_helper"
2+
require "../../src/lavinmq/http/oauth2/pkce"
3+
4+
describe "OAuth2" do
5+
describe "OAuthController when OAuth is NOT configured" do
6+
it "GET /oauth/authorize returns 503" do
7+
with_http_server do |http, _|
8+
response = ::HTTP::Client.get(http.test_uri("/oauth/authorize"), headers: ::HTTP::Headers.new)
9+
response.status_code.should eq 503
10+
response.body.should contain("OAuth not configured")
11+
end
12+
end
13+
14+
it "GET /oauth/callback redirects to login" do
15+
with_http_server do |http, _|
16+
response = ::HTTP::Client.get(http.test_uri("/oauth/callback"), headers: ::HTTP::Headers.new)
17+
response.status_code.should eq 302
18+
response.headers["Location"].should contain("OAuth%20not%20configured")
19+
end
20+
end
21+
end
22+
23+
describe "OAuthController /oauth/callback error handling" do
24+
it "redirects to login when oauth_state cookie is missing" do
25+
with_http_server do |http, _|
26+
LavinMQ::Config.instance.oauth_client_id = "test-client"
27+
LavinMQ::Config.instance.oauth_issuer_url = URI.parse("https://idp.example.com")
28+
29+
response = ::HTTP::Client.get(
30+
http.test_uri("/oauth/callback?state=abc123&code=authcode"),
31+
headers: ::HTTP::Headers.new
32+
)
33+
response.status_code.should eq 302
34+
response.headers["Location"].should contain("Missing%20OAuth%20state")
35+
end
36+
end
37+
38+
it "redirects to login on state mismatch" do
39+
with_http_server do |http, _|
40+
LavinMQ::Config.instance.oauth_client_id = "test-client"
41+
LavinMQ::Config.instance.oauth_issuer_url = URI.parse("https://idp.example.com")
42+
43+
headers = ::HTTP::Headers{
44+
"Cookie" => "oauth_state=correct_state:verifier123",
45+
}
46+
response = ::HTTP::Client.get(
47+
http.test_uri("/oauth/callback?state=wrong_state&code=authcode"),
48+
headers: headers
49+
)
50+
response.status_code.should eq 302
51+
response.headers["Location"].should contain("State%20mismatch")
52+
end
53+
end
54+
55+
it "redirects to login when code query parameter is missing" do
56+
with_http_server do |http, _|
57+
LavinMQ::Config.instance.oauth_client_id = "test-client"
58+
LavinMQ::Config.instance.oauth_issuer_url = URI.parse("https://idp.example.com")
59+
60+
headers = ::HTTP::Headers{
61+
"Cookie" => "oauth_state=mystate:verifier123",
62+
}
63+
response = ::HTTP::Client.get(
64+
http.test_uri("/oauth/callback?state=mystate"),
65+
headers: headers
66+
)
67+
response.status_code.should eq 302
68+
response.headers["Location"].should contain("Missing%20authorization%20code")
69+
end
70+
end
71+
end
72+
73+
describe "AuthHandler JWT cookie" do
74+
it "rejects a fake JWT in the m cookie with 401" do
75+
with_http_server do |http, _|
76+
headers = ::HTTP::Headers{
77+
"Cookie" => "m=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.fakepayload.fakesignature",
78+
}
79+
response = ::HTTP::Client.get(http.test_uri("/api/whoami"), headers: headers)
80+
response.status_code.should eq 401
81+
end
82+
end
83+
84+
it "still allows basic auth" do
85+
with_http_server do |http, _|
86+
response = http.get("/api/whoami")
87+
response.status_code.should eq 200
88+
end
89+
end
90+
end
91+
92+
describe "PKCE" do
93+
it "generates a verifier of at least 43 characters" do
94+
verifier, _ = LavinMQ::HTTP::OAuth2::PKCE.generate
95+
verifier.size.should be >= 43
96+
end
97+
98+
it "generates a correct challenge (SHA256 of verifier, base64url-encoded)" do
99+
verifier, challenge = LavinMQ::HTTP::OAuth2::PKCE.generate
100+
expected = Base64.urlsafe_encode(
101+
OpenSSL::Digest.new("SHA256").update(verifier).final,
102+
padding: false
103+
)
104+
challenge.should eq expected
105+
end
106+
107+
it "produces different verifiers on each call" do
108+
verifier1, _ = LavinMQ::HTTP::OAuth2::PKCE.generate
109+
verifier2, _ = LavinMQ::HTTP::OAuth2::PKCE.generate
110+
verifier1.should_not eq verifier2
111+
end
112+
end
113+
114+
describe "OAuthController /oauth/enabled" do
115+
it "returns enabled=false when OAuth is not configured" do
116+
with_http_server do |http, _|
117+
response = ::HTTP::Client.get(http.test_uri("/oauth/enabled"), headers: ::HTTP::Headers.new)
118+
response.status_code.should eq 200
119+
JSON.parse(response.body)["enabled"].as_bool.should be_false
120+
end
121+
end
122+
123+
it "returns enabled=true when OAuth is configured" do
124+
with_http_server do |http, _|
125+
LavinMQ::Config.instance.oauth_client_id = "test-client"
126+
LavinMQ::Config.instance.oauth_issuer_url = URI.parse("https://idp.example.com")
127+
128+
response = ::HTTP::Client.get(http.test_uri("/oauth/enabled"), headers: ::HTTP::Headers.new)
129+
response.status_code.should eq 200
130+
JSON.parse(response.body)["enabled"].as_bool.should be_true
131+
end
132+
end
133+
end
134+
end

src/lavinmq/auth/jwt/jwks_fetcher.cr

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,11 @@ module LavinMQ
1919

2020
property issuer : String
2121
property jwks_uri : String
22+
property authorization_endpoint : String?
23+
property token_endpoint : String?
2224

23-
def initialize(*, @issuer : String, @jwks_uri : String)
25+
def initialize(*, @issuer : String, @jwks_uri : String,
26+
@authorization_endpoint : String? = nil, @token_endpoint : String? = nil)
2427
end
2528
end
2629

@@ -92,8 +95,7 @@ module LavinMQ
9295
1.hour
9396
end
9497

95-
def fetch_jwks : JWKSResult
96-
# Discover jwks_uri from OIDC configuration
98+
def fetch_oidc_config : OIDCConfiguration
9799
body, _ = fetch_url("#{@issuer_url}/.well-known/openid-configuration")
98100
oidc_config = OIDCConfiguration.from_json(body)
99101

@@ -103,9 +105,13 @@ module LavinMQ
103105
raise "OIDC issuer mismatch: expected #{@issuer_url}, got #{oidc_issuer}"
104106
end
105107

106-
jwks_uri = oidc_config.jwks_uri
108+
oidc_config
109+
end
110+
111+
def fetch_jwks : JWKSResult
112+
oidc_config = fetch_oidc_config
107113

108-
body, headers = fetch_url(jwks_uri)
114+
body, headers = fetch_url(oidc_config.jwks_uri)
109115
jwks = JWKSResponse.from_json(body)
110116
public_keys = extract_public_keys_from_jwks(jwks)
111117
ttl = extract_jwks_ttl(headers)

src/lavinmq/auth/jwt/token_verifier.cr

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,8 @@ module LavinMQ
107107
end
108108

109109
unless audiences.includes?(expected)
110+
pp audiences
111+
pp expected
110112
raise JWT::VerificationError.new("Token audience does not match expected value")
111113
end
112114
end

src/lavinmq/config/options.cr

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,10 @@ module LavinMQ
367367
property oauth_audience : String? = nil
368368
@[IniOpt(section: "oauth", ini_name: jwks_cache_ttl)]
369369
property oauth_jwks_cache_ttl : Time::Span = 1.hours
370+
@[IniOpt(section: "oauth", ini_name: client_id)]
371+
property oauth_client_id : String? = nil
372+
@[IniOpt(section: "oauth", ini_name: mgmt_base_url)]
373+
property oauth_mgmt_base_url : String? = nil
370374
end
371375
end
372376
end
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
require "../controller"
2+
require "../router"
3+
require "../oauth2/pkce"
4+
require "../oauth2/token_exchange"
5+
require "../../auth/jwt/jwks_fetcher"
6+
7+
module LavinMQ
8+
module HTTP
9+
class OAuthController
10+
include Router
11+
12+
Log = LavinMQ::Log.for "http.oauth"
13+
14+
def initialize(@authenticator : Auth::Authenticator)
15+
register_routes
16+
end
17+
18+
private def register_routes
19+
get "/oauth/enabled" { |context, _params| handle_enabled(context) }
20+
get "/oauth/authorize" { |context, _params| handle_authorize(context) }
21+
get "/oauth/callback" { |context, _params| handle_callback(context) }
22+
end
23+
24+
private def handle_enabled(context) : ::HTTP::Server::Context
25+
config = Config.instance
26+
enabled = !!(config.oauth_client_id && config.oauth_issuer_url)
27+
context.response.content_type = "application/json"
28+
{enabled: enabled}.to_json(context.response)
29+
context
30+
end
31+
32+
private def handle_authorize(context) : ::HTTP::Server::Context
33+
config = Config.instance
34+
issuer_url = config.oauth_issuer_url || oauth_error(context, 503, "OAuth not configured")
35+
client_id = config.oauth_client_id || oauth_error(context, 503, "OAuth not configured")
36+
37+
oidc = Auth::JWT::JWKSFetcher.new(issuer_url, config.oauth_jwks_cache_ttl).fetch_oidc_config
38+
auth_ep = oidc.authorization_endpoint || raise "OIDC missing authorization_endpoint"
39+
40+
verifier, challenge = OAuth2::PKCE.generate
41+
state = Random::Secure.urlsafe_base64(32)
42+
43+
context.response.cookies << ::HTTP::Cookie.new(
44+
name: "oauth_state",
45+
value: "#{state}:#{verifier}",
46+
path: "/oauth",
47+
http_only: true,
48+
secure: true,
49+
samesite: ::HTTP::Cookie::SameSite::Lax,
50+
max_age: 5.minutes
51+
)
52+
53+
redirect_uri = build_redirect_uri(context.request)
54+
params = ::URI::Params.build do |p|
55+
p.add "client_id", client_id
56+
p.add "redirect_uri", redirect_uri
57+
p.add "response_type", "code"
58+
p.add "scope", "openid profile"
59+
p.add "code_challenge", challenge
60+
p.add "code_challenge_method", "S256"
61+
p.add "state", state
62+
end
63+
64+
context.response.content_type = "application/json"
65+
{authorize_url: "#{auth_ep}?#{params}"}.to_json(context.response)
66+
context
67+
rescue OAuthError
68+
context
69+
rescue ex
70+
Log.error(exception: ex) { "OAuth authorize failed: #{ex.message}" }
71+
context.response.status_code = 502
72+
context.response.content_type = "application/json"
73+
{reason: "OAuth authorization failed"}.to_json(context.response)
74+
context
75+
end
76+
77+
private def handle_callback(context) : ::HTTP::Server::Context
78+
config = Config.instance
79+
issuer_url = config.oauth_issuer_url || oauth_redirect_error(context, "OAuth not configured")
80+
client_id = config.oauth_client_id || oauth_redirect_error(context, "OAuth not configured")
81+
82+
cookie_value = context.request.cookies["oauth_state"]?.try(&.value) || oauth_redirect_error(context, "Missing OAuth state")
83+
sep = cookie_value.index(':') || oauth_redirect_error(context, "Invalid OAuth state cookie")
84+
cookie_state = cookie_value[0...sep]
85+
code_verifier = cookie_value[sep + 1..]
86+
87+
oauth_redirect_error(context, "State mismatch") unless context.request.query_params["state"]? == cookie_state
88+
code = context.request.query_params["code"]? || oauth_redirect_error(context, "Missing authorization code")
89+
90+
oidc = Auth::JWT::JWKSFetcher.new(issuer_url, config.oauth_jwks_cache_ttl).fetch_oidc_config
91+
token_ep = oidc.token_endpoint || raise "OIDC missing token_endpoint"
92+
93+
redirect_uri = build_redirect_uri(context.request)
94+
token_response = OAuth2::TokenExchange.new(token_ep, client_id).exchange(code, redirect_uri, code_verifier)
95+
96+
auth_context = Auth::Context.new("", token_response.access_token.to_slice, context.request.remote_address)
97+
oauth_redirect_error(context, "Token validation failed") unless @authenticator.authenticate(auth_context)
98+
99+
context.response.cookies << ::HTTP::Cookie.new(
100+
name: "m",
101+
value: token_response.access_token,
102+
path: "/",
103+
secure: true,
104+
samesite: ::HTTP::Cookie::SameSite::Strict,
105+
max_age: 8.hours
106+
)
107+
context.response.cookies << ::HTTP::Cookie.new(
108+
name: "oauth_state",
109+
value: "",
110+
path: "/oauth",
111+
max_age: 0.seconds
112+
)
113+
114+
context.response.status = ::HTTP::Status::FOUND
115+
context.response.headers["Location"] = "/"
116+
context
117+
rescue OAuthError
118+
context
119+
rescue ex
120+
Log.error(exception: ex) { "OAuth callback failed: #{ex.message}" }
121+
context.response.status = ::HTTP::Status::FOUND
122+
context.response.headers["Location"] = "/login?error=#{URI.encode_path_segment("OAuth authentication failed")}"
123+
context
124+
end
125+
126+
private def oauth_error(context, status_code, message) : NoReturn
127+
context.response.status_code = status_code
128+
context.response.content_type = "application/json"
129+
{reason: message}.to_json(context.response)
130+
raise OAuthError.new
131+
end
132+
133+
private def oauth_redirect_error(context, message) : NoReturn
134+
context.response.status = ::HTTP::Status::FOUND
135+
context.response.headers["Location"] = "/login?error=#{URI.encode_path_segment(message)}"
136+
raise OAuthError.new
137+
end
138+
139+
private def build_redirect_uri(request : ::HTTP::Request) : String
140+
if base_url = Config.instance.oauth_mgmt_base_url
141+
"#{base_url.chomp("/")}/oauth/callback"
142+
else
143+
host = request.headers["Host"]? || "localhost"
144+
scheme = request.headers["X-Forwarded-Proto"]? || "http"
145+
"#{scheme}://#{host}/oauth/callback"
146+
end
147+
end
148+
149+
class OAuthError < Exception; end
150+
end
151+
end
152+
end

src/lavinmq/http/handler/auth_handler.cr

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@ module LavinMQ
1414
context.user = @direct_user
1515
end
1616

17-
if auth = cookie_auth(context) || basic_auth(context)
17+
if user = jwt_cookie_auth(context)
18+
context.user = user
19+
elsif auth = cookie_auth(context) || basic_auth(context)
1820
username, password = auth
1921
if user = authenticate(username, password, context.request.remote_address)
2022
context.user = user
@@ -42,6 +44,17 @@ module LavinMQ
4244
end
4345
end
4446

47+
private def jwt_cookie_auth(context) : Auth::BaseUser?
48+
if m = context.request.cookies["m"]?
49+
value = m.value
50+
if value.starts_with?("eyJ")
51+
auth_context = Auth::Context.new("", value.to_slice, context.request.remote_address)
52+
user = @authenticator.authenticate(auth_context)
53+
return user if user && !user.tags.empty?
54+
end
55+
end
56+
end
57+
4558
private def decode(base64) : Tuple(String, String)?
4659
string = Base64.decode_string(base64)
4760
if idx = string.index(':')

0 commit comments

Comments
 (0)