diff options
author | Jordan Bracco <href@random.sh> | 2021-08-12 16:10:40 +0200 |
---|---|---|
committer | Jordan Bracco <href@random.sh> | 2021-08-12 16:10:40 +0200 |
commit | 681cd1eda65d584e98f845b095382777aed67597 (patch) | |
tree | 5007033362d99e7a1ef40e566daff7463d977b57 /apps | |
parent | Kratos flows (diff) |
Hydra login/consent oauth2 flows
Diffstat (limited to 'apps')
-rw-r--r-- | apps/ory/src/ory_hydra.erl | 51 | ||||
-rw-r--r-- | apps/styx_web/priv/assets/app.css | 139 | ||||
-rw-r--r-- | apps/styx_web/src/styx_web_app.erl | 2 | ||||
-rw-r--r-- | apps/styx_web/src/styx_web_error.erl | 5 | ||||
-rw-r--r-- | apps/styx_web/src/styx_web_launchpad.erl | 17 | ||||
-rw-r--r-- | apps/styx_web/src/styx_web_oauth2_consent.erl | 79 | ||||
-rw-r--r-- | apps/styx_web/src/styx_web_oauth2_login.erl | 61 | ||||
-rw-r--r-- | apps/styx_web/templates/oauth2_consent_form.dtl | 34 |
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> |