From af9204cf63e5ece573fa52f79237f2344bb3ebb6 Mon Sep 17 00:00:00 2001 From: dklawren Date: Tue, 20 Nov 2018 16:01:38 -0500 Subject: [PATCH] Bug 1354589 - Implement OAuth2 on BMO --- Bugzilla.pm | 8 + Bugzilla/Quantum.pm | 4 + Bugzilla/Quantum/OAuth2.pm | 424 ++++++++++++++++++ Bugzilla/Test/Util.pm | 31 +- t/mojo-oauth2.t | 177 ++++++++ .../account/auth/confirm_scopes.html.tmpl | 44 ++ template/en/default/global/messages.html.tmpl | 9 + 7 files changed, 696 insertions(+), 1 deletion(-) create mode 100644 Bugzilla/Quantum/OAuth2.pm create mode 100644 t/mojo-oauth2.t create mode 100644 template/en/default/account/auth/confirm_scopes.html.tmpl diff --git a/Bugzilla.pm b/Bugzilla.pm index b03d96462..6b9755b28 100644 --- a/Bugzilla.pm +++ b/Bugzilla.pm @@ -379,6 +379,14 @@ sub login { $class->user->update_last_seen_date(); } + # If Mojo native app is requesting login, we need to possibly redirect + my $C = $Bugzilla::Quantum::CGI::C; + if ($C->session->{override_login_target}) { + my $mojo_url = Mojo::URL->new($C->session->{override_login_target}); + $mojo_url->query($C->session->{cgi_params}); + $C->redirect_to($mojo_url); + } + return $class->user; } diff --git a/Bugzilla/Quantum.pm b/Bugzilla/Quantum.pm index e33e01d61..3e765008d 100644 --- a/Bugzilla/Quantum.pm +++ b/Bugzilla/Quantum.pm @@ -21,6 +21,7 @@ use Bugzilla::Extension (); use Bugzilla::Install::Requirements (); use Bugzilla::Logging; use Bugzilla::Quantum::CGI; +use Bugzilla::Quantum::OAuth2 qw(oauth2); use Bugzilla::Quantum::SES; use Bugzilla::Quantum::Home; use Bugzilla::Quantum::Static; @@ -46,6 +47,9 @@ sub startup { $self->plugin('ForwardedFor') if Bugzilla->has_feature('better_xff'); $self->plugin('Bugzilla::Quantum::Plugin::Helpers'); + # OAuth2 Support + oauth2($self); + # hypnotoad is weird and doesn't look for MOJO_LISTEN itself. $self->config( hypnotoad => { diff --git a/Bugzilla/Quantum/OAuth2.pm b/Bugzilla/Quantum/OAuth2.pm new file mode 100644 index 000000000..87d1aaf0a --- /dev/null +++ b/Bugzilla/Quantum/OAuth2.pm @@ -0,0 +1,424 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# This Source Code Form is "Incompatible With Secondary Licenses", as +# defined by the Mozilla Public License, v. 2.0. + +package Bugzilla::Quantum::OAuth2; + +use 5.10.1; +use strict; +use warnings; + +use Bugzilla; +use Bugzilla::Constants; +use Bugzilla::Error; +use Bugzilla::Logging; +use Bugzilla::Util; +use Bugzilla::Token; + +use DateTime; + +use Mojo::Util qw(secure_compare); + +use base qw(Exporter); +our @EXPORT_OK = qw(oauth2); + +sub oauth2 { + my ($self) = @_; + + $self->plugin( + 'OAuth2::Server' => { + login_resource_owner => \&_resource_owner_logged_in, + confirm_by_resource_owner => \&_resource_owner_confirm_scopes, + verify_client => \&_verify_client, + store_auth_code => \&_store_auth_code, + verify_auth_code => \&_verify_auth_code, + store_access_token => \&_store_access_token, + verify_access_token => \&_verify_access_token, + } + ); + + $self->helper( + 'bugzilla.oauth' => sub { + my ($c, @scopes) = @_; + + my $oauth = $c->oauth(@scopes); + + if ($oauth && $oauth->{user_id}) { + my $user = Bugzilla::User->check({id => $oauth->{user_id}, cache => 1}); + Bugzilla->set_user($user); + return $user; + } + + return undef; + } + ); + + return 1; +} + +sub _resource_owner_logged_in { + my (%args) = @_; + my $c = $args{mojo_controller}; + + $c->session->{override_login_target} = $c->url_for('current'); + $c->session->{cgi_params} = $c->req->params->to_hash; + + $c->bugzilla->login(LOGIN_REQUIRED) || return; + + delete $c->session->{override_login_target}; + delete $c->session->{cgi_params}; + + return 1; +} + +sub _resource_owner_confirm_scopes { + my (%args) = @_; + my ($c, $client_id, $scopes_ref) + = @args{qw/ mojo_controller client_id scopes /}; + + my $is_allowed = $c->param("oauth_confirm_${client_id}"); + + # if user hasn't yet allowed the client access, or if they denied + # access last time, we check [again] with the user for access + if (!defined $is_allowed) { + my $client + = Bugzilla->dbh->selectrow_hashref( + 'SELECT * FROM oauth2_client WHERE id = ?', + undef, $client_id); + my $vars = { + client => $client, + scopes => $scopes_ref, + token => issue_session_token('oauth_confirm_scopes') + }; + $c->stash(%$vars); + $c->render( + template => 'account/auth/confirm_scopes', + handler => 'bugzilla' + ); + return undef; + } + + my $token = $c->param('token'); + check_token_data($token, 'oauth_confirm_scopes'); + delete_token($token); + + return $is_allowed; +} + +sub _verify_client { + my (%args) = @_; + my ($c, $client_id, $scopes_ref) + = @args{qw/ mojo_controller client_id scopes /}; + my $dbh = Bugzilla->dbh; + + if (!@{$scopes_ref}) { + INFO('Client did not provide scopes'); + return (0, 'invalid_scope'); + } + + if ( + my $client_data = $dbh->selectrow_hashref( + 'SELECT * FROM oauth2_client WHERE id = ?', + undef, $client_id + ) + ) + { + if (!$client_data->{active}) { + INFO("Client ($client_id) is not active"); + return (0, 'unauthorized_client'); + } + + foreach my $rqd_scope (@{$scopes_ref}) { + my $scope_allowed = $dbh->selectrow_array( + 'SELECT allowed FROM oauth2_client_scope + JOIN oauth2_scope ON oauth2_scope.id = oauth2_client_scope.scope_id + WHERE client_id = ? AND oauth2_scope.description = ?', undef, + $client_id, $rqd_scope + ); + if (defined $scope_allowed) { + if (!$scope_allowed) { + INFO("Client disallowed scope ($rqd_scope)"); + return (0, 'access_denied'); + } + } + else { + INFO("Client lacks scope ($rqd_scope)"); + return (0, 'invalid_scope'); + } + } + + return (1); + } + + INFO("Client ($client_id) does not exist"); + return (0, 'unauthorized_client'); +} + +sub _store_auth_code { + my (%args) = @_; + my ($c, $auth_code, $client_id, $expires_in, $uri, $scopes_ref) + = @args{ + qw/ mojo_controller auth_code client_id expires_in redirect_uri scopes /}; + my $dbh = Bugzilla->dbh; + + my $user_id = Bugzilla->user->id; + + $dbh->do( + 'INSERT INTO oauth2_auth_code VALUES (?, ?, ?, ?, ?, 0)', + undef, + $auth_code, + $client_id, + Bugzilla->user->id, + DateTime->from_epoch(epoch => time + $expires_in), + $uri + ); + + foreach my $rqd_scope (@{$scopes_ref}) { + my $scope_id + = $dbh->selectrow_array( + 'SELECT id FROM oauth2_scope WHERE description = ?', + undef, $rqd_scope); + if ($scope_id) { + $dbh->do('INSERT INTO oauth2_auth_code_scope VALUES (?, ?, 1)', + undef, $auth_code, $scope_id); + } + else { + ERROR("Unknown scope ($rqd_scope) in _store_auth_code"); + } + } + + return; +} + +sub _verify_auth_code { + my (%args) = @_; + my ($c, $client_id, $client_secret, $auth_code, $uri) + = @args{ + qw/ mojo_controller client_id client_secret auth_code redirect_uri /}; + my $dbh = Bugzilla->dbh; + + my $client_data + = $dbh->selectrow_hashref('SELECT * FROM oauth2_client WHERE id = ?', + undef, $client_id); + $client_data || return (0, 'unauthorized_client'); + + my $auth_code_data = $dbh->selectrow_hashref( + 'SELECT expires, verified, redirect_uri, user_id FROM oauth2_auth_code WHERE client_id = ? AND auth_code = ?', + undef, $client_id, $auth_code + ); + + if (!$auth_code_data + or $auth_code_data->{verified} + or ($uri ne $auth_code_data->{redirect_uri}) + or (datetime_from($auth_code_data->{expires})->epoch <= time) + or !secure_compare($client_secret, $client_data->{secret})) + { + INFO('Auth code does not exist') if !$auth_code; + INFO('Client secret does not match') + if !secure_compare($client_secret, $client_data->{secret}); + + if ($auth_code) { + INFO('Client secret does not match') + if ($uri && $auth_code_data->{redirect_uri} ne $uri); + INFO('Auth code expired') if ($auth_code_data->{expires} <= time); + + if ($auth_code_data->{verified}) { + + # the auth code has been used before - we must revoke the auth code + # and any associated access tokens (same client_id and user_id) + INFO( 'Auth code already used to get access token, ' + . 'revoking all associated access tokens'); + $dbh->do('DELETE FROM oauth2_auth_code WHERE auth_code = ?', + undef, $auth_code); + $dbh->do( + 'DELETE FROM oauth2_access_token WHERE client_id = ? AND user_id = ?', + undef, $client_id, $auth_code_data->{user_id} + ); + } + } + + return (0, 'invalid_grant'); + } + + $dbh->do('UPDATE oauth2_auth_code SET verified = 1 WHERE auth_code = ?', + undef, $auth_code); + + # scopes are those that were requested in the authorization request, not + # those stored in the client (i.e. what the auth request restriced scopes + # to and not everything the client is capable of) + my $scope_descriptions = $dbh->selectcol_arrayref( + 'SELECT oauth2_scope.description FROM oauth2_scope + JOIN oauth2_auth_code_scope ON oauth2_scope.id = oauth2_auth_code_scope.scope_id + WHERE oauth2_auth_code_scope.auth_code = ?', undef, $auth_code + ); + + my %scope = map { $_ => 1 } @{$scope_descriptions}; + + return ($client_id, undef, {%scope}, $auth_code_data->{user_id}); +} + +sub _store_access_token { + my (%args) = @_; + my ($c, $client, $auth_code, $access_token, $refresh_token, $expires_in, + $scopes, $old_refresh_token) + = @args{ + qw/ mojo_controller client_id auth_code access_token refresh_token expires_in scopes old_refresh_token / + }; + my $dbh = Bugzilla->dbh; + my ($user_id); + + if (!defined $auth_code && $old_refresh_token) { + # must have generated an access token via a refresh token so revoke the + # old access token and refresh token (also copy required data if missing) + my $prev_refresh_token + = $dbh->selectrow_hashref( + 'SELECT * FROM oauth2_refresh_token WHERE refresh_token = ?', + undef, $old_refresh_token); + my $prev_access_token + = $dbh->selectrow_hashref( + 'SELECT * FROM oauth2_access_token WHERE access_token = ?', + undef, $prev_refresh_token->{access_token}); + + # access tokens can be revoked, whilst refresh tokens can remain so we + # need to get the data from the refresh token as the access token may + # no longer exist at the point that the refresh token is used + my $scope_descriptions = $dbh->selectall_array( + 'SELECT oauth2_scope.description FROM oauth2_scope + JOIN oauth2_access_token_scope ON scope.id = oauth2_access_token_scope.scope_id + WHERE access_token = ?', undef, $old_refresh_token + ); + $scopes //= map { $_ => 1 } @{ $scope_descriptions }; + + $user_id = $prev_refresh_token->{user_id}; + } + else { + $user_id + = $dbh->selectrow_array( + 'SELECT user_id FROM oauth2_auth_code WHERE auth_code = ?', + undef, $auth_code); + } + + if (ref $client) { + $scopes //= $client->{scope}; + $user_id //= $client->{user_id}; + $client = $client->{client_id}; + } + + foreach my $token_type (qw/ access refresh /) { + my $table = "oauth2_${token_type}_token"; + + # if the client has en existing access/refresh token we need to revoke it + $dbh->do("DELETE FROM $table WHERE client_id = ? AND user_id = ?", + undef, $client, $user_id); + } + + $dbh->do( + 'INSERT INTO oauth2_access_token VALUES (?, ?, ?, ?, ?)', undef, + $access_token, $refresh_token, + $client, $user_id, + DateTime->from_epoch(epoch => time + $expires_in) + ); + + $dbh->do('INSERT INTO oauth2_refresh_token VALUES (?, ?, ?, ?)', + undef, $refresh_token, $access_token, $client, $user_id); + + foreach my $rqd_scope (keys %{$scopes}) { + my $scope_id + = $dbh->selectrow_array( + 'SELECT id FROM oauth2_scope WHERE description = ?', + undef, $rqd_scope); + if ($scope_id) { + foreach my $related (qw/ access_token refresh_token /) { + my $table = "oauth2_${related}_scope"; + $dbh->do( + "INSERT INTO $table VALUES (?, ?, ?)", + undef, + $related eq 'access_token' ? $access_token : $refresh_token, + $scope_id, + $scopes->{$rqd_scope} + ); + } + } + else { + ERROR("Unknown scope ($rqd_scope) in _store_access_token"); + } + } + + return; +} + +sub _verify_access_token { + my (%args) = @_; + my ($c, $access_token, $scopes_ref) + = @args{qw/ mojo_controller access_token scope /}; + my $dbh = Bugzilla->dbh; + + if ( + my $refresh_token_data = $dbh->selectrow_hashref( + 'SELECT * FROM oauth2_refresh_token WHERE access_token = ?', undef, + $access_token + ) + ) + { + foreach my $scope (@{$scopes_ref // []}) { + my $scope_allowed = $dbh->selectrow_array( + 'SELECT allowed FROM oauth2_refresh_token_scope + JOIN oauth2_scope ON oauth2_scope.id = oauth2_refresh_token_scope.scope_id + WHERE refresh_token = ? AND oauth2_scope.description = ?', undef, + $access_token, $scope + ); + + if (!defined $scope_allowed || !$scope_allowed) { + INFO("Refresh token doesn't have scope ($scope)"); + return (0, 'invalid_grant'); + } + } + + return { + client_id => $refresh_token_data->{client_id}, + user_id => $refresh_token_data->{user_id}, + }; + } + elsif ( + my $access_token_data = $dbh->selectrow_hashref( + 'SELECT expires, client_id, user_id FROM oauth2_access_token WHERE access_token = ?', + undef, + $access_token + ) + ) + { + if (datetime_from($access_token_data->{expires})->epoch <= time) { + INFO('Access token has expired'); + $dbh->do('DELETE FROM oauth2_access_token WHERE access_token = ?', + undef, $access_token); + return (0, 'invalid_grant'); + } + + foreach my $scope (@{$scopes_ref // []}) { + my $scope_allowed = $dbh->selectrow_array( + 'SELECT allowed FROM oauth2_access_token_scope + JOIN oauth2_scope ON oauth2_access_token_scope.scope_id = oauth2_scope.id + WHERE scope.description = ? AND access_token = ?', undef, $scope, + $access_token + ); + if (!defined $scope_allowed || !$scope_allowed) { + INFO("Access token doesn't have scope ($scope)"); + return (0, 'invalid_grant'); + } + } + + return { + client_id => $access_token_data->{client_id}, + user_id => $access_token_data->{user_id}, + }; + } + else { + INFO('Access token does not exist'); + return (0, 'invalid_grant'); + } +} + +1; diff --git a/Bugzilla/Test/Util.pm b/Bugzilla/Test/Util.pm index 9fbc151f7..b8485f29e 100644 --- a/Bugzilla/Test/Util.pm +++ b/Bugzilla/Test/Util.pm @@ -12,10 +12,12 @@ use strict; use warnings; use base qw(Exporter); -our @EXPORT = qw(create_user issue_api_key mock_useragent_tx); +our @EXPORT + = qw(create_user create_oauth_client issue_api_key mock_useragent_tx); use Bugzilla::User; use Bugzilla::User::APIKey; +use Bugzilla::Util qw(generate_random_password); use Mojo::Message::Response; use Test2::Tools::Mock qw(mock); @@ -32,6 +34,33 @@ sub create_user { }); } +sub create_oauth_client { + my ($description, $scopes) = @_; + my $dbh = Bugzilla->dbh; + + my $id = generate_random_password(20); + my $secret = generate_random_password(40); + + $dbh->do( + 'INSERT INTO oauth2_client (id, description, secret) VALUES (?, ?, ?)', + undef, $id, $description, $secret); + + foreach my $scope (@{$scopes}) { + my $scope_id + = $dbh->selectrow_array('SELECT id FROM oauth2_scope WHERE description = ?', + undef, $scope); + if (!$scope_id) { + die "Scope $scope not found"; + } + $dbh->do( + 'INSERT INTO oauth2_client_scope (client_id, scope_id, allowed) VALUES (?, ?, 1)', + undef, $id, $scope_id + ); + } + + return $dbh->selectrow_hashref('SELECT * FROM oauth2_client WHERE id = ?', undef, $id); +} + sub issue_api_key { my ($login, $given_api_key) = @_; my $user = Bugzilla::User->check({ name => $login }); diff --git a/t/mojo-oauth2.t b/t/mojo-oauth2.t new file mode 100644 index 000000000..904ee8169 --- /dev/null +++ b/t/mojo-oauth2.t @@ -0,0 +1,177 @@ +#!/usr/bin/perl +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +# +# This Source Code Form is "Incompatible With Secondary Licenses", as +# defined by the Mozilla Public License, v. 2.0. +use strict; +use warnings; +use 5.10.1; +use lib qw( . lib local/lib/perl5 ); + +BEGIN { + $ENV{LOG4PERL_CONFIG_FILE} = 'log4perl-t.conf'; + $ENV{BUGZILLA_DISABLE_HOSTAGE} = 1; +} + +use Bugzilla::Test::MockDB; +use Bugzilla::Test::MockParams (password_complexity => 'no_constraints'); +use Bugzilla::Test::Util qw(create_user create_oauth_client); + +use Test2::V0; +use Test::Mojo; + +my $oauth_login = 'oauth@mozilla.bugs'; +my $oauth_password = 'password123456789!'; +my $referer = Bugzilla->localconfig->{urlbase}; +my $stash = {}; + +# Create user to use as OAuth2 resource owner +create_user($oauth_login, $oauth_password); + +# Create a new OAuth2 client used for testing +my $oauth_client = create_oauth_client('Shiny New OAuth Client', ['user:read']); +ok $oauth_client->{id}, 'New client id (' . $oauth_client->{id} . ')'; +ok $oauth_client->{secret}, 'New client secret (' . $oauth_client->{secret} . ')'; + +my $t = Test::Mojo->new('Bugzilla::Quantum'); + +# Allow 1 redirect max +$t->ua->max_redirects(1); + +# Custom routes and hooks required to support running the tests +_setup_routes($t->app->routes); +$t->app->hook(after_dispatch => sub { $stash = shift->stash }); + +# User should be logged out so /oauth/authorize should redirect to a login screen +$t->get_ok( + '/oauth/authorize' => {Referer => $referer} => form => { + client_id => $oauth_client->{id}, + response_type => 'code', + state => 'state', + scope => 'user:read', + redirect_uri => '/oauth/redirect' + } +)->status_is(200) + ->element_exists('div.login-form input[name=Bugzilla_login_token]') + ->text_is('html head title' => 'Log in to Bugzilla'); + +# Login the user in using the resource owner username and password +# Once logged in, we should automatically be redirected to the confirm +# scopes page. +$t->post_ok( + '/login' => {Referer => $referer} => form => { + Bugzilla_login => $oauth_login, + Bugzilla_password => $oauth_password, + Bugzilla_restrictlogin => 1, + GoAheadAndLogIn => 1, + client_id => $oauth_client->{id}, + response_type => 'code', + state => 'state', + scope => 'user:read', + redirect_uri => '/oauth/redirect' + } +)->status_is(200)->text_is('title' => 'Confirm OAuth2 Scopes'); + +# Get the csrf token to allow submitting the scope confirmation form +my $csrf_token = $t->tx->res->dom->at('input[name=token]')->val; +ok $csrf_token, "Get csrf token ($csrf_token)"; + +# Redirect and get the auth code needed for obtaining an access token +# Once we accept the scopes requested, we should get redirected to the +# URI specified in the redirect_uri value. In this case a simple text page. +$t->get_ok( + '/oauth/authorize' => {Referer => $referer} => form => { + "oauth_confirm_" . $oauth_client->{id} => 1, + token => $csrf_token, + client_id => $oauth_client->{id}, + response_type => 'code', + state => 'state', + scope => 'user:read', + redirect_uri => '/oauth/redirect' + } +)->status_is(200)->content_is('Redirect Success!'); + +# The redirect page (normally an external site associated with the +# OAuth2 client) should verify the state token and also get a temporary +# auth code that will be used to request an access token. +my $state = $stash->{state}; +ok $state eq 'state', "State was returned correctly"; +my $auth_code = $stash->{auth_code}; +ok $auth_code, "Get auth code ($auth_code)"; + +# Contact the OAuth2 server using the auth code to obtain an access token +# This happens as a backend POST the the server and is not visible to the +# end user. +$t->post_ok( + '/oauth/access_token' => {Referer => $referer} => form => { + client_id => $oauth_client->{id}, + client_secret => $oauth_client->{secret}, + code => $auth_code, + grant_type => 'authorization_code', + redirect_uri => '/oauth/redirect', + } +)->status_is(200)->json_has('access_token', 'Has access token') + ->json_has('refresh_token', 'Has refresh token') + ->json_has('token_type', 'Has token type'); + +my $access_data = $t->tx->res->json; + +# Using the access token (bearer) we are able to authenticate for an API call. + +# 1. Access API unauthenticated and should generate a login_required error +$t->get_ok('/oauth/whoami')->status_is(401) + ->json_is('/error' => 'login_required'); + +# 2. Passing a Bearer header containing the access token, the server should +# allow us to get data about our user +$t->get_ok('/oauth/whoami' => + {Authorization => 'Bearer ' . $access_data->{access_token}}) + ->status_is(200)->json_is('/name' => $oauth_login); + +done_testing; + +sub _setup_routes { + my $r = shift; + + # Add /oauth/redirect route for checking final redirection + $r->get( + '/oauth/redirect' => sub { + my $c = shift; + $c->stash(state => $c->param('state'), auth_code => $c->param('code')); + $c->render(status => 200, text => 'Redirect Success!'); + return; + } + ); + + # API call for testing oauth authentication + $r->get( + '/oauth/whoami' => sub { + my $c = shift; + + my $user = $c->bugzilla->oauth('user:read'); + + if ($user && $user->id) { + $c->render( + status => 200, + json => { + id => $user->id, + name => $user->login, + realname => $user->name + } + ); + } + else { + $c->render( + status => 401, + json => { + error => 'login_required', + error_description => + 'You must log in before using this part of Bugzilla.' + } + ); + } + } + ); +} diff --git a/template/en/default/account/auth/confirm_scopes.html.tmpl b/template/en/default/account/auth/confirm_scopes.html.tmpl new file mode 100644 index 000000000..76b51e1f8 --- /dev/null +++ b/template/en/default/account/auth/confirm_scopes.html.tmpl @@ -0,0 +1,44 @@ +[%# This Source Code Form is subject to the terms of the Mozilla Public + # License, v. 2.0. If a copy of the MPL was not distributed with this + # file, You can obtain one at http://mozilla.org/MPL/2.0/. + # + # This Source Code Form is "Incompatible With Secondary Licenses", as + # defined by the Mozilla Public License, v. 2.0. + #%] + +[% PROCESS global/header.html.tmpl + title = "Confirm OAuth2 Scopes" %] + +

[% title FILTER html %]

+

+ A third-party website [% client.description FILTER html %] would like to have + the following access to your [% terms.Bugzilla %] account. +

+ +

+Scopes: +

+

+ +

Do you want this website to have the above access to your [% terms.Bugzilla %] + account?

+ +
+
+ + + + [% FOREACH field = c.req.params.names %] + + [% END %] +
+
+ +[% PROCESS global/footer.html.tmpl %] diff --git a/template/en/default/global/messages.html.tmpl b/template/en/default/global/messages.html.tmpl index 0a7b4ea29..8a9a368bd 100644 --- a/template/en/default/global/messages.html.tmpl +++ b/template/en/default/global/messages.html.tmpl @@ -808,6 +808,15 @@ [% ELSIF message_tag == "install_workflow_init" %] Setting up the default status workflow... + [% ELSIF message_tag == 'oauth_client_created' %] + The OAuth2 client [% client.description FILTER html %] has been created. + + [% ELSIF message_tag == 'oauth_client_updated' %] + The OAuth2 client [% client.description FILTER html %] has been updated. + + [% ELSIF message_tag == "oauth_client_deleted" %] + The OAuth2 client [% client.description FILTER html %] has been deleted. + [% ELSIF message_tag == "product_created" %] [% title = "Product Created" %] The product [% product.name FILTER html %] has been created. You will need to -- 2.47.3