Newer
Older
<?php
namespace Drupal\login_gov\Plugin\OpenIDConnectClient;
use Drupal\Component\Serialization\Json;
use Drupal\Component\Utility\Crypt;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\GeneratedUrl;

John Franklin
committed
use Drupal\Core\Link;
use Drupal\Core\Url;
use Drupal\openid_connect\Plugin\OpenIDConnectClientBase;
use Firebase\JWT\JWK;
use Firebase\JWT\JWT;
/**
* Login.gov OpenID Connect client.
*
* Implements OpenID Connect Client plugin for Login.gov.
*
* @OpenIDConnectClient(
* id = "login_gov",
* label = @Translation("Login.Gov")
* )
*/
class OpenIDConnectLoginGovClient extends OpenIDConnectClientBase {
/**

John Franklin
committed
* A list of data fields available on login.gov.

John Franklin
committed
* @var array

John Franklin
committed
'all_emails' => 'All emails',
'given_name' => 'First name',
'family_name' => 'Last name',
'address' => 'Address',
'phone' => 'Phone',
'birthdate' => 'Date of birth',
'social_security_number' => 'Social security number',
'verified_at' => 'Verification timestamp',
'x509' => 'x509',
'x509_subject' => 'x509 Subject',
'x509_presented' => 'x509 Presented',
];
/**
* A list of fields we always request from the site.

John Franklin
committed
* @var array
*/

John Franklin
committed
'sub' => 'UUID',
'email' => 'Email',
'ial' => 'Identity Assurance Level',
'aal' => 'Authenticator Assurance Level',
];
/**
* A mapping of userinfo fields to the scopes required to receive them.

John Franklin
committed
* @var array
*/

John Franklin
committed
'sub' => 'openid',
'email' => 'email',
'all_emails' => 'all_emails',
'ial' => 'openid',
'aal' => 'openid',
'given_name' => 'profile:name',
'family_name' => 'profile:name',
'address' => 'address',
'phone' => 'phone',
'birthdate' => 'profile:birthdate',
'social_security_number' => 'social_security_number',
'verified_at' => 'profile:verified_at',
'x509' => 'x509',
'x509_subject' => 'x509:subject',
'x509_presented' => 'x509:presented',
'x509_issuer' => 'x509:issuer',
];
/**
* {@inheritdoc}
*/
public function defaultConfiguration(): array {
return [
'client_id' => '',

John Franklin
committed
'acr_level' => 'ial/1',
'require_piv' => FALSE,
'force_reauth' => FALSE,
'sandbox_mode' => TRUE,

John Franklin
committed
'userinfo_fields' => [],
'verified_within' => [
'count' => 1,
'units' => 'y',
],

John Franklin
committed
'private_key' => '',
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
];
}
/**
* {@inheritdoc}
*/
public function buildConfigurationForm(array $form, FormStateInterface $form_state): array {
$form['client_id'] = [
'#title' => $this->t('Client ID'),
'#type' => 'textfield',
'#default_value' => $this->configuration['client_id'],
'#required' => TRUE,
'#description' => $this->t('The client ID is called "Issuer" in login.gov, and looks like urn:gov:gsa:openidconnect.profiles:sp:sso:<em>agency</em>:<em>application</em>'),
];
$form['sandbox_mode'] = [
'#title' => $this->t('Sandbox Mode'),
'#type' => 'checkbox',
'#description' => $this->t('Check here to use the identitysandbox.gov test environment.'),
'#default_value' => $this->configuration['sandbox_mode'],
];
$form['acr_level'] = [
'#title' => $this->t('Authentication Assurance Level'),
'#type' => 'checkboxes',
'#options' => [
'ial/1' => $this->t('IAL 1 - Basic'),
'ial/2' => $this->t('IAL 2 - Verified Identity'),
'aal/2' => $this->t('AAL 2 - Users must re-authenticate every 12 hours'),
'aal/3' => $this->t('AAL 3 - Users must authenticate with WebAuthn or PIV/CAC'),
],
'#default_value' => $this->configuration['acr_level'],
];
$form['require_piv'] = [
'#title' => $this->t('Require PIV/CAC with AAL 3'),
'#type' => 'checkbox',
'#default_value' => $this->configuration['require_piv'],
'#states' => [
'visible' => [':input[name="settings[acr_level][aal/3]"]' => ['checked' => TRUE]],
],
];
$form['verified_within'] = [
'#title' => $this->t('Verified within'),
'#type' => 'item',
'#description' => $this->t('Must be no shorter than 30 days. Set to 0 for unlimited.'),
'#states' => [
'invisible' => [':input[name="settings[acr_level]"]' => ['value' => 'ial/1']],
],
];
$form['verified_within']['count'] = [
'#type' => 'number',
'#default_value' => $this->configuration['verified_within']['count'],
];
$form['verified_within']['units'] = [
'#type' => 'select',
'#options' => [
'd' => $this->t('days'),
'w' => $this->t('weeks'),
'm' => $this->t('months'),
'y' => $this->t('years'),
],
'#default_value' => $this->configuration['verified_within']['units'],
];

John Franklin
committed
$form['userinfo_fields'] = [
'#title' => $this->t('User fields'),
'#type' => 'select',
'#multiple' => TRUE,

John Franklin
committed
'#description' => $this->t('List of fields to fetch, which will translate to the required scopes. Some fields require IAL/2 Authentication Assurance Level. See the @login_gov_documentation for more details. The Email and UUID (sub) fields are always fetched.', ['@login_gov_documentation' => Link::fromTextAndUrl($this->t('Login.gov documentation'), Url::fromUri('https://developers.login.gov/attributes/'))->toString()]),
'#default_value' => $this->configuration['userinfo_fields'],
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
];
$form['force_reauth'] = [
'#title' => $this->t('Force Reauthorization'),
'#type' => 'checkbox',
'#default_value' => $this->configuration['force_reauth'],
'#description' => $this->t('Require the user to login again to Login.gov. <em>Requires login.gov administrator approval.</em>'),
];
$form['private_key'] = [
'#title' => $this->t('Private key in PEM format'),
'#type' => 'textarea',
'#default_value' => $this->configuration['private_key'],
'#description' => $this->t('Need to put this someplace more secure.'),
];
// Add some custom CSS.
$form['#attached']['library'][] = 'login_gov/login-gov-config';
return $form;
}
/**
* {@inheritdoc}
*/
public function getEndpoints(): array {
return $this->configuration['sandbox_mode'] ? [
'authorization' => 'https://idp.int.identitysandbox.gov/openid_connect/authorize',
'token' => 'https://idp.int.identitysandbox.gov/api/openid_connect/token',
'userinfo' => 'https://idp.int.identitysandbox.gov/api/openid_connect/userinfo',
'end_session' => 'https://idp.int.identitysandbox.gov/openid_connect/logout',
'certs' => 'https://idp.int.identitysandbox.gov/api/openid_connect/certs',
] :
[
'authorization' => 'https://secure.login.gov/openid_connect/authorize',
'token' => 'https://secure.login.gov/api/openid_connect/token',
'userinfo' => 'https://secure.login.gov/api/openid_connect/userinfo',
'end_session' => 'https://secure.login.gov/openid_connect/logout',
'certs' => 'https://secure.login.gov/api/openid_connect/certs',
];
}
/**
* {@inheritdoc}
*/
protected function getRequestOptions(string $authorization_code, string $redirect_uri): array {
$endpoints = $this->getEndpoints();
// Build the client assertion.
// See https://developers.login.gov/oidc/#token
$client_assertion_payload = [
'iss' => $this->configuration['client_id'],
'sub' => $this->configuration['client_id'],
'aud' => $endpoints['token'],
'jti' => $this->generateNonce(),
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
];
// Add the client assertion to the list of options.
$options = [
'client_assertion' => $this->signJwtPayload($client_assertion_payload),
'client_assertion_type' => 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
'code' => $authorization_code,
'grant_type' => 'authorization_code',
];
return [
'form_params' => $options,
'headers' => [
'Accept' => 'application/json',
],
];
}
/**
* Sign the JWT.
*
* @param array $payload
* An array of key-value pairs.
*
* @return string
* The signed JWT.
*/
public function signJwtPayload(array $payload): string {
return JWT::encode($payload, $this->getPrivateKey(), 'RS256');
}
/**
* Return the private key for signing the JWTs.
*
* The private key in PEM format.
*/
protected function getPrivateKey(): ?string {
return $this->configuration['private_key'];
}
/**
* Get login.gov's public signing key.
*
* @return array|null
* A list of public keys.
*/
protected function getPeerPublicKeys(): ?array {
$endpoints = $this->getEndpoints();
$keys_json = $this->httpClient->get($endpoints['certs'])->getBody()->getContents();
$keys = Json::decode($keys_json);
return JWK::parseKeySet($keys);
}
/**
* Generate a one-time use code word, a nonce.
*
* The length of the nonce.
*
* @return string
* The nonce.
*/
protected function generateNonce(int $length = 26): string {
return substr(Crypt::randomBytesBase64($length), 0, $length);
}
/**
* Generate the acr_values portion of the URL options.
*
* @return string
* The Authentication Context Class Reference value.
*/
protected function generateAcrValue(): string {
$acrs = [];
foreach (array_filter($this->configuration['acr_level']) as $acr_level) {
$param = ($acr_level == 'aal/3' && $this->configuration['require_piv']) ? '?hspd12=true' : '';
$acrs[] = 'http://idmanagement.gov/ns/assurance/' . $acr_level . $param;
}
return implode(' ', $acrs);
}
/**
* {@inheritdoc}
*/
protected function getUrlOptions(string $scope, GeneratedUrl $redirect_uri): array {
$options = parent::getUrlOptions($scope, $redirect_uri);
$nonce = $this->generateNonce();
$options['query'] += [
'acr_values' => $this->generateAcrValue(),
'nonce' => $nonce,
$this->requestStack->getCurrentRequest()->getSession()->set('login_gov.nonce', $nonce);
if ($this->configuration['acr_level'] == '2' && $this->configuration['verified_within']['count']) {
$options['query']['verified_within'] = $this->configuration['verified_within']['count'] . $this->configuration['verified_within']['units'];
}
$options['query']['prompt'] = $this->configuration['force_reauth'] ? 'login' : 'select_account';
return $options;
}
/**
* {@inheritdoc}
*/
public function retrieveTokens(string $authorization_code): ?array {
$tokens = parent::retrieveTokens($authorization_code);
// Verify the nonce is the one we sent earlier.
if (!empty($tokens['id_token'])) {
$keys = $this->getPeerPublicKeys();
$decoded_tokens = JWT::decode($tokens['id_token'], $keys);
$session_nonce = $this->requestStack->getCurrentRequest()->getSession()->get('login_gov.nonce');
if (!empty($session_nonce) && ($decoded_tokens->nonce !== $session_nonce)) {
return NULL;
}
}
return $tokens;
}
/**
* {@inheritdoc}
*/
public function getClientScopes(): ?array {
$fields = static::$alwaysFetchFields + ($this->configuration['userinfo_fields'] ?? []);
return array_values(array_unique(array_intersect_key(static::$fieldToScopeMap, $fields)));