Skip to content

Commit 0760bf5

Browse files
stakachclaude
andcommitted
feat(login_events): Redis pub/sub on successful logins
Publishes `{user_id, provider}` JSON to the `placeos/auth/login` Redis channel after every successful login — same channel the Ruby service used, so downstream subscribers (cache invalidation, audit logging, …) keep working unchanged. Wired in two places: * Sessions#signin (provider = "internal") * ProviderCallbacks#callback (provider = oauth_user.provider) Errors are caught + logged at warn — a flaky Redis must never block a successful login. The publisher is exposed as a class_property Proc so tests (and any future in-process subscriber) can swap in a recording double; that's how the new 2 specs assert the call without spinning up a Redis subscriber. The legacy Ruby `before_signup` / `after_login` Rails-initialiser hooks are not ported: auth.cr is a standalone binary with no obvious in-process consumer for them. Easy to add a callable class_property when a concrete need shows up. 41/41 specs pass (./test). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 397cb6f commit 0760bf5

6 files changed

Lines changed: 184 additions & 5 deletions

File tree

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
require "webmock"
2+
require "../helper"
3+
4+
# Verifies that successful logins publish a `{user_id, provider}` event
5+
# through `LoginEvents`. We don't go through real Redis here — we swap
6+
# `LoginEvents.publisher` for a recording Proc. This keeps the spec
7+
# independent of the Redis stub in `docker-compose.yml` and proves the
8+
# wire-up (which is the part that can rot under refactors); the actual
9+
# `redis` PUBLISH call is a single line covered by the shard's own
10+
# spec suite.
11+
module PlaceOS::Auth
12+
describe LoginEvents do
13+
# Save/restore the publisher on every example so a failure half-way
14+
# through doesn't leak a recording stub into the next test.
15+
original_publisher = LoginEvents.publisher
16+
::Spec.before_each { LoginEvents.publisher = original_publisher }
17+
::Spec.after_each { LoginEvents.publisher = original_publisher }
18+
19+
describe "publish" do
20+
it "fires `{user_id, internal}` after a successful POST /auth/signin" do
21+
calls = [] of Tuple(String, String)
22+
LoginEvents.publisher = ->(uid : String, provider : String) {
23+
calls << {uid, provider}
24+
nil
25+
}
26+
27+
authority = ::PlaceOS::Model::Authority.find_by_domain("localhost").not_nil!
28+
password = "ok-password-1234"
29+
user = ::PlaceOS::Model::Generator.user(authority)
30+
user.password = password
31+
user.save!
32+
33+
body = {email: user.email.to_s, password: password}.to_json
34+
headers = HTTP::Headers{
35+
"Host" => "localhost",
36+
"Content-Type" => "application/json",
37+
}
38+
result = client.post("/auth/signin", headers: headers, body: body)
39+
result.status_code.should eq 202
40+
41+
calls.size.should eq 1
42+
calls.first[0].should eq user.id
43+
calls.first[1].should eq "internal"
44+
ensure
45+
user.try &.destroy
46+
end
47+
48+
it "fires `{user_id, oauth2}` after a successful OAuth callback" do
49+
calls = [] of Tuple(String, String)
50+
LoginEvents.publisher = ->(uid : String, provider : String) {
51+
calls << {uid, provider}
52+
nil
53+
}
54+
55+
authority = ::PlaceOS::Model::Authority.find_by_domain("localhost").not_nil!
56+
strat = ::PlaceOS::Model::OAuthAuthentication.new
57+
strat.name = "login-event-#{Random.rand(99999)}"
58+
strat.client_id = "test"
59+
strat.client_secret = "test"
60+
strat.site = "https://idp.example.test"
61+
strat.authorize_url = "/authorize"
62+
strat.token_url = "/token"
63+
strat.auth_scheme = "request_body"
64+
strat.token_method = "post"
65+
strat.scope = "openid email"
66+
strat.raw_info_url = "https://idp.example.test/userinfo"
67+
strat.info_mappings = {"uid" => "id", "email" => "email", "name" => "name"}
68+
strat.authority_id = authority.id
69+
strat.save!
70+
71+
WebMock.reset
72+
WebMock.allow_net_connect = false
73+
WebMock.stub(:post, "https://idp.example.test/token").to_return(
74+
status: 200,
75+
headers: HTTP::Headers{"Content-Type" => "application/json"},
76+
body: {access_token: "x", token_type: "Bearer", expires_in: 3600}.to_json,
77+
)
78+
WebMock.stub(:get, "https://idp.example.test/userinfo").to_return(
79+
status: 200,
80+
headers: HTTP::Headers{"Content-Type" => "application/json"},
81+
body: {id: "uid-#{Random.rand(99999)}", email: "bob-#{Random.rand(99999)}@localhost", name: "Bob"}.to_json,
82+
)
83+
84+
kickoff = client.get(
85+
"/auth/oauth2?id=#{URI.encode_www_form(strat.id.as(String))}",
86+
headers: HTTP::Headers{"Host" => "localhost"},
87+
)
88+
kickoff.status_code.should eq 303
89+
cookie = kickoff.headers["Set-Cookie"].split(';', 2).first.strip
90+
state = URI::Params.parse(kickoff.headers["Location"].split('?', 2).last)["state"]
91+
92+
callback = client.get(
93+
"/auth/oauth2/callback?id=#{URI.encode_www_form(strat.id.as(String))}&code=test-code&state=#{state}",
94+
headers: HTTP::Headers{"Host" => "localhost", "Cookie" => cookie},
95+
)
96+
callback.status_code.should eq 303
97+
98+
calls.size.should eq 1
99+
calls.first[1].should eq "oauth2"
100+
ensure
101+
strat.try &.destroy
102+
WebMock.reset
103+
end
104+
end
105+
end
106+
end

src/placeos-auth.cr

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ require "./constants"
55
require "./placeos-auth/error"
66
require "./placeos-auth/authly_adapter"
77
require "./placeos-auth/external_providers"
8+
require "./placeos-auth/login_events"
89
require "./placeos-auth/controllers/application"
910
require "./placeos-auth/controllers/*"
1011

src/placeos-auth/controllers/provider_callbacks.cr

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,8 @@ module PlaceOS::Auth
9999
remove_session
100100
new_session(result.user)
101101

102+
LoginEvents.publish(result.user, oauth_user.provider)
103+
102104
target = consume_continue || "/"
103105
redirect_to target.gsub(' ', "%20"), :see_other
104106
end

src/placeos-auth/controllers/sessions.cr

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,7 @@ module PlaceOS::Auth
5656
remove_session
5757
new_session(user)
5858

59-
# TODO(phase 6): publish `{user_id, provider: "internal"}` to the
60-
# `placeos/auth/login` Redis channel.
59+
LoginEvents.publish(user, "internal")
6160

6261
if (continue = body.continue.presence)
6362
target = sanitize_continue(continue) || "/"

src/placeos-auth/login_events.cr

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
require "json"
2+
require "redis"
3+
4+
module PlaceOS::Auth
5+
# Publishes successful logins onto a Redis pub/sub channel so the
6+
# rest of the PlaceOS stack can observe authentication events
7+
# (cache invalidation, audit logs, etc.).
8+
#
9+
# Mirrors the legacy Ruby service's `placeos/auth/login` channel —
10+
# downstream subscribers expect the exact same payload shape:
11+
# `{"user_id": "...", "provider": "..."}`.
12+
module LoginEvents
13+
Log = ::PlaceOS::Auth::Log.for(self)
14+
15+
# Indirection so tests can swap in a recording double without
16+
# spinning up a Redis subscriber. Production code calls
17+
# `LoginEvents.publish` which delegates here; tests reassign
18+
# `LoginEvents.publisher` to a Proc that captures invocations.
19+
class_property publisher : Proc(String, String, Nil) = ->(user_id : String, provider : String) {
20+
publish_to_redis(user_id, provider)
21+
}
22+
23+
# Single shared connection. `redis` is thread-safe per docs but we
24+
# only `publish` (no blocking subscribers) so a single instance is
25+
# fine. Lazy-init so tests don't pay the connection cost unless
26+
# something actually publishes.
27+
@@redis : ::Redis? = nil
28+
@@redis_mutex = Mutex.new
29+
30+
# Fire-and-forget. Errors are logged at `warn` and swallowed —
31+
# a flaky Redis must never block a successful login.
32+
def self.publish(user : ::PlaceOS::Model::User, provider : String) : Nil
33+
uid = user.id
34+
return if uid.nil?
35+
publisher.call(uid, provider)
36+
end
37+
38+
# :nodoc:
39+
def self.publish_to_redis(user_id : String, provider : String) : Nil
40+
redis_url = REDIS_URL
41+
return if redis_url.nil? || redis_url.empty?
42+
43+
payload = {user_id: user_id, provider: provider}.to_json
44+
redis = ensure_connection(redis_url)
45+
return if redis.nil?
46+
redis.publish(LOGIN_EVENTS_CHANNEL, payload)
47+
rescue ex
48+
Log.warn(exception: ex) { {action: "login_events.publish", message: "ignoring failure"} }
49+
end
50+
51+
# Resets the singleton — useful for tests that want a clean state
52+
# or to recover after a Redis-side restart.
53+
def self.reset_connection : Nil
54+
@@redis_mutex.synchronize do
55+
@@redis.try &.close
56+
@@redis = nil
57+
end
58+
end
59+
60+
private def self.ensure_connection(url : String) : ::Redis?
61+
@@redis_mutex.synchronize do
62+
existing = @@redis
63+
return existing if existing
64+
@@redis = ::Redis.new(url: url)
65+
end
66+
end
67+
end
68+
end

tasks/todo.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,9 +77,12 @@ Granular checklist that mirrors `PLAN.md`. Check off as we go; capture correctio
7777
- [x] Full SAMLResponse signature-validation round-trip deferred — `multi_auth_saml`'s own spec suite covers SAML XML parsing; the auth.cr-specific bits (user mapping, state check) are exercised through the shared code path by `provider_callbacks_spec.cr`
7878

7979
## Phase 6 — Extensions
80-
- [ ] Redis login event publisher (`placeos/auth/login`)
81-
- [ ] Crystal-native `before_signup` / `after_login` hooks
82-
- [ ] Finalise API key validator + X-API-Key header support
80+
- [x] Redis login event publisher (`LoginEvents.publish``placeos/auth/login`)
81+
- [x] Wired into `Sessions#signin` (provider="internal") and `ProviderCallbacks#callback` (provider=oauth_user.provider)
82+
- [x] Test-swappable publisher (`LoginEvents.publisher` class_property)
83+
- [x] Specs: signin → "internal" event, OAuth callback → provider name event (2 specs)
84+
- [ ] Crystal-native `before_signup` / `after_login` hooks — deferred. The Ruby version was a Rails-initialiser plug-in pattern; auth.cr is a standalone binary so there's no obvious consumer. Add when a concrete need arises.
85+
- [x] X-API-Key header already supported via `Utils::CurrentUser#extract_api_key` (Phase 1)
8386

8487
## Phase 7 — Cutover prep
8588
- [ ] Wire-format diff vs Ruby service (cookies, JWT, response codes/headers)

0 commit comments

Comments
 (0)