Skip to content

Commit 4df2c3e

Browse files
committed
First pass at a basic OIDC flow
1 parent 22aec15 commit 4df2c3e

File tree

3 files changed

+217
-0
lines changed

3 files changed

+217
-0
lines changed

lib/ret/oauth_token.ex

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,20 @@ defmodule Ret.OAuthToken do
3636

3737
token
3838
end
39+
40+
def token_for_oidc_request(topic_key, session_id) do
41+
{:ok, token, _claims} =
42+
Ret.OAuthToken.encode_and_sign(
43+
# OAuthTokens do not have a resource associated with them
44+
nil,
45+
%{topic_key: topic_key, session_id: session_id, aud: :ret_oidc},
46+
allowed_algos: ["HS512"],
47+
ttl: {10, :minutes},
48+
allowed_drift: 60 * 1000
49+
)
50+
51+
token
52+
end
3953
end
4054

4155
defmodule Ret.OAuthTokenSecretFetcher do
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
defmodule RetWeb.OIDCAuthChannel do
2+
@moduledoc "Ret Web Channel for OIDC Authentication"
3+
4+
use RetWeb, :channel
5+
import Canada, only: [can?: 2]
6+
7+
alias Ret.{Statix, Account, OAuthToken}
8+
9+
intercept(["auth_credentials"])
10+
11+
def join("oidc:" <> _topic_key, _payload, socket) do
12+
# Expire channel in 5 minutes
13+
Process.send_after(self(), :channel_expired, 60 * 1000 * 5)
14+
15+
# Rate limit joins to reduce attack surface
16+
:timer.sleep(500)
17+
18+
Statix.increment("ret.channels.oidc.joins.ok")
19+
{:ok, %{session_id: socket.assigns.session_id}, socket}
20+
end
21+
22+
defp module_config(key) do
23+
Application.get_env(:ret, __MODULE__)[key]
24+
end
25+
26+
defp get_redirect_uri(), do: RetWeb.Endpoint.url() <> "/verify"
27+
28+
defp get_authorize_url(state, nonce) do
29+
"#{module_config(:endpoint)}authorize?" <>
30+
URI.encode_query(%{
31+
response_type: "code",
32+
response_mode: "query",
33+
client_id: module_config(:client_id),
34+
scope: module_config(:scopes),
35+
state: state,
36+
nonce: nonce,
37+
redirect_uri: get_redirect_uri()
38+
})
39+
end
40+
41+
def handle_in("auth_request", _payload, socket) do
42+
if Map.get(socket.assigns, :nonce) do
43+
{:reply, {:error, "Already started an auth request on this session"}, socket}
44+
else
45+
"oidc:" <> topic_key = socket.topic
46+
oidc_state = Ret.OAuthToken.token_for_oidc_request(topic_key, socket.assigns.session_id)
47+
nonce = SecureRandom.uuid()
48+
authorize_url = get_authorize_url(oidc_state, nonce)
49+
50+
socket = socket |> assign(:nonce, nonce)
51+
52+
IO.inspect("Started oauth flow with oidc_state #{oidc_state}, authorize_url: #{authorize_url}")
53+
{:reply, {:ok, %{authorize_url: authorize_url}}, socket}
54+
end
55+
end
56+
57+
def handle_in("auth_verified", %{"token" => code, "payload" => state}, socket) do
58+
Process.send_after(self(), :close_channel, 1000 * 5)
59+
60+
IO.inspect("Verify OIDC auth!!!!!")
61+
62+
# Slow down token guessing
63+
:timer.sleep(500)
64+
65+
"oidc:" <> expected_topic_key = socket.topic
66+
67+
# TODO since we already have session_id secured by a token on the other end using a JWT for state may be overkill
68+
case OAuthToken.decode_and_verify(state) do
69+
{:ok,
70+
%{
71+
"topic_key" => topic_key,
72+
"session_id" => session_id,
73+
"aud" => "ret_oidc"
74+
}}
75+
when topic_key == expected_topic_key ->
76+
%{
77+
"access_token" => access_token,
78+
"id_token" => raw_id_token
79+
} = fetch_oidc_access_token(code)
80+
81+
# TODO lookup pubkey by kid in header
82+
%JOSE.JWS{fields: %{"kid" => kid}} = JOSE.JWT.peek_protected(raw_id_token)
83+
IO.inspect(kid)
84+
85+
pub_key = module_config(:verification_key) |> JOSE.JWK.from_pem()
86+
87+
case JOSE.JWT.verify_strict(pub_key, module_config(:allowed_algos), raw_id_token)
88+
|> IO.inspect() do
89+
{true,
90+
%JOSE.JWT{
91+
fields: %{
92+
"aud" => _aud,
93+
"nonce" => nonce,
94+
"preferred_username" => remote_username,
95+
"sub" => remote_user_id
96+
}
97+
}, _jws} ->
98+
# TODO we may want to verify some more fields like issuer and expiration time
99+
100+
# %{"sub" => remote_user_id, "preferred_username" => remote_username} =
101+
# fetch_oidc_user_info(access_token) |> IO.inspect()
102+
103+
broadcast_credentials_and_payload(
104+
remote_user_id,
105+
%{email: remote_username},
106+
%{session_id: session_id, nonce: nonce},
107+
socket
108+
)
109+
110+
{:reply, :ok, socket}
111+
112+
{false, _jwt, _jws} ->
113+
{:reply, {:error, %{msg: "invalid OIDC token from endpoint"}}, socket}
114+
115+
{:error, _} ->
116+
{:reply, {:error, %{msg: "error verifying token"}}, socket}
117+
end
118+
119+
# TODO we may want to be less specific about errors
120+
{:ok, _} ->
121+
{:reply, {:error, %{msg: "Invalid topic key"}}, socket}
122+
123+
{:error, error} ->
124+
{:reply, {:error, error}, socket}
125+
end
126+
end
127+
128+
def fetch_oidc_access_token(oauth_code) do
129+
body = {
130+
:form,
131+
[
132+
client_id: module_config(:client_id),
133+
client_secret: module_config(:client_secret),
134+
grant_type: "authorization_code",
135+
redirect_uri: get_redirect_uri(),
136+
code: oauth_code,
137+
scope: module_config(:scopes)
138+
]
139+
}
140+
141+
# todo handle error response
142+
"#{module_config(:endpoint)}token"
143+
|> Ret.HttpUtils.retry_post_until_success(body, [{"content-type", "application/x-www-form-urlencoded"}])
144+
|> Map.get(:body)
145+
|> Poison.decode!()
146+
end
147+
148+
# def fetch_oidc_user_info(access_token) do
149+
# "#{module_config(:endpoint)}userinfo"
150+
# |> Ret.HttpUtils.retry_get_until_success([{"authorization", "Bearer #{access_token}"}])
151+
# |> Map.get(:body)
152+
# |> Poison.decode!()
153+
# |> IO.inspect()
154+
# end
155+
156+
def handle_in(_event, _payload, socket) do
157+
{:noreply, socket}
158+
end
159+
160+
def handle_info(:close_channel, socket) do
161+
GenServer.cast(self(), :close)
162+
{:noreply, socket}
163+
end
164+
165+
def handle_info(:channel_expired, socket) do
166+
GenServer.cast(self(), :close)
167+
{:noreply, socket}
168+
end
169+
170+
def handle_out(
171+
"auth_credentials" = event,
172+
%{credentials: credentials, user_info: user_info, verification_info: verification_info},
173+
socket
174+
) do
175+
Process.send_after(self(), :close_channel, 1000 * 5)
176+
IO.inspect("checking creds")
177+
IO.inspect(socket)
178+
IO.inspect(verification_info)
179+
180+
if Map.get(socket.assigns, :session_id) == Map.get(verification_info, :session_id) and
181+
Map.get(socket.assigns, :nonce) == Map.get(verification_info, :nonce) do
182+
IO.inspect("sending creds")
183+
push(socket, event, %{credentials: credentials, user_info: user_info})
184+
end
185+
186+
{:noreply, socket}
187+
end
188+
189+
defp broadcast_credentials_and_payload(nil, _user_info, _verification_info, _socket), do: nil
190+
191+
defp broadcast_credentials_and_payload(identifier_hash, user_info, verification_info, socket) do
192+
account_creation_enabled = can?(nil, create_account())
193+
account = identifier_hash |> Account.account_for_login_identifier_hash(account_creation_enabled)
194+
credentials = account |> Account.credentials_for_account()
195+
196+
broadcast!(socket, "auth_credentials", %{
197+
credentials: credentials,
198+
user_info: user_info,
199+
verification_info: verification_info
200+
})
201+
end
202+
end

lib/ret_web/channels/session_socket.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ defmodule RetWeb.SessionSocket do
55
channel("hub:*", RetWeb.HubChannel)
66
channel("link:*", RetWeb.LinkChannel)
77
channel("auth:*", RetWeb.AuthChannel)
8+
channel("oidc:*", RetWeb.OIDCAuthChannel)
89

910
def id(socket) do
1011
"session:#{socket.assigns.session_id}"

0 commit comments

Comments
 (0)