├── .github └── workflows │ └── php.yml ├── .gitignore ├── .test-secrets └── apple.p8 ├── CHANGELOG.md ├── LICENSE ├── README.md ├── attributemap ├── amazon2name.php ├── apple2name.php ├── bitbucket2name.php ├── facebook2name.php ├── linkedin2name.php ├── microsoft2name.php ├── oidc2name.php └── orcid2name.php ├── composer.json ├── docker └── config │ ├── authsources.php │ └── config-override.php ├── docs ├── APPLE.md ├── AUTHPROC.md ├── BITBUCKET.md ├── GOOGLE.md ├── LINKEDIN.md ├── MICROSOFT.md ├── ORCID.md └── PKCE.md ├── locales └── en │ └── LC_MESSAGES │ └── authoauth2.po ├── phpcs.xml ├── phpunit.xml ├── psalm.xml ├── public └── .keep ├── routing ├── routes │ └── routes.php └── services │ └── services.yml ├── samples └── apple │ └── authsources.php ├── src ├── AttributeManipulator.php ├── Auth │ └── Source │ │ ├── BitbucketAuth.php │ │ ├── LinkedInV2Auth.php │ │ ├── MicrosoftHybridAuth.php │ │ ├── OAuth2.php │ │ ├── OpenIDConnect.php │ │ └── OrcidOIDCAuth.php ├── Codebooks │ ├── LegacyRoutesEnum.php │ ├── Oauth2ErrorsEnum.php │ └── RoutesEnum.php ├── ConfigTemplate.php ├── Controller │ ├── ErrorController.php │ ├── OIDCLogoutController.php │ ├── Oauth2Controller.php │ └── Traits │ │ ├── ErrorTrait.php │ │ └── RequestTrait.php ├── Lib │ └── RequestUtilities.php ├── Providers │ ├── AdjustableGenericProvider.php │ └── OpenIDConnectProvider.php └── locators │ ├── HTTPLocator.php │ ├── SourceService.php │ └── SourceServiceLocator.php ├── templates └── errors │ └── consent.twig └── tests ├── bootstrap.php ├── config ├── authsources.php ├── config.php ├── jwks-cert.pem ├── jwks-key.pem └── ssp2_3-config.php └── lib ├── AttributeManipulatorTest.php ├── Auth └── Source │ ├── LinkedInV2AuthTest.php │ ├── MicrosoftHybridAuthTest.php │ ├── OAuth2Test.php │ ├── OpenIDConnectTest.php │ └── OrcidOIDCAuthTest.php ├── Codebooks ├── Oauth2ErrorsEnumTest.php └── RoutesEnumTest.php ├── Controller ├── ErrorControllerTest.php ├── OIDCLogoutControllerTest.php ├── Oauth2ControllerTest.php └── Trait │ ├── ErrorTraitTest.php │ └── RequestTraitTest.php ├── MockOAuth2Provider.php ├── MockOpenIDConnectProvider.php ├── Providers ├── AdjustableGenericProviderTest.php └── OpenIDConnectProviderTest.php └── RedirectException.php /.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'] 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@v3 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@v3 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.2' || 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.2' && matrix.operating-system == 'ubuntu-latest' }} 71 | uses: actions/upload-artifact@v3 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.2' && 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.2' && 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 | php-version: '8.2' 104 | extensions: mbstring, xml 105 | tools: composer:v2 106 | coverage: none 107 | 108 | - name: Setup problem matchers for PHP 109 | run: echo "::add-matcher::${{ runner.tool_cache }}/php.json" 110 | 111 | - uses: actions/checkout@v3 112 | 113 | - name: Get composer cache directory 114 | id: composer-cache 115 | run: echo "::set-output name=dir::$(composer config cache-files-dir)" 116 | 117 | - name: Cache composer dependencies 118 | uses: actions/cache@v3 119 | with: 120 | path: ${{ steps.composer-cache.outputs.dir }} 121 | key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} 122 | restore-keys: ${{ runner.os }}-composer- 123 | 124 | - name: Install Composer dependencies 125 | run: composer install --no-progress --prefer-dist --optimize-autoloader 126 | 127 | - name: Security check for locked dependencies 128 | uses: symfonycorp/security-checker-action@v3 129 | 130 | - name: Update Composer dependencies 131 | run: composer update --no-progress --prefer-dist --optimize-autoloader 132 | 133 | - name: Security check for updated dependencies 134 | uses: symfonycorp/security-checker-action@v3 135 | 136 | quality: 137 | name: Quality control 138 | runs-on: [ ubuntu-latest ] 139 | needs: [ basic-tests ] 140 | 141 | steps: 142 | - name: Setup PHP, with composer and extensions 143 | id: setup-php 144 | uses: shivammathur/setup-php@v2 #https://github.com/shivammathur/setup-php 145 | with: 146 | php-version: '8.2' 147 | tools: composer:v2 148 | extensions: mbstring, xml 149 | 150 | - name: Setup problem matchers for PHP 151 | run: echo "::add-matcher::${{ runner.tool_cache }}/php.json" 152 | 153 | - uses: actions/checkout@v3 154 | 155 | - name: Get composer cache directory 156 | id: composer-cache 157 | run: echo "::set-output name=dir::$(composer config cache-files-dir)" 158 | 159 | - name: Cache composer dependencies 160 | uses: actions/cache@v3 161 | with: 162 | path: ${{ steps.composer-cache.outputs.dir }} 163 | key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} 164 | restore-keys: ${{ runner.os }}-composer- 165 | 166 | - name: Install Composer dependencies 167 | run: composer install --no-progress --prefer-dist --optimize-autoloader 168 | 169 | - uses: actions/download-artifact@v3 170 | with: 171 | name: build-data 172 | path: ${{ github.workspace }}/build 173 | 174 | - name: Codecov 175 | uses: codecov/codecov-action@v3 176 | 177 | - name: PHP Code Sniffer 178 | if: always() 179 | run: php vendor/bin/phpcs 180 | 181 | - name: Psalm 182 | if: always() 183 | run: php vendor/bin/psalm --no-cache --show-info=true --shepherd --php-version=${{ steps.setup-php.outputs.php-version }} 184 | 185 | - name: Psalter 186 | if: always() 187 | run: php vendor/bin/psalter --issues=UnnecessaryVarAnnotation --dry-run --php-version=${{ steps.setup-php.outputs.php-version }} 188 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | vendor/ 3 | *~ 4 | .\#* 5 | .DS_Store 6 | .phpunit.result.cache 7 | .test-secrets 8 | composer.lock 9 | build/ -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /attributemap/amazon2name.php: -------------------------------------------------------------------------------- 1 | ['cn', 'displayName'], 7 | 'amazon.email' => 'mail', 8 | 'amazon.user_id' => 'uid', 9 | ]; -------------------------------------------------------------------------------- /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/bitbucket2name.php: -------------------------------------------------------------------------------- 1 | 'displayName', 7 | 'bitbucket.account_id' => 'uid', 8 | 'bitbucket.email' => 'mail', 9 | ]; 10 | -------------------------------------------------------------------------------- /attributemap/facebook2name.php: -------------------------------------------------------------------------------- 1 | 'givenName', 7 | 'facebook.last_name' => 'sn', 8 | 'facebook.name' => ['cn', 'displayName'], 9 | 'facebook.email' => 'mail', 10 | 'facebook.id' => 'uid', 11 | ]; -------------------------------------------------------------------------------- /attributemap/linkedin2name.php: -------------------------------------------------------------------------------- 1 | 'givenName', 7 | 'linkedin.lastName' => 'sn', 8 | 'linkedin.id' => 'uid', // any b64 character 9 | 'linkedin.emailAddress' => 'mail', 10 | ]; 11 | -------------------------------------------------------------------------------- /attributemap/microsoft2name.php: -------------------------------------------------------------------------------- 1 | 'displayName', 7 | 'microsoft.id' => 'uid', 8 | 'microsoft.mail' => 'mail', 9 | 'microsoft.surname' => 'sn', 10 | 'microsoft.givenName' => 'givenName', 11 | 'microsoft.name' => 'cn', 12 | ]; -------------------------------------------------------------------------------- /attributemap/oidc2name.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 | ]; -------------------------------------------------------------------------------- /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": "^6.0|^5.0|^4.3|^3.4", 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /docker/config/config-override.php: -------------------------------------------------------------------------------- 1 | '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) -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /public/.keep: -------------------------------------------------------------------------------- 1 | ... 2 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /routing/services/services.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | services: 4 | # default configuration for services in *this* file 5 | _defaults: 6 | public: false 7 | autowire: true # Automatically injects dependencies in your services. 8 | autoconfigure: true # Automatically registers your services as commands, event subscribers, etc. 9 | 10 | SimpleSAML\Module\authoauth2\Controller\: 11 | resource: '../../src/Controller/*' 12 | exclude: 13 | - '../src/Controller/Traits/*' 14 | public: true 15 | autowire: true -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | } 52 | } 53 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/Codebooks/LegacyRoutesEnum.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/Controller/Traits/ErrorTrait.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/locators/SourceService.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 | -------------------------------------------------------------------------------- /templates/errors/consent.twig: -------------------------------------------------------------------------------- 1 | {% set pagetitle = 'noconsent_title'|trans %} 2 | {% extends "base.twig" %} 3 | 4 | {% block content %} 5 |

{{ 'noconsent_error' |trans }}

6 | {% endblock %} 7 | -------------------------------------------------------------------------------- /tests/bootstrap.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 | -------------------------------------------------------------------------------- /tests/config/config.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 | -------------------------------------------------------------------------------- /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/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 | ] 123 | ], 124 | [$validIdToken, $conflictedRequestAttributes, [ 125 | 'microsoft.name' => ['Steve Stratus'], 126 | 'microsoft.mail' => ['steve.stratus@outlook.com'], 127 | 'microsoft.id' => ['11111111'], 128 | 'microsoft.@odata.context' => ['https://graph.microsoft.com/v1.0/$metadata#directoryObjects'], 129 | 'microsoft.value.0.@odata.type' => ['#microsoft.graph.group'], 130 | 'microsoft.value.0.id' => ['11111111-1111-1111-1111-111111111111'], 131 | ] 132 | ], 133 | 134 | ]; 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /tests/lib/Controller/ErrorControllerTest.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /tests/lib/MockOpenIDConnectProvider.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 | -------------------------------------------------------------------------------- /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/RedirectException.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 | --------------------------------------------------------------------------------