aboutsummaryrefslogtreecommitdiff
path: root/apps
diff options
context:
space:
mode:
authorJordan Bracco <href@random.sh>2021-08-12 16:10:40 +0200
committerJordan Bracco <href@random.sh>2021-08-12 16:10:40 +0200
commit681cd1eda65d584e98f845b095382777aed67597 (patch)
tree5007033362d99e7a1ef40e566daff7463d977b57 /apps
parentKratos flows (diff)
Hydra login/consent oauth2 flows
Diffstat (limited to 'apps')
-rw-r--r--apps/ory/src/ory_hydra.erl51
-rw-r--r--apps/styx_web/priv/assets/app.css139
-rw-r--r--apps/styx_web/src/styx_web_app.erl2
-rw-r--r--apps/styx_web/src/styx_web_error.erl5
-rw-r--r--apps/styx_web/src/styx_web_launchpad.erl17
-rw-r--r--apps/styx_web/src/styx_web_oauth2_consent.erl79
-rw-r--r--apps/styx_web/src/styx_web_oauth2_login.erl61
-rw-r--r--apps/styx_web/templates/oauth2_consent_form.dtl34
8 files changed, 386 insertions, 2 deletions
diff --git a/apps/ory/src/ory_hydra.erl b/apps/ory/src/ory_hydra.erl
new file mode 100644
index 0000000..e667487
--- /dev/null
+++ b/apps/ory/src/ory_hydra.erl
@@ -0,0 +1,51 @@
+-module(ory_hydra).
+-export([url/0, admin_url/0, login_request/1, accept_login_request/2, consent_request/1, accept_consent_request/2, reject_consent_request/2]).
+
+login_request(Challenge) ->
+ Url = [admin_url(), "/oauth2/auth/requests/login?login_challenge=", Challenge],
+ Headers = [{"accept", "application/json"}],
+ api_response(hackney:request(get, Url, [], <<>>, [])).
+
+accept_login_request(Challenge, Data) ->
+ Url = [admin_url(), "/oauth2/auth/requests/login/accept?login_challenge=", Challenge],
+ Headers = [{"accept", "application/json"}, {"content_type", "application/json"}],
+ Json = jsone:encode(Data),
+ api_response(hackney:request(put, Url, Headers, Json, [])).
+
+consent_request(Challenge) ->
+ Url = [admin_url(), "/oauth2/auth/requests/consent?consent_challenge=", Challenge],
+ Headers = [{"accept", "application/json"}],
+ api_response(hackney:request(get, Url, [], <<>>, [])).
+
+accept_consent_request(Challenge, Data) ->
+ Url = [admin_url(), "/oauth2/auth/requests/consent/accept?consent_challenge=", Challenge],
+ Headers = [{"accept", "application/json"}, {"content_type", "application/json"}],
+ Json = jsone:encode(Data),
+ api_response(hackney:request(put, Url, Headers, Json, [])).
+
+reject_consent_request(Challenge, Data) ->
+ Url = [admin_url(), "/oauth2/auth/requests/consent/reject?consent_challenge=", Challenge],
+ Headers = [{"accept", "application/json"}, {"content_type", "application/json"}],
+ Json = jsone:encode(Data),
+ api_response(hackney:request(put, Url, Headers, Json, [])).
+
+
+admin_url() ->
+ {ok, Value} = application:get_env(ory, hydra_admin_url),
+ Value.
+
+url() ->
+ {ok, Value} = application:get_env(ory, hydra_url),
+ Value.
+
+api_response(Error = {error, Error}) ->
+ logger:error("ory_kratos hackney error: ~p", [Error]),
+ {error, #{<<"code">> => 503, <<"status">> => "Not Available", <<"message">> => "This service isn't available at the moment."}};
+api_response({ok, 200, _, Client}) ->
+ {ok, Body} = hackney:body(Client),
+ {ok, jsone:decode(Body)};
+api_response({ok, Code, _, Client}) ->
+ {ok, Body} = hackney:body(Client),
+ JSON = #{<<"error">> := Error} = jsone:decode(Body),
+ logger:debug("hydra error: ~p", [JSON]),
+ {error, Error}.
diff --git a/apps/styx_web/priv/assets/app.css b/apps/styx_web/priv/assets/app.css
index 32d2d21..4b54190 100644
--- a/apps/styx_web/priv/assets/app.css
+++ b/apps/styx_web/priv/assets/app.css
@@ -632,6 +632,22 @@ video {
}
}
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border-width: 0;
+}
+
+.relative {
+ position: relative;
+}
+
.mt-8 {
margin-top: 2rem;
}
@@ -652,17 +668,79 @@ video {
margin-top: 1rem;
}
+.ml-3 {
+ margin-left: 0.75rem;
+}
+
+.flex {
+ display: flex;
+}
+
+.h-5 {
+ height: 1.25rem;
+}
+
+.h-4 {
+ height: 1rem;
+}
+
+.w-4 {
+ width: 1rem;
+}
+
+.items-start {
+ align-items: flex-start;
+}
+
+.items-center {
+ align-items: center;
+}
+
.space-y-6 > :not([hidden]) ~ :not([hidden]) {
--tw-space-y-reverse: 0;
margin-top: calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));
margin-bottom: calc(1.5rem * var(--tw-space-y-reverse));
}
+.space-y-5 > :not([hidden]) ~ :not([hidden]) {
+ --tw-space-y-reverse: 0;
+ margin-top: calc(1.25rem * calc(1 - var(--tw-space-y-reverse)));
+ margin-bottom: calc(1.25rem * var(--tw-space-y-reverse));
+}
+
+.rounded {
+ border-radius: 0.25rem;
+}
+
+.border-gray-300 {
+ --tw-border-opacity: 1;
+ border-color: rgba(209, 213, 219, var(--tw-border-opacity));
+}
+
.text-sm {
font-size: 0.875rem;
line-height: 1.25rem;
}
+.font-medium {
+ font-weight: 500;
+}
+
+.text-indigo-600 {
+ --tw-text-opacity: 1;
+ color: rgba(79, 70, 229, var(--tw-text-opacity));
+}
+
+.text-gray-700 {
+ --tw-text-opacity: 1;
+ color: rgba(55, 65, 81, var(--tw-text-opacity));
+}
+
+.text-gray-500 {
+ --tw-text-opacity: 1;
+ color: rgba(107, 114, 128, var(--tw-text-opacity));
+}
+
.root {
display: flex;
min-height: 100vh;
@@ -930,6 +1008,12 @@ video {
}
}
+.btn-group {
+ display: flex;
+ width: 100%;
+ justify-content: center;
+}
+
.btn-submit {
display: flex;
width: 100%;
@@ -980,6 +1064,56 @@ video {
}
}
+.btn-reject {
+ display: flex;
+ width: 100%;
+ justify-content: center;
+ border-radius: 0.375rem;
+ border-width: 1px;
+ border-color: transparent;
+ --tw-bg-opacity: 1;
+ background-color: rgba(220, 38, 38, var(--tw-bg-opacity));
+ padding-top: 0.5rem;
+ padding-bottom: 0.5rem;
+ padding-left: 1rem;
+ padding-right: 1rem;
+ font-size: 0.875rem;
+ line-height: 1.25rem;
+ font-weight: 500;
+ --tw-text-opacity: 1;
+ color: rgba(255, 255, 255, var(--tw-text-opacity));
+ --tw-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
+ box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
+}
+
+.btn-reject:hover {
+ --tw-bg-opacity: 1;
+ background-color: rgba(185, 28, 28, var(--tw-bg-opacity));
+}
+
+.btn-reject:focus {
+ outline: 2px solid transparent;
+ outline-offset: 2px;
+ --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
+ --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);
+ box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
+ --tw-ring-opacity: 1;
+ --tw-ring-color: rgba(239, 68, 68, var(--tw-ring-opacity));
+ --tw-ring-offset-width: 2px;
+}
+
+@media (prefers-color-scheme: dark) {
+ .btn-reject:hover {
+ --tw-bg-opacity: 1;
+ background-color: rgba(239, 68, 68, var(--tw-bg-opacity));
+ }
+
+ .btn-reject:focus {
+ --tw-ring-opacity: 1;
+ --tw-ring-color: rgba(252, 165, 165, var(--tw-ring-opacity));
+ }
+}
+
.label {
display: block;
font-size: 0.875rem;
@@ -995,3 +1129,8 @@ video {
color: rgba(209, 213, 219, var(--tw-text-opacity));
}
}
+
+.focus\:ring-indigo-500:focus {
+ --tw-ring-opacity: 1;
+ --tw-ring-color: rgba(99, 102, 241, var(--tw-ring-opacity));
+}
diff --git a/apps/styx_web/src/styx_web_app.erl b/apps/styx_web/src/styx_web_app.erl
index 1386e71..39ce39b 100644
--- a/apps/styx_web/src/styx_web_app.erl
+++ b/apps/styx_web/src/styx_web_app.erl
@@ -42,6 +42,8 @@ routes() ->
%% Hydra
{"/account/oauth2/login", styx_web_oauth2_login, undefined},
{"/account/oauth2/consent", styx_web_oauth2_consent, undefined},
+ {"/account/oauth2/logout", styx_web_oauth2_logout, undefined},
+ {"/account/oauth2/error", styx_web_error, oauth2},
%% Static
{"/account/app.css", cowboy_static, {priv_file, styx_web, "assets/app.css"}},
diff --git a/apps/styx_web/src/styx_web_error.erl b/apps/styx_web/src/styx_web_error.erl
index 8b40371..262bedc 100644
--- a/apps/styx_web/src/styx_web_error.erl
+++ b/apps/styx_web/src/styx_web_error.erl
@@ -2,8 +2,13 @@
-behaviour(cowboy_handler).
-export([init/2]).
+init(Req, not_found) ->
+ reply(Req, 404, <<"Not Found">>, undefined);
init(Req, State = #{code := Code, status := Status}) ->
reply(Req, Code, Status, maps:get(message, State, undefined));
+init(Req, oauth2) ->
+ {ok, ErrorDescription} = styx_web:req_param(Req, <<"error_description">>),
+ reply(Req, 500, <<"Error">>, ErrorDescription);
init(Req = #{method := <<"GET">>}, State) ->
{ok, ErrorId} = styx_web:req_param(Req, <<"id">>),
{ok, Error} = ory_kratos:error(ErrorId),
diff --git a/apps/styx_web/src/styx_web_launchpad.erl b/apps/styx_web/src/styx_web_launchpad.erl
index 382c1cd..d767148 100644
--- a/apps/styx_web/src/styx_web_launchpad.erl
+++ b/apps/styx_web/src/styx_web_launchpad.erl
@@ -7,10 +7,23 @@ init(Req0, State) ->
case ory_kratos:whoami(Cookie) of
{ok, Session = #{<<"active">> := true}} ->
logger:debug("Session ~p", [Session]),
- Html = styx_web:render(Req0, launchpad_dtl, [{"session", Session}, {"name", styx_web:identity_name(Session)}]),
- {ok, styx_web:reply_html(Req0, 200, Html), State};
+ oauth2_login(Req0, State, Session, styx_web_oauth2_login:get_cookie(Req0));
{error, #{<<"code">> := 401}} ->
{ok, styx_web:temporary_redirect(Req0, <<"/login">>), State};
{error, Error = #{<<"code">> := Code, <<"status">> := Status, <<"message">> := Msg}} ->
styx_web_error:init(Req0, #{code => Code, status => Status, message => maps:get(<<"reason">>, Error, Msg)})
end.
+
+oauth2_login(Req0, State, _Session, {ok, Challenge}) ->
+ Req1 = styx_web_oauth2_login:unset_cookie(Req0),
+ Req = styx_web:temporary_redirect(Req1, <<"/account/oauth2/login?login_challenge=", Challenge/binary>>),
+ {ok, Req, State};
+oauth2_login(Req, State, Session, {error, no_oauth2_challenge_cookie}) ->
+ launchpad(Req, State, Session, application:get_env(styx_web, launchpad, true)).
+
+launchpad(Req0, State, _Session, {url, Url}) ->
+ Req = styx_web:temporary_redirect(Req0, Url),
+ {ok, Req, State};
+launchpad(Req0, State, Session, _) ->
+ Html = styx_web:render(Req0, launchpad_dtl, [{"session", Session}, {"name", styx_web:identity_name(Session)}]),
+ {ok, styx_web:reply_html(Req0, 200, Html), State}.
diff --git a/apps/styx_web/src/styx_web_oauth2_consent.erl b/apps/styx_web/src/styx_web_oauth2_consent.erl
new file mode 100644
index 0000000..c9f57be
--- /dev/null
+++ b/apps/styx_web/src/styx_web_oauth2_consent.erl
@@ -0,0 +1,79 @@
+-module(styx_web_oauth2_consent).
+-behaviour(cowboy_handler).
+-export([init/2]).
+
+init(Req = #{method := <<"GET">>}, State) ->
+ init_(Req, State, styx_web:req_param(Req, <<"consent_challenge">>));
+init(Req = #{method := <<"POST">>}, State) ->
+ init_(Req, State, styx_web:req_param(Req, <<"consent_challenge">>));
+init(Req, _) ->
+ styx_web_error:init(Req, #{code => 404, status => <<"Not Found">>}).
+
+init_(Req0, State, {ok, Challenge}) ->
+ Req = styx_web_oauth2_login:unset_cookie(Req0),
+ Cookie = cowboy_req:header(<<"cookie">>, Req),
+ authentication(Req, State, Challenge, ory_kratos:whoami(Cookie));
+init_(Req, _, {error, {missing_param, _}}) ->
+ styx_web_error:init(Req, not_found).
+
+authentication(Req0, State, Challenge, {ok, Session = #{<<"active">> := true}}) ->
+ do(Req0, State, Session, ory_hydra:consent_request(Challenge));
+authentication(Req0, State, _, Error) ->
+ error(Req0, State, Error).
+
+do(Req0 = #{method := <<"GET">>}, State, Session, {ok, Flow = #{<<"skip">> := true, <<"requested_scope">> := Scopes}}) ->
+ ConsentData = #{<<"grant_scope">> => Scopes},
+ case ory_hydra:accept_consent_request(Challenge, ConsentData) of
+ {ok, #{<<"redirect_to">> := Url}} ->
+ Req = styx_web:temporary_redirect(Req0, Url),
+ {ok, Req, State};
+ Error ->
+ error(Req0, State, Error)
+ end;
+do(Req0 = #{method := <<"GET">>}, State, Session, {ok, Flow = #{<<"client">> := Client}}) ->
+ %% FIXME client_name can be blank, not just undefined.
+ logger:debug("oAuth request ~p", [Flow]),
+ AppName = maps:get(<<"client_name">>, Client, maps:get(<<"client_id">>, Client, <<"Unnamed App">>)),
+ Assigns = [{"page_title", ["Authorize ", AppName]}, {"flow", Flow}],
+ Html = styx_web:render(Req0, oauth2_consent_form_dtl, Assigns),
+ Req = styx_web:reply_html(Req0, 200, Html),
+ {ok, Req, State};
+do(Req0 = #{method := <<"POST">>}, State, Session, {ok, Flow}) ->
+ {ok, Data, Req} = cowboy_req:read_urlencoded_body(Req0),
+ post(Req, State, Session, Flow, Data).
+
+post(Req0, State, Session, Flow, Data) ->
+ Consent = case lists:keyfind(<<"consent">>, 1, Data) of
+ {_, <<"true">>} -> true;
+ _ -> false
+ end,
+ consent(Req0, State, Session, Flow, Data, Consent).
+
+
+consent(Req0, State, _Session, #{<<"challenge">> := Challenge}, Data, true) ->
+ ScopesFun = fun
+ ({<<"scope-", S/binary>>, <<"on">>}, Acc) -> [S | Acc];
+ (_, Acc) -> Acc
+ end,
+ Scopes = lists:foldl(ScopesFun, [], Data),
+ ConsentData = #{<<"grant_scope">> => Scopes},
+ case ory_hydra:accept_consent_request(Challenge, ConsentData) of
+ {ok, #{<<"redirect_to">> := Url}} ->
+ Req = styx_web:temporary_redirect(Req0, Url),
+ {ok, Req, State};
+ Error ->
+ error(Req0, State, Error)
+ end;
+consent(Req0, State, _Session, #{<<"challenge">> := Challenge}, _, false) ->
+ Data = #{<<"error">> => <<"User denied access.">>, <<"status_code">> => 403},
+ case ory_hydra:reject_consent_request(Challenge, Data) of
+ {ok, #{<<"redirect_to">> := Url}} ->
+ Req = styx_web:temporary_redirect(Req0, Url),
+ {ok, Req, State};
+ Error ->
+ error(Req0, State, Error)
+ end.
+
+
+error(Req, State, {error, Error = #{<<"code">> := Code, <<"status">> := Status, <<"message">> := Msg}}) ->
+ styx_web_error:init(Req, #{code => Code, status => Status, message => Msg}).
diff --git a/apps/styx_web/src/styx_web_oauth2_login.erl b/apps/styx_web/src/styx_web_oauth2_login.erl
new file mode 100644
index 0000000..2413f2d
--- /dev/null
+++ b/apps/styx_web/src/styx_web_oauth2_login.erl
@@ -0,0 +1,61 @@
+-module(styx_web_oauth2_login).
+-behaviour(cowboy_handler).
+-export([init/2]).
+-export([get_cookie/1, unset_cookie/1]).
+-define(CHALLENGE_COOKIE, <<"_styx_oauth2_login">>).
+-define(CHALLENGE_MAX_AGE, 1800).
+-define(REMEMBER_MAX_AGE, 1800).
+
+init(Req = #{method := <<"GET">>}, State) ->
+ get(Req, State, styx_web:req_param(Req, <<"login_challenge">>));
+init(Req, _) ->
+ styx_web_error:init(Req, #{code => 404, status => <<"Not Found">>}).
+
+get_cookie(Req) ->
+ Cookies = cowboy_req:parse_cookies(Req),
+ case lists:keyfind(<<"lang">>, 1, Cookies) of
+ {?CHALLENGE_COOKIE, Challenge} -> {ok, Challenge};
+ _ -> {error, no_oauth2_challenge_cookie}
+ end.
+
+unset_cookie(Req) ->
+ cowboy_req:set_resp_cookie(?CHALLENGE_COOKIE, <<"null">>, Req, #{max_age => 0, http_only => true, path => <<"/">>}).
+
+get(Req0, State, {ok, Challenge}) ->
+ case ory_hydra:login_request(Challenge) of
+ {ok, Request} ->
+ Req = unset_cookie(Req0),
+ logger:debug("Got challenge for auth req ~p", [Request]),
+ authenticate(Req, State, Request);
+ {error, Error = #{<<"code">> := Code, <<"status">> := Status, <<"message">> := Msg}} ->
+ styx_web_error:init(Req0, #{code => Code, status => status, message => maps:get(<<"reason">>, Error, Msg)})
+ end;
+get(Req, State, {error, {missing_param, _}}) ->
+ styx_web_error:init(Req, not_found).
+
+authenticate(Req, State, Request) ->
+ Cookie = cowboy_req:header(<<"cookie">>, Req),
+ case ory_kratos:whoami(Cookie) of
+ {ok, Session = #{<<"active">> := true}} ->
+ challenge(Req, State, Request, Session);
+ {error, #{<<"code">> := 401}} ->
+ challenge(Req, State, Request, undefined);
+ {error, Error = #{<<"code">> := Code, <<"status">> := Status, <<"message">> := Msg}} ->
+ styx_web_error:init(Req, #{code => Code, status => Status, message => maps:get(<<"reason">>, Error, Msg)})
+ end.
+
+challenge(Req0, State, #{<<"challenge">> := Challenge}, undefined) ->
+ Req1 = cowboy_req:set_resp_cookie(?CHALLENGE_COOKIE, Challenge, Req0, #{max_age => ?CHALLENGE_MAX_AGE, http_only => true}),
+ Req = styx_web:temporary_redirect(Req1, <<"/login">>),
+ {ok, Req, State};
+%% XXX: What's the point of loggin in the user again?
+%%challenge(Req, State, Request = #{<<"skip">> := false}, Session) ->
+challenge(Req0, State, Request = #{<<"challenge">> := Challenge}, Session = #{<<"active">> := true, <<"identity">> := #{<<"id">> := Id, <<"traits">> := Traits}}) ->
+ Data = #{<<"subject">> => Id, <<"remember">> => true, <<"remember_for">> => ?REMEMBER_MAX_AGE, <<"context">> => Traits},
+ case ory_hydra:accept_login_request(Challenge, Data) of
+ {ok, #{<<"redirect_to">> := Redirect}} ->
+ Req = styx_web:temporary_redirect(Req0, Redirect),
+ {ok, Req, State};
+ {error, Error = #{<<"code">> := Code, <<"status">> := Status, <<"message">> := Msg}} ->
+ styx_web_error:init(Req0, #{code => Code, status => Status, message => maps:get(<<"reason">>, Error, Msg)})
+ end.
diff --git a/apps/styx_web/templates/oauth2_consent_form.dtl b/apps/styx_web/templates/oauth2_consent_form.dtl
new file mode 100644
index 0000000..b3f1614
--- /dev/null
+++ b/apps/styx_web/templates/oauth2_consent_form.dtl
@@ -0,0 +1,34 @@
+<div class="header">
+ <h2>{{page_title}}</h2>
+ <p>This application is requesting access to your account:</p>
+</div>
+
+<div class="mt-8">
+
+ <ul class="oauth-scopes">
+ <div class="mt-6">
+ <form action="#" method="POST" class="space-y-6">
+
+ <fieldset class="space-y-5">
+ <legend class="sr-only">Notifications</legend>
+ {% for scope in flow.requested_scope %}
+ <div class="relative flex items-start">
+ <div class="flex items-center h-5">
+ <input id="scope-{{scope}}" aria-describedby="scope-{{scope}}-description" name="scope-{{scope}}" type="checkbox" checked class="focus:ring-indigo-500 h-4 w-4 text-indigo-600 border-gray-300 rounded">
+ </div>
+ <div class="ml-3 text-sm">
+ <label for="scope-{{scope}}" class="font-medium text-gray-700">{{scope}}</label>
+ <span id="scope-{{scope}}-description" class="text-gray-500"><span class="sr-only">{{scope}}</span></span>
+ </div>
+ </div>
+ {% endfor %}
+ </fieldset>
+
+
+ <div class="btn-group">
+ <button type="submit" class="btn-submit" name="consent" value="true">Accept</button>
+ <button type="submit" class="btn-reject" name="consent" value="false">Reject</button>
+ </div>
+ </form>
+ </div>
+</div>