]> BookStack Code Mirror - bookstack/blob - app/Auth/Access/Oidc/OidcService.php
Added test to cover export body start/end partial usage
[bookstack] / app / Auth / Access / Oidc / OidcService.php
1 <?php
2
3 namespace BookStack\Auth\Access\Oidc;
4
5 use function auth;
6 use BookStack\Auth\Access\LoginService;
7 use BookStack\Auth\Access\RegistrationService;
8 use BookStack\Auth\User;
9 use BookStack\Exceptions\JsonDebugException;
10 use BookStack\Exceptions\StoppedAuthenticationException;
11 use BookStack\Exceptions\UserRegistrationException;
12 use function config;
13 use Illuminate\Support\Facades\Cache;
14 use League\OAuth2\Client\OptionProvider\HttpBasicAuthOptionProvider;
15 use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
16 use Psr\Http\Client\ClientInterface as HttpClient;
17 use function trans;
18 use function url;
19
20 /**
21  * Class OpenIdConnectService
22  * Handles any app-specific OIDC tasks.
23  */
24 class OidcService
25 {
26     protected RegistrationService $registrationService;
27     protected LoginService $loginService;
28     protected HttpClient $httpClient;
29
30     /**
31      * OpenIdService constructor.
32      */
33     public function __construct(RegistrationService $registrationService, LoginService $loginService, HttpClient $httpClient)
34     {
35         $this->registrationService = $registrationService;
36         $this->loginService = $loginService;
37         $this->httpClient = $httpClient;
38     }
39
40     /**
41      * Initiate an authorization flow.
42      *
43      * @throws OidcException
44      *
45      * @return array{url: string, state: string}
46      */
47     public function login(): array
48     {
49         $settings = $this->getProviderSettings();
50         $provider = $this->getProvider($settings);
51
52         return [
53             'url'   => $provider->getAuthorizationUrl(),
54             'state' => $provider->getState(),
55         ];
56     }
57
58     /**
59      * Process the Authorization response from the authorization server and
60      * return the matching, or new if registration active, user matched to the
61      * authorization server. Throws if the user cannot be auth if not authenticated.
62      *
63      * @throws JsonDebugException
64      * @throws OidcException
65      * @throws StoppedAuthenticationException
66      * @throws IdentityProviderException
67      */
68     public function processAuthorizeResponse(?string $authorizationCode): User
69     {
70         $settings = $this->getProviderSettings();
71         $provider = $this->getProvider($settings);
72
73         // Try to exchange authorization code for access token
74         $accessToken = $provider->getAccessToken('authorization_code', [
75             'code' => $authorizationCode,
76         ]);
77
78         return $this->processAccessTokenCallback($accessToken, $settings);
79     }
80
81     /**
82      * @throws OidcException
83      */
84     protected function getProviderSettings(): OidcProviderSettings
85     {
86         $config = $this->config();
87         $settings = new OidcProviderSettings([
88             'issuer'                => $config['issuer'],
89             'clientId'              => $config['client_id'],
90             'clientSecret'          => $config['client_secret'],
91             'redirectUri'           => url('/oidc/callback'),
92             'authorizationEndpoint' => $config['authorization_endpoint'],
93             'tokenEndpoint'         => $config['token_endpoint'],
94         ]);
95
96         // Use keys if configured
97         if (!empty($config['jwt_public_key'])) {
98             $settings->keys = [$config['jwt_public_key']];
99         }
100
101         // Run discovery
102         if ($config['discover'] ?? false) {
103             try {
104                 $settings->discoverFromIssuer($this->httpClient, Cache::store(null), 15);
105             } catch (OidcIssuerDiscoveryException $exception) {
106                 throw new OidcException('OIDC Discovery Error: ' . $exception->getMessage());
107             }
108         }
109
110         $settings->validate();
111
112         return $settings;
113     }
114
115     /**
116      * Load the underlying OpenID Connect Provider.
117      */
118     protected function getProvider(OidcProviderSettings $settings): OidcOAuthProvider
119     {
120         return new OidcOAuthProvider($settings->arrayForProvider(), [
121             'httpClient'     => $this->httpClient,
122             'optionProvider' => new HttpBasicAuthOptionProvider(),
123         ]);
124     }
125
126     /**
127      * Calculate the display name.
128      */
129     protected function getUserDisplayName(OidcIdToken $token, string $defaultValue): string
130     {
131         $displayNameAttr = $this->config()['display_name_claims'];
132
133         $displayName = [];
134         foreach ($displayNameAttr as $dnAttr) {
135             $dnComponent = $token->getClaim($dnAttr) ?? '';
136             if ($dnComponent !== '') {
137                 $displayName[] = $dnComponent;
138             }
139         }
140
141         if (count($displayName) == 0) {
142             $displayName[] = $defaultValue;
143         }
144
145         return implode(' ', $displayName);
146     }
147
148     /**
149      * Extract the details of a user from an ID token.
150      *
151      * @return array{name: string, email: string, external_id: string}
152      */
153     protected function getUserDetails(OidcIdToken $token): array
154     {
155         $id = $token->getClaim('sub');
156
157         return [
158             'external_id' => $id,
159             'email'       => $token->getClaim('email'),
160             'name'        => $this->getUserDisplayName($token, $id),
161         ];
162     }
163
164     /**
165      * Processes a received access token for a user. Login the user when
166      * they exist, optionally registering them automatically.
167      *
168      * @throws OidcException
169      * @throws JsonDebugException
170      * @throws StoppedAuthenticationException
171      */
172     protected function processAccessTokenCallback(OidcAccessToken $accessToken, OidcProviderSettings $settings): User
173     {
174         $idTokenText = $accessToken->getIdToken();
175         $idToken = new OidcIdToken(
176             $idTokenText,
177             $settings->issuer,
178             $settings->keys,
179         );
180
181         if ($this->config()['dump_user_details']) {
182             throw new JsonDebugException($idToken->getAllClaims());
183         }
184
185         try {
186             $idToken->validate($settings->clientId);
187         } catch (OidcInvalidTokenException $exception) {
188             throw new OidcException("ID token validate failed with error: {$exception->getMessage()}");
189         }
190
191         $userDetails = $this->getUserDetails($idToken);
192         $isLoggedIn = auth()->check();
193
194         if (empty($userDetails['email'])) {
195             throw new OidcException(trans('errors.oidc_no_email_address'));
196         }
197
198         if ($isLoggedIn) {
199             throw new OidcException(trans('errors.oidc_already_logged_in'));
200         }
201
202         try {
203             $user = $this->registrationService->findOrRegister(
204                 $userDetails['name'],
205                 $userDetails['email'],
206                 $userDetails['external_id']
207             );
208         } catch (UserRegistrationException $exception) {
209             throw new OidcException($exception->getMessage());
210         }
211
212         $this->loginService->login($user, 'oidc');
213
214         return $user;
215     }
216
217     /**
218      * Get the OIDC config from the application.
219      */
220     protected function config(): array
221     {
222         return config('oidc');
223     }
224 }