mirror of
https://github.com/Part-DB/Part-DB-server.git
synced 2026-08-01 06:01:42 +00:00
Added authorization endpoints
This commit is contained in:
parent
01a747da1d
commit
37108dbf56
6 changed files with 406 additions and 0 deletions
|
|
@ -70,6 +70,11 @@ security:
|
||||||
# We get into trouble with the U2F authentication, if the calls to the trees trigger an 2FA login
|
# We get into trouble with the U2F authentication, if the calls to the trees trigger an 2FA login
|
||||||
# This settings should not do much harm, because a read only access to show available data structures is not really critical
|
# This settings should not do much harm, because a read only access to show available data structures is not really critical
|
||||||
- { path: "^/\\w{2}/tree", role: PUBLIC_ACCESS }
|
- { path: "^/\\w{2}/tree", role: PUBLIC_ACCESS }
|
||||||
|
# The OAuth2 consent screen (see App\EventListener\OAuth\AuthorizationConsentListener) needs a
|
||||||
|
# fully logged-in user - AuthorizationRequestResolveEventFactory throws otherwise. Requiring full
|
||||||
|
# authentication here (rather than relying on is_authenticated()) triggers Symfony's normal
|
||||||
|
# redirect-to-login-then-back flow for anonymous requests, same as the user settings pages.
|
||||||
|
- { path: ^/authorize, role: IS_AUTHENTICATED_FULLY }
|
||||||
# Restrict access to API to users, which has the API access permission
|
# Restrict access to API to users, which has the API access permission
|
||||||
- { path: "^/api", allow_if: 'is_granted("@api.access_api") and is_authenticated()' }
|
- { path: "^/api", allow_if: 'is_granted("@api.access_api") and is_authenticated()' }
|
||||||
# Restrict access to KICAD to users, which has API access permission
|
# Restrict access to KICAD to users, which has API access permission
|
||||||
|
|
|
||||||
103
src/EventListener/OAuth/AuthorizationConsentListener.php
Normal file
103
src/EventListener/OAuth/AuthorizationConsentListener.php
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||||
|
*
|
||||||
|
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
|
* by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\EventListener\OAuth;
|
||||||
|
|
||||||
|
use App\Entity\UserSystem\ApiTokenLevel;
|
||||||
|
use League\Bundle\OAuth2ServerBundle\Event\AuthorizationRequestResolveEvent;
|
||||||
|
use League\Bundle\OAuth2ServerBundle\OAuth2Events;
|
||||||
|
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||||
|
use Symfony\Component\HttpFoundation\RequestStack;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||||
|
use Symfony\Component\Security\Csrf\CsrfToken;
|
||||||
|
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
|
||||||
|
use Twig\Environment;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the consent screen for the OAuth2 authorization code flow (see
|
||||||
|
* config/packages/league_oauth2_server.yaml) and turns the user's decision into an approval/denial of
|
||||||
|
* the AuthorizationRequestResolveEvent.
|
||||||
|
*
|
||||||
|
* league/oauth2-server-bundle 1.2 has no built-in consent UI or listener for this event at all (unlike
|
||||||
|
* some other OAuth2 bundles) - the "user" on the event is resolved directly from the current Symfony
|
||||||
|
* security session by AuthorizationRequestResolveEventFactory, which throws if nobody is logged in; the
|
||||||
|
* /authorize access_control entry (IS_AUTHENTICATED_FULLY) makes sure a login happens first.
|
||||||
|
*
|
||||||
|
* The consent form resubmits to the exact same URL (same query string, so
|
||||||
|
* league/oauth2-server re-derives an identical AuthorizationRequest - it only reads request params from
|
||||||
|
* the query string, never the POST body), with the actual decision + a CSRF token in the POST body.
|
||||||
|
*/
|
||||||
|
#[AsEventListener(event: OAuth2Events::AUTHORIZATION_REQUEST_RESOLVE)]
|
||||||
|
class AuthorizationConsentListener
|
||||||
|
{
|
||||||
|
public const CSRF_TOKEN_ID = 'oauth_authorize';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly RequestStack $requestStack,
|
||||||
|
private readonly Environment $twig,
|
||||||
|
private readonly CsrfTokenManagerInterface $csrfTokenManager,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __invoke(AuthorizationRequestResolveEvent $event): void
|
||||||
|
{
|
||||||
|
$request = $this->requestStack->getMainRequest();
|
||||||
|
|
||||||
|
if ($request?->isMethod('POST') && $request->request->has('oauth_decision')) {
|
||||||
|
$token = new CsrfToken(self::CSRF_TOKEN_ID, (string) $request->request->get('_csrf_token'));
|
||||||
|
if (!$this->csrfTokenManager->isTokenValid($token)) {
|
||||||
|
throw new AccessDeniedHttpException('Invalid CSRF token.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$event->resolveAuthorization('approve' === $request->request->get('oauth_decision'));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = new Response($this->twig->render('oauth/authorize.html.twig', [
|
||||||
|
'client' => $event->getClient(),
|
||||||
|
'levels' => $this->scopesToLevels($event->getScopes()),
|
||||||
|
'csrf_token_id' => self::CSRF_TOKEN_ID,
|
||||||
|
]));
|
||||||
|
|
||||||
|
$event->setResponse($response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scopes are just App\Entity\UserSystem\ApiTokenLevel case names lowercased (see
|
||||||
|
* App\Security\OAuth\OAuthBearerAuthenticator and config/packages/league_oauth2_server.yaml's
|
||||||
|
* scopes/role_prefix) - map them back for a human-readable consent screen.
|
||||||
|
*
|
||||||
|
* @param \League\Bundle\OAuth2ServerBundle\ValueObject\Scope[] $scopes
|
||||||
|
* @return ApiTokenLevel[]
|
||||||
|
*/
|
||||||
|
private function scopesToLevels(array $scopes): array
|
||||||
|
{
|
||||||
|
$requested = array_map(static fn ($scope) => (string) $scope, $scopes);
|
||||||
|
|
||||||
|
return array_values(array_filter(
|
||||||
|
ApiTokenLevel::cases(),
|
||||||
|
static fn (ApiTokenLevel $level) => \in_array(strtolower($level->name), $requested, true)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
74
src/EventListener/OAuth/OAuthExternalRedirectListener.php
Normal file
74
src/EventListener/OAuth/OAuthExternalRedirectListener.php
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||||
|
*
|
||||||
|
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
|
* by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\EventListener\OAuth;
|
||||||
|
|
||||||
|
use Nelmio\SecurityBundle\ExternalRedirect\ExternalRedirectResponse;
|
||||||
|
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||||
|
use Symfony\Component\HttpKernel\Event\ResponseEvent;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exempts the OAuth2 /authorize and /token redirect responses (league/oauth2-server-bundle) from
|
||||||
|
* NelmioSecurityBundle's generic external-redirect protection (config/packages/nelmio_security.yaml's
|
||||||
|
* external_redirects, which 403s any redirect to a host not on its fixed allow_list).
|
||||||
|
*
|
||||||
|
* This is safe specifically because league/oauth2-server has *already* strictly validated the redirect
|
||||||
|
* target against the OAuth client's own pre-registered redirect_uris (AuthCodeGrant::validateRedirectUri())
|
||||||
|
* before this response was created - Nelmio's allow_list just has no way to know about that per-client
|
||||||
|
* validation, since it's a static, app-wide list. This only re-exempts this one response object; it does
|
||||||
|
* not disable Nelmio's protection anywhere else.
|
||||||
|
*
|
||||||
|
* Must run *before* Nelmio\SecurityBundle\EventListener\ExternalRedirectListener (registered at the
|
||||||
|
* default kernel.response priority of 0), hence the explicit positive priority here.
|
||||||
|
*/
|
||||||
|
#[AsEventListener(event: 'kernel.response', priority: 32)]
|
||||||
|
class OAuthExternalRedirectListener
|
||||||
|
{
|
||||||
|
private const OAUTH_REDIRECT_PATHS = ['/authorize', '/token'];
|
||||||
|
|
||||||
|
public function __invoke(ResponseEvent $event): void
|
||||||
|
{
|
||||||
|
if (!$event->isMainRequest()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!\in_array($event->getRequest()->getPathInfo(), self::OAUTH_REDIRECT_PATHS, true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $event->getResponse();
|
||||||
|
if ($response instanceof ExternalRedirectResponse || !$response->isRedirect()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$target = $response->headers->get('Location');
|
||||||
|
$host = \is_string($target) ? parse_url($target, PHP_URL_HOST) : null;
|
||||||
|
if (!\is_string($host)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$exemptResponse = new ExternalRedirectResponse($target, [$host], $response->getStatusCode());
|
||||||
|
$exemptResponse->headers->add($response->headers->all());
|
||||||
|
|
||||||
|
$event->setResponse($exemptResponse);
|
||||||
|
}
|
||||||
|
}
|
||||||
35
templates/oauth/authorize.html.twig
Normal file
35
templates/oauth/authorize.html.twig
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
{% extends "main_card.html.twig" %}
|
||||||
|
|
||||||
|
{% block title %}{% trans %}oauth.authorize.title{% endtrans %}{% endblock %}
|
||||||
|
|
||||||
|
{% block card_title %}<h5>
|
||||||
|
<i class="fa-solid fa-key fa-fw" aria-hidden="true"></i>
|
||||||
|
{% trans %}oauth.authorize.title{% endtrans %}
|
||||||
|
</h5>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block card_content %}
|
||||||
|
<p>{% trans with {'%client%': client.name} %}oauth.authorize.description{% endtrans %}</p>
|
||||||
|
|
||||||
|
{% if levels is not empty %}
|
||||||
|
<p>{% trans %}oauth.authorize.scopes_intro{% endtrans %}</p>
|
||||||
|
<ul>
|
||||||
|
{% for level in levels %}
|
||||||
|
<li>{{ (level.translationKey)|trans }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<p class="text-muted">{% trans %}oauth.authorize.revoke_hint{% endtrans %}</p>
|
||||||
|
|
||||||
|
<form method="post" action="{{ app.request.requestUri }}">
|
||||||
|
<input type="hidden" name="_csrf_token" value="{{ csrf_token(csrf_token_id) }}">
|
||||||
|
|
||||||
|
<button type="submit" name="oauth_decision" value="deny" class="btn btn-secondary">
|
||||||
|
{% trans %}oauth.authorize.deny{% endtrans %}
|
||||||
|
</button>
|
||||||
|
<button type="submit" name="oauth_decision" value="approve" class="btn btn-primary">
|
||||||
|
{% trans %}oauth.authorize.approve{% endtrans %}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
153
tests/EventListener/OAuth/AuthorizationConsentListenerTest.php
Normal file
153
tests/EventListener/OAuth/AuthorizationConsentListenerTest.php
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
<?php
|
||||||
|
/*
|
||||||
|
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
|
||||||
|
*
|
||||||
|
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published
|
||||||
|
* by the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\EventListener\OAuth;
|
||||||
|
|
||||||
|
use App\Entity\UserSystem\User;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use League\Bundle\OAuth2ServerBundle\Manager\ClientManagerInterface;
|
||||||
|
use League\Bundle\OAuth2ServerBundle\Model\Client;
|
||||||
|
use League\Bundle\OAuth2ServerBundle\ValueObject\Grant;
|
||||||
|
use League\Bundle\OAuth2ServerBundle\ValueObject\RedirectUri;
|
||||||
|
use League\Bundle\OAuth2ServerBundle\ValueObject\Scope;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drives the real GET/POST /authorize HTTP flow (routes/controller provided by
|
||||||
|
* league/oauth2-server-bundle, consent screen provided by
|
||||||
|
* App\EventListener\OAuth\AuthorizationConsentListener), rather than calling AuthorizationServer
|
||||||
|
* directly - this is the only test covering the actual consent screen and its CSRF handling.
|
||||||
|
*/
|
||||||
|
final class AuthorizationConsentListenerTest extends WebTestCase
|
||||||
|
{
|
||||||
|
private function createTestClient(\Symfony\Bundle\FrameworkBundle\KernelBrowser $httpClient, string $redirectUri): Client
|
||||||
|
{
|
||||||
|
$client = new Client('Test Client', 'test-client-'.bin2hex(random_bytes(8)), null);
|
||||||
|
$client->setRedirectUris(new RedirectUri($redirectUri));
|
||||||
|
$client->setGrants(new Grant('authorization_code'), new Grant('refresh_token'));
|
||||||
|
$client->setScopes(new Scope('read_only'));
|
||||||
|
$client->setActive(true);
|
||||||
|
|
||||||
|
static::getContainer()->get(ClientManagerInterface::class)->save($client);
|
||||||
|
|
||||||
|
return $client;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function authorizeUrl(Client $client, string $redirectUri): string
|
||||||
|
{
|
||||||
|
$verifier = rtrim(strtr(base64_encode(random_bytes(40)), '+/', '-_'), '=');
|
||||||
|
$challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
|
||||||
|
|
||||||
|
return '/authorize?'.http_build_query([
|
||||||
|
'response_type' => 'code',
|
||||||
|
'client_id' => $client->getIdentifier(),
|
||||||
|
'redirect_uri' => $redirectUri,
|
||||||
|
'code_challenge' => $challenge,
|
||||||
|
'code_challenge_method' => 'S256',
|
||||||
|
'scope' => 'read_only',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUnauthenticatedRequestIsRejected(): void
|
||||||
|
{
|
||||||
|
// Note: config/packages/test/security.yaml overrides the "main" firewall's entry_point to
|
||||||
|
// "http_basic" for the whole test environment, so unauthenticated requests to any
|
||||||
|
// IS_AUTHENTICATED_FULLY-protected route (including /authorize) get a 401 "Basic realm"
|
||||||
|
// challenge here, not the production redirect-to-/login behaviour of
|
||||||
|
// App\Security\AuthenticationEntryPoint. Same convention as
|
||||||
|
// App\Tests\Controller\AuthorizationTest::testUnauthenticatedIsUnauthorizedOnWriteRoutes.
|
||||||
|
$httpClient = static::createClient();
|
||||||
|
$redirectUri = 'https://client.example.invalid/callback';
|
||||||
|
$client = $this->createTestClient($httpClient, $redirectUri);
|
||||||
|
|
||||||
|
$httpClient->request('GET', $this->authorizeUrl($client, $redirectUri));
|
||||||
|
self::assertResponseStatusCodeSame(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testApproveRedirectsWithAuthorizationCode(): void
|
||||||
|
{
|
||||||
|
$httpClient = static::createClient();
|
||||||
|
$entityManager = static::getContainer()->get(EntityManagerInterface::class);
|
||||||
|
$admin = $entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']);
|
||||||
|
self::assertInstanceOf(User::class, $admin);
|
||||||
|
$httpClient->loginUser($admin);
|
||||||
|
|
||||||
|
$redirectUri = 'https://client.example.invalid/callback';
|
||||||
|
$client = $this->createTestClient($httpClient, $redirectUri);
|
||||||
|
|
||||||
|
$crawler = $httpClient->request('GET', $this->authorizeUrl($client, $redirectUri));
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
|
||||||
|
$form = $crawler->selectButton('Approve')->form();
|
||||||
|
$httpClient->submit($form);
|
||||||
|
|
||||||
|
self::assertResponseRedirects();
|
||||||
|
$location = $httpClient->getResponse()->headers->get('Location');
|
||||||
|
self::assertNotNull($location);
|
||||||
|
self::assertStringStartsWith($redirectUri, $location);
|
||||||
|
parse_str((string) parse_url($location, PHP_URL_QUERY), $query);
|
||||||
|
self::assertArrayHasKey('code', $query);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDenyRedirectsWithAccessDeniedError(): void
|
||||||
|
{
|
||||||
|
$httpClient = static::createClient();
|
||||||
|
$entityManager = static::getContainer()->get(EntityManagerInterface::class);
|
||||||
|
$admin = $entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']);
|
||||||
|
self::assertInstanceOf(User::class, $admin);
|
||||||
|
$httpClient->loginUser($admin);
|
||||||
|
|
||||||
|
$redirectUri = 'https://client.example.invalid/callback';
|
||||||
|
$client = $this->createTestClient($httpClient, $redirectUri);
|
||||||
|
|
||||||
|
$crawler = $httpClient->request('GET', $this->authorizeUrl($client, $redirectUri));
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
|
||||||
|
$form = $crawler->selectButton('Deny')->form();
|
||||||
|
$httpClient->submit($form);
|
||||||
|
|
||||||
|
self::assertResponseRedirects();
|
||||||
|
$location = $httpClient->getResponse()->headers->get('Location');
|
||||||
|
self::assertNotNull($location);
|
||||||
|
parse_str((string) parse_url($location, PHP_URL_QUERY), $query);
|
||||||
|
self::assertSame('access_denied', $query['error'] ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMissingCsrfTokenIsRejected(): void
|
||||||
|
{
|
||||||
|
$httpClient = static::createClient();
|
||||||
|
$entityManager = static::getContainer()->get(EntityManagerInterface::class);
|
||||||
|
$admin = $entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']);
|
||||||
|
self::assertInstanceOf(User::class, $admin);
|
||||||
|
$httpClient->loginUser($admin);
|
||||||
|
|
||||||
|
$redirectUri = 'https://client.example.invalid/callback';
|
||||||
|
$client = $this->createTestClient($httpClient, $redirectUri);
|
||||||
|
$url = $this->authorizeUrl($client, $redirectUri);
|
||||||
|
|
||||||
|
$httpClient->request('GET', $url);
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
|
||||||
|
$httpClient->request('POST', $url, ['oauth_decision' => 'approve', '_csrf_token' => 'invalid']);
|
||||||
|
self::assertResponseStatusCodeSame(403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13823,5 +13823,41 @@ Buerklin-API Authentication server:
|
||||||
<target>Using this info provider can cause additional costs, or has very strict rate limits.</target>
|
<target>Using this info provider can cause additional costs, or has very strict rate limits.</target>
|
||||||
</segment>
|
</segment>
|
||||||
</unit>
|
</unit>
|
||||||
|
<unit id="oaAuth01" name="oauth.authorize.title">
|
||||||
|
<segment>
|
||||||
|
<source>oauth.authorize.title</source>
|
||||||
|
<target>Authorize application</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="oaAuth02" name="oauth.authorize.description">
|
||||||
|
<segment>
|
||||||
|
<source>oauth.authorize.description</source>
|
||||||
|
<target>The application "%client%" wants to access your Part-DB account.</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="oaAuth03" name="oauth.authorize.scopes_intro">
|
||||||
|
<segment>
|
||||||
|
<source>oauth.authorize.scopes_intro</source>
|
||||||
|
<target>It is requesting the following level of access:</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="oaAuth04" name="oauth.authorize.revoke_hint">
|
||||||
|
<segment>
|
||||||
|
<source>oauth.authorize.revoke_hint</source>
|
||||||
|
<target>You can revoke this access at any time from your user settings.</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="oaAuth05" name="oauth.authorize.approve">
|
||||||
|
<segment>
|
||||||
|
<source>oauth.authorize.approve</source>
|
||||||
|
<target>Approve</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
|
<unit id="oaAuth06" name="oauth.authorize.deny">
|
||||||
|
<segment>
|
||||||
|
<source>oauth.authorize.deny</source>
|
||||||
|
<target>Deny</target>
|
||||||
|
</segment>
|
||||||
|
</unit>
|
||||||
</file>
|
</file>
|
||||||
</xliff>
|
</xliff>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue