├── public
└── .keep
├── docker
└── config
│ ├── config-override.php
│ └── authsources.php
├── .gitignore
├── templates
└── errors
│ └── consent.twig
├── attributemap
├── amazon2name.php
├── bitbucket2name.php
├── linkedin2name.php
├── apple2name.php
├── facebook2name.php
├── microsoft2name.php
├── oidc2name.php
└── orcid2name.php
├── locales
└── en
│ └── LC_MESSAGES
│ └── authoauth2.po
├── .test-secrets
└── apple.p8
├── src
├── Codebooks
│ ├── RoutesEnum.php
│ ├── LegacyRoutesEnum.php
│ └── Oauth2ErrorsEnum.php
├── Lib
│ └── RequestUtilities.php
├── locators
│ ├── HTTPLocator.php
│ ├── SourceServiceLocator.php
│ └── SourceService.php
├── Controller
│ ├── Traits
│ │ ├── ErrorTrait.php
│ │ └── RequestTrait.php
│ ├── ErrorController.php
│ ├── OIDCLogoutController.php
│ └── Oauth2Controller.php
├── Auth
│ └── Source
│ │ ├── MicrosoftHybridAuth.php
│ │ ├── BitbucketAuth.php
│ │ ├── LinkedInV2Auth.php
│ │ ├── OrcidOIDCAuth.php
│ │ └── OpenIDConnect.php
├── Providers
│ ├── AdjustableGenericProvider.php
│ └── OpenIDConnectProvider.php
├── AttributeManipulator.php
└── ConfigTemplate.php
├── tests
├── config
│ ├── config.php
│ ├── jwks-key.pem
│ ├── jwks-cert.pem
│ └── authsources.php
├── bootstrap.php
└── lib
│ ├── RedirectException.php
│ ├── MockOpenIDConnectProvider.php
│ ├── Controller
│ ├── ErrorControllerTest.php
│ ├── Trait
│ │ ├── ErrorTraitTest.php
│ │ └── RequestTraitTest.php
│ ├── OIDCLogoutControllerTest.php
│ └── Oauth2ControllerTest.php
│ ├── Codebooks
│ ├── RoutesEnumTest.php
│ └── Oauth2ErrorsEnumTest.php
│ ├── AttributeManipulatorTest.php
│ ├── MockOAuth2Provider.php
│ ├── Providers
│ ├── AdjustableGenericProviderTest.php
│ └── OpenIDConnectProviderTest.php
│ └── Auth
│ └── Source
│ ├── LinkedInV2AuthTest.php
│ ├── MicrosoftHybridAuthTest.php
│ ├── OrcidOIDCAuthTest.php
│ └── OpenIDConnectTest.php
├── routing
├── services
│ └── services.yml
└── routes
│ └── routes.php
├── phpcs.xml
├── phpunit.xml
├── docs
├── PKCE.md
├── LINKEDIN.md
├── BITBUCKET.md
├── ORCID.md
├── MICROSOFT.md
├── AUTHPROC.md
├── APPLE.md
└── GOOGLE.md
├── samples
└── apple
│ └── authsources.php
├── composer.json
├── psalm.xml
├── CHANGELOG.md
└── .github
└── workflows
└── php.yml
/public/.keep:
--------------------------------------------------------------------------------
1 | ...
2 |
--------------------------------------------------------------------------------
/docker/config/config-override.php:
--------------------------------------------------------------------------------
1 | {{ 'noconsent_error' |trans }}
6 | {% endblock %}
7 |
--------------------------------------------------------------------------------
/attributemap/amazon2name.php:
--------------------------------------------------------------------------------
1 | ['cn', 'displayName'],
7 | 'amazon.email' => 'mail',
8 | 'amazon.user_id' => 'uid',
9 | ];
--------------------------------------------------------------------------------
/attributemap/bitbucket2name.php:
--------------------------------------------------------------------------------
1 | 'displayName',
7 | 'bitbucket.account_id' => 'uid',
8 | 'bitbucket.email' => 'mail',
9 | ];
10 |
--------------------------------------------------------------------------------
/locales/en/LC_MESSAGES/authoauth2.po:
--------------------------------------------------------------------------------
1 | msgid "noconsent_error"
2 | msgstr "You must consent/allow access to your profile information. Press the back button and then allow/grant access."
3 |
4 | msgid "noconsent_title"
5 | msgstr "Consent Needed"
6 |
--------------------------------------------------------------------------------
/attributemap/linkedin2name.php:
--------------------------------------------------------------------------------
1 | 'givenName',
7 | 'linkedin.lastName' => 'sn',
8 | 'linkedin.id' => 'uid', // any b64 character
9 | 'linkedin.emailAddress' => 'mail',
10 | ];
11 |
--------------------------------------------------------------------------------
/.test-secrets/apple.p8:
--------------------------------------------------------------------------------
1 | -----BEGIN PRIVATE KEY-----
2 | MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQg1mmL6FjgQMo7kePl
3 | VBM0r0l40n5kv11SF4xVEyc/uRCgCgYIKoZIzj0DAQehRANCAAQs2igA/L+35rO9
4 | 6V40sy9tgrr/ZpIESAyd1iplpBiR5Siqkq1zSgE8gF1cxRG0bt/n2lzaEp0tb5SU
5 | Dvzd7eW4
6 | -----END PRIVATE KEY-----
7 |
--------------------------------------------------------------------------------
/attributemap/apple2name.php:
--------------------------------------------------------------------------------
1 | 'givenName',
7 | 'apple.name.lastName' => 'sn',
8 | 'apple.email' => 'mail',
9 | 'apple.sub' => 'uid',
10 | // apple.isPrivateEmail : There is no common name for this attribute
11 | ];
--------------------------------------------------------------------------------
/attributemap/facebook2name.php:
--------------------------------------------------------------------------------
1 | 'givenName',
7 | 'facebook.last_name' => 'sn',
8 | 'facebook.name' => ['cn', 'displayName'],
9 | 'facebook.email' => 'mail',
10 | 'facebook.id' => 'uid',
11 | ];
--------------------------------------------------------------------------------
/src/Codebooks/RoutesEnum.php:
--------------------------------------------------------------------------------
1 | 'displayName',
7 | 'microsoft.id' => 'uid',
8 | 'microsoft.mail' => 'mail',
9 | 'microsoft.surname' => 'sn',
10 | 'microsoft.givenName' => 'givenName',
11 | 'microsoft.name' => 'cn',
12 | ];
--------------------------------------------------------------------------------
/tests/config/config.php:
--------------------------------------------------------------------------------
1 | 'uid',
8 | 'oidc.family_name' => 'sn',
9 | 'oidc.given_name' => 'givenName',
10 | 'oidc.name' => 'cn',
11 | 'oidc.preferred_username' => 'displayName',
12 | 'oidc.email' => 'mail',
13 | ];
--------------------------------------------------------------------------------
/attributemap/orcid2name.php:
--------------------------------------------------------------------------------
1 | 'eduPersonOrcid', // URI with a 16-digit number
8 | 'orcid.sub' => 'uid',
9 | 'orcid.family_name' => 'sn',
10 | 'orcid.given_name' => 'givenName',
11 | 'orcid.name' => 'cn',
12 | 'orcid.preferred_username' => 'displayName',
13 | 'orcid.email' => 'mail',
14 | ];
--------------------------------------------------------------------------------
/src/Codebooks/Oauth2ErrorsEnum.php:
--------------------------------------------------------------------------------
1 | url = $url;
18 | }
19 |
20 | /**
21 | * @return string|null
22 | */
23 | public function getUrl(): ?string
24 | {
25 | return $this->url;
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/phpcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | By default it is less stringent about long lines than other coding standards
7 |
8 |
9 | src
10 | tests
11 | public
12 |
13 |
14 | tests/config/*
15 |
16 |
17 |
18 |
19 |
20 |
21 |
--------------------------------------------------------------------------------
/src/Lib/RequestUtilities.php:
--------------------------------------------------------------------------------
1 | isMethod('GET')) {
20 | $params = $request->query->all();
21 | } elseif ($request->isMethod('POST')) {
22 | $params = $request->request->all();
23 | }
24 |
25 | return $params;
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/tests/config/jwks-cert.pem:
--------------------------------------------------------------------------------
1 | -----BEGIN CERTIFICATE-----
2 | MIIBxTCCAW+gAwIBAgIUF6cgHno1ftvK5NSTMgKzb+N/9cUwDQYJKoZIhvcNAQEL
3 | BQAwNzELMAkGA1UEBhMCTk8xEzARBgNVBAgMClNvbWUtU3RhdGUxEzARBgNVBAoM
4 | ClNpbXBsZVNBTUwwHhcNMTkwODIzMTMxODAzWhcNMjkwODIwMTMxODAzWjA3MQsw
5 | CQYDVQQGEwJOTzETMBEGA1UECAwKU29tZS1TdGF0ZTETMBEGA1UECgwKU2ltcGxl
6 | U0FNTDBcMA0GCSqGSIb3DQEBAQUAA0sAMEgCQQDDblC3zTyaroUJr2vVcxsXRrI4
7 | X1KWpOewfI8qdVCK1efXcjJO/lStq0l3rPHzX2g6VIFKS897E4thBdqbRYHPAgMB
8 | AAGjUzBRMB0GA1UdDgQWBBSle2ndlvLV99q5YZGROsv+MEIjATAfBgNVHSMEGDAW
9 | gBSle2ndlvLV99q5YZGROsv+MEIjATAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3
10 | DQEBCwUAA0EACx6Z1TGX74wL65mvOEqz3BurPqQlPp7q4bywm3GtMZY7xU/vpyiD
11 | ldRq83U+KnyEmQ9IrrzYXs8ReQ3GSY7q/g==
12 | -----END CERTIFICATE-----
13 |
--------------------------------------------------------------------------------
/src/locators/HTTPLocator.php:
--------------------------------------------------------------------------------
1 | http)) {
24 | $this->http = new HTTP();
25 | }
26 | return $this->http;
27 | }
28 |
29 | /**
30 | * @param ?HTTP $http
31 | */
32 | public function setHttp(?HTTP $http): void
33 | {
34 | $this->http = $http;
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/tests/lib/MockOpenIDConnectProvider.php:
--------------------------------------------------------------------------------
1 | sourceService)) {
24 | $this->sourceService = new SourceService();
25 | }
26 | return $this->sourceService;
27 | }
28 |
29 | /**
30 | * @param SourceService $sourceService
31 | */
32 | public function setSourceService(SourceService $sourceService): void
33 | {
34 | $this->sourceService = $sourceService;
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/src/locators/SourceService.php:
--------------------------------------------------------------------------------
1 | config = Configuration::loadFromArray([
23 | 'baseurlpath' => '/',
24 | ]);
25 |
26 | $this->controller = new ErrorController($this->config);
27 | }
28 |
29 | public function testConsent(): void
30 | {
31 | $request = Request::create('/consent', 'GET');
32 | $response = $this->controller->consent($request);
33 |
34 | $this->assertInstanceOf(Template::class, $response);
35 | $this->assertEquals('authoauth2:errors/consent.twig', $response->getTemplateName());
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/Controller/ErrorController.php:
--------------------------------------------------------------------------------
1 | config = $config ?? Configuration::getInstance();
29 | }
30 |
31 | /**
32 | * Show error consent view.
33 | *
34 | * @param Request $request
35 | * @return Response
36 | * @throws \Exception
37 | */
38 | public function consent(Request $request): Response
39 | {
40 | return new Template($this->config, 'authoauth2:errors/consent.twig');
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/phpunit.xml:
--------------------------------------------------------------------------------
1 |
2 |
11 |
12 |
13 | ./tests
14 |
15 |
16 |
17 |
20 |
21 | ./src
22 |
23 |
24 | ./ConfigTemplate.php
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
--------------------------------------------------------------------------------
/tests/lib/Codebooks/RoutesEnumTest.php:
--------------------------------------------------------------------------------
1 | assertEquals('linkback', RoutesEnum::Linkback->value);
15 | }
16 |
17 | public function testLogoutEnum(): void
18 | {
19 | $this->assertEquals('logout', RoutesEnum::Logout->value);
20 | }
21 |
22 | public function testLoggedOutEnum(): void
23 | {
24 | $this->assertEquals('loggedout', RoutesEnum::LoggedOut->value);
25 | }
26 |
27 | public function testConsentErrorEnum(): void
28 | {
29 | $this->assertEquals('errors/consent', RoutesEnum::ConsentError->value);
30 | }
31 |
32 | public function testAllEnumCases(): void
33 | {
34 | $expected = [
35 | 'Linkback' => 'linkback',
36 | 'Logout' => 'logout',
37 | 'LoggedOut' => 'loggedout',
38 | 'ConsentError' => 'errors/consent',
39 | ];
40 |
41 | foreach (RoutesEnum::cases() as $case) {
42 | $this->assertSame($expected[$case->name], $case->value);
43 | }
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/docs/PKCE.md:
--------------------------------------------------------------------------------
1 | # PKCE support
2 |
3 | PKCE (Proof Key for Code Exchange) is an extension to the OAuth2 protocol that is used to secure the
4 | authorization code flow against CSRF (cross site request forgery) and code injection attacks.
5 | PKCE is recommended in almost all OAuth use cases. Some servers or operators require the clients to use PKCE.
6 |
7 | ## Usage
8 |
9 | Enable PKCE by setting the `pkceMethod` configuration key to a valid method (only `S256` is recommended).
10 | Note: `plain` is also a valid method, but not recommended, see the link to 'thephpleague/oauth2-client' below for details.
11 |
12 | ### Example configuration
13 |
14 | Below is an example demonstrating how to configure the `authoauth2` module for PKCE:
15 |
16 | ```php
17 | // config/authsources.php
18 |
19 | $config = [
20 | 'my-oidc-auth-source' => [
21 | 'authoauth2:OpenIDConnect',
22 |
23 | 'issuer' => 'https://my-issuer',
24 | 'clientId' => 'client-id',
25 | 'clientSecret' => 'client-secret',
26 |
27 | // activate PKCE with the S256 method
28 | 'pkceMethod' => 'S256',
29 | ]
30 | ];
31 | ```
32 |
33 | ## Links
34 |
35 | - See https://github.com/thephpleague/oauth2-client/blob/master/docs/usage.md#authorization-code-grant-with-pkce
36 | for implementation notes of the underlying library.
37 | - RFC 7636 for PKCE: https://datatracker.ietf.org/doc/html/rfc7636
38 |
--------------------------------------------------------------------------------
/samples/apple/authsources.php:
--------------------------------------------------------------------------------
1 | array(
6 | // Must install correct provider with: composer require patrickbussmann/oauth2-apple
7 | 'authoauth2:OAuth2',
8 | 'attributePrefix' => 'apple.',
9 | // Improve log lines
10 | 'label' => 'apple',
11 | // Logging http traffic causes the provider to see a blank body when it tries to read json response
12 | // since the body stream doesn't get reset correctly
13 | // 'logHttpTraffic' => true,
14 | 'logIdTokenJson' => true,
15 | 'providerClass' => 'League\OAuth2\Client\Provider\Apple',
16 | 'teamId' => 'UPV4CB4H6W', // // 1A234BFK46 https://developer.apple.com/account/#/membership/ (Team ID)
17 | 'clientId' => 'edu.illinois.idpproxy.apple',
18 | 'redirectUri' => 'https://apple.test.idpproxy.illinois.edu/simplesaml/module.php/authoauth2/linkback.php',
19 | 'keyFileId' => 'D4ZC3N2PKF', // 1ABC6523AA https://developer.apple.com/account/resources/authkeys/list (Key ID)
20 | 'keyFilePath' => __DIR__ . '/../cert/apple.p8', // __DIR__ . '/AuthKey_1ABC6523AA.p8' -> Download key above. p8 is same format at pem
21 | ),
22 |
23 | // This is a authentication source which handles admin authentication.
24 | 'admin' => array(
25 | // The default is to use core:AdminPassword, but it can be replaced with
26 | // any authentication source.
27 |
28 | 'core:AdminPassword',
29 | ),
30 |
31 | );
32 |
33 |
--------------------------------------------------------------------------------
/docs/LINKEDIN.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | **Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)*
4 |
5 | - [LinkedIn as authsource](#linkedin-as-authsource)
6 | - [Enabling OIDC in your LinkedIn App](#enabling-oidc-in-your-linkedin-app)
7 | - [Usage](#usage)
8 |
9 |
10 |
11 | # LinkedIn as authsource
12 |
13 | The `LinkedInV2Auth` authsource has been deprecated, and we now recommend the use of OIDC, which is enabled in the LinkedIn developer portal via their [Sign In with LinkedIn V2](https://learn.microsoft.com/en-us/linkedin/consumer/integrations/self-serve/sign-in-with-linkedin-v2#what-is-openid-connect) product. Use of OIDC facilitates the use of standard configuration patterns and claims endpoints.
14 |
15 | ## Enabling OIDC in your LinkedIn App
16 |
17 | OIDC can be enabled in your existing LinkedIn App by simply adding **Sign In with LinkedIn v2** to your app's products. See the [Cirrus Identity Blog article](https://blog.cirrusidentity.com/enabling-linkedins-oidc-authentication) for details.
18 |
19 | # Usage
20 |
21 | ```php
22 | 'linkedin' => [
23 | 'authoauth2:OAuth2',
24 | 'template' => 'LinkedInOIDC',
25 | 'clientId' => $apiKey,
26 | 'clientSecret' => $apiSecret,
27 | // Adjust the scopes: default is to request 'openid' (required),
28 | // 'profile' and 'email'
29 | // 'scopes' => ['openid', 'profile'],
30 | ]
31 | ```
32 |
--------------------------------------------------------------------------------
/tests/lib/Codebooks/Oauth2ErrorsEnumTest.php:
--------------------------------------------------------------------------------
1 | assertSame('access_denied', Oauth2ErrorsEnum::AccessDenied->value);
15 | $this->assertSame('consent_required', Oauth2ErrorsEnum::ConsentRequired->value);
16 | $this->assertSame('invalid_scope', Oauth2ErrorsEnum::InvalidScope->value);
17 | $this->assertSame('user_cancelled_authorize', Oauth2ErrorsEnum::UserCancelledAuthorize->value);
18 | $this->assertSame('user_cancelled_login', Oauth2ErrorsEnum::UserCancelledLogin->value);
19 | $this->assertSame('user_denied', Oauth2ErrorsEnum::UserDenied->value);
20 | }
21 |
22 | public function testEnumKeys(): void
23 | {
24 | $this->assertSame(Oauth2ErrorsEnum::AccessDenied, Oauth2ErrorsEnum::from('access_denied'));
25 | $this->assertSame(Oauth2ErrorsEnum::ConsentRequired, Oauth2ErrorsEnum::from('consent_required'));
26 | $this->assertSame(Oauth2ErrorsEnum::InvalidScope, Oauth2ErrorsEnum::from('invalid_scope'));
27 | $this->assertSame(Oauth2ErrorsEnum::UserCancelledAuthorize, Oauth2ErrorsEnum::from('user_cancelled_authorize'));
28 | $this->assertSame(Oauth2ErrorsEnum::UserCancelledLogin, Oauth2ErrorsEnum::from('user_cancelled_login'));
29 | $this->assertSame(Oauth2ErrorsEnum::UserDenied, Oauth2ErrorsEnum::from('user_denied'));
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/docs/BITBUCKET.md:
--------------------------------------------------------------------------------
1 | **Table of Contents**
2 |
3 | - [Bitbucket as authsource](#bitbucket-as-authsource)
4 | - [Usage](#usage)
5 | - [Creating Bitbucket OAuth Client](#creating-bitbucket-oauth-client)
6 |
7 | # Bitbucket as authsource
8 |
9 | Bitbucket recommends using OAuth2 and their apis. Bitbucket apis return data in a
10 | json format and require additional API calls to get an email address. You need to use the
11 | `authoauth2:BitbucketAuth` authsource since Bitbucket doesn't conform
12 | the expected OIDC/OAuth pattern.
13 |
14 | # Usage
15 |
16 | ```php
17 | 'bitbucket' => [
18 | 'authoauth2:BitbucketAuth',
19 | 'clientId' => $apiKey,
20 | 'clientSecret' => $apiSecret,
21 | // Adjust the scopes: default is to request email and account
22 | //'scopes' => ['account', 'email'],
23 | ],
24 | ```
25 |
26 | # Creating Bitbucket OAuth Client
27 |
28 | Bitbucket provides [documentation](https://confluence.atlassian.com/bitbucket/oauth-on-bitbucket-cloud-238027431.html). Follow the section related to 'Create a consumer' to create an OAuth consumer.
29 | You will need to add the correct Callback URL to your OAuth2 client in the Bitbucket console. Use a URL of the form below, and set hostname, SSP_PATH and optionally port to the correct values.
30 |
31 | https://hostname/SSP_PATH/module.php/authoauth2/linkback.php
32 |
33 | You will then need to change your `authsource` configuration to match the example usage above.
34 |
35 | On your idp side you may need to use `bitbucket2name` attribute mapping from this module.
36 |
37 | ```php
38 | // Convert bitbucket names to ldap friendly names
39 | 10 => array(
40 | 'class' => 'core:AttributeMap',
41 | 'authoauth2:bitbucket2name'
42 | ),
43 | ```
44 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "cirrusidentity/simplesamlphp-module-authoauth2",
3 | "description": "SSP Module for Oauth2 authentication sources",
4 | "type": "simplesamlphp-module",
5 | "keywords": [
6 | "simplesamlphp",
7 | "oauth2",
8 | "oidc"
9 | ],
10 | "license": "LGPL-2.1-only",
11 | "require": {
12 | "php": "^8.1",
13 | "simplesamlphp/composer-module-installer": "^1.1",
14 | "league/oauth2-client": "^2.7",
15 | "simplesamlphp/simplesamlphp": "^v2.3",
16 | "firebase/php-jwt": "^5.5|^6",
17 | "kevinrob/guzzle-cache-middleware": "^4.1.1",
18 | "psr/cache": "^1.0|^2.0|^3.0",
19 | "symfony/cache": "^7.0|^6.0|^5.0",
20 | "ext-json": "*"
21 | },
22 | "require-dev": {
23 | "simplesamlphp/simplesamlphp-test-framework": "^1.7",
24 | "phpunit/phpunit": "^10",
25 | "psalm/plugin-phpunit": "^0.19.0",
26 | "squizlabs/php_codesniffer": "^3.7"
27 | },
28 | "autoload": {
29 | "psr-4": {
30 | "SimpleSAML\\Module\\authoauth2\\": "src/"
31 | }
32 | },
33 | "autoload-dev": {
34 | "psr-4": {
35 | "Test\\SimpleSAML\\": "tests/lib/"
36 | }
37 | },
38 | "config": {
39 | "allow-plugins": {
40 | "simplesamlphp/composer-module-installer": true,
41 | "dealerdirect/phpcodesniffer-composer-installer": false,
42 | "simplesamlphp/composer-xmlprovider-installer": false,
43 | "phpstan/extension-installer": true
44 | }
45 | },
46 | "suggest": {
47 | "patrickbussmann/oauth2-apple": "Used to provide Apple sign in functionality"
48 | },
49 | "scripts": {
50 | "validate": [
51 | "vendor/bin/phpunit --no-coverage --testdox",
52 | "vendor/bin/phpcs -p",
53 | "vendor/bin/psalm --no-cache"
54 | ],
55 | "tests": [
56 | "vendor/bin/phpunit --no-coverage"
57 | ]
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/routing/routes/routes.php:
--------------------------------------------------------------------------------
1 | add(RoutesEnum::Linkback->name, RoutesEnum::Linkback->value)
22 | ->controller([Oauth2Controller::class, 'linkback']);
23 | $routes->add(RoutesEnum::Logout->name, RoutesEnum::Logout->value)
24 | ->controller([OIDCLogoutController::class, 'logout']);
25 | $routes->add(RoutesEnum::LoggedOut->name, RoutesEnum::LoggedOut->value)
26 | ->controller([OIDCLogoutController::class, 'loggedout']);
27 | $routes->add(RoutesEnum::ConsentError->name, RoutesEnum::ConsentError->value)
28 | ->controller([ErrorController::class, 'consent']);
29 |
30 | // Legacy Routes
31 | $routes->add(LegacyRoutesEnum::LegacyLinkback->name, LegacyRoutesEnum::LegacyLinkback->value)
32 | ->controller([Oauth2Controller::class, 'linkback']);
33 | $routes->add(LegacyRoutesEnum::LegacyLogout->name, LegacyRoutesEnum::LegacyLogout->value)
34 | ->controller([OIDCLogoutController::class, 'logout']);
35 | $routes->add(LegacyRoutesEnum::LegacyLoggedOut->name, LegacyRoutesEnum::LegacyLoggedOut->value)
36 | ->controller([OIDCLogoutController::class, 'loggedout']);
37 | $routes->add(LegacyRoutesEnum::LegacyConsentError->name, LegacyRoutesEnum::LegacyConsentError->value)
38 | ->controller([ErrorController::class, 'consent']);
39 | };
40 |
--------------------------------------------------------------------------------
/tests/lib/AttributeManipulatorTest.php:
--------------------------------------------------------------------------------
1 | 'b',
22 | 'complex' => ['e' => 'f'],
23 | 'arrayValues' => ['a', 'b', 'c', 123, null],
24 | 'bool' => false,
25 | 'num' => 123,
26 | 'missing' => null,
27 | // Google plus style emails are array of objects that have key value pairs
28 | "emails" => [
29 | 0 => [
30 | "value" => "monitor@cirrusidentity.com",
31 | "type" => "account"
32 | ],
33 | ],
34 | ];
35 |
36 | $attributeManipulator = new AttributeManipulator();
37 | $flattenAttributes = $attributeManipulator->prefixAndFlatten($attributes);
38 | // Single values always become arrays and complex objects are flattened, and not strings are stringified
39 | $expectedAttributes = [
40 | 'a' => ['b'],
41 | 'complex.e' => ['f'],
42 | 'arrayValues' => ['a', 'b', 'c', '123'],
43 | 'bool' => ['false'],
44 | 'num' => ['123'],
45 | 'emails.0.value' => ['monitor@cirrusidentity.com'],
46 | 'emails.0.type' => ['account'],
47 | ];
48 | $this->assertEquals($expectedAttributes, $flattenAttributes);
49 |
50 | $this->assertEquals($expectedAttributes, (new Attributes())->normalizeAttributesArray($flattenAttributes));
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/src/Auth/Source/MicrosoftHybridAuth.php:
--------------------------------------------------------------------------------
1 | getValues())) {
38 | Logger::error('mshybridauth: ' . $this->getLabel() . ' no id_token returned');
39 | return;
40 | }
41 |
42 | $idTokenData = $this->extraIdTokenAttributes((string)$accessToken->getValues()['id_token']);
43 | $prefix = $this->getAttributePrefix();
44 |
45 | if (array_key_exists('email', $idTokenData)) {
46 | $state['Attributes'][$prefix . 'mail'] = [$idTokenData['email']];
47 | }
48 | if (array_key_exists('name', $idTokenData)) {
49 | $state['Attributes'][$prefix . 'name'] = [$idTokenData['name']];
50 | }
51 | if (array_key_exists('tid', $idTokenData)) {
52 | $state['Attributes'][$prefix . 'tid'] = [$idTokenData['tid']];
53 | }
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/src/Providers/AdjustableGenericProvider.php:
--------------------------------------------------------------------------------
1 | 'user'] would add the value of 'uid' from
17 | * the token response json as the query param 'user' to the resource owner details endpoint
18 | */
19 | protected array $tokenFieldsToUserDetailsUrl = [];
20 |
21 | protected function getConfigurableOptions()
22 | {
23 | return array_merge(
24 | parent::getConfigurableOptions(),
25 | ['tokenFieldsToUserDetailsUrl']
26 | );
27 | }
28 |
29 |
30 | public function getResourceOwnerDetailsUrl(AccessToken $token)
31 | {
32 | $url = parent::getResourceOwnerDetailsUrl($token);
33 | $toAdd = [];
34 | // Use the array rather than ->getValues() since it has more components
35 | $responseValues = $token->jsonSerialize();
36 | if ($this->tokenFieldsToUserDetailsUrl) {
37 | foreach ($this->tokenFieldsToUserDetailsUrl as $field => $param) {
38 | if (!is_string($param)) {
39 | throw new \Exception('Query param for field ' . $field . ' must be a string');
40 | }
41 | if (array_key_exists($field, $responseValues)) {
42 | $toAdd[$param] = $responseValues[$field];
43 | } else {
44 | Logger::debug("authoauth2: Token response missing field '$field'");
45 | }
46 | }
47 | }
48 | if ($toAdd) {
49 | $url = (new HTTP())->addURLParameters($url, $toAdd);
50 | }
51 | return $url;
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/docs/ORCID.md:
--------------------------------------------------------------------------------
1 | # ORCID as an AuthSource
2 |
3 | ORCID supports both OAuth 2.0 and OpenID Connect 1.0 for logging users in.
4 |
5 | # Usage
6 | ## Recommended Config
7 |
8 | We ended up creating a subclass of the generic `OpenIDConnect` called `OrcidOIDCAuth`. This is because ORCID supports [OpenID Connect Discovery](https://openid.net/specs/openid-connect-discovery-1_0.html) which allows for dynamic configuration of authorization/token endpoints. ORCID provides name attributes via the `id_token`, but the email address must be retrieved via a separate API call (which uses the `urlResourceOwnerEmail` property in the default `OrcidOIDC` config template).
9 |
10 |
11 | ```php
12 | //authsources.php
13 | 'microsoft' => [
14 | 'authoauth2:OrcidOIDCAuth',
15 | 'clientId' => 'my-client',
16 | 'clientSecret' => 'eyM-mysecret'
17 | ],
18 | ```
19 |
20 | If you are using this with a SAML IdP then you can map the standard OIDC attributes to regular friendly names in your `authproc` section of `saml20-idp-hosted.php`.
21 |
22 | ```php
23 | // saml20-idp-hosted.php
24 | $metadata['myEntityId'] = [
25 | 'authproc' => [
26 | // Convert oidc names to ldap friendly names
27 | 90 => ['class' => 'core:AttributeMap', 'authoauth2:orcid2name'],
28 | ],
29 | // other IdP config options
30 | ]
31 | ```
32 |
33 |
34 | ## Gotchas
35 |
36 | * ORCID allows users to add multiple email addresses to their user profile, and each of these addresses can be configured to be released publically (or not). This is performed out-of-band via the ORCID website, **not** as part of the OAuth2/OIDC authorization process. Of these email addresses, one may be marked as "primary" (although the primary address does not necessarily have to be released by the user).
37 | * The ORCID AuthSource will attempt to retrieve the primary email address (if visible) and return it in the `oidc.email` attribute. If none of the visible email addresses are marked as "primary", then the first email address returned is used. If no email addresses are visible, the `oidc.email` attribute will not be set.
38 |
39 | # Creating ORCID Public API Client
40 |
41 | Visit [https://orcid.org/developer-tools](https://orcid.org/developer-tools) to register an ORCID public API client. You must [create an ORCID ID](https://orcid.org/register) before registering a public API client.
42 |
--------------------------------------------------------------------------------
/tests/lib/Controller/Trait/ErrorTraitTest.php:
--------------------------------------------------------------------------------
1 | parseError($request);
23 | $this->assertEquals(['', ''], $result);
24 | }
25 |
26 | public function testParseErrorWithErrorAndDescription(): void
27 | {
28 | $request = Request::create(uri: 'localhost', parameters: [
29 | 'error' => 'sample_error',
30 | 'error_description' => 'This is a sample error description'
31 | ]);
32 |
33 | // Test
34 | $result = $this->parseError($request);
35 | $this->assertEquals(['sample_error', 'This is a sample error description'], $result);
36 | }
37 |
38 |
39 | public function testParseErrorWithErrorOnly(): void
40 | {
41 | $request = Request::create(uri: 'localhost', parameters: [
42 | 'error' => 'sample_error'
43 | ]);
44 |
45 | // Test
46 | $result = $this->parseError($request);
47 | $this->assertEquals(['sample_error', ''], $result);
48 | }
49 |
50 | public function testParseErrorWithDescriptionOnly(): void
51 | {
52 | $request = Request::create(uri: 'localhost', parameters: [
53 | 'error_description' => 'This is a sample error description'
54 | ]);
55 |
56 | // Test
57 | $result = $this->parseError($request);
58 | $this->assertEquals(['', 'This is a sample error description'], $result);
59 | }
60 |
61 | public function testWillNotParseUnrecognizedQueryParam(): void
62 | {
63 | $request = Request::create(uri: 'localhost', parameters: [
64 | 'error2' => 'This is a sample error description'
65 | ]);
66 |
67 | // Test
68 | $result = $this->parseError($request);
69 | $this->assertEquals(['', ''], $result);
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/tests/lib/MockOAuth2Provider.php:
--------------------------------------------------------------------------------
1 | 'https://mock.com/authorize',
29 | 'urlAccessToken' => 'https://mock.com/token',
30 | 'urlResourceOwnerDetails' => 'https://mock.com/userInfo',
31 | ];
32 | parent::__construct(array_merge($options, $defaultOptions), $collaborators);
33 | }
34 |
35 | public function getAccessToken($grant, array $options = []): AccessTokenInterface|AccessToken
36 | {
37 | return self::$delegate->getAccessToken($grant, $options);
38 | }
39 |
40 | public function getResourceOwner(AccessToken $token): ResourceOwnerInterface
41 | {
42 | return self::$delegate->getResourceOwner($token);
43 | }
44 |
45 | public function getAuthenticatedRequest($method, $url, $token, array $options = [])
46 | {
47 | return self::$delegate->getAuthenticatedRequest($method, $url, $token, $options);
48 | }
49 |
50 | public static function setDelegate(AbstractProvider $delegate): void
51 | {
52 | self::$delegate = $delegate;
53 | }
54 |
55 | public function getParsedResponse(RequestInterface $request)
56 | {
57 | return self::$delegate->getParsedResponse($request);
58 | }
59 |
60 | public function setPkceCode($pkceCode): void
61 | {
62 | self::$delegate->setPkceCode($pkceCode);
63 | }
64 |
65 | public function getPkceCode(): ?string
66 | {
67 | return self::$delegate->getPkceCode();
68 | }
69 |
70 | /**
71 | * Clear any cached internal state.
72 | */
73 | public static function clearInternalState(): void
74 | {
75 | self::$delegate = null;
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/docs/MICROSOFT.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | **Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)*
4 |
5 | - [Microsoft as an AuthSource](#microsoft-as-an-authsource)
6 | - [Usage](#usage)
7 | - [Recommended Config](#recommended-config)
8 | - [Gotchas](#gotchas)
9 | - [Creating Microsoft Converged app](#creating-microsoft-converged-app)
10 |
11 |
12 |
13 | # Microsoft as an AuthSource
14 |
15 | Microsoft provides several APIs for logging users in. There is Graph v1 and v2, OpenID Connect and Live Connect.
16 | Live Connect is being deprecated.
17 | The Graph apis allow you to specify if any user (both Consumer or Azure AD) can log in, just Consumer, just Azure AD or
18 | just a specific Azure AD tenant.
19 |
20 |
21 | # Usage
22 | ## Recommended Config
23 |
24 | We ended up creating a sub class of the generic `authsource` called `MicrosoftHybridAuth`. This is because the OIDC `id_token`
25 | and the response from the graph api contain different sets of attributes. For example for consumer users (e.g. hotmail or outlook.com)
26 | the `id_token` will provide email but not first name and last name, while the graph api will provide first name and last name
27 | but not email. The subclass uses the profile data from the graph api and the email and full name from the OIDC `id_token`
28 |
29 |
30 |
31 | ```php
32 | //authsources.php
33 | 'microsoft' => [
34 | 'authoauth2:MicrosoftHybridAuth',
35 | 'clientId' => 'my-client',
36 | 'clientSecret' => 'eyM-mysecret'
37 | ],
38 | ```
39 |
40 | and if are using this with a SAML IdP then you can map the OIDC attributes to regular friendly names in your `authproc` section of `saml20-idp-hosted.php`.
41 |
42 | ```php
43 | // saml20-idp-hosted.php
44 | $metadata['myEntityId'] = [
45 | 'authproc' => [
46 | // Convert oidc names to ldap friendly names
47 | 90 => ['class' => 'core:AttributeMap', 'authoauth2:microsoft2name'],
48 | ],
49 | // other IdP config options
50 | ]
51 | ```
52 | ## Gotchas
53 |
54 | * Azure AD only seems to return an email address if the user has an O365 subscription.
55 | * The Graph OIDC user info endpoint only returns a targeted `sub` id. The `id_token` has
56 | to be inspected to find the email address.
57 |
58 |
59 | # Creating Microsoft Converged app
60 |
61 | Visit https://apps.dev.microsoft.com and add a converged app.
62 |
63 |
64 |
65 |
--------------------------------------------------------------------------------
/psalm.xml:
--------------------------------------------------------------------------------
1 |
2 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
--------------------------------------------------------------------------------
/src/AttributeManipulator.php:
--------------------------------------------------------------------------------
1 | $value) {
27 | if ($value === null) {
28 | continue;
29 | }
30 | if (\is_array($value)) {
31 | if ($this->isSimpleSequentialArray($value)) {
32 | $result[$prefix . $key] = $this->stringify($value);
33 | } else {
34 | $result += $this->prefixAndFlatten($value, $prefix . $key . '.');
35 | }
36 | } else {
37 | // User strval to handle non-string types
38 | $result[$prefix . $key] = [$this->stringify($value)];
39 | }
40 | }
41 | return $result;
42 | }
43 |
44 | /**
45 | * Attempt to stringify the input
46 | * @param mixed $input if an array stringify the values, removing nulls
47 | * @return array|string
48 | */
49 | protected function stringify(mixed $input): array|string
50 | {
51 | if (\is_bool($input)) {
52 | return $input ? 'true' : 'false';
53 | } elseif (\is_array($input)) {
54 | $array = [];
55 | foreach ($input as $key => $value) {
56 | if ($value === null) {
57 | continue;
58 | }
59 | $array[$key] = $this->stringify($value);
60 | }
61 | return $array;
62 | }
63 | return (string)$input;
64 | }
65 |
66 | /**
67 | * Determine if the array is a sequential [ 'a', 'b'] or [ 0 => 'a', 1 => 'b'] array with all values being
68 | * simple types
69 | * @param array $array The array to check
70 | * @return bool true if is sequential and values are simple (not array)
71 | */
72 | private function isSimpleSequentialArray(array $array): bool
73 | {
74 | foreach ($array as $key => $value) {
75 | if (!is_int($key) || is_array($value)) {
76 | return false;
77 | }
78 | }
79 | return true;
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/docs/AUTHPROC.md:
--------------------------------------------------------------------------------
1 | # AUTHPROC support
2 |
3 | In SimpleSAMLphp (SSP) there is an API where you can do something after authentication is complete.
4 | Authentication processing filters (AuthProc filters) postprocess authentication information received from the
5 | authentication sources.
6 |
7 | SSP provides built in support for running authproc on SAML SP and IdPs, while other protocols must add their own
8 | support.
9 |
10 | The `authoauth2` module provides a way to postprocess the authentication information
11 | similar to regular SAML SPs. The module provides two ways to configure it:
12 | * Via 'authproc.oauth2' configuration option in `config.php`. These will run for all OAuth2 authsources
13 | * Via `authproc` configuration option on a oauth2 authsource. These filters will just run for that authsource.
14 |
15 | ## Supported AuthProc features
16 |
17 | * Attribute manipulation
18 | * User interaction (via redirects and authproc flow resumption)
19 | * Generally any filter that does not require SAML metadata.
20 |
21 | ## Limitations
22 |
23 | Some AuthProc filters rely on SAML metadata to function and are unlikely to work as expected.
24 | For example, `saml:FilterScopes` looks at the allowed scopes in the SAML IdP's metadata and filters
25 | attributes and this would not work in this module.
26 |
27 | ## Usage
28 |
29 | Add `authproc` to you authsource or add the 'authproc.oauth2' config option to `config.php` to enable
30 | for all OAuth2 authsources. See SSP's [Auth Proc documentation](https://simplesamlphp.org/docs/stable/simplesamlphp-authproc.html).
31 |
32 | ## Example configuration
33 |
34 | Below is an example demonstrating how to configure the `authoauth2` module for PKCE with the `S256` method and session storage strategy:
35 |
36 | ```php
37 | // config/authsources.php
38 |
39 | $config = [
40 | 'my-oidc-auth-source' => [
41 | 'authoauth2:OpenIDConnect',
42 |
43 | 'issuer' => 'https://my-issuer',
44 | 'clientId' => 'client-id',
45 | 'clientSecret' => 'client-secret',
46 |
47 | 'authproc' => [
48 | 20 => [
49 | 'class' => 'preprodwarning:Warning'
50 | ],
51 | 25 => [
52 | 'class' => 'core:AttributeAdd',
53 | '%replace',
54 | 'groups' => ['users', 'members'],
55 | ],
56 | // The authproc are run in order by key, not by order defined,
57 | // which means this authproc will run first and have its output overwritten by the
58 | // above authproc (number 25)
59 | 15 => [
60 | 'class' => 'core:AttributeAdd',
61 | '%replace',
62 | 'groups' => ['should', 'be', 'replaced'],
63 | ],
64 | ]
65 | ]
66 | ];
67 | ```
68 |
69 | ## Links
70 |
71 | - See https://simplesamlphp.org/docs/stable/simplesamlphp-authproc.html for the regular SSP authproc documentation.
72 |
--------------------------------------------------------------------------------
/src/Auth/Source/BitbucketAuth.php:
--------------------------------------------------------------------------------
1 |
17 | * @package SimpleSAMLphp
18 | */
19 | class BitbucketAuth extends OAuth2
20 | {
21 | public function __construct(array $info, array $config)
22 | {
23 | // Set some defaults
24 | if (!array_key_exists('template', $config)) {
25 | $config['template'] = 'Bitbucket';
26 | }
27 | parent::__construct($info, $config);
28 | }
29 |
30 |
31 | /**
32 | * Query Bitbucket's email endpoint if needed.
33 | * Public for testing
34 | * @param AccessToken $accessToken
35 | * @param AbstractProvider $provider
36 | * @param array $state
37 | */
38 | public function postFinalStep(AccessToken $accessToken, AbstractProvider $provider, array &$state): void
39 | {
40 | if (!in_array('email', $this->config->getArray('scopes'))) {
41 | // We didn't request email scope originally
42 | return;
43 | }
44 | $emailUrl = $this->getConfig()->getString('urlResourceOwnerEmail');
45 | $request = $provider->getAuthenticatedRequest('GET', $emailUrl, $accessToken);
46 | try {
47 | $response = $this->retry(
48 | /**
49 | * @return mixed
50 | * @throws IdentityProviderException
51 | */
52 | function () use ($provider, $request) {
53 | return $provider->getParsedResponse($request);
54 | }
55 | );
56 | } catch (Exception $e) {
57 | // not getting email shouldn't fail the authentication
58 | Logger::error(
59 | 'BitbucketAuth: ' . $this->getLabel() . ' exception email query response ' . $e->getMessage()
60 | );
61 | return;
62 | }
63 |
64 | // if the user has multiple email addresses, pick the primary one
65 | if (is_array($response) && isset($response['size'])) {
66 | for ($i = 0; $i < $response['size']; $i++) {
67 | /** @psalm-suppress MixedArrayAccess */
68 | if ($response['values'][$i]['is_primary'] === 'true' && $response['values'][$i]['type'] === 'email') {
69 | $prefix = $this->getAttributePrefix();
70 | $state['Attributes'][$prefix . 'email'] = [$response['values'][$i]['email']];
71 | }
72 | }
73 | } else {
74 | Logger::error(
75 | 'BitbucketAuth: ' . $this->getLabel() . ' invalid email query response ' . var_export($response, true)
76 | );
77 | }
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # simplesamlphp-module-authoauth2 Changelog
2 |
3 | ## v5.0.0-beta.1
4 | * Upgrade to min SSP 2.3 and php 8.1
5 | * Move to controllers and routes
6 | * Update default callback/redirect URLS to not include `.php` extension
7 |
8 | ## v4.1.0
9 | _Release: 2024-10-01
10 | * Allow urlResourceOwnerDetails to be overridden for OIDC
11 |
12 | ## v4.1.0-beta.2
13 | _Release: 2024-02-13
14 | * Update consent template to twig
15 |
16 | ## v4.1.0-beta.1
17 | _Release: 2024-01-29
18 | * Test against php 8.3
19 | * Add support for PKCE
20 | * Add support for running authproc filters
21 | * Require league/oauth2-client ^2.7
22 |
23 | ## v4.0.0
24 | _Release: 2023-08-04
25 | * No changes from v4.0.0-beta.2
26 |
27 | ## v4.0.0-beta.2
28 | _Release: 2023-08-04
29 | * LinkedIn OIDC Template
30 | * Deprecate old LinkedIn auth method
31 | * Upgrade `kevinrob/guzzle-cache-middleware` to fix Guzzle promise issue
32 | * Allow more versions of `psr/cache` and `symfony/cache`
33 |
34 | ## v4.0.0-beta.1
35 | _Release: 2023-03-01
36 | * Move `lib` to `src`
37 | * Move `www` to `public`
38 | * Use ssp2 final release
39 | * firebase/php-jwt 6 support
40 |
41 | ## v4.0.0-alpha.1
42 |
43 | _Release: 2022-11-16
44 | * Make OIDC discovery configrable
45 | * SSP 2 compatability
46 | * Improved psalm code quality
47 | * Better source code typing
48 |
49 | ## v3.3.0
50 |
51 | _Release: 2023-06-12
52 | * LinkedIn OIDC Template
53 | * Deprecate old LinkedIn auth method
54 | * Upgrade `kevinrob/guzzle-cache-middleware` to fix Guzzle promise issue
55 |
56 | ## v3.2.0
57 |
58 | _Release: 2022-10-12
59 | * Amazon template
60 | * Apple template
61 | * Orcid auth source
62 | * OIDC auth source now supports `scopes` setting
63 | * Move to phpunit 8
64 | * Increase min php version
65 |
66 | ## v3.1.0
67 |
68 | _Release: 2020-04-09
69 | * Allow additional authenticated urls to be queried for attributes
70 | * Update dependencies
71 |
72 | ## v3.0.0
73 |
74 | _Release: 2019-12-03
75 | * Bumb min SSP version to 1.17
76 | * Better OIDC support
77 | ** Logout
78 | ** Query .well-known endpoint
79 | * Bitbucket
80 |
81 | ## v2.1.0
82 |
83 | _Release: 2019-01-29
84 | * LinkedIn V2 authsource
85 | * Make attribute conversion method overridable
86 | * Some code style cleanup
87 |
88 | ## v2.0.0
89 |
90 | _Release: 2018-11-29
91 | * Behavior changes from v1
92 | * User canceling consent sends them to error page rather than throwing USER_ABORT. Behavior is configurable
93 | * Automatic retry on network errors. Behavior is configurable
94 | * Option tokenFieldsToUserDetailsUrl to indicate which fields from token response should
95 | be query params on user info request
96 | * If user cancels consent, send them to page saying consent must be provided.
97 | * Perform 1 retry on network errors
98 | * Use ssp 1.16.2 as the dependency
99 | * Add php 7.1 and 7.2 to travis builds
100 | * PSR-2 styling
101 | * Add Microsoft authsource
102 | * Allow logging of id_token json
103 | * Template for YahooOIDC, MicrosoftOIDC, LinkedIn and Facebook
104 | * Add support for enabling http request/response logging
105 | * Add general debug information
106 |
107 | ## v1.0.0
108 |
109 | _Released: 2018-08-21
110 |
111 | * Generic OAuth2/OIDC module
112 | * Template for Google OIDC
113 | * OIDC attribute map
114 | * Instructions
115 | * Tips for migrating from old/alternate modules
116 |
--------------------------------------------------------------------------------
/tests/lib/Providers/AdjustableGenericProviderTest.php:
--------------------------------------------------------------------------------
1 | 'https://www.facebook.com/dialog/oauth',
17 | 'urlAccessToken' => 'https://graph.facebook.com/oauth/access_token',
18 | 'urlResourceOwnerDetails' => 'https://graph.facebook.com/me?fields=123',
19 | ];
20 |
21 | /**
22 | * @param array $tokenResponse
23 | * @param string $expectedQueryString
24 | *
25 | * @throws \Exception
26 | */
27 | #[DataProvider('adjustProvider')]
28 | public function testAdjustingResourceOwnerUrl(array $tokenResponse, string $expectedQueryString): void
29 | {
30 |
31 | $token = new AccessToken($tokenResponse);
32 | $config = self::REQUIRED_PROVIDER_CONFIG + [
33 | 'tokenFieldsToUserDetailsUrl' => [
34 | 'uid' => 'uid',
35 | 'rename' => 'newname',
36 | 'access_token' => 'access_token'
37 | ]
38 | ];
39 | $provider = new AdjustableGenericProvider($config);
40 | $url = $provider->getResourceOwnerDetailsUrl($token);
41 | $query = parse_url($url, PHP_URL_QUERY);
42 | $this->assertEquals($expectedQueryString, $query);
43 | }
44 |
45 | public static function adjustProvider(): array
46 | {
47 | return [
48 | [
49 | ['uid' => 'abc', 'rename' => '123', 'ignore' => 'ig', 'access_token' => 'secret'],
50 | 'fields=123&uid=abc&newname=123&access_token=secret'
51 | ],
52 | [['access_token' => 'secret'], 'fields=123&access_token=secret'],
53 | ];
54 | }
55 |
56 | /**
57 | * Test only adjusting the url if configured
58 | */
59 | public function testNoAdjustmentsToUrl(): void
60 | {
61 | $provider = new AdjustableGenericProvider(self::REQUIRED_PROVIDER_CONFIG);
62 | $token = new AccessToken(['access_token' => 'abc', 'someid' => 123]);
63 | $url = $provider->getResourceOwnerDetailsUrl($token);
64 | $this->assertEquals('https://graph.facebook.com/me?fields=123', $url);
65 | }
66 |
67 | /**
68 | * Confirm scope can be set with scopes or authoricationUrl.scope
69 | */
70 | public function testSetScopes(): void
71 | {
72 | $provider = new AdjustableGenericProvider(self::REQUIRED_PROVIDER_CONFIG);
73 | $url = $provider->getAuthorizationUrl();
74 | $request = Request::create($url);
75 | $this->assertFalse($request->query->has('scope'), 'no default scopes');
76 |
77 | $url = $provider->getAuthorizationUrl(['scope' => 'otherscope']);
78 | $request = Request::create($url);
79 | $this->assertEquals('otherscope', $request->query->get('scope'));
80 |
81 | $provider = new AdjustableGenericProvider(self::REQUIRED_PROVIDER_CONFIG + ['scopes' => ['openid']]);
82 |
83 | $url = $provider->getAuthorizationUrl();
84 | $request = Request::create($url);
85 | $this->assertEquals('openid', $request->query->get('scope'));
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/docs/APPLE.md:
--------------------------------------------------------------------------------
1 |
2 | # Apple:
3 | * only returns name on first login
4 | * includes email in the id_token
5 | * Apple for School accounts may not have email at all.
6 | * `sub` (user id) is tied to your application (or the apps you group together in Apple's developer console)
7 |
8 | # Testing with Apple
9 | To test authenticating as if it is your first login:
10 | 1. Visit https://appleid.apple.com/account/manage
11 | 2. Click 'Sign in with Apple'
12 | 3. Click the app you want to 'Stop using Sign in with Apple'
13 | 4. On your next login to that app you will be prompted to choose what email to release and your name
14 | will be release on initial login.
15 |
16 | # Configuration
17 |
18 | Apple integration uses the provider from this package `patrickbussmann/oauth2-apple:~0.2.10`.
19 | You must install it, and provide these 4 settings
20 |
21 | ```php
22 | 'authoauth2:OAuth2',
23 | 'template' => 'AppleLeague',
24 | 'teamId' => $appleTeamId,
25 | 'clientId' => $apiKey,
26 | 'keyFileId' => $privateKeyId,
27 | 'keyFilePath' => $privateKeyPath,
28 |
29 | // Other settings that are provider specific (like logging) may or may not work on the Apple provider
30 | ```
31 |
32 | If you are using this with a SAML IdP then you can map the Apple attributes to regular friendly names in your `authproc` section of `saml20-idp-hosted.php`.
33 |
34 | ```php
35 | // saml20-idp-hosted.php
36 | $metadata['myEntityId'] = [
37 | 'authproc' => [
38 | // Convert oidc names to ldap friendly names
39 | 90 => ['class' => 'core:AttributeMap', 'authoauth2:apple2name'],
40 | ],
41 | // other IdP config options
42 | ]
43 | ```
44 |
45 |
46 | # POC Testing
47 |
48 | Testing locally with a docker image to prove out configuration. You won't need this for your setup.
49 |
50 | ```
51 | # Run ssp image
52 | docker run --name ssp-apple-oidc \
53 | --mount type=bind,source="$(pwd)/samples/apple/authsources.php",target=/var/simplesamlphp/config/authsources.php,readonly \
54 | --mount type=bind,source="$(pwd)/.test-secrets/apple.p8",target=/var/simplesamlphp/cert/apple.p8,readonly \
55 | -e SSP_ADMIN_PASSWORD=secret1 \
56 | -e SSP_LOG_LEVEL=7 \
57 | -p 443:443 cirrusid/simplesamlphp
58 |
59 | # Then get shell on image to install some stuff
60 | docker exec -it ssp-apple-oidc bash
61 | cd /var/simplesamlphp/
62 | # Use a fork of the oauth2-apple module to get `sub` in the resource owner.
63 | composer config repositories.apple vcs https://github.com/pradtke/oauth2-apple.git
64 | composer require cirrusidentity/simplesamlphp-module-authoauth2 patrickbussmann/oauth2-apple:dev-owner_to_array_fix
65 |
66 |
67 |
68 | # In theory composer can be handle at run time, but for me composer was complaining of a full disk if it ran during container init
69 | docker run --name ssp-apple-oidc \
70 | --mount type=bind,source="$(pwd)/samples/apple/authsources.php",target=/var/simplesamlphp/config/authsources.php,readonly \
71 | --mount type=bind,source="$(pwd)/.test-secrets/apple.p8",target=/var/simplesamlphp/cert/apple.p8,readonly \
72 | -e SSP_ADMIN_PASSWORD=secret1 \
73 | -e COMPOSER_REQUIRE="cirrusidentity/simplesamlphp-module-authoauth2 patrickbussmann/oauth2-apple" \
74 | -e SSP_ENABLED_MODULES="authoauth2" \
75 | -p 443:443 cirrusid/simplesamlphp
76 | ```
77 |
78 | Edit your `/etc/hosts` file to make `apple.test.idpproxy.illinois.edu` route to local host and then visit
79 | `https://apple.test.idpproxy.illinois.edu/simplesaml/module.php/core/authenticate.php?as=appleTest` to
80 | initiate a login to Apple. Non-secret values such as keyId and teamId
81 |
82 | # Documentation
83 |
84 | * [TN3107: Resolving Sign in with Apple response errors](https://developer.apple.com/documentation/technotes/tn3107-resolving-sign-in-with-apple-response-errors)
--------------------------------------------------------------------------------
/src/Controller/OIDCLogoutController.php:
--------------------------------------------------------------------------------
1 | config = $config ?? Configuration::getInstance();
52 | }
53 |
54 | /**
55 | * @throws NoState
56 | * @throws BadRequest
57 | */
58 | public function loggedout(Request $request): void
59 | {
60 | $this->parseRequest($request);
61 | Logger::debug('authoauth2: logout request=' . var_export($this->requestParams, true));
62 |
63 | \assert(\is_array($this->state));
64 |
65 | $this->getSourceService()->completeLogout($this->state);
66 | // @codeCoverageIgnoreStart
67 | }
68 |
69 | /**
70 | * @throws BadRequest
71 | * @throws CriticalConfigurationError
72 | * @throws \Exception
73 | */
74 | public function logout(Request $request): void
75 | {
76 | $this->parseRequestParamsSingleton($request);
77 | Logger::debug('authoauth2: logout request=' . var_export($this->requestParams, true));
78 | // Find the authentication source
79 | if (!isset($this->requestParams['authSource'])) {
80 | throw new BadRequest('No authsource in the request');
81 | }
82 | $sourceId = $this->requestParams['authSource'];
83 | if (empty($sourceId) || !\is_string($sourceId)) {
84 | throw new BadRequest('Authsource ID invalid');
85 | }
86 | $logoutRoute = $this->config->getOptionalBoolean('useLegacyRoutes', false) ?
87 | LegacyRoutesEnum::LegacyLogout->value : RoutesEnum::Logout->value;
88 | $this->getAuthSource($sourceId)
89 | ->logout([
90 | 'oidc:localLogout' => true,
91 | 'ReturnTo' => $this->config->getBasePath() . $logoutRoute,
92 | ]);
93 | }
94 |
95 | /**
96 | * Create and return an instance with the specified authsource.
97 | *
98 | * @param string $authSource The id of the authentication source.
99 | *
100 | * @return Simple The authentication source.
101 | */
102 | public function getAuthSource(string $authSource): Simple
103 | {
104 | return new Simple($authSource);
105 | }
106 | }
107 |
--------------------------------------------------------------------------------
/docs/GOOGLE.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | **Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)*
4 |
5 | - [Google as an AuthSource](#google-as-an-authsource)
6 | - [Usage](#usage)
7 | - [Recommended Config](#recommended-config)
8 | - [Restricting hosted domain](#restricting-hosted-domain)
9 | - [Creating Google OIDC Client](#creating-google-oidc-client)
10 |
11 |
12 |
13 | # Google as an AuthSource
14 |
15 | Google provides OIDC (and previously Google Plus) endpoints for
16 | learning about a user. The OIDC endpoints require fewer client API
17 | permissions and return data in a standardized format. The Google Plus
18 | endpoints can return more data about a user but require Google Plus
19 | permissions and return data in a Google specific format. The Google
20 | Plus APIs will be shutting down sometime in 2019 so we recommend using
21 | the OIDC endpoints.
22 |
23 | You can also choose between using the generic OAuth/OIDC implementation or using
24 | a [Google specific library](https://github.com/thephpleague/oauth2-google/).
25 |
26 | # Usage
27 | ## Recommended Config
28 |
29 | We recommend using the OIDC configuration with the generic OAuth2 authsource. This
30 | requires the least configuration.
31 |
32 |
33 | ```php
34 | //authsources.php
35 | 'google' => [
36 | 'authoauth2:OAuth2',
37 | 'template' => 'GoogleOIDC',
38 | 'clientId' => 'myclient.apps.googleusercontent.com',
39 | 'clientSecret' => 'eyM-mysecret'
40 | ],
41 | ```
42 |
43 | and if are using this with a SAML IdP then you can map the OIDC attributes to regular friendly names in your `authproc` section of `saml20-idp-hosted.php`.
44 |
45 | ```php
46 | // saml20-idp-hosted.php
47 | $metadata['myEntityId'] = [
48 | 'authproc' => [
49 | // Convert oidc names to ldap friendly names
50 | 90 => ['class' => 'core:AttributeMap', 'authoauth2:oidc2name'],
51 | ],
52 | // other IdP config options
53 | ]
54 | ```
55 |
56 | ## Restricting hosted domain
57 |
58 | If you want to restrict the hosted domain of a user you can pass the
59 | `hd` query parameter to Google. You **must** ensure the `hd` value
60 | returned from Google matches what you expect - a user could remove the
61 | `hd` from the browser flow and login with any account.
62 |
63 | * Out of date *
64 | TODO: Once https://github.com/thephpleague/oauth2-google/pull/54 is accepted into the oauth2-google project then
65 | this check would be done automatically. This example would then need to be updated to use that project
66 |
67 | ```php
68 | // Using the generic provider
69 | 'google' => [
70 | 'authoauth2:OAuth2',
71 | 'template' => 'GoogleOIDC',
72 | 'clientId' => 'myclient.apps.googleusercontent.com',
73 | 'clientSecret' => 'eyM-mysecret'
74 | 'urlAuthorizeOptions' => [
75 | 'hd' => 'cirrusidentity.com',
76 | ],
77 | ],
78 | ```
79 |
80 | # Creating Google OIDC Client
81 |
82 | Google provides [documentation](https://developers.google.com/identity/protocols/OpenIDConnect#appsetup). Follow the section related to 'Setting up OAuth 2.0' to setup an API project and create an OAuth2 client. If you intend to use the Google Plus API (instead of OIDC) than you must enable it from the API library in Google's developer console.
83 |
84 | The section in the documentation about accessing the service, authentication and server flows are performed by this module.
85 |
86 | You will need to add the correct redirect URI to your OAuth2 client in the Google console. Use a url of the form below, and set hostname, SSP_PATH and optionally port to the correct values.
87 |
88 | https://hostname/SSP_PATH/module.php/authoauth2/linkback.php
89 |
90 |
--------------------------------------------------------------------------------
/src/Controller/Traits/RequestTrait.php:
--------------------------------------------------------------------------------
1 | parseRequestParamsSingleton($request);
52 | if (!isset($this->requestParams['state'])) {
53 | return false;
54 | }
55 | /** @var ?string $stateId */
56 | $stateId = $this->requestParams['state'];
57 | if (empty($stateId)) {
58 | return false;
59 | }
60 | return str_starts_with($stateId, $this->expectedPrefix);
61 | }
62 |
63 | /**
64 | * @throws NoState
65 | * @throws BadRequest
66 | */
67 | public function parseRequest(Request $request): void
68 | {
69 | if (!$this->stateIsValid($request)) {
70 | $message = match ($request->attributes->get('_route')) {
71 | LegacyRoutesEnum::LegacyLogout->name,
72 | // phpcs:ignore Generic.Files.LineLength.TooLong
73 | RoutesEnum::Logout->name => 'Either missing state parameter on OpenID Connect logout callback, or cannot be handled by authoauth2',
74 | LegacyRoutesEnum::LegacyLinkback->name,
75 | // phpcs:ignore Generic.Files.LineLength.TooLong
76 | RoutesEnum::Linkback->name => 'Either missing state parameter on OAuth2 login callback, or cannot be handled by authoauth2',
77 | default => 'An error occured'
78 | };
79 | throw new BadRequest($message);
80 | }
81 | $stateIdWithPrefix = (string)($this->requestParams['state'] ?? '');
82 | $stateId = substr($stateIdWithPrefix, \strlen($this->expectedPrefix));
83 |
84 | $this->state = $this->loadState($stateId, $this->expectedStageState);
85 |
86 | // Find the authentication source
87 | if (
88 | $this->state === null
89 | || !\array_key_exists($this->expectedStateAuthId, $this->state)
90 | ) {
91 | throw new BadRequest('No authsource id data in state for ' . $this->expectedStateAuthId);
92 | }
93 |
94 | if (empty($this->state[$this->expectedStateAuthId])) {
95 | throw new BadRequest('Source ID is undefined');
96 | }
97 |
98 | $this->sourceId = (string)$this->state[$this->expectedStateAuthId];
99 | $this->source = $this->getSourceService()->getById($this->sourceId, OAuth2::class);
100 | if ($this->source === null) {
101 | throw new BadRequest('Could not find authentication source with id ' . $this->sourceId);
102 | }
103 | }
104 |
105 | /**
106 | * Retrieve saved state.
107 | *
108 | * @param string $id
109 | * @param string $stage
110 | * @param bool $allowMissing
111 | *
112 | * @return array|null
113 | * @throws NoState
114 | */
115 | public function loadState(string $id, string $stage, bool $allowMissing = false): ?array
116 | {
117 | return State::loadState($id, $stage, $allowMissing);
118 | }
119 |
120 | /**
121 | * @param Request $request
122 | */
123 | public function parseRequestParamsSingleton(Request $request): void
124 | {
125 | if (empty($this->requestParams)) {
126 | $this->requestParams = RequestUtilities::getRequestParams($request);
127 | }
128 | }
129 | }
130 |
--------------------------------------------------------------------------------
/docker/config/authsources.php:
--------------------------------------------------------------------------------
1 | [
7 | 'authoauth2:OAuth2',
8 | 'useLegacyRoutes' => true,
9 | 'template' => 'Facebook',
10 | // App is in development mode and can be used to login as a test user
11 | 'clientId' => '1223209798230151',
12 | 'clientSecret' => '61cb2fdddc5a16998924360c1a9a726f',
13 | /**
14 | * This the app's test user that can be used to authenticate:
15 | * email: open_nzwvghb_user@tfbnw.net
16 | * password: SSPisMyFavorite2022
17 | */
18 | ],
19 |
20 | 'templateAuthProcFacebook' => [
21 | 'authoauth2:OAuth2',
22 | 'useLegacyRoutes' => true,
23 | 'template' => 'Facebook',
24 | // App is in development mode and can be used to login as a test user
25 | 'clientId' => '1223209798230151',
26 | 'clientSecret' => '61cb2fdddc5a16998924360c1a9a726f',
27 | /**
28 | * This the app's test user that can be used to authenticate:
29 | * email: open_nzwvghb_user@tfbnw.net
30 | * password: SSPisMyFavorite2022
31 | */
32 | 'authproc' => [
33 | 20 => [
34 | 'class' => 'preprodwarning:Warning'
35 | ],
36 | 25 => [
37 | 'class' => 'core:AttributeAdd',
38 | '%replace',
39 | 'groups' => ['users', 'members'],
40 | ],
41 | // The authproc should be run in order by key, not by order defined,
42 | // which means this authproc will run first and have its output overwritten by the
43 | // above authproc
44 | 15 => [
45 | 'class' => 'core:AttributeAdd',
46 | '%replace',
47 | 'groups' => ['should', 'be', 'replaced'],
48 | ],
49 | ]
50 | ],
51 |
52 | 'templateMicrosoft' => [
53 | 'authoauth2:OAuth2',
54 | 'useLegacyRoutes' => true,
55 | 'template' => 'MicrosoftGraphV1',
56 | 'clientId' => 'f579dc6e-58f5-41a8-8bbf-96d54eacfe8d',
57 | 'clientSecret' => 'GXc8Q~mgI7kTBllrvpBthUEioeARdjrRYORSyda4',
58 | ],
59 |
60 | /** Test using Google OIDC but with config explicitly define rather than pulled from .well-know */
61 | 'templateGoogle' => [
62 | 'authoauth2:OAuth2',
63 | 'useLegacyRoutes' => true,
64 | 'template' => 'GoogleOIDC',
65 | 'clientId' => '105348996343-6jb2828gnlo07mop7b08gjse1ms77bm0.apps.googleusercontent.com',
66 | 'clientSecret' => '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',
67 | ],
68 |
69 | /** Test using the OpenIDConnect functionality to interact with Google. This configures itself from `.well-known/openid-configuration` */
70 | 'googleOIDCSource' => [
71 | 'authoauth2:OpenIDConnect',
72 | 'useLegacyRoutes' => true,
73 | 'issuer' => 'https://accounts.google.com',
74 |
75 | 'clientId' => '105348996343-6jb2828gnlo07mop7b08gjse1ms77bm0.apps.googleusercontent.com',
76 | 'clientSecret' => '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',
77 | /**
78 | * This the app's test user that can be used to authenticate:
79 | * email: open_nzwvghb_user@tfbnw.net
80 | * password: SSPisMyFavorite2022
81 | */
82 | ],
83 |
84 | /** Using the OIDC authsource for MS logins */
85 | 'microsoftOIDCSource' => [
86 | 'authoauth2:OpenIDConnect',
87 | 'useLegacyRoutes' => true,
88 | 'issuer' => 'https://sts.windows.net/{tenantid}/',
89 | // When using the 'common' discovery endpoint it allows any Azure user to authenticate, however
90 | // the token issuer is tenant specific and will not match what is in the common discovery document.
91 | 'validateIssuer' => false, // issuer is just used to confirm correct discovery endpoint loaded
92 | 'discoveryUrl' => 'https://login.microsoftonline.com/common/.well-known/openid-configuration',
93 | 'clientId' => 'f579dc6e-58f5-41a8-8bbf-96d54eacfe8d',
94 | 'clientSecret' => 'GXc8Q~mgI7kTBllrvpBthUEioeARdjrRYORSyda4',
95 | ],
96 |
97 | 'microsoftOIDCPkceSource' => [
98 | 'authoauth2:OpenIDConnect',
99 | 'useLegacyRoutes' => true,
100 | 'issuer' => 'https://sts.windows.net/{tenantid}/',
101 | // When using the 'common' discovery endpoint it allows any Azure user to authenticate, however
102 | // the token issuer is tenant specific and will not match what is in the common discovery document.
103 | 'validateIssuer' => false, // issuer is just used to confirm correct discovery endpoint loaded
104 | 'discoveryUrl' => 'https://login.microsoftonline.com/common/.well-known/openid-configuration',
105 | 'clientId' => 'f579dc6e-58f5-41a8-8bbf-96d54eacfe8d',
106 | 'clientSecret' => 'GXc8Q~mgI7kTBllrvpBthUEioeARdjrRYORSyda4',
107 | 'pkceMethod' => 'S256',
108 | ],
109 |
110 |
111 | // This is a authentication source which handles admin authentication.
112 | 'admin' => array(
113 | // The default is to use core:AdminPassword, but it can be replaced with
114 | // any authentication source.
115 |
116 | 'core:AdminPassword',
117 | ),
118 |
119 | );
120 |
--------------------------------------------------------------------------------
/src/Auth/Source/LinkedInV2Auth.php:
--------------------------------------------------------------------------------
1 | [$resourceOwnerAttributes["id"]]
65 | ];
66 | foreach (['firstName', 'lastName'] as $attributeName) {
67 | $value = $this->getFirstValueFromMultiLocaleString($attributeName, $resourceOwnerAttributes);
68 | if (!empty($value)) {
69 | $attributes[$prefix . $attributeName] = [$value];
70 | }
71 | }
72 |
73 |
74 | return $attributes;
75 | }
76 |
77 | /**
78 | * LinkedIn's attribute values are complex subobjects per
79 | * https://docs.microsoft.com/en-us/linkedin/shared/references/v2/object-types#multilocalestring
80 | * @param string $attributeName The multiLocalString attribute to check
81 | * @param array $attributes All the LinkedIn attributes
82 | * @return string|false|null Return the first value or null/false if there is no value
83 | */
84 | private function getFirstValueFromMultiLocaleString(string $attributeName, array $attributes): false|string|null
85 | {
86 | if (isset($attributes[$attributeName]['localized']) && \is_array($attributes[$attributeName]['localized'])) {
87 | // reset gives us the first value from the multivalued associate localized array
88 | return reset($attributes[$attributeName]['localized']);
89 | }
90 | return null;
91 | }
92 |
93 |
94 | /**
95 | * Query LinkedIn's email endpoint if needed.
96 | * Public for testing
97 | * @param AccessToken $accessToken
98 | * @param AbstractProvider $provider
99 | * @param array $state
100 | */
101 | public function postFinalStep(AccessToken $accessToken, AbstractProvider $provider, array &$state): void
102 | {
103 | if (!in_array('r_emailaddress', $this->config->getArray('scopes'))) {
104 | // We didn't request email scope originally
105 | return;
106 | }
107 | $emailUrl = $this->getConfig()->getString('urlResourceOwnerEmail');
108 | $request = $provider->getAuthenticatedRequest('GET', $emailUrl, $accessToken);
109 | try {
110 | $response = $this->retry(
111 | /**
112 | * @return mixed
113 | */
114 | function () use ($provider, $request) {
115 | return $provider->getParsedResponse($request);
116 | }
117 | );
118 | } catch (Exception $e) {
119 | // not getting email shouldn't fail the authentication
120 | Logger::error(
121 | 'linkedInv2Auth: ' . $this->getLabel() . ' exception email query response ' . $e->getMessage()
122 | );
123 | return;
124 | }
125 |
126 | if (\is_array($response) && isset($response['elements'][0]['handle~']['emailAddress'])) {
127 | /**
128 | * A valid response for email lookups is:
129 | * {
130 | * "elements" : [ {
131 | * "handle" : "urn:li:emailAddress:5266785132",
132 | * "handle~" : {
133 | * "emailAddress" : "patrick+testuser@cirrusidentity.com"
134 | * }
135 | * } ]
136 | * }
137 | */
138 | $prefix = $this->getAttributePrefix();
139 | /** @psalm-suppress MixedArrayAccess */
140 | $state['Attributes'][$prefix . 'emailAddress'] = [$response['elements'][0]['handle~']['emailAddress']];
141 | } else {
142 | Logger::error(
143 | 'linkedInv2Auth: ' . $this->getLabel() . ' invalid email query response ' . var_export($response, true)
144 | );
145 | }
146 | }
147 | }
148 |
--------------------------------------------------------------------------------
/src/Controller/Oauth2Controller.php:
--------------------------------------------------------------------------------
1 | parseRequest($request);
55 | Logger::debug('authoauth2: linkback request=' . var_export($this->requestParams, true));
56 |
57 | // Required for psalm
58 | \assert($this->source instanceof OAuth2);
59 | \assert(\is_array($this->state));
60 | \assert(\is_string($this->sourceId));
61 |
62 | // Handle Identify Provider error
63 | if (empty($this->requestParams['code'])) {
64 | $this->handleError($this->source, $this->state, $request);
65 | // Used to facilitate testing
66 | return;
67 | }
68 |
69 | try {
70 | $this->source->finalStep($this->state, (string)$this->requestParams['code']);
71 | } catch (IdentityProviderException $e) {
72 | // phpcs:ignore Generic.Files.LineLength.TooLong
73 | Logger::error("authoauth2: error in '$this->sourceId' msg '{$e->getMessage()}' body '" . var_export($e->getResponseBody(), true) . "'");
74 | State::throwException(
75 | $this->state,
76 | new AuthSource($this->sourceId, 'Error on oauth2 linkback endpoint.', $e)
77 | );
78 | } catch (\Exception $e) {
79 | Logger::error("authoauth2: error in '$this->sourceId' '" . get_class($e) . "' msg '{$e->getMessage()}'");
80 | State::throwException(
81 | $this->state,
82 | new AuthSource($this->sourceId, 'Error on oauth2 linkback endpoint.', $e)
83 | );
84 | }
85 |
86 | $this->getSourceService()->completeAuth($this->state);
87 | }
88 |
89 | /**
90 | * @throws Exception
91 | */
92 | protected function handleError(OAuth2 $source, array $state, Request $request): void
93 | {
94 | // Errors can be pretty inconsistent
95 | // XXX We do not have the ability to parse hash parameters in the backend, for example
96 | // https://example.com/ssp/module.php/authoauth2/linkback#error=invalid_scope
97 | /** @var string $error */
98 | /** @var string $error_description */
99 | [$error, $error_description] = $this->parseError($request);
100 | $oauth2ErrorsValues = array_column(Oauth2ErrorsEnum::cases(), 'value');
101 | if (\in_array($error, $oauth2ErrorsValues, true)) {
102 | $msg = 'authoauth2: Authsource '
103 | . $source->getAuthId()
104 | . ' User denied access: '
105 | . $error
106 | . ' Msg: '
107 | . $error_description;
108 | Logger::debug($msg);
109 | if ($source->getConfig()->getOptionalBoolean('useConsentErrorPage', true)) {
110 | $consentErrorRoute = $source->getConfig()->getOptionalBoolean('useLegacyRoutes', false) ?
111 | LegacyRoutesEnum::LegacyConsentError->value : RoutesEnum::ConsentError->value;
112 | $consentErrorPageUrl = Module::getModuleURL("authoauth2/$consentErrorRoute");
113 | $this->getHttp()->redirectTrustedURL($consentErrorPageUrl);
114 | // We should never get here. This is to facilitate testing. If we do get here then
115 | // something bad happened
116 | return;
117 | } else {
118 | $e = new UserAborted();
119 | State::throwException($state, $e);
120 | }
121 | }
122 |
123 | $errorMsg = 'Authentication failed: [' . $error . '] ' . $error_description;
124 | Logger::debug("authoauth2: Authsource '" . $source->getAuthId() . "' return error $errorMsg");
125 | $e = new AuthSource($source->getAuthId(), $errorMsg);
126 | State::throwException($state, $e);
127 | }
128 | }
129 |
--------------------------------------------------------------------------------
/tests/lib/Providers/OpenIDConnectProviderTest.php:
--------------------------------------------------------------------------------
1 | expectException(IdentityProviderException::class);
44 | $this->expectExceptionMessage($expectedMessage);
45 |
46 | $configDir = !empty(getenv('SIMPLESAMLPHP_CONFIG_DIR')) ? (string)getenv('SIMPLESAMLPHP_CONFIG_DIR') : '';
47 | MockOpenIDConnectProvider::setSigningKeys([
48 | 'mykey' => file_get_contents($configDir . '/jwks-cert.pem')
49 | ]);
50 | $provider = new MockOpenIDConnectProvider([
51 | 'issuer' => 'niceidp',
52 | 'clientId' => 'test client id',
53 | ]);
54 | $provider->verifyIDToken($idToken);
55 | }
56 |
57 | /**
58 | * Confirm scope can be set with scopes or authoricationUrl.scope
59 | */
60 | public function testSetScopes(): void
61 | {
62 | $provider = new OpenIDConnectProvider(
63 | ['issuer' => 'https://accounts.google.com']
64 | );
65 | $url = $provider->getAuthorizationUrl();
66 | $request = Request::create($url);
67 | $this->assertEquals('openid profile', $request->query->get('scope'));
68 |
69 | $url = $provider->getAuthorizationUrl(['scope' => 'otherscope']);
70 | $request = Request::create($url);
71 | $this->assertEquals('otherscope', $request->query->get('scope'));
72 |
73 | $provider = new OpenIDConnectProvider(
74 | ['issuer' => 'https://accounts.google.com', 'scopes' => ['openid']]
75 | );
76 | $url = $provider->getAuthorizationUrl();
77 | $request = Request::create($url);
78 | $this->assertEquals('openid', $request->query->get('scope'));
79 | }
80 |
81 | public function testConfiguringDiscoveryUrl(): void
82 | {
83 | $provider = new OpenIDConnectProvider(
84 | ['issuer' => 'https://accounts.example.com']
85 | );
86 | $this->assertEquals(
87 | 'https://accounts.example.com/.well-known/openid-configuration',
88 | $provider->getDiscoveryUrl()
89 | );
90 |
91 | $provider = new OpenIDConnectProvider(
92 | [
93 | 'issuer' => 'https://accounts.example.com',
94 | 'discoveryUrl' => 'https://otherhost.example.com/path/path2/.well-known/openid-configuration'
95 | ]
96 | );
97 | $this->assertEquals(
98 | 'https://otherhost.example.com/path/path2/.well-known/openid-configuration',
99 | $provider->getDiscoveryUrl()
100 | );
101 | }
102 |
103 | public function testGetPkceMethodGetsSetFromConfig(): void
104 | {
105 | $provider = new OpenIDConnectProvider(
106 | ['issuer' => 'https://accounts.example.com']
107 | );
108 | // make the protected getPkceMethod available
109 | $reflection = new \ReflectionClass($provider);
110 | $method = $reflection->getMethod('getPkceMethod');
111 | $method->setAccessible(true);
112 | $this->assertNull($method->invoke($provider));
113 |
114 | $provider = new OpenIDConnectProvider([
115 | 'issuer' => 'https://accounts.example.com',
116 | 'pkceMethod' => 'S256'
117 | ]);
118 | // make the protected getPkceMethod available
119 | $reflection = new \ReflectionClass($provider);
120 | $method = $reflection->getMethod('getPkceMethod');
121 | $method->setAccessible(true);
122 | $this->assertEquals('S256', $method->invoke($provider));
123 | }
124 | }
125 |
--------------------------------------------------------------------------------
/tests/lib/Auth/Source/LinkedInV2AuthTest.php:
--------------------------------------------------------------------------------
1 | 'linked'], []);
34 | $attributes = $linkedInAuth->convertResourceOwnerAttributes($userAttributes, 'linkedin.');
35 | $this->assertEquals($expectedAttributes, $attributes);
36 | }
37 |
38 | public static function attributeConversionProvider(): array
39 | {
40 | return [
41 | [['id' => 'abc'], ['linkedin.id' => ['abc']]],
42 | [
43 | [
44 | 'id' => 'abc',
45 | 'firstName' => ['localized' => ['en_US' => 'Jon', 'en_CA' => 'John']],
46 | 'lastName' => ['not-used']
47 | ],
48 | ['linkedin.id' => ['abc'], 'linkedin.firstName' => ['Jon']]
49 | ],
50 | [
51 | [
52 | 'id' => 'abc',
53 | 'firstName' => ['localized' => ['en_US' => 'Jon', 'en_CA' => 'John']],
54 | 'lastName' => ['localized' => ['en_CA' => 'Smith']],
55 | ],
56 | ['linkedin.id' => ['abc'], 'linkedin.firstName' => ['Jon'], 'linkedin.lastName' => ['Smith']]
57 | ],
58 | ];
59 | }
60 |
61 | public function testNoEmailCallIfNotRequested(): void
62 | {
63 | $linkedInAuth = new LinkedInV2Auth(['AuthId' => 'linked'], ['scopes' => ['r_liteprofile']]);
64 | $state = [];
65 | /** @var AbstractProvider $mock */
66 | /** @psalm-suppress MixedMethodCall */
67 | $mock = $this->getMockBuilder(AbstractProvider::class)
68 | ->disableOriginalConstructor()
69 | ->getMock();
70 | $mock->expects($this->never())
71 | ->method('getAuthenticatedRequest');
72 | $linkedInAuth->postFinalStep(new AccessToken(['access_token' => 'abc']), $mock, $state);
73 |
74 | $this->assertEquals([], $state, "State array not changed");
75 | }
76 |
77 | /**
78 | * @param array $emailResponse The response from the email endpoint
79 | * @param array $expectedAttributes What the SSP attributes are expected to be
80 | *
81 | * @throws Exception
82 | */
83 | #[DataProvider('getEmailProvider')]
84 | public function testGettingEmail(array $emailResponse, array $expectedAttributes): void
85 | {
86 | $linkedInAuth = new LinkedInV2Auth(['AuthId' => 'linked'], []);
87 | $state = [
88 | 'Attributes' => [
89 | 'linkedin.id' => ['abc']
90 | ]
91 | ];
92 |
93 | $token = new AccessToken(['access_token' => 'abc']);
94 | /** @var AbstractProvider $mock */
95 | /** @psalm-suppress MixedMethodCall */
96 | $mock = $this->getMockBuilder(AbstractProvider::class)
97 | ->disableOriginalConstructor()
98 | ->getMock();
99 |
100 | $mockRequest = $this->createMock(RequestInterface::class);
101 | $mock->method('getAuthenticatedRequest')
102 | ->with('GET', 'https://api.linkedin.com/v2/emailAddress?q=members&projection=(elements*(handle~))', $token)
103 | ->willReturn($mockRequest);
104 |
105 | $mock->method('getParsedResponse')
106 | ->with($mockRequest)
107 | ->willReturn($emailResponse);
108 | $linkedInAuth->postFinalStep($token, $mock, $state);
109 |
110 | $this->assertEquals(
111 | $expectedAttributes,
112 | $state['Attributes'],
113 | 'mail should be added'
114 | );
115 | }
116 |
117 | public static function getEmailProvider(): array
118 | {
119 | return [
120 | [
121 | // valid email response
122 | [
123 | "elements" => [
124 | [
125 | "handle" => "urn:li:emailAddress:5266785132",
126 | "handle~" => [
127 | "emailAddress" => "testuser@cirrusidentity.com"
128 | ]
129 | ]
130 | ]
131 | ],
132 | // email added
133 | ['linkedin.id' => ['abc'], 'linkedin.emailAddress' => ['testuser@cirrusidentity.com']]
134 | ],
135 | [
136 | [
137 | 'someerror' => 'errormessage'
138 | ],
139 | // email not added
140 | ['linkedin.id' => ['abc']],
141 | ],
142 | ];
143 | }
144 | }
145 |
--------------------------------------------------------------------------------
/tests/config/authsources.php:
--------------------------------------------------------------------------------
1 | array(
8 | 'authoauth2:OAuth2',
9 | // *** Required for all integrations ***
10 | 'urlAuthorize' => 'https://www.facebook.com/dialog/oauth',
11 | 'urlAccessToken' => 'https://graph.facebook.com/oauth/access_token',
12 | 'urlResourceOwnerDetails' => 'https://graph.facebook.com/me?fields=id,name,first_name,last_name,email',
13 | // *** Required for facebook ***
14 | // Test App
15 | 'clientId' => '133972730583345',
16 | 'clientSecret' => '36aefb235314bad5df075363b79cbbcd',
17 | // *** Optional ***
18 | // Custom query parameters to add to authorize request
19 | 'urlAuthorizeOptions' => [
20 | 'auth_type' => 'reauthenticate',
21 | // request email access
22 | 'req_perm' => 'email',
23 | ],
24 | ),
25 |
26 | 'genericAmazonTest' => array(
27 | 'authoauth2:OAuth2',
28 | // *** Required for all***
29 | 'urlAuthorize' => 'https://www.amazon.com/ap/oa',
30 | 'urlAccessToken' => 'https://api.amazon.com/auth/o2/token',
31 | 'urlResourceOwnerDetails' => 'https://api.amazon.com/user/profile',
32 | // *** required for amazon ***
33 | // Test App.
34 | 'clientId' => 'amzn1.application-oa2-client.94d04152358d4f989473fecdf8553e25',
35 | 'clientSecret' => '8681bdd290df87fe1eea2d821d7dadc39fd4f89e599dfaa8a50c5656aae16980',
36 | 'scopes' => 'profile',
37 | // *** Optional ***
38 | // Allow changing the default redirectUri
39 | 'redirectUri' => 'https://abc.tutorial.stack-dev.cirrusidentity.com:8732/module.php/authoauth2/linkback',
40 | ),
41 |
42 | 'genericGoogleTest' => array(
43 | 'authoauth2:OAuth2',
44 | // *** Required for all***
45 | 'urlAuthorize' => 'https://accounts.google.com/o/oauth2/auth',
46 | 'urlAccessToken' => 'https://accounts.google.com/o/oauth2/token',
47 | 'urlResourceOwnerDetails' => 'https://www.googleapis.com/plus/v1/people/me/openIdConnect',
48 | // userinfo doesn't need need Google Plus API access
49 | // 'urlResourceOwnerDetails' => 'https://www.googleapis.com/oauth2/v3/userinfo',
50 | //'urlResourceOwnerDetails' => 'https://www.googleapis.com/plus/v1/people/me?fields=id,name',
51 | // *** required for google ***
52 | // Test App.
53 | 'clientId' => '685947170891-0fcfnkkt6q0veqhvlpbr7a98i29p8rlf.apps.googleusercontent.com',
54 | 'clientSecret' => 'wV0FdFs_KEF1oY7XcBGq2TzM',
55 | 'scopes' => array(
56 | 'openid',
57 | 'email',
58 | 'profile'
59 | ),
60 | 'scopeSeparator' => ' ',
61 | // *** Optional ***
62 | // Allow changing the default redirectUri
63 | ),
64 |
65 | 'googleTempate' => [
66 | 'authoauth2:OAuth2',
67 | 'template' => 'GoogleOIDC',
68 | // Client with Google Plus API access disabled
69 | 'clientId' => '815042564757-2ek814rm61bjtih4tpar8qh0pkrciifc.apps.googleusercontent.com',
70 | 'clientSecret' => 'eyM-J6cOa3FlhIeKtyd4nDX9'
71 | ],
72 |
73 | 'googleTest' => array(
74 | // Must install correct provider with: composer require league/oauth2-google
75 | 'authoauth2:OAuth2',
76 | 'providerClass' => 'League\OAuth2\Client\Provider\Google',
77 | // *** required for google ***
78 | // Test App with Google Plus access
79 | 'clientId' => '685947170891-0fcfnkkt6q0veqhvlpbr7a98i29p8rlf.apps.googleusercontent.com',
80 | 'clientSecret' => 'wV0FdFs_KEF1oY7XcBGq2TzM',
81 | ),
82 | //OpenID Connect provider https://accounts.google.com
83 | 'https://accounts.google.com' => array(
84 | 'authoauth2:OpenIDConnect',
85 |
86 | // Scopes to request, should include openid
87 | 'scopes' => ['openid', 'profile'],
88 |
89 | // Configured client id and secret
90 | 'clientId' => '685947170891-0fcfnkkt6q0veqhvlpbr7a98i29p8rlf.apps.googleusercontent.com',
91 | 'clientSecret' => 'wV0FdFs_KEF1oY7XcBGq2TzM',
92 |
93 | 'scopeSeparator' => ' ',
94 | 'issuer' => 'https://accounts.google.com',
95 | 'urlAuthorize' => 'https://accounts.google.com/o/oauth2/v2/auth',
96 | 'urlAccessToken' => 'https://oauth2.googleapis.com/token',
97 | 'urlResourceOwnerDetails' => 'https://openidconnect.googleapis.com/v1/userinfo',
98 | 'keys' => array (
99 | 'df8d9ee403bcc7185ad51041194bd3433742d9aa' => '-----BEGIN PUBLIC KEY-----
100 | MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnQgOafNApTMwKerFuGXD
101 | j8HZ7hUSFPUV4/SzYj79SF5giP0IfF6Ksnb5Jy0pQ/MXQ6XNuh6eZqCfAPXUwHto
102 | xE29jpe6L6DGKPLTr8RTbNhdIsorc1yXiPcail58gftq1fmegZw0KO6QtBpKYnBW
103 | oZw4PJkuP8ZdGanA0btsZRRRYVmSOKuYDNHfVJlcrD4cqAOL3BPjWQIrZszwTVmw
104 | 0FjiU9KfGtU0rDYnas+mZv1qfetZkTA3YPTqSspCNZDbGCVXpJnr4pai0E7lxFgD
105 | NDN2IDk955Pf8eG8oNCfqkHXfnWDrTlXP7SSrYmEaBPcmMKOHdjyrYPk0lWI8+ur
106 | XwIDAQAB
107 | -----END PUBLIC KEY-----',
108 | 'f6f80f37f21c23e61f2be4231e27d269d6695329' => '-----BEGIN PUBLIC KEY-----
109 | MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA8Q+bsTm7MfrGQsnigd+0
110 | ix9EYUesUEJWGpK6jRjArdphVkE7xHqrHbIGQcFrRKOeatHDCXtBKDWTbVOJugCc
111 | 5EC8CeH+q54VU5YxunooUCK4jTQW1piLq0BpOKM0dbHxpEQtGRwA6Yu52ZKafswG
112 | 64BYo44kX0pPgi4sssUSn0dz0fIrcA8MSa8iffICPKfe757I3en7XTypKFs5BCPo
113 | PAhYHoCqrQnOoRh7ieVvAQUeiaKASjngGSo+5GWpsMzQO05+2J3vId01f0oRUTJY
114 | trKppNS8LxXr8BXSp66SBwgXZEhFLOcmnM9zZEAPt/DMd3IQZUaOF3w5h3ZUHMXc
115 | zwIDAQAB
116 | -----END PUBLIC KEY-----',
117 | )
118 | ),
119 |
120 | // ORCID OpenID Connect Provider
121 | 'orcidOIDCTest' => array_merge(\SimpleSAML\Module\authoauth2\ConfigTemplate::OrcidOIDC, [
122 | 'clientId' => 'APP-PRIZEPSDX1RMMI34',
123 | 'clientSecret' => '7a91a2a0-f118-447d-8401-71ba07815eb7',
124 | // *** Optional ***
125 | // Allow changing the default redirectUri
126 | 'redirectUri' => 'https://abc.tutorial.stack-dev.cirrusidentity.com:8732/module.php/authoauth2/linkback',
127 | ]),
128 |
129 | // This is a authentication source which handles admin authentication.
130 | 'admin' => array(
131 | // The default is to use core:AdminPassword, but it can be replaced with
132 | // any authentication source.
133 |
134 | 'core:AdminPassword',
135 | ),
136 |
137 | );
138 |
--------------------------------------------------------------------------------
/src/Auth/Source/OrcidOIDCAuth.php:
--------------------------------------------------------------------------------
1 | getAttributePrefix();
137 |
138 | $emailUrl = $this->getConfig()->getString('urlResourceOwnerEmail');
139 | /** @psalm-suppress MixedArrayAccess */
140 | $request = $provider->getAuthenticatedRequest(
141 | 'GET',
142 | strtr($emailUrl, ['@orcid' => $state['Attributes'][$prefix . 'sub'][0]]),
143 | $accessToken,
144 | ['headers' => ['Accept' => 'application/json']]
145 | );
146 | try {
147 | /** @psalm-suppress MixedAssignment */
148 | $response = $this->retry(
149 | /**
150 | * @return mixed
151 | * @throws IdentityProviderException
152 | */
153 | function () use ($provider, $request) {
154 | return $provider->getParsedResponse($request);
155 | }
156 | );
157 | } catch (Exception $e) {
158 | // not getting email shouldn't fail the authentication
159 | Logger::error(
160 | 'OrcidOIDCAuth: ' . $this->getLabel() . ' exception email query response ' . $e->getMessage()
161 | );
162 | return;
163 | }
164 | $email = $this->parseEmailLookupResponse($response);
165 | if ($email !== null && \is_array($state['Attributes'])) {
166 | /** @psalm-suppress MixedArrayAssignment */
167 | $state['Attributes'][$prefix . 'email'][] = $email;
168 | } else {
169 | Logger::error(
170 | 'OrcidOIDCAuth: ' . $this->getLabel() . ' invalid email query response ' . var_export($response, true)
171 | );
172 | }
173 | }
174 | }
175 |
--------------------------------------------------------------------------------
/tests/lib/Controller/OIDCLogoutControllerTest.php:
--------------------------------------------------------------------------------
1 | expectedStageState;
29 | }
30 |
31 | public function getExpectedPrefix(): string
32 | {
33 | return $this->expectedPrefix;
34 | }
35 | }
36 |
37 | // phpcs:ignore PSR1.Classes.ClassDeclaration.MultipleClasses
38 | class OIDCLogoutControllerTest extends TestCase
39 | {
40 | /** @var OIDCLogoutControllerMock */
41 | private $controller;
42 | /** @var SourceService */
43 | private $sourceServiceMock;
44 | /** @var \PHPUnit\Framework\MockObject\MockObject|(OAuth2&\PHPUnit\Framework\MockObject\MockObject) */
45 | private $oauth2Mock;
46 | /** @var \PHPUnit\Framework\MockObject\MockObject|(Simple&\PHPUnit\Framework\MockObject\MockObject) */
47 | private $simpleMock;
48 | private array $stateMock;
49 | private array $parametersMock;
50 |
51 | /**
52 | * @throws Exception
53 | */
54 | protected function setUp(): void
55 | {
56 | $this->parametersMock = ['state' => OAuth2::STATE_PREFIX . '-statefoo'];
57 | $this->stateMock = [OAuth2::AUTHID => 'testSourceId'];
58 |
59 | // Create the mock controller
60 | $this->createControllerMock(['getSourceService', 'loadState', 'getAuthSource']);
61 | }
62 |
63 | public function testExpectedConstVariables(): void
64 | {
65 | $this->assertEquals(OpenIDConnect::STAGE_LOGOUT, $this->controller->getExpectedStageState());
66 | $this->assertEquals(OAuth2::STATE_PREFIX . '-', $this->controller->getExpectedPrefix());
67 | }
68 |
69 | public static function requestMethod(): array
70 | {
71 | return [
72 | 'GET' => ['GET'],
73 | 'POST' => ['POST'],
74 | ];
75 | }
76 |
77 | #[DataProvider('requestMethod')]
78 | public function testLoggedOutSuccess(string $requestMethod): void
79 | {
80 | $parameters = [
81 | ...$this->parametersMock,
82 | ];
83 |
84 | $request = Request::create(
85 | uri: 'https://localhost/auth/authorize',
86 | method: $requestMethod,
87 | parameters: $parameters,
88 | );
89 |
90 | /** @psalm-suppress UndefinedMethod,MixedMethodCall */
91 | $this->sourceServiceMock
92 | ->expects($this->once())
93 | ->method('completeLogout')
94 | ->with($this->stateMock);
95 |
96 | $this->controller->loggedout($request);
97 | }
98 |
99 | #[DataProvider('requestMethod')]
100 | public function testLogoutWithoutAuthSourceThrowsBadRequest(string $requestMethod): void
101 | {
102 | $parameters = [
103 | ...$this->parametersMock,
104 | ];
105 |
106 | $request = Request::create(
107 | uri: 'https://localhost/auth/authorize',
108 | method: $requestMethod,
109 | parameters: $parameters,
110 | );
111 |
112 | $this->expectException(BadRequest::class);
113 | $this->expectExceptionMessage('No authsource in the request');
114 |
115 | $this->controller->logout($request);
116 | }
117 |
118 | #[DataProvider('requestMethod')]
119 | public function testLogoutWithInvalidAuthSourceThrowsBadRequest(string $requestMethod): void
120 | {
121 | $parameters = [
122 | 'authSource' => ['INVALID SOURCE ID'],
123 | ...$this->parametersMock,
124 | ];
125 |
126 | $request = Request::create(
127 | uri: 'https://localhost/auth/authorize',
128 | method: $requestMethod,
129 | parameters: $parameters,
130 | );
131 |
132 | $this->expectException(BadRequest::class);
133 | $this->expectExceptionMessage('Authsource ID invalid');
134 |
135 | $this->controller->logout($request);
136 | }
137 |
138 | #[DataProvider('requestMethod')]
139 | public function testSuccessfullLogout(string $requestMethod): void
140 | {
141 | $parameters = [
142 | 'authSource' => 'authsourceid',
143 | ...$this->parametersMock,
144 | ];
145 |
146 | $request = Request::create(
147 | uri: 'https://localhost/auth/authorize',
148 | method: $requestMethod,
149 | parameters: $parameters,
150 | );
151 |
152 | $logoutConfig = [
153 | 'oidc:localLogout' => true,
154 | 'ReturnTo' => '/' . RoutesEnum::Logout->value
155 | ];
156 |
157 | /** @psalm-suppress UndefinedMethod,MixedMethodCall */
158 | $this->simpleMock
159 | ->expects($this->once())
160 | ->method('logout')
161 | ->with($logoutConfig);
162 |
163 | $this->controller->logout($request);
164 | }
165 |
166 | // Mock helper function
167 | private function createControllerMock(array $methods): void
168 | {
169 | $this->oauth2Mock = $this->getMockBuilder(OAuth2::class)
170 | ->disableOriginalConstructor()
171 | ->getMock();
172 |
173 | $this->simpleMock = $this->getMockBuilder(Simple::class)
174 | ->disableOriginalConstructor()
175 | ->onlyMethods(['logout'])
176 | ->getMock();
177 |
178 | $this->sourceServiceMock = $this->getMockBuilder(SourceService::class)
179 | ->onlyMethods(['completeLogout', 'getById'])
180 | ->getMock();
181 |
182 | $this->controller = $this->getMockBuilder(OIDCLogoutControllerMock::class)
183 | ->onlyMethods($methods)
184 | ->getMock();
185 |
186 | /** @psalm-suppress UndefinedMethod,MixedMethodCall */
187 | $this->controller
188 | ->method('getSourceService')
189 | ->willReturn($this->sourceServiceMock);
190 |
191 | $this->controller
192 | ->method('getAuthSource')
193 | ->willReturn($this->simpleMock);
194 |
195 | $this->sourceServiceMock
196 | ->method('getById')
197 | ->with('testSourceId', OAuth2::class)
198 | ->willReturn($this->oauth2Mock);
199 |
200 | $this->controller
201 | ->method('loadState')
202 | ->willReturn($this->stateMock);
203 | }
204 | }
205 |
--------------------------------------------------------------------------------
/.github/workflows/php.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on: [push, pull_request]
4 |
5 | permissions:
6 | pull-requests: write
7 | contents: read
8 |
9 | jobs:
10 | basic-tests:
11 | name: Syntax and unit tests, PHP ${{ matrix.php-versions }}, ${{ matrix.operating-system }}
12 | runs-on: ${{ matrix.operating-system }}
13 | strategy:
14 | fail-fast: false
15 | matrix:
16 | operating-system: [ubuntu-latest]
17 | php-versions: ['8.1', '8.2', '8.3', '8.4']
18 |
19 | steps:
20 | - name: Setup PHP, with composer and extensions
21 | uses: shivammathur/setup-php@v2 #https://github.com/shivammathur/setup-php
22 | with:
23 | php-version: ${{ matrix.php-versions }}
24 | extensions: intl, mbstring, mysql, pdo, pdo_sqlite, xml
25 | tools: composer:v2
26 | ini-values: error_reporting=E_ALL
27 | coverage: pcov
28 |
29 | - name: Setup problem matchers for PHP
30 | run: echo "::add-matcher::${{ runner.tool_cache }}/php.json"
31 |
32 | - name: Setup problem matchers for PHPUnit
33 | run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json"
34 |
35 | - name: Set git to use LF
36 | run: |
37 | git config --global core.autocrlf false
38 | git config --global core.eol lf
39 |
40 | - uses: actions/checkout@v4
41 |
42 | - name: Get composer cache directory
43 | id: composer-cache
44 | run: echo "::set-output name=dir::$(composer config cache-files-dir)"
45 |
46 | - name: Cache composer dependencies
47 | uses: actions/cache@v4
48 | with:
49 | path: ${{ steps.composer-cache.outputs.dir }}
50 | key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
51 | restore-keys: ${{ runner.os }}-composer-
52 |
53 | - name: Validate composer.json and composer.lock
54 | run: composer validate
55 |
56 | - name: Install Composer dependencies
57 | run: composer install --no-progress --prefer-dist --optimize-autoloader
58 |
59 | - name: Decide whether to run code coverage or not
60 | if: ${{ matrix.php-versions != '8.4' || matrix.operating-system != 'ubuntu-latest' }}
61 | run: |
62 | echo "NO_COVERAGE=--no-coverage" >> $GITHUB_ENV
63 |
64 | - name: Run unit tests
65 | run: |
66 | echo $NO_COVERAGE
67 | ./vendor/bin/phpunit $NO_COVERAGE
68 |
69 | - name: Save coverage data
70 | if: ${{ matrix.php-versions == '8.4' && matrix.operating-system == 'ubuntu-latest' }}
71 | uses: actions/upload-artifact@v4
72 | with:
73 | name: build-data
74 | path: ${{ github.workspace }}/build
75 |
76 | - name: List files in the workspace
77 | if: ${{ matrix.php-versions == '8.4' && matrix.operating-system == 'ubuntu-latest' }}
78 | run: |
79 | ls -la ${{ github.workspace }}/build
80 | ls -la ${{ github.workspace }}/build/logs
81 |
82 | - name: Code Coverage Report
83 | if: ${{ matrix.php-versions == '8.4' && matrix.operating-system == 'ubuntu-latest' }}
84 | uses: irongut/CodeCoverageSummary@v1.3.0
85 | with:
86 | filename: build/logs/cobertura.xml
87 | format: markdown
88 | badge: true
89 | fail_below_min: true
90 | hide_branch_rate: false
91 | hide_complexity: true
92 | indicators: true
93 | output: both
94 | thresholds: '60 80'
95 |
96 | security:
97 | name: Security checks
98 | runs-on: [ ubuntu-latest ]
99 | steps:
100 | - name: Setup PHP, with composer and extensions
101 | uses: shivammathur/setup-php@v2 #https://github.com/shivammathur/setup-php
102 | with:
103 | # Should be the lowest supported version
104 | php-version: '8.1'
105 | extensions: mbstring, xml
106 | tools: composer:v2
107 | coverage: none
108 |
109 | - name: Setup problem matchers for PHP
110 | run: echo "::add-matcher::${{ runner.tool_cache }}/php.json"
111 |
112 | - uses: actions/checkout@v4
113 |
114 | - name: Get composer cache directory
115 | id: composer-cache
116 | run: echo "::set-output name=dir::$(composer config cache-files-dir)"
117 |
118 | - name: Cache composer dependencies
119 | uses: actions/cache@v4
120 | with:
121 | path: ${{ steps.composer-cache.outputs.dir }}
122 | key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
123 | restore-keys: ${{ runner.os }}-composer-
124 |
125 | - name: Install Composer dependencies
126 | run: composer install --no-progress --prefer-dist --optimize-autoloader
127 |
128 | - name: Security check for locked dependencies
129 | uses: symfonycorp/security-checker-action@v3
130 |
131 | - name: Update Composer dependencies
132 | run: composer update --no-progress --prefer-dist --optimize-autoloader
133 |
134 | - name: Security check for updated dependencies
135 | uses: symfonycorp/security-checker-action@v3
136 |
137 | quality:
138 | name: Quality control
139 | runs-on: [ ubuntu-latest ]
140 | needs: [ basic-tests ]
141 |
142 | steps:
143 | - name: Setup PHP, with composer and extensions
144 | id: setup-php
145 | uses: shivammathur/setup-php@v2 #https://github.com/shivammathur/setup-php
146 | with:
147 | # Should be the higest supported version, so we can use the newest tools
148 | php-version: '8.4'
149 | tools: composer:v2
150 | # optional performance gain for psalm: opcache
151 | extensions: mbstring, opcache, xml
152 |
153 | - name: Setup problem matchers for PHP
154 | run: echo "::add-matcher::${{ runner.tool_cache }}/php.json"
155 |
156 | - uses: actions/checkout@v4
157 |
158 | - name: Get composer cache directory
159 | id: composer-cache
160 | run: echo "::set-output name=dir::$(composer config cache-files-dir)"
161 |
162 | - name: Cache composer dependencies
163 | uses: actions/cache@v4
164 | with:
165 | path: ${{ steps.composer-cache.outputs.dir }}
166 | key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
167 | restore-keys: ${{ runner.os }}-composer-
168 |
169 | - name: Install Composer dependencies
170 | run: composer install --no-progress --prefer-dist --optimize-autoloader
171 |
172 | - uses: actions/download-artifact@v4
173 | with:
174 | name: build-data
175 | path: ${{ github.workspace }}/build
176 |
177 | - name: Codecov
178 | uses: codecov/codecov-action@v3
179 |
180 | - name: PHP Code Sniffer
181 | if: always()
182 | run: php vendor/bin/phpcs
183 |
184 | - name: Psalm
185 | if: always()
186 | run: php vendor/bin/psalm --no-cache --show-info=true --shepherd --php-version=${{ steps.setup-php.outputs.php-version }}
187 |
188 | - name: Psalter
189 | if: always()
190 | run: php vendor/bin/psalter --issues=UnnecessaryVarAnnotation --dry-run --php-version=${{ steps.setup-php.outputs.php-version }}
191 |
--------------------------------------------------------------------------------
/src/Auth/Source/OpenIDConnect.php:
--------------------------------------------------------------------------------
1 | getHttpClient();
49 | /** @psalm-suppress DeprecatedMethod */
50 | $handler = $httpClient->getConfig('handler');
51 | if (!($handler instanceof HandlerStack)) {
52 | $newhandler = HandlerStack::create();
53 | /** @psalm-suppress MixedArgument */
54 | $newhandler->push($handler);
55 | /** @psalm-suppress DeprecatedMethod */
56 | $httpClient->getConfig()['handler'] = $newhandler;
57 | $handler = $newhandler;
58 | }
59 | $cacheDir = Configuration::getInstance()->getString('cachedir') . '/oidc-cache';
60 | $handler->push(
61 | new CacheMiddleware(
62 | new PrivateCacheStrategy(
63 | new Psr6CacheStorage(
64 | new FilesystemAdapter('', 0, $cacheDir)
65 | )
66 | ),
67 | )
68 | );
69 | return $provider;
70 | }
71 |
72 | /**
73 | * Convert values from the state parameter of the authenticated call into options to the authorization request.
74 | *
75 | * Any parameter prefixed with oidc: are added (without the prefix), in
76 | * addition, isPassive and ForceAuthn are converted into prompt=none and
77 | * prompt=login respectively
78 | *
79 | * @param array $state
80 | * @return array
81 | */
82 | protected function getAuthorizeOptionsFromState(array &$state): array
83 | {
84 | $result = [];
85 |
86 | /** @var array|string $value */
87 | foreach ($state as $key => $value) {
88 | if (
89 | \is_string($key)
90 | && strncmp($key, 'oidc:', 5) === 0
91 | ) {
92 | $result[substr($key, 5)] = $value;
93 | }
94 | }
95 | if (\array_key_exists('ForceAuthn', $state) && $state['ForceAuthn']) {
96 | $result['prompt'] = 'login';
97 | }
98 | if (\array_key_exists('isPassive', $state) && $state['isPassive']) {
99 | $result['prompt'] = 'none';
100 | }
101 | return $result;
102 | }
103 |
104 |
105 | /**
106 | * This method is overriding the default empty implementation to parse attributes received in the id_token, and
107 | * place them into the attributes array.
108 | *
109 | * @inheritdoc
110 | */
111 | protected function postFinalStep(AccessToken $accessToken, AbstractProvider $provider, array &$state): void
112 | {
113 | $prefix = $this->getAttributePrefix();
114 | $id_token = (string)$accessToken->getValues()['id_token'];
115 | $id_token_claims = $this->extraIdTokenAttributes($id_token);
116 | $state['Attributes'] = array_merge($this->convertResourceOwnerAttributes(
117 | $id_token_claims,
118 | $prefix . 'id_token' . '.'
119 | ), (array)$state['Attributes']);
120 | $state['id_token'] = $id_token;
121 | $state['PersistentAuthData'][] = 'id_token';
122 | $state['LogoutState'] = ['id_token' => $id_token];
123 | }
124 |
125 | /**
126 | * Log out from upstream idp if possible
127 | *
128 | * @param array &$state Information about the current logout operation.
129 | * @return void
130 | */
131 | public function logout(array &$state): void
132 | {
133 | $providerLabel = $this->getLabel();
134 | if (array_key_exists('oidc:localLogout', $state) && $state['oidc:localLogout'] === true) {
135 | Logger::debug("authoauth2: $providerLabel OP initiated logout");
136 | return;
137 | }
138 | $provider = $this->getProvider($this->config);
139 | if (!$provider instanceof OpenIDConnectProvider) {
140 | Logger::warning('OIDC provider is wrong class');
141 | return;
142 | }
143 | $endSessionEndpoint = $provider->getEndSessionEndpoint();
144 | if (!$endSessionEndpoint) {
145 | Logger::debug("authoauth2: $providerLabel OP does not provide an 'end_session_endpoint'," .
146 | " not doing anything for logout");
147 | return;
148 | }
149 |
150 | if (!array_key_exists('id_token', $state)) {
151 | Logger::debug("authoauth2: $providerLabel No id_token in state, not doing anything for logout");
152 | return;
153 | }
154 | $id_token = (string)$state['id_token'];
155 |
156 | $postLogoutUrl = $this->config->getOptionalString('postLogoutRedirectUri', null);
157 | if (!$postLogoutUrl) {
158 | $logoutRoute = $this->config->getOptionalBoolean('useLegacyRoutes', false) ?
159 | LegacyRoutesEnum::LegacyLoggedOut->value : RoutesEnum::LoggedOut->value;
160 | $postLogoutUrl = Module::getModuleURL("authoauth2/$logoutRoute");
161 | }
162 |
163 | // We are going to need the authId in order to retrieve this authentication source later, in the callback
164 | $state[self::AUTHID] = $this->getAuthId();
165 |
166 | $stateID = State::saveState($state, self::STAGE_LOGOUT);
167 | // We use the real HTTP class rather than the injected one to avoid having to mock/stub
168 | // this method for tests
169 | $endSessionURL = (new HTTP())->addURLParameters($endSessionEndpoint, [
170 | 'id_token_hint' => $id_token,
171 | 'post_logout_redirect_uri' => $postLogoutUrl,
172 | 'state' => self::STATE_PREFIX . '-' . $stateID,
173 | ]);
174 | $this->getHttp()->redirectTrustedURL($endSessionURL);
175 | // @codeCoverageIgnoreStart
176 | }
177 | }
178 |
--------------------------------------------------------------------------------
/tests/lib/Auth/Source/MicrosoftHybridAuthTest.php:
--------------------------------------------------------------------------------
1 | 'oauth2'];
32 | $config = [
33 | 'template' => 'MicrosoftGraphV1',
34 | 'providerClass' => MockOAuth2Provider::class,
35 | 'authenticatedApiRequests' => ['https://mock.com/v1.0/me/memberOf'],
36 | ];
37 | $state = [\SimpleSAML\Auth\State::ID => 'stateId'];
38 |
39 | /** @var AbstractProvider $mock */
40 | /** @psalm-suppress MixedMethodCall */
41 | $mock = $this->getMockBuilder(AbstractProvider::class)
42 | ->disableOriginalConstructor()
43 | ->getMock();
44 |
45 | $token = new AccessToken(['access_token' => 'stubToken', 'id_token' => $idToken]);
46 | $mock->method('getAccessToken')
47 | ->with('authorization_code', ['code' => $code])
48 | ->willReturn($token);
49 |
50 | // graph api seems to return null for email
51 | $attributes = ['id' => 'a76d6a7a097c1e9d', 'mail' => null];
52 | $user = new GenericResourceOwner($attributes, 'userId');
53 |
54 | $mock->method('getResourceOwner')
55 | ->with($token)
56 | ->willReturn($user);
57 |
58 | $mockRequest = $this->createMock(RequestInterface::class);
59 | $mock->method('getAuthenticatedRequest')
60 | ->with('GET', 'https://mock.com/v1.0/me/memberOf', $token)
61 | ->willReturn($mockRequest);
62 |
63 | $mock->method('getParsedResponse')
64 | ->with($mockRequest)
65 | ->willReturn($authenticatedRequestAttributes);
66 |
67 | MockOAuth2Provider::setDelegate($mock);
68 |
69 | // when: turning a code into a token and then into a resource owner attributes
70 | $authOAuth2 = new MicrosoftHybridAuth($info, $config);
71 | $authOAuth2->finalStep($state, $code);
72 |
73 | // then: The attributes should be returned based on the getResourceOwner call
74 | $this->assertEquals($expectedAttributes, $state['Attributes']);
75 | }
76 |
77 |
78 | public static function combineOidcAndGraphProfileProvider(): array
79 | {
80 | $expectedGraphAttributes = ['microsoft.id' => ['a76d6a7a097c1e9d'],
81 | 'microsoft.@odata.context' => ['https://graph.microsoft.com/v1.0/$metadata#directoryObjects'],
82 | 'microsoft.value.0.@odata.type' => ['#microsoft.graph.group'],
83 | 'microsoft.value.0.id' => ['11111111-1111-1111-1111-111111111111']];
84 | // A Graph Id token. note: only the payload is valid. Header and signature are not
85 | // phpcs:ignore Generic.Files.LineLength.TooLong
86 | $validIdToken = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsImtpZCI6IjFMVE16YWtpaGlSbGFfOHoyQkVKVlhlV01xbyJ9.eyJ2ZXIiOiIyLjAiLCJpc3MiOiJodHRwczovL2xvZ2luLm1pY3Jvc29mdG9ubGluZS5jb20vOTE4ODA0MGQtNmM2Ny00YzViLWIxMTItMzZhMzA0YjY2ZGFkL3YyLjAiLCJzdWIiOiJBQUFBQUFBQUFBQUFBQUFBQUFBQUFORHBFcUNHa3lPVVFDTXpHOHRGYUUiLCJhdWQiOiI5ZTdkZTIyZS0zYTE3LTQ0ZmQtODdjNy1jNmVjZWIxYmVlMGUiLCJleHAiOjE1Mzk5NjUwNDUsImlhdCI6MTUzOTg3ODM0NSwibmJmIjoxNTM5ODc4MzQ1LCJuYW1lIjoiU3RldmUgU3RyYXR1cyIsInByZWZlcnJlZF91c2VybmFtZSI6InN0ZXZlLnN0cmF0dXNAb3V0bG9vay5jb20iLCJvaWQiOiIwMDAwMDAwMC0wMDAwLTAwMDAtYTc2ZDZhN2EwOTdjMWU5ZCIsImVtYWlsIjoic3RldmUuc3RyYXR1c0BvdXRsb29rLmNvbSIsInRpZCI6IjkxODgwNDBkLTZjNjctNGM1Yi1iMTEyMzZhMzA0YjY2ZGFkIiwiYWlvIjoiRGI1YmRMSHBaSkdla0h3czlxaHlkUkFHSGR1cSFvUDdpS1cxYzFFQkd2dWhDWnZXS2luS0FoVnFZV3NtYSEwT3ZiRTFmV1J2TUF3NHFLUVBud3N6akQwKkd2N1RsbFpOY2FxcDQ0eTM0ZyJ9.SjNeBS11Qa2eXKLhxSApShFMLQ9nDjTXT27JZm3cctM';
87 | $authenticatedRequestAttributes = [
88 | '@odata.context' => 'https://graph.microsoft.com/v1.0/$metadata#directoryObjects',
89 | 'value' => [
90 | 0 => [
91 | '@odata.type' => '#microsoft.graph.group',
92 | 'id' => '11111111-1111-1111-1111-111111111111',
93 | ],
94 | ],
95 | ];
96 | $conflictedRequestAttributes = [
97 | 'id' => ['11111111'],
98 | 'name' => ['Steve Stratus'],
99 | 'mail' => ['steve.stratus@outlook.com'],
100 | '@odata.context' => 'https://graph.microsoft.com/v1.0/$metadata#directoryObjects',
101 | 'value' => [
102 | 0 => [
103 | '@odata.type' => '#microsoft.graph.group',
104 | 'id' => '11111111-1111-1111-1111-111111111111',
105 | ],
106 | ],
107 | ];
108 | return [
109 | // jwt, expected attributes
110 | ['invalidJwt', $authenticatedRequestAttributes, $expectedGraphAttributes],
111 | ['', $authenticatedRequestAttributes, $expectedGraphAttributes],
112 | [null, $authenticatedRequestAttributes, $expectedGraphAttributes],
113 | ['blah.abc.egd', $authenticatedRequestAttributes, $expectedGraphAttributes],
114 | [$validIdToken, $authenticatedRequestAttributes,
115 | [
116 | 'microsoft.name' => ['Steve Stratus'],
117 | 'microsoft.mail' => ['steve.stratus@outlook.com'],
118 | 'microsoft.id' => ['a76d6a7a097c1e9d'],
119 | 'microsoft.@odata.context' => ['https://graph.microsoft.com/v1.0/$metadata#directoryObjects'],
120 | 'microsoft.value.0.@odata.type' => ['#microsoft.graph.group'],
121 | 'microsoft.value.0.id' => ['11111111-1111-1111-1111-111111111111'],
122 | 'microsoft.tid' => ['9188040d-6c67-4c5b-b11236a304b66dad'],
123 | ],
124 | ],
125 | [
126 | $validIdToken,
127 | $conflictedRequestAttributes,
128 | [
129 | 'microsoft.name' => ['Steve Stratus'],
130 | 'microsoft.mail' => ['steve.stratus@outlook.com'],
131 | 'microsoft.id' => ['11111111'],
132 | 'microsoft.@odata.context' => ['https://graph.microsoft.com/v1.0/$metadata#directoryObjects'],
133 | 'microsoft.value.0.@odata.type' => ['#microsoft.graph.group'],
134 | 'microsoft.value.0.id' => ['11111111-1111-1111-1111-111111111111'],
135 | 'microsoft.tid' => ['9188040d-6c67-4c5b-b11236a304b66dad'],
136 | ],
137 | ],
138 | ];
139 | }
140 | }
141 |
--------------------------------------------------------------------------------
/tests/lib/Controller/Trait/RequestTraitTest.php:
--------------------------------------------------------------------------------
1 | state = $state;
36 | }
37 |
38 | public function getSourceId(): ?string
39 | {
40 | return $this->sourceId;
41 | }
42 | }
43 |
44 | // phpcs:ignore PSR1.Classes.ClassDeclaration.MultipleClasses
45 | class RequestTraitTest extends TestCase
46 | {
47 | use RequestTrait;
48 |
49 | private Request $request;
50 | private GenericController $controller;
51 |
52 | protected function setUp(): void
53 | {
54 | parent::setUp();
55 | $this->request = new Request();
56 | $this->expectedStateAuthId = OAuth2::AUTHID;
57 | $this->controller = $this->getMockBuilder(GenericController::class)
58 | ->onlyMethods(['loadState', 'getSourceService'])
59 | ->getMock();
60 | }
61 |
62 | public function testStateIsValidWithMissingState(): void
63 | {
64 | $this->assertFalse($this->controller->stateIsValid($this->request));
65 | }
66 |
67 | public function testStateIsValidWithEmptyState(): void
68 | {
69 | $this->request->query->set('state', '');
70 | $this->assertFalse($this->controller->stateIsValid($this->request));
71 | }
72 |
73 | public function testStateIsValidWithValidState(): void
74 | {
75 | $this->request->query->set('state', OAuth2::STATE_PREFIX . '|example');
76 | $this->assertTrue($this->controller->stateIsValid($this->request));
77 | }
78 |
79 | public function testStateIsValidWithInvalidState(): void
80 | {
81 | $this->request->query->set('state', 'invalid|example');
82 | $this->assertFalse($this->controller->stateIsValid($this->request));
83 | }
84 |
85 | public function testParseRequestWithInvalidState(): void
86 | {
87 | $this->expectException(BadRequest::class);
88 | $this->expectExceptionMessage('An error occured');
89 | $this->request->attributes->set('_route', 'invalid_route');
90 | $this->controller->parseRequest($this->request);
91 | }
92 |
93 | public function testParseRequestWithEmptyState(): void
94 | {
95 | $this->request->query->set('state', '');
96 | $this->expectException(BadRequest::class);
97 | $this->expectExceptionMessage('An error occured');
98 | $this->request->attributes->set('_route', 'invalid_route');
99 | $this->controller->parseRequest($this->request);
100 | }
101 |
102 | public function testParseRequestWithValidState(): void
103 | {
104 | $this->request->query->set('state', OAuth2::STATE_PREFIX . '|valid_state');
105 | $this->request->attributes->set('_route', 'valid_route');
106 |
107 | /** @psalm-suppress UndefinedMethod,MixedMethodCall */
108 | $this->controller->method('loadState')->willReturn([OAuth2::AUTHID => 'test_authsource_id']);
109 |
110 | $mockSourceService = $this->getMockBuilder(SourceService::class)->getMock();
111 | $mockSource = $this->getMockBuilder(Source::class)->disableOriginalConstructor()->getMock();
112 | /** @psalm-suppress UndefinedMethod,MixedMethodCall */
113 | $this->controller->method('getSourceService')->willReturn($mockSourceService);
114 |
115 | $mockSourceService->method('getById')->willReturn($mockSource);
116 |
117 | $this->controller->parseRequest($this->request);
118 |
119 | $this->assertEquals('test_authsource_id', $this->controller->getSourceId());
120 | }
121 |
122 | public function testParseRequestWithNoStateAuthId(): void
123 | {
124 | $this->request->query->set('state', OAuth2::STATE_PREFIX . '|valid_state_with_missing_authid');
125 | $this->request->attributes->set('_route', 'valid_route');
126 |
127 | /** @psalm-suppress UndefinedMethod,MixedMethodCall */
128 | $this->controller->method('loadState')->willReturn(null);
129 |
130 | $this->expectException(BadRequest::class);
131 | $this->expectExceptionMessage('No authsource id data in state for ' . OAuth2::AUTHID);
132 |
133 | $this->controller->parseRequest($this->request);
134 | }
135 |
136 | public function testParseRequestWithEmptyAuthSourceId(): void
137 | {
138 | $this->request->query->set('state', OAuth2::STATE_PREFIX . '|valid_state_with_empty_authid');
139 | $this->request->attributes->set('_route', 'valid_route');
140 |
141 | /** @psalm-suppress UndefinedMethod,MixedMethodCall */
142 | $this->controller->method('loadState')->willReturn([OAuth2::AUTHID => '']);
143 |
144 | $this->expectException(BadRequest::class);
145 | $this->expectExceptionMessage('Source ID is undefined');
146 |
147 | $this->controller->parseRequest($this->request);
148 | }
149 |
150 | public function testParseRequestWithInvalidSource(): void
151 | {
152 | $this->request->query->set('state', OAuth2::STATE_PREFIX . '|valid_state_with_invalid_source');
153 | $this->request->attributes->set('_route', 'valid_route');
154 |
155 | /** @psalm-suppress UndefinedMethod,MixedMethodCall */
156 | $this->controller->method('loadState')->willReturn([OAuth2::AUTHID => 'invalid_source_id']);
157 |
158 | $mockSourceService = $this->getMockBuilder(SourceService::class)->getMock();
159 | /** @psalm-suppress UndefinedMethod,MixedMethodCall */
160 | $this->controller->method('getSourceService')->willReturn($mockSourceService);
161 | $mockSourceService->method('getById')->willReturn(null);
162 |
163 | $this->expectException(BadRequest::class);
164 | $this->expectExceptionMessage('Could not find authentication source with id invalid_source_id');
165 |
166 | $this->controller->parseRequest($this->request);
167 | }
168 |
169 | public function testParsePostGetRequest(): void
170 | {
171 | $request = Request::create(
172 | uri: 'https://localhost/auth/authorize',
173 | method: 'POST',
174 | parameters: [ 'error' => 'invalid_request'],
175 | );
176 |
177 | $expected = ['error' => 'invalid_request'];
178 |
179 | $this->parseRequestParamsSingleton($request);
180 | $this->assertEquals($expected, $this->requestParams);
181 |
182 | $request = Request::create(
183 | uri: 'https://localhost/auth/authorize',
184 | method: 'GET',
185 | parameters: [ 'error' => 'invalid_request']
186 | );
187 |
188 | $this->parseRequestParamsSingleton($request);
189 | $this->assertEquals($expected, $this->requestParams);
190 | }
191 | }
192 |
--------------------------------------------------------------------------------
/tests/lib/Auth/Source/OrcidOIDCAuthTest.php:
--------------------------------------------------------------------------------
1 | null,
28 | "email" => [],
29 | "path" => "/0000-0000-0000-0000/email"
30 | ],
31 | null
32 | ],
33 | // no primary, but one non-primary
34 | [
35 | [
36 | "last-modified-date" => [
37 | "value" => 1664233445808
38 | ],
39 | "email" => [
40 | [
41 | "created-date" => [
42 | "value" => 1661900382942
43 | ],
44 | "last-modified-date" => [
45 | "value" => 1664233445808
46 | ],
47 | "source" => [
48 | "source-orcid" => [
49 | "uri" => "https =>//orcid.org/0000-0000-0000-0000",
50 | "path" => "0000-0000-0000-0000",
51 | "host" => "orcid.org"
52 | ],
53 | "source-client-id" => null,
54 | "source-name" => [
55 | "value" => "Test User"
56 | ],
57 | "assertion-origin-orcid" => null,
58 | "assertion-origin-client-id" => null,
59 | "assertion-origin-name" => null
60 | ],
61 | "email" => "non-primary@example.org",
62 | "path" => null,
63 | "visibility" => "public",
64 | "verified" => true,
65 | "primary" => false,
66 | "put-code" => null
67 | ]
68 | ],
69 | "path" => "/0000-0000-0000-0000/email"
70 | ],
71 | "non-primary@example.org"
72 | ],
73 | // primary and non-primary
74 | [
75 | [
76 | "last-modified-date" => [
77 | "value" => 1664233809699
78 | ],
79 | "email" => [
80 | [
81 | "created-date" => [
82 | "value" => 1487980758777
83 | ],
84 | "last-modified-date" => [
85 | "value" => 1664233809699
86 | ],
87 | "source" => [
88 | "source-orcid" => [
89 | "uri" => "https =>//orcid.org/0000-0000-0000-0000",
90 | "path" => "0000-0000-0000-0000",
91 | "host" => "orcid.org"
92 | ],
93 | "source-client-id" => null,
94 | "source-name" => [
95 | "value" => "Test User"
96 | ],
97 | "assertion-origin-orcid" => null,
98 | "assertion-origin-client-id" => null,
99 | "assertion-origin-name" => null
100 | ],
101 | "email" => "non-primary@example.org",
102 | "path" => null,
103 | "visibility" => "public",
104 | "verified" => true,
105 | "primary" => false,
106 | "put-code" => null
107 | ],
108 | [
109 | "created-date" => [
110 | "value" => 1661900382942
111 | ],
112 | "last-modified-date" => [
113 | "value" => 1664233445808
114 | ],
115 | "source" => [
116 | "source-orcid" => [
117 | "uri" => "https =>//orcid.org/0000-0000-0000-0000",
118 | "path" => "0000-0000-0000-0000",
119 | "host" => "orcid.org"
120 | ],
121 | "source-client-id" => null,
122 | "source-name" => [
123 | "value" => "Test User"
124 | ],
125 | "assertion-origin-orcid" => null,
126 | "assertion-origin-client-id" => null,
127 | "assertion-origin-name" => null
128 | ],
129 | "email" => "primary@example.org",
130 | "path" => null,
131 | "visibility" => "public",
132 | "verified" => true,
133 | "primary" => true,
134 | "put-code" => null
135 | ]
136 | ],
137 | "path" => "/0000-0000-0000-0000/email"
138 | ],
139 | "primary@example.org"
140 | ],
141 | // only primary
142 | [
143 | [
144 | "last-modified-date" => [
145 | "value" => 1664233445808
146 | ],
147 | "email" => [
148 | [
149 | "created-date" => [
150 | "value" => 1661900382942
151 | ],
152 | "last-modified-date" => [
153 | "value" => 1664233445808
154 | ],
155 | "source" => [
156 | "source-orcid" => [
157 | "uri" => "https =>//orcid.org/0000-0000-0000-0000",
158 | "path" => "0000-0000-0000-0000",
159 | "host" => "orcid.org"
160 | ],
161 | "source-client-id" => null,
162 | "source-name" => [
163 | "value" => "Test User"
164 | ],
165 | "assertion-origin-orcid" => null,
166 | "assertion-origin-client-id" => null,
167 | "assertion-origin-name" => null
168 | ],
169 | "email" => "primary@example.org",
170 | "path" => null,
171 | "visibility" => "public",
172 | "verified" => true,
173 | "primary" => true,
174 | "put-code" => null
175 | ]
176 | ],
177 | "path" => "/0000-0000-0000-0000/email"
178 | ],
179 | "primary@example.org"
180 | ]
181 | ];
182 | }
183 |
184 | /**
185 | * @param array $emailResponse The JSON response from the email endpoint
186 | * @param string|null $expectedEmail What the resolved email address should be
187 | */
188 | #[DataProvider('emailResponseProvider')]
189 | public function testEmailResolution(array $emailResponse, ?string $expectedEmail): void
190 | {
191 | $orcidAuth = new OrcidOIDCAuth(['AuthId' => 'orcid'], []);
192 | $email = $orcidAuth->parseEmailLookupResponse($emailResponse);
193 |
194 | $this->assertEquals(
195 | $expectedEmail,
196 | $email
197 | );
198 | }
199 | }
200 |
--------------------------------------------------------------------------------
/tests/lib/Auth/Source/OpenIDConnectTest.php:
--------------------------------------------------------------------------------
1 | self::AUTH_ID];
27 | return new OpenIDConnect($info, $config);
28 | }
29 |
30 | public static function setUpBeforeClass(): void
31 | {
32 | // Some of the constructs in this test cause a Configuration to be created prior to us
33 | // setting the one we want to use for the test.
34 | Configuration::clearInternalState();
35 | }
36 |
37 | public static function finalStepsDataProvider(): array
38 | {
39 | return [
40 | [
41 | [
42 | 'providerClass' => MockOAuth2Provider::class,
43 | 'attributePrefix' => 'test.',
44 | 'retryOnError' => 1,
45 | 'clientId' => 'test client id',
46 | ],
47 | // phpcs:disable
48 | new AccessToken([
49 | 'access_token' => 'stubToken',
50 | 'id_token' => 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiYXVkIjoidGVzdCBjbGllbnQgaWQiLCJpYXQiOjE1MTYyMzkwMjJ9.emHrAifV1IyvmTXh3lYX0oAFqqZInhDlclIlTUumut0',
51 | ]),
52 | // phpcs:enable
53 | [
54 | 'test.name' => ['Bob'],
55 | 'test.id_token.sub' => ['1234567890'],
56 | 'test.id_token.iat' => [1516239022],
57 | 'test.id_token.aud' => ['test client id'],
58 | ],
59 | ]
60 | ];
61 | }
62 |
63 | public static function finalStepsDataProviderWithAuthenticatedApiRequest(): array
64 | {
65 | return [
66 | [
67 | [
68 | 'providerClass' => MockOAuth2Provider::class,
69 | 'attributePrefix' => 'test.',
70 | 'retryOnError' => 1,
71 | 'authenticatedApiRequests' => ['https://mock.com/v1.0/me/memberOf'],
72 |
73 | ],
74 | // phpcs:disable
75 | new AccessToken([
76 | 'access_token' => 'stubToken',
77 | 'id_token' => 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiYXVkIjoidGVzdCBjbGllbnQgaWQiLCJpYXQiOjE1MTYyMzkwMjJ9.emHrAifV1IyvmTXh3lYX0oAFqqZInhDlclIlTUumut0',
78 |
79 | ]),
80 | // phpcs:enable
81 | [
82 | 'test.name' => ['Bob'],
83 | 'test.additionalResource' => ['info'],
84 | 'test.id_token.sub' => ['1234567890'],
85 | 'test.id_token.iat' => [1516239022],
86 | 'test.id_token.aud' => ['test client id'],
87 | ],
88 | ]
89 | ];
90 | }
91 |
92 | public static function authenticateDataProvider(): array
93 | {
94 | MockOpenIDConnectProvider::setConfig([
95 | 'authorization_endpoint' => 'https://example.com/auth',
96 | 'token_endpoint' => 'https://example.com/token',
97 | 'userinfo_endpoint' => 'https://example.com/userinfo',
98 |
99 | ]);
100 | $config = [
101 | 'issuer' => 'https://example.com',
102 | 'clientId' => 'test client id',
103 | 'providerClass' => MockOpenIDConnectProvider::class,
104 | ];
105 | return [
106 | [
107 | $config,
108 | [
109 | State::ID => 'stateId',
110 | 'ForceAuthn' => true,
111 | ],
112 | // phpcs:ignore Generic.Files.LineLength.TooLong
113 | 'https://example.com/auth?prompt=login&state=authoauth2%7CstateId&scope=openid%20profile&response_type=code&approval_prompt=auto&redirect_uri=http%3A%2F%2Flocalhost%2Fmodule.php%2Fauthoauth2%2Flinkback&client_id=test%20client%20id'
114 | ],
115 | [
116 | $config,
117 | [
118 | State::ID => 'stateId',
119 | 'isPassive' => true,
120 | ],
121 | // phpcs:ignore Generic.Files.LineLength.TooLong
122 | 'https://example.com/auth?prompt=none&state=authoauth2%7CstateId&scope=openid%20profile&response_type=code&approval_prompt=auto&redirect_uri=http%3A%2F%2Flocalhost%2Fmodule.php%2Fauthoauth2%2Flinkback&client_id=test%20client%20id'
123 | ],
124 | [
125 | $config,
126 | [
127 | State::ID => 'stateId',
128 | 'oidc:acr_values' => 'Level4 Level3',
129 | 'oidc:display' => 'popup',
130 | ],
131 | // phpcs:ignore Generic.Files.LineLength.TooLong
132 | 'https://example.com/auth?acr_values=Level4%20Level3&display=popup&state=authoauth2%7CstateId&scope=openid%20profile&response_type=code&approval_prompt=auto&redirect_uri=http%3A%2F%2Flocalhost%2Fmodule.php%2Fauthoauth2%2Flinkback&client_id=test%20client%20id'
133 | ],
134 | ];
135 | }
136 |
137 | public static function authprocTokenProvider(): array
138 | {
139 | return [
140 | [
141 | new AccessToken([
142 | 'access_token' => 'stubToken',
143 | //phpcs:ignore Generic.Files.LineLength.TooLong
144 | 'id_token' => 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiYXVkIjoidGVzdCBjbGllbnQgaWQiLCJpYXQiOjE1MTYyMzkwMjJ9.emHrAifV1IyvmTXh3lYX0oAFqqZInhDlclIlTUumut0',
145 | ]),
146 | ]
147 | ];
148 | }
149 |
150 | public function testLogoutNoEndpointConfigured(): void
151 | {
152 | MockOpenIDConnectProvider::setConfig([
153 | 'authorization_endpoint' => 'https://example.com/auth',
154 | 'token_endpoint' => 'https://example.com/token',
155 | 'userinfo_endpoint' => 'https://example.com/userinfo',
156 | ]);
157 | $as = $this->getInstance([
158 | 'issuer' => 'https://example.com',
159 | 'providerClass' => MockOpenIDConnectProvider::class,
160 | ]);
161 | $state = [];
162 | $this->assertNull($as->logout($state));
163 | }
164 |
165 | public function testLogoutNoIDTokenInState(): void
166 | {
167 | MockOpenIDConnectProvider::setConfig([
168 | 'authorization_endpoint' => 'https://example.com/auth',
169 | 'token_endpoint' => 'https://example.com/token',
170 | 'userinfo_endpoint' => 'https://example.com/userinfo',
171 | 'end_session_endpoint' => 'https://example.org/logout',
172 | ]);
173 | $as = $this->getInstance([
174 | 'issuer' => 'https://example.com',
175 | 'providerClass' => MockOpenIDConnectProvider::class,
176 | ]);
177 | $state = [];
178 | $this->assertNull($as->logout($state));
179 | }
180 |
181 | public function testLogoutRedirects(): void
182 | {
183 | $expectedUrl = 'https://example.org/logout?id_token_hint=myidtoken'
184 | . '&post_logout_redirect_uri=http%3A%2F%2Flocalhost%2Fmodule.php%2Fauthoauth2%2Floggedout'
185 | . '&state=authoauth2-stateId';
186 | // Override redirect behavior
187 | $http = $this->createMock(HTTP::class);
188 | $http->method('redirectTrustedURL')
189 | ->with($expectedUrl)
190 | ->willThrowException(
191 | new RedirectException('redirectTrustedURL', $expectedUrl)
192 | );
193 |
194 | MockOpenIDConnectProvider::setConfig([
195 | 'authorization_endpoint' => 'https://example.com/auth',
196 | 'token_endpoint' => 'https://example.com/token',
197 | 'userinfo_endpoint' => 'https://example.com/userinfo',
198 | 'end_session_endpoint' => 'https://example.org/logout',
199 |
200 | ]);
201 |
202 | $as = $this->getInstance([
203 | 'issuer' => 'https://example.com',
204 | 'providerClass' => MockOpenIDConnectProvider::class,
205 | ]);
206 | $as->setHttp($http);
207 | $state = [
208 | 'id_token' => 'myidtoken',
209 | State::ID => 'stateId',
210 | ];
211 | try {
212 | $this->assertNull($as->logout($state));
213 | $this->fail('Redirect expected');
214 | } catch (RedirectException $e) {
215 | $this->assertEquals('redirectTrustedURL', $e->getMessage());
216 | $this->assertEquals($expectedUrl, $e->getUrl());
217 | }
218 | }
219 | }
220 |
--------------------------------------------------------------------------------
/src/ConfigTemplate.php:
--------------------------------------------------------------------------------
1 | 'https://www.amazon.com/ap/oa',
15 | 'urlAccessToken' => 'https://api.amazon.com/auth/o2/token',
16 | 'urlResourceOwnerDetails' => 'https://api.amazon.com/user/profile',
17 | 'scopes' => 'profile',
18 | // Prefix attributes so we can use the amazon2name
19 | 'attributePrefix' => 'amazon.',
20 | // Improve log lines
21 | 'label' => 'amazon'
22 | ];
23 |
24 | public const AppleLeague = [
25 | 'authoauth2:OAuth2',
26 | 'attributePrefix' => 'apple.',
27 | // Improve log lines
28 | 'label' => 'apple',
29 | 'logIdTokenJson' => true,
30 | // You must install composer require patrickbussmann/oauth2-apple:~0.2.10
31 | 'providerClass' => 'League\OAuth2\Client\Provider\Apple',
32 | // You must set these four settings
33 | //'teamId' => $appleTeamId,
34 | //'clientId' => $apiKey,
35 | // 'keyFileId' => $privateKeyId,
36 | // 'keyFilePath' => $privateKeyPath
37 | ];
38 |
39 | public const Facebook = [
40 | 'authoauth2:OAuth2',
41 | // *** Facebook endpoints ***
42 | 'urlAuthorize' => 'https://www.facebook.com/dialog/oauth',
43 | 'urlAccessToken' => 'https://graph.facebook.com/oauth/access_token',
44 | // Add requested attributes as fields
45 | 'urlResourceOwnerDetails' => 'https://graph.facebook.com/me',
46 | 'urlResourceOwnerOptions' => [
47 | 'fields' => 'id,name,first_name,last_name,email'
48 | ],
49 | 'scopes' => 'email',
50 | // Prefix attributes so we can use the facebook2name
51 | 'attributePrefix' => 'facebook.',
52 |
53 | // Improve log lines
54 | 'label' => 'facebook'
55 | ];
56 |
57 | public const GoogleOIDC = [
58 | 'authoauth2:OAuth2',
59 | // *** Google Endpoints ***
60 | 'urlAuthorize' => 'https://accounts.google.com/o/oauth2/v2/auth',
61 | 'urlAccessToken' => 'https://oauth2.googleapis.com/token',
62 | 'urlResourceOwnerDetails' => 'https://openidconnect.googleapis.com/v1/userinfo',
63 |
64 | 'scopes' => [
65 | 'openid',
66 | 'email',
67 | 'profile'
68 | ],
69 | 'scopeSeparator' => ' ',
70 | // Prefix attributes so we can use the standard oidc2name attributemap
71 | 'attributePrefix' => 'oidc.',
72 |
73 | // Improve log lines
74 | 'label' => 'google'
75 | ];
76 |
77 | // Deprecated
78 | public const LinkedIn = [
79 | 'authoauth2:OAuth2',
80 | // *** LinkedIn Endpoints ***
81 | 'urlAuthorize' => 'https://www.linkedin.com/oauth/v2/authorization',
82 | 'urlAccessToken' => 'https://www.linkedin.com/oauth/v2/accessToken',
83 | // phpcs:ignore Generic.Files.LineLength.TooLong
84 | 'urlResourceOwnerDetails' => 'https://api.linkedin.com/v1/people/~:(id,first-name,last-name,email-address)?format=json',
85 | //scopes are the default ones configured for your application
86 | 'attributePrefix' => 'linkedin.',
87 | 'scopeSeparator' => ' ',
88 | // Improve log lines
89 | 'label' => 'linkedin'
90 | ];
91 |
92 | // Deprecated
93 | public const LinkedInV2 = [
94 | 'authoauth2:LinkedInV2Auth',
95 | // *** LinkedIn Endpoints ***
96 | 'urlAuthorize' => 'https://www.linkedin.com/oauth/v2/authorization',
97 | 'urlAccessToken' => 'https://www.linkedin.com/oauth/v2/accessToken',
98 | 'urlResourceOwnerDetails' => 'https://api.linkedin.com/v2/me',
99 | 'urlResourceOwnerEmail' => 'https://api.linkedin.com/v2/emailAddress?q=members&projection=(elements*(handle~))',
100 | //scopes are the default ones configured for your application
101 | 'attributePrefix' => 'linkedin.',
102 | 'scopes' => [
103 | 'r_liteprofile',
104 | // This requires additional api call to the urlResourceOwnerEmail url
105 | 'r_emailaddress',
106 | ],
107 | 'scopeSeparator' => ' ',
108 | // Improve log lines
109 | 'label' => 'linkedin'
110 | ];
111 |
112 | //https://learn.microsoft.com/en-us/linkedin/consumer/integrations/self-serve/sign-in-with-linkedin-v2
113 | public const LinkedInOIDC = [
114 | 'authoauth2:OAuth2',
115 | // *** LinkedIn oidc Endpoints ***
116 | 'urlAuthorize' => 'https://www.linkedin.com/oauth/v2/authorization',
117 | 'urlAccessToken' => 'https://www.linkedin.com/oauth/v2/accessToken',
118 | 'urlResourceOwnerDetails' => 'https://api.linkedin.com/v2/userinfo',
119 | 'attributePrefix' => 'oidc.',
120 | 'scopes' => ['openid', 'email', 'profile'],
121 | 'scopeSeparator' => ' ',
122 |
123 | // Improve log lines
124 | 'label' => 'linkedin'
125 | ];
126 |
127 | //https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-protocols-oidc
128 | //https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration
129 | // WARNING: The OIDC user resource endpoint only returns sub, which is a targeted id.
130 | // You must decode the id token instead to determine user attributes. There you will
131 | // find oid which is the ID you are probably expecting if you are moving from the live apis.
132 | public const MicrosoftOIDC = [
133 | 'authoauth2:OAuth2',
134 | // *** Microsoft oidc Endpoints ***
135 | 'urlAuthorize' => 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
136 | 'urlAccessToken' => 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
137 | 'urlResourceOwnerDetails' => 'https://graph.microsoft.com/oidc/userinfo',
138 | 'attributePrefix' => 'oidc.',
139 | 'scopes' => ['openid', 'email', 'profile'],
140 | 'scopeSeparator' => ' ',
141 |
142 | // Improve log lines
143 | 'label' => 'microsoft'
144 | ];
145 |
146 | public const MicrosoftGraphV1 = [
147 | 'authoauth2:MicrosoftHybridAuth',
148 | // *** Microsoft graph Endpoints ***
149 | 'urlAuthorize' => 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
150 | 'urlAccessToken' => 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
151 | 'urlResourceOwnerDetails' => 'https://graph.microsoft.com/v1.0/me/',
152 | 'attributePrefix' => 'microsoft.',
153 | // graph v1 requires user.read
154 | 'scopes' => ['openid', 'email', 'profile', 'user.read'],
155 | 'scopeSeparator' => ' ',
156 |
157 | // Improve log lines
158 | 'label' => 'microsoft'
159 | ];
160 |
161 | public const YahooOIDC = [
162 | 'authoauth2:OAuth2',
163 | // *** Yahoo Endpoints ***
164 | 'urlAuthorize' => 'https://api.login.yahoo.com/oauth2/request_auth',
165 | 'urlAccessToken' => 'https://api.login.yahoo.com/oauth2/get_token',
166 | 'urlResourceOwnerDetails' => 'https://api.login.yahoo.com/openid/v1/userinfo',
167 | 'scopes' => [
168 | 'openid',
169 | // Yahoo doesn't support standard OIDC claims, like email and profile
170 | // 'email',
171 | // 'profile',
172 | // Yahoo prefers the sdpp-w scope for getting acess to user's email, however it prompts user for write access.
173 | // Leaving it out makes things work fine IF you picked being able to edit private profile when creating your app
174 | // 'sdpp-w',
175 | ],
176 | 'scopeSeparator' => ' ',
177 | // Prefix attributes so we can use the standard oidc2name attributemap
178 | 'attributePrefix' => 'oidc.',
179 |
180 | // Improve log lines
181 | 'label' => 'yahoo'
182 | ];
183 |
184 | // TODO: weibo is work in progress
185 | public const Weibo = [
186 | 'authoauth2:OAuth2',
187 | // *** Weibo Endpoints ***
188 | 'urlAuthorize' => 'https://api.weibo.com/oauth2/authorize',
189 | 'urlAccessToken' => 'https://api.weibo.com/oauth2/access_token',
190 | 'urlResourceOwnerDetails' => 'https://api.weibo.com/2/users/show.json',
191 | 'attributePrefix' => 'weibo.',
192 | 'scopeSeparator' => ' ',
193 | // Improve log lines
194 | 'label' => 'weibo',
195 | // uid attribute from token response needs to be included in user details call
196 | 'tokenFieldsToUserDetailsUrl' => ['uid' => 'uid', 'access_token' => 'access_token'],
197 | ];
198 |
199 | public const Bitbucket = [
200 | 'authoauth2:BitbucketAuth',
201 | // *** Bitbucket Endpoints ***
202 | 'urlAuthorize' => 'https://bitbucket.org/site/oauth2/authorize',
203 | 'urlAccessToken' => 'https://bitbucket.org/site/oauth2/access_token',
204 | 'urlResourceOwnerDetails' => 'https://api.bitbucket.org/2.0/user',
205 | 'urlResourceOwnerEmail' => 'https://api.bitbucket.org/2.0/user/emails',
206 | //scopes are the default ones configured for your application
207 | 'attributePrefix' => 'bitbucket.',
208 | 'scopes' => ['account', 'email'],
209 | 'scopeSeparator' => ' ',
210 | // Improve log lines
211 | 'label' => 'bitbucket'
212 | ];
213 |
214 | public const OrcidOIDC = [
215 | 'authoauth2:OrcidOIDCAuth',
216 | // *** ORCID support OpenID Connect discovery protocol ***
217 | 'issuer' => 'https://orcid.org',
218 | // email requires a separate API call
219 | 'urlResourceOwnerEmail' => 'https://pub.orcid.org/v3.0/@orcid/email',
220 | // Prefix attributes so we can use the standard oidc2name attributemap
221 | 'attributePrefix' => 'oidc.',
222 | ];
223 | }
224 | // phpcs:enable
225 |
--------------------------------------------------------------------------------
/tests/lib/Controller/Oauth2ControllerTest.php:
--------------------------------------------------------------------------------
1 | expectedStageState;
36 | }
37 |
38 | public function getExpectedPrefix(): string
39 | {
40 | return $this->expectedPrefix;
41 | }
42 | }
43 |
44 | // phpcs:ignore PSR1.Classes.ClassDeclaration.MultipleClasses
45 | class Oauth2ControllerTest extends TestCase
46 | {
47 | /** @var Oauth2ControllerMock */
48 | private $controller;
49 | /** @var HTTP */
50 | private $httpMock;
51 | /** @var \PHPUnit\Framework\MockObject\MockObject|(OAuth2&\PHPUnit\Framework\MockObject\MockObject) */
52 | private $oauth2Mock;
53 | /** @var SourceService */
54 | private $sourceServiceMock;
55 | private array $stateMock;
56 | private array $parametersMock;
57 |
58 | protected function setUp(): void
59 | {
60 | $this->oauth2Mock = $this->getMockBuilder(OAuth2::class)
61 | ->disableOriginalConstructor()
62 | ->onlyMethods(['finalStep', 'getConfig'])
63 | ->getMock();
64 | $this->sourceServiceMock = $this->getMockBuilder(SourceService::class)
65 | ->onlyMethods(['getById', 'completeAuth'])
66 | ->getMock();
67 |
68 |
69 | $this->httpMock = $this->getMockBuilder(HTTP::class)->getMock();
70 | $this->parametersMock = ['state' => OAuth2::STATE_PREFIX . '|statefoo'];
71 | $this->stateMock = [OAuth2::AUTHID => 'testSourceId'];
72 | }
73 |
74 | public function testExpectedConstVariables(): void
75 | {
76 | $this->createControllerMock(['getSourceService', 'loadState']);
77 | $this->assertEquals(OAuth2::STAGE_INIT, $this->controller->getExpectedStageState());
78 | $this->assertEquals(OAuth2::STATE_PREFIX . '|', $this->controller->getExpectedPrefix());
79 | }
80 |
81 | public static function requestMethod(): array
82 | {
83 | return [
84 | 'GET' => ['GET'],
85 | 'POST' => ['POST'],
86 | ];
87 | }
88 |
89 | #[DataProvider('requestMethod')]
90 | public function testLinkbackValidCode(string $requestMethod): void
91 | {
92 | $this->createControllerMock(['getSourceService', 'loadState', 'getHttp']);
93 | $parameters = [
94 | 'code' => 'validCode',
95 | ...$this->parametersMock,
96 | ];
97 |
98 | $request = Request::create(
99 | uri: 'https://localhost/auth/authorize',
100 | method: $requestMethod,
101 | parameters: $parameters,
102 | );
103 |
104 | $this->oauth2Mock->expects($this->once())
105 | ->method('finalStep')
106 | ->with($this->stateMock, 'validCode');
107 |
108 | $this->sourceServiceMock->expects($this->once())
109 | ->method('completeAuth')
110 | ->with($this->stateMock);
111 |
112 | $this->controller->linkback($request);
113 | }
114 |
115 | #[DataProvider('requestMethod')]
116 | public function testLinkbackWithNoCode(string $requestMethod): void
117 | {
118 | $this->createControllerMock(['getSourceService', 'loadState', 'handleError']);
119 |
120 | $parameters = [
121 | ...$this->parametersMock,
122 | ];
123 |
124 | $request = Request::create(
125 | uri: 'https://localhost/auth/authorize',
126 | method: $requestMethod,
127 | parameters: $parameters,
128 | );
129 |
130 | /** @psalm-suppress UndefinedMethod,MixedMethodCall */
131 | $this->controller
132 | ->expects($this->once())
133 | ->method('handleError');
134 |
135 | $this->controller->linkback($request);
136 | }
137 |
138 | #[DataProvider('requestMethod')]
139 | public function testLinkbackWithIdentityProviderException(string $requestMethod): void
140 | {
141 | $this->createControllerMock(['getSourceService', 'loadState', 'getHttp']);
142 |
143 | $parameters = [
144 | 'code' => 'validCode',
145 | ...$this->parametersMock,
146 | ];
147 |
148 | $request = Request::create(
149 | uri: 'https://localhost/auth/authorize',
150 | method: $requestMethod,
151 | parameters: $parameters,
152 | );
153 |
154 | $this->oauth2Mock->expects($this->once())
155 | ->method('finalStep')
156 | ->willThrowException(new IdentityProviderException('Error Message', 0, ['body' => 'error body']));
157 |
158 | $this->expectException(AuthSource::class);
159 |
160 | $this->controller->linkback($request);
161 | }
162 |
163 | public static function configuration(): array
164 | {
165 | return [ //datasets
166 | 'useConsentPage (GET)' => [ // dataset 0
167 | [
168 | ['useConsentErrorPage' => true],
169 | ],
170 | 'GET'
171 | ],
172 | 'useConsentPage & legacyRoute (GET)' => [ // data set 1
173 | [
174 | ['useConsentErrorPage' => true, 'useLegacyRoutes' => true],
175 | ],
176 | 'GET'
177 | ],
178 | 'useConsentPage (POST)' => [ // dataset 0
179 | [
180 | ['useConsentErrorPage' => true],
181 | ],
182 | 'POST'
183 | ],
184 | 'useConsentPage & legacyRoute (POST)' => [ // data set 1
185 | [
186 | ['useConsentErrorPage' => true, 'useLegacyRoutes' => true],
187 | ],
188 | 'POST'
189 | ],
190 | ];
191 | }
192 |
193 | /**
194 | * @throws Exception
195 | */
196 | #[DataProvider('configuration')]
197 | public function testHandleErrorWithConsentedError(array $configuration, string $requestMethod): void
198 | {
199 | $this->createControllerMock(['getSourceService', 'loadState', 'getHttp']);
200 |
201 | $request = Request::create(
202 | uri: 'https://localhost/auth/authorize',
203 | method: $requestMethod,
204 | parameters: [
205 | ...$this->parametersMock,
206 | 'error' => 'invalid_scope',
207 | 'error_description' => 'Invalid scope',
208 | ],
209 | );
210 |
211 | $this->oauth2Mock
212 | ->method('getConfig')
213 | ->willReturn(new Configuration($configuration, 'test'));
214 |
215 | $this->controller->method('getHttp')->willReturn($this->httpMock);
216 |
217 | $this->httpMock->expects($this->once())
218 | ->method('redirectTrustedURL')
219 | ->with('http://localhost/module.php/authoauth2/errors/consent');
220 |
221 | $this->controller->linkback($request);
222 | }
223 |
224 | public static function oauth2errors(): array
225 | {
226 | return [
227 | 'oauth2 valid error code (GET)' => [
228 | [
229 | 'error' => 'invalid_scope',
230 | 'error_description' => 'Invalid scope'
231 | ],
232 | UserAborted::class,
233 | 'GET'
234 | ],
235 | 'oauth2 invalid error code (GET)' => [
236 | [
237 | 'error' => 'invalid_error',
238 | 'error_description' => 'Invalid error'
239 | ],
240 | AuthSource::class,
241 | 'GET'
242 | ],
243 | 'oauth2 valid error code (POST)' => [
244 | [
245 | 'error' => 'invalid_scope',
246 | 'error_description' => 'Invalid scope'
247 | ],
248 | UserAborted::class,
249 | 'POST'
250 | ],
251 | 'oauth2 invalid error code(POST)' => [
252 | [
253 | 'error' => 'invalid_error',
254 | 'error_description' => 'Invalid error'
255 | ],
256 | AuthSource::class,
257 | 'POST'
258 | ]
259 | ];
260 | }
261 |
262 | #[DataProvider('oauth2errors')]
263 | public function testHandleErrorThrowException(array $errorResponse, string $className, string $requestMethod): void
264 | {
265 | $this->createControllerMock(['getSourceService', 'loadState', 'getHttp']);
266 |
267 | $request = Request::create(
268 | uri: 'https://localhost/auth/authorize',
269 | method: $requestMethod,
270 | parameters: [
271 | ...$this->parametersMock,
272 | ...$errorResponse,
273 | ],
274 | );
275 | $configArray = ['useConsentErrorPage' => false];
276 |
277 | $this->oauth2Mock
278 | ->method('getConfig')
279 | ->willReturn(new Configuration($configArray, 'test'));
280 |
281 | $this->controller->method('getHttp')->willReturn($this->httpMock);
282 |
283 | $this->expectException($className);
284 |
285 | $this->controller->linkback($request);
286 | }
287 |
288 | protected function createControllerMock(array $methods): void
289 | {
290 | $this->controller = $this->getMockBuilder(Oauth2ControllerMock::class)
291 | ->onlyMethods($methods)
292 | ->getMock();
293 |
294 | /** @psalm-suppress UndefinedMethod,MixedMethodCall */
295 | $this->controller
296 | ->method('getSourceService')
297 | ->willReturn($this->sourceServiceMock);
298 |
299 | $this->sourceServiceMock
300 | ->method('getById')
301 | ->with('testSourceId', OAuth2::class)
302 | ->willReturn($this->oauth2Mock);
303 |
304 | $this->controller
305 | ->method('loadState')
306 | ->willReturn($this->stateMock);
307 | }
308 | }
309 |
--------------------------------------------------------------------------------
/src/Providers/OpenIDConnectProvider.php:
--------------------------------------------------------------------------------
1 | issuer = $optionsConfig->getString('issuer');
67 | $this->discoveryUrl = $optionsConfig->getOptionalString(
68 | 'discoveryUrl',
69 | rtrim($this->issuer, '/') . self::CONFIGURATION_PATH
70 | );
71 | $this->defaultScopes = $optionsConfig->getOptionalArray('scopes', ['openid', 'profile']);
72 | $this->validateIssuer = $optionsConfig->getOptionalBoolean('validateIssuer', true);
73 | $this->urlResourceOwnerDetails = $optionsConfig->getOptionalString('urlResourceOwnerDetails', null);
74 | }
75 |
76 | /**
77 | * {@inheritdoc}
78 | */
79 | protected function getScopeSeparator(): string
80 | {
81 | return ' ';
82 | }
83 |
84 | /**
85 | * @return array
86 | */
87 | protected function getDefaultScopes(): array
88 | {
89 | return $this->defaultScopes;
90 | }
91 |
92 | /**
93 | * @param ResponseInterface $response
94 | * @param $data
95 | *
96 | * @return void
97 | * @throws IdentityProviderException
98 | * @psalm-suppress MissingParamType
99 | */
100 | protected function checkResponse(ResponseInterface $response, $data): void
101 | {
102 | /** @var string $error */
103 | /** @var array|string $data */
104 | $error = null;
105 | if (!empty($data[$this->responseError])) {
106 | if (\is_string($data[$this->responseError])) {
107 | $error = $data[$this->responseError];
108 | } else {
109 | $error = var_export($data[$this->responseError], true);
110 | }
111 | }
112 | if ($error || $response->getStatusCode() >= 400) {
113 | throw new IdentityProviderException($error ?? '', 0, $data);
114 | }
115 | }
116 |
117 | /**
118 | * @param array $response
119 | * @param AccessToken $token
120 | *
121 | * @return GenericResourceOwner
122 | */
123 | protected function createResourceOwner(array $response, AccessToken $token)
124 | {
125 | return new GenericResourceOwner($response, 'id');
126 | }
127 |
128 | /**
129 | * Do any required verification of the id token and return an array of decoded claims
130 | *
131 | * @param string $id_token Raw id token as string
132 | *
133 | * @throws IdentityProviderException
134 | */
135 | public function verifyIdToken(string $id_token): void
136 | {
137 | try {
138 | $keysRaw = $this->getSigningKeys();
139 | $keys = [];
140 | // Be explicit about key algorithms to avoid bug reports of key confusion.
141 | foreach ($keysRaw as $kid => $key) {
142 | $keys[$kid] = new JWT\Key($key, 'RS256');
143 | }
144 | // Once firebase/php-jwt 5.5 support is dropped we can move to firebase's parsing
145 | //JWT\JWK::parseKeySet($keys, 'RS256');
146 | $claims = JWT\JWT::decode($id_token, $keys);
147 | $aud = is_array($claims->aud) ? $claims->aud : [$claims->aud];
148 |
149 | if (!in_array($this->clientId, $aud)) {
150 | throw new IdentityProviderException("ID token has incorrect audience", 0, $claims->aud);
151 | }
152 | // When working with Azure the issuer is tenant specific, but the discovery metadata can be for all tenants
153 | if ($this->validateIssuer && $claims->iss !== $this->issuer) {
154 | throw new IdentityProviderException(
155 | "ID token has incorrect issuer. Expected '{$this->issuer}' recieved '{$claims->iss}'",
156 | 0,
157 | $claims->iss
158 | );
159 | }
160 | } catch (\UnexpectedValueException $e) {
161 | throw new IdentityProviderException("ID token validation failed", 0, $e->getMessage());
162 | }
163 | }
164 |
165 | /**
166 | * {@inheritDoc}
167 | * @psalm-suppress MoreSpecificImplementedParamType superClass has phpdoc doesn't align with parameter type
168 | */
169 | protected function prepareAccessTokenResponse(array $result)
170 | {
171 | $result = parent::prepareAccessTokenResponse($result);
172 | $this->verifyIdToken((string)$result['id_token']);
173 | return $result;
174 | }
175 |
176 | /**
177 | * @return string
178 | */
179 | public function getDiscoveryUrl(): string
180 | {
181 | return $this->discoveryUrl;
182 | }
183 |
184 | /**
185 | * @return Configuration
186 | * @throws IdentityProviderException
187 | */
188 | protected function getOpenIDConfiguration(): Configuration
189 | {
190 | if (isset($this->openIdConfiguration)) {
191 | return $this->openIdConfiguration;
192 | }
193 |
194 | $req = $this->getRequest('GET', $this->getDiscoveryUrl());
195 | /** @var array $config */
196 | $config = $this->getParsedResponse($req);
197 | $requiredEndPoints = ['authorization_endpoint', 'token_endpoint', 'jwks_uri', 'issuer', 'userinfo_endpoint'];
198 | foreach ($requiredEndPoints as $key) {
199 | if (!\array_key_exists($key, $config)) {
200 | throw new \UnexpectedValueException('OpenID Configuration data misses required key: ' . $key);
201 | }
202 | if (!\is_string($config[$key])) {
203 | throw new \UnexpectedValueException('OpenID Configuration data for key: ' . $key . ' is not a string');
204 | }
205 | if (!str_starts_with($config[$key], 'https://')) {
206 | throw new \UnexpectedValueException(
207 | 'OpenID Configuration data for key ' . $key .
208 | ' should be url. Got: ' . $config[$key]
209 | );
210 | }
211 | }
212 | if ($config['issuer'] !== $this->issuer) {
213 | throw new \UnexpectedValueException(
214 | 'OpenID Configuration data contains unexpected issuer: ' . (string)$config['issuer'] .
215 | ' expected: ' . $this->issuer
216 | );
217 | }
218 | $optionalEndPoints = ['end_session_endpoint'];
219 | foreach ($optionalEndPoints as $key) {
220 | if (array_key_exists($key, $config)) {
221 | if (!is_string($config[$key])) {
222 | throw new \UnexpectedValueException("OpenID Configuration data for key: " . $key .
223 | " is not a string");
224 | }
225 | if (substr($config[$key], 0, 8) !== 'https://') {
226 | throw new \UnexpectedValueException("OpenID Configuration data for key " . $key .
227 | " should be url. Got: " . $config[$key]);
228 | }
229 | }
230 | }
231 | $this->openIdConfiguration = Configuration::loadFromArray($config);
232 | return $this->openIdConfiguration;
233 | }
234 |
235 | /**
236 | * @param string $input
237 | * @return false|string
238 | */
239 | protected static function base64urlDecode(string $input): false|string
240 | {
241 | return base64_decode(strtr($input, '-_', '+/'));
242 | }
243 |
244 | /**
245 | * @throws IdentityProviderException
246 | * @return array $keys
247 | *
248 | * The response will get us a mixed value back. As a result we suppress the MixedAssignment error
249 | * @psalm-suppress MixedAssignment
250 | */
251 | protected function getSigningKeys(): array
252 | {
253 | $url = $this->getOpenIDConfiguration()->getString('jwks_uri');
254 | /** @var array $jwks */
255 | $jwks = $this->getParsedResponse($this->getRequest('GET', $url));
256 | $keys = [];
257 | foreach ($jwks['keys'] as $key) {
258 | /** @psalm-var array $key */
259 | $kid = $key['kid'];
260 | if (\array_key_exists('x5c', $key)) {
261 | /** @var array $x5c */
262 | $x5c = $key['x5c'];
263 | $keys[$kid] = "-----BEGIN CERTIFICATE-----\n" . (string)$x5c[0] . "\n-----END CERTIFICATE-----";
264 | } elseif ($key['kty'] === 'RSA') {
265 | $e = self::base64urlDecode($key['e']);
266 | $n = self::base64urlDecode($key['n']);
267 | if (!$n || !$e) {
268 | Logger::warning('Failed to base64 decode key data for key id: ' . $kid);
269 | continue;
270 | }
271 | $keys[$kid] = \RobRichards\XMLSecLibs\XMLSecurityKey::convertRSA($n, $e);
272 | } else {
273 | Logger::warning('Failed to load key data for key id: ' . $kid);
274 | }
275 | }
276 | return $keys;
277 | }
278 |
279 | /**
280 | * Returns the base URL for authorizing a client.
281 | *
282 | * Eg. https://oauth.service.com/authorize
283 | *
284 | * @return string
285 | * @throws IdentityProviderException
286 | */
287 | public function getBaseAuthorizationUrl(): string
288 | {
289 | return $this->getOpenIDConfiguration()->getString("authorization_endpoint");
290 | }
291 |
292 | /**
293 | * Returns the base URL for requesting an access token.
294 | *
295 | * Eg. https://oauth.service.com/token
296 | *
297 | * @param array $params
298 | *
299 | * @return string
300 | * @throws IdentityProviderException
301 | */
302 | public function getBaseAccessTokenUrl(array $params): string
303 | {
304 | return $this->getOpenIDConfiguration()->getString('token_endpoint');
305 | }
306 |
307 | /**
308 | * {@inheritDoc}
309 | */
310 | public function getResourceOwnerDetailsUrl(AccessToken $token): string
311 | {
312 | return $this->urlResourceOwnerDetails ?? $this->getOpenIDConfiguration()->getString("userinfo_endpoint");
313 | }
314 |
315 | /**
316 | * @return string|null
317 | * @throws IdentityProviderException
318 | */
319 | public function getEndSessionEndpoint(): ?string
320 | {
321 | $config = $this->getOpenIDConfiguration();
322 | return $config->getOptionalString('end_session_endpoint', null);
323 | }
324 |
325 | /**
326 | * @return string|null
327 | */
328 | protected function getPkceMethod(): ?string
329 | {
330 | return $this->pkceMethod ?: parent::getPkceMethod();
331 | }
332 | }
333 |
--------------------------------------------------------------------------------