BookStackApp / BookStack

A platform to create documentation/wiki content built with PHP & Laravel
https://www.bookstackapp.com/
MIT License
15.17k stars 1.9k forks source link

OIDC using Zitadel does not work #4682

Closed megastary closed 11 months ago

megastary commented 11 months ago

Describe the Bug

When trying to use Zitadel Identity server for OIDC login to bookstack, it always fails as it does not expect audience claim to be array. According to standard, aud should usually be array, only in special case, when only one audience is available, it may present it as string. Source: https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.3
It may not be usually presented as an array, but sadly Zitadel always sends aud as an array and there is currently no way to disable that behaviour, though it's kinda expected as they do not break the standard with that implementation.

Stack trace in log:

[2023-11-19 19:30:28] production.ERROR: invalid_grant {"exception":"[object] (League\\OAuth2\\Client\\Provider\\Exception\\IdentityProviderException(code: 400): invalid_grant at /var/www/bookstack/app/Access/Oidc/OidcOAuthProvider.php:104)
[stacktrace]
#0 /var/www/bookstack/vendor/league/oauth2-client/src/Provider/AbstractProvider.php(726): BookStack\\Access\\Oidc\\OidcOAuthProvider->checkResponse()
#1 /var/www/bookstack/vendor/league/oauth2-client/src/Provider/AbstractProvider.php(635): League\\OAuth2\\Client\\Provider\\AbstractProvider->getParsedResponse()
#2 /var/www/bookstack/app/Access/Oidc/OidcService.php(67): League\\OAuth2\\Client\\Provider\\AbstractProvider->getAccessToken()
#3 /var/www/bookstack/app/Access/Controllers/OidcController.php(57): BookStack\\Access\\Oidc\\OidcService->processAuthorizeResponse()
#4 /var/www/bookstack/vendor/laravel/framework/src/Illuminate/Routing/Controller.php(54): BookStack\\Access\\Controllers\\OidcController->callback()
#5 /var/www/bookstack/vendor/laravel/framework/src/Illuminate/Routing/ControllerDispatcher.php(43): Illuminate\\Routing\\Controller->callAction()
#6 /var/www/bookstack/vendor/laravel/framework/src/Illuminate/Routing/Route.php(259): Illuminate\\Routing\\ControllerDispatcher->dispatch()
#7 /var/www/bookstack/vendor/laravel/framework/src/Illuminate/Routing/Route.php(205): Illuminate\\Routing\\Route->runController()
#8 /var/www/bookstack/vendor/laravel/framework/src/Illuminate/Routing/Router.php(798): Illuminate\\Routing\\Route->run()
#9 /var/www/bookstack/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php(141): Illuminate\\Routing\\Router->Illuminate\\Routing\\{closure}()
#10 /var/www/bookstack/app/Http/Middleware/CheckGuard.php(27): Illuminate\\Pipeline\\Pipeline->Illuminate\\Pipeline\\{closure}()
#11 /var/www/bookstack/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php(180): BookStack\\Http\\Middleware\\CheckGuard->handle()
#12 /var/www/bookstack/app/Http/Middleware/Localization.php(45): Illuminate\\Pipeline\\Pipeline->Illuminate\\Pipeline\\{closure}()
#13 /var/www/bookstack/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php(180): BookStack\\Http\\Middleware\\Localization->handle()
#14 /var/www/bookstack/app/Http/Middleware/RunThemeActions.php(26): Illuminate\\Pipeline\\Pipeline->Illuminate\\Pipeline\\{closure}()
#15 /var/www/bookstack/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php(180): BookStack\\Http\\Middleware\\RunThemeActions->handle()
#16 /var/www/bookstack/app/Http/Middleware/CheckEmailConfirmed.php(47): Illuminate\\Pipeline\\Pipeline->Illuminate\\Pipeline\\{closure}()
#17 /var/www/bookstack/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php(180): BookStack\\Http\\Middleware\\CheckEmailConfirmed->handle()
#18 /var/www/bookstack/app/Http/Middleware/PreventAuthenticatedResponseCaching.php(21): Illuminate\\Pipeline\\Pipeline->Illuminate\\Pipeline\\{closure}()
#19 /var/www/bookstack/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php(180): BookStack\\Http\\Middleware\\PreventAuthenticatedResponseCaching->handle()
#20 /var/www/bookstack/vendor/laravel/framework/src/Illuminate/Foundation/Http/Middleware/VerifyCsrfToken.php(78): Illuminate\\Pipeline\\Pipeline->Illuminate\\Pipeline\\{closure}()
#21 /var/www/bookstack/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php(180): Illuminate\\Foundation\\Http\\Middleware\\VerifyCsrfToken->handle()
#22 /var/www/bookstack/vendor/laravel/framework/src/Illuminate/View/Middleware/ShareErrorsFromSession.php(49): Illuminate\\Pipeline\\Pipeline->Illuminate\\Pipeline\\{closure}()
#23 /var/www/bookstack/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php(180): Illuminate\\View\\Middleware\\ShareErrorsFromSession->handle()
#24 /var/www/bookstack/vendor/laravel/framework/src/Illuminate/Session/Middleware/StartSession.php(121): Illuminate\\Pipeline\\Pipeline->Illuminate\\Pipeline\\{closure}()
#25 /var/www/bookstack/vendor/laravel/framework/src/Illuminate/Session/Middleware/StartSession.php(64): Illuminate\\Session\\Middleware\\StartSession->handleStatefulRequest()
#26 /var/www/bookstack/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php(180): Illuminate\\Session\\Middleware\\StartSession->handle()
#27 /var/www/bookstack/vendor/laravel/framework/src/Illuminate/Cookie/Middleware/AddQueuedCookiesToResponse.php(37): Illuminate\\Pipeline\\Pipeline->Illuminate\\Pipeline\\{closure}()
#28 /var/www/bookstack/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php(180): Illuminate\\Cookie\\Middleware\\AddQueuedCookiesToResponse->handle()
#29 /var/www/bookstack/vendor/laravel/framework/src/Illuminate/Cookie/Middleware/EncryptCookies.php(67): Illuminate\\Pipeline\\Pipeline->Illuminate\\Pipeline\\{closure}()
#30 /var/www/bookstack/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php(180): Illuminate\\Cookie\\Middleware\\EncryptCookies->handle()
#31 /var/www/bookstack/app/Http/Middleware/ApplyCspRules.php(33): Illuminate\\Pipeline\\Pipeline->Illuminate\\Pipeline\\{closure}()
#32 /var/www/bookstack/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php(180): BookStack\\Http\\Middleware\\ApplyCspRules->handle()
#33 /var/www/bookstack/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php(116): Illuminate\\Pipeline\\Pipeline->Illuminate\\Pipeline\\{closure}()
#34 /var/www/bookstack/vendor/laravel/framework/src/Illuminate/Routing/Router.php(797): Illuminate\\Pipeline\\Pipeline->then()
#35 /var/www/bookstack/vendor/laravel/framework/src/Illuminate/Routing/Router.php(776): Illuminate\\Routing\\Router->runRouteWithinStack()
#36 /var/www/bookstack/vendor/laravel/framework/src/Illuminate/Routing/Router.php(740): Illuminate\\Routing\\Router->runRoute()
#37 /var/www/bookstack/vendor/laravel/framework/src/Illuminate/Routing/Router.php(729): Illuminate\\Routing\\Router->dispatchToRoute()
#38 /var/www/bookstack/vendor/laravel/framework/src/Illuminate/Foundation/Http/Kernel.php(190): Illuminate\\Routing\\Router->dispatch()
#39 /var/www/bookstack/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php(141): Illuminate\\Foundation\\Http\\Kernel->Illuminate\\Foundation\\Http\\{closure}()
#40 /var/www/bookstack/vendor/laravel/framework/src/Illuminate/Http/Middleware/TrustProxies.php(39): Illuminate\\Pipeline\\Pipeline->Illuminate\\Pipeline\\{closure}()
#41 /var/www/bookstack/app/Http/Middleware/TrustProxies.php(41): Illuminate\\Http\\Middleware\\TrustProxies->handle()
#42 /var/www/bookstack/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php(180): BookStack\\Http\\Middleware\\TrustProxies->handle()
#43 /var/www/bookstack/vendor/laravel/framework/src/Illuminate/Foundation/Http/Middleware/TransformsRequest.php(21): Illuminate\\Pipeline\\Pipeline->Illuminate\\Pipeline\\{closure}()
#44 /var/www/bookstack/vendor/laravel/framework/src/Illuminate/Foundation/Http/Middleware/TrimStrings.php(40): Illuminate\\Foundation\\Http\\Middleware\\TransformsRequest->handle()
#45 /var/www/bookstack/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php(180): Illuminate\\Foundation\\Http\\Middleware\\TrimStrings->handle()
#46 /var/www/bookstack/vendor/laravel/framework/src/Illuminate/Foundation/Http/Middleware/ValidatePostSize.php(27): Illuminate\\Pipeline\\Pipeline->Illuminate\\Pipeline\\{closure}()
#47 /var/www/bookstack/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php(180): Illuminate\\Foundation\\Http\\Middleware\\ValidatePostSize->handle()
#48 /var/www/bookstack/vendor/laravel/framework/src/Illuminate/Foundation/Http/Middleware/PreventRequestsDuringMaintenance.php(86): Illuminate\\Pipeline\\Pipeline->Illuminate\\Pipeline\\{closure}()
#49 /var/www/bookstack/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php(180): Illuminate\\Foundation\\Http\\Middleware\\PreventRequestsDuringMaintenance->handle()
#50 /var/www/bookstack/vendor/laravel/framework/src/Illuminate/Pipeline/Pipeline.php(116): Illuminate\\Pipeline\\Pipeline->Illuminate\\Pipeline\\{closure}()
#51 /var/www/bookstack/vendor/laravel/framework/src/Illuminate/Foundation/Http/Kernel.php(165): Illuminate\\Pipeline\\Pipeline->then()
#52 /var/www/bookstack/vendor/laravel/framework/src/Illuminate/Foundation/Http/Kernel.php(134): Illuminate\\Foundation\\Http\\Kernel->sendRequestThroughRouter()
#53 /var/www/bookstack/public/index.php(52): Illuminate\\Foundation\\Http\\Kernel->handle()
#54 {main}

Steps to Reproduce

  1. Configure OIDC according to docs
  2. Click on sign in with SSO
  3. See error: ID token validate failewd with error: Token audience value has 2 values, Expected 1

Expected Behaviour

  1. Configure OIDC according to docs
  2. Click on sign in with SSO
  3. Successfully log in

Screenshots or Additional Context

bookstack_bug

Browser Details

Brave 1.60.118 Chromium: 119.0.6045.163 on Windows 11

Exact BookStack Version

v23.10.2

ssddanbrown commented 11 months ago

Hi @megastary, Please see #4147 for a lot of prior context and conversation on this.

My comment in #4200 provides an example of a workaround that can be use to make zitadel's behavior compatible.

megastary commented 11 months ago

Hi @ssddanbrown,

thank you very much for responding with all I needed! Sorry that I did not find mentioned issue myself.
On #4147 I personally side with @the-voidl and think that Bookstack should be able to handle array on its own as it is imho clearly stated in RFC 7519, but you made working workaround and it's great!
The only thing I think could be improved is to have those tips how to setup Zitadel SSO in docs. I guess the reason is that currently it is clearly new and not that big, but as stated by @the-voidl, Zitadel may not be the only identity server that sends array in audience, so maybe some general heads up could be included in https://www.bookstackapp.com/docs/admin/oidc-auth/ docs page? Also to be fair, I wish Zitadel could be the flexible one and allow us to send aud as string as there are many applications that do not support array in the aud claim. I will try to chat with maintainers to see if there are any plans to implement this.

Now to results. I got it working!

  1. In Zitadel, create new project
  2. Create Web application
  3. Select Code configuration
  4. Copy Client Id and Client Secret for later use
  5. In Token settings, enable User Info inside ID Token authtoken option
  6. In Bookstack, create following file (and folder structure) /var/www/bookstack/themes/custom/functions.php

    <?php
    
    use BookStack\Facades\Theme;
    use BookStack\Theming\ThemeEvents;
    
    Theme::listen(ThemeEvents::OIDC_ID_TOKEN_PRE_VALIDATE, function (array $idTokenData, array $accessTokenData) {
      if (is_array($idTokenData['aud']) && in_array($idTokenData['azp'], $idTokenData['aud'])) {
          return array_merge($idTokenData, [
              'aud' => [$idTokenData['azp']]
          ]);
      }
    });
  7. Add following options to /var/www/bookstack/.env

    # Set OIDC to be the authentication method
    AUTH_METHOD=oidc
    
    # Control if BookStack automatically initiates login via your OIDC system
    # if it's the only authentication method. Prevents the need for the
    # user to click the "Login with x" button on the login page.
    # Setting this to true enables auto-initiation.
    AUTH_AUTO_INITIATE=true
    
    # Set the display name to be shown on the login button.
    # (Login with <name>)
    OIDC_NAME="SSO"
    
    # Name of the claims(s) to use for the user's display name.
    # Can have multiple attributes listed, separated with a '|' in which
    # case those values will be joined with a space.
    # Example: OIDC_DISPLAY_NAME_CLAIMS=given_name|family_name
    OIDC_DISPLAY_NAME_CLAIMS=name
    
    # OAuth Client ID to access the identity provider
    OIDC_CLIENT_ID=ClientId
    
    # OAuth Client Secret to access the identity provider
    OIDC_CLIENT_SECRET=ClientSecret
    
    # Issuer URL
    # Must start with 'https://'
    OIDC_ISSUER=https://your-zitadel-instance.example.com
    
    # Enable auto-discovery of endpoints and token keys.
    # As per the standard, expects the service to serve a
    # `<issuer>/.well-known/openid-configuration` endpoint.
    OIDC_ISSUER_DISCOVER=true
    
    # Load custom functions from custom template
    APP_THEME=custom
  8. Login with Zitadel SSO to Bookstack

So overall, it is indeed doable and quite easy to do! As a low priority improvement could be function to that pairs Bookstack's Email Confirmation with Zitadels info in token, which states if e-mail is verified, in other words to delegate that check to identtiy server. Example response from Zitadel (last line):

{
  "iss": "https:\/\/your-zitadel-instance.example.com",
  "sub": "12345",
  "aud": [
    "12345@bookstack"
  ],
  "exp": 12345,
  "iat": 12345,
  "auth_time": 12345,
  "amr": [
    "password",
    "pwd",
    "mfa",
    "user"
  ],
  "azp": "12345@bookstack",
  "client_id": "12345@bookstack",
  "at_hash": "hash",
  "c_hash": "hash",
  "name": "Name Surname",
  "given_name": "Name",
  "family_name": "Surname",
  "locale": "en",
  "updated_at": 12345,
  "preferred_username": "name@example.com",
  "email": "name@example.com",
  "email_verified": true
}
ssddanbrown commented 11 months ago

Good to hear the workaround works for you here!

think that Bookstack should be able to handle array on its own as it is imho clearly stated in RFC 7519

Just to confirm, BookStack does accept an array or string value as per the RFC, it's just that it also validates that property to my strict interpretation of the OIDC spec, so rejects when there's more that one value since that's never expected in the OIDC flow scenario for BookStack.

Chaz6 commented 10 months ago

@megastary thanks for the tips, I was able to get login working with Zitadel! Did you have any luck with group sync? I cannot seem to figure out how to get my Zitadel roles working. I have created a role called "Wiki Admin" and I have an equivalent role in BookStack, but it is no getting applied when a user logs in.

ssddanbrown commented 10 months ago

@Chaz6 You can use the OIDC_DUMP_USER_DETAILS=true option to help see if the details are being provided by Zitadel and, if so, how they are named.

Details in our docs: https://www.bookstackapp.com/docs/admin/oidc-auth/#debugging Example in video of using this to debug: https://youtu.be/TJQ4NJrMvkw?t=1154 (19:14 mark)

megastary commented 10 months ago

@megastary thanks for the tips, I was able to get login working with Zitadel! Did you have any luck with group sync? I cannot seem to figure out how to get my Zitadel roles working. I have created a role called "Wiki Admin" and I have an equivalent role in BookStack, but it is no getting applied when a user logs in.

@Chaz6 I think the trick part was to enable Assert Roles on Authentication

This is my config which works:

image

image

And .env for bookstack

OIDC_USER_TO_GROUPS=true
OIDC_GROUPS_CLAIM=groups
OIDC_REMOVE_FROM_GROUPS=false
baua1310 commented 4 months ago

Hi,

I successfully setup OIDC with Zitadel using the steps from @megastary. But after some time, after 24 hours at the latest, I get this error:

image ID token validation failed with error: Token signature could not be validated using the provided keys.

Deleting the bookstack docker container and recreating it fixes the error for some hours.

Anybody else having this error? Am I missing a configuration?

Now to results. I got it working!

  1. In Zitadel, create new project
  2. Create Web application
  3. Select Code configuration
  4. Copy Client Id and Client Secret for later use
  5. In Token settings, enable User Info inside ID Token authtoken option
  6. In Bookstack, create following file (and folder structure) /var/www/bookstack/themes/custom/functions.php
<?php

use BookStack\Facades\Theme;
use BookStack\Theming\ThemeEvents;

Theme::listen(ThemeEvents::OIDC_ID_TOKEN_PRE_VALIDATE, function (array $idTokenData, array $accessTokenData) {
    if (is_array($idTokenData['aud']) && in_array($idTokenData['azp'], $idTokenData['aud'])) {
        return array_merge($idTokenData, [
            'aud' => [$idTokenData['azp']]
        ]);
    }
});
  1. Add following options to /var/www/bookstack/.env
# Set OIDC to be the authentication method
AUTH_METHOD=oidc

# Control if BookStack automatically initiates login via your OIDC system
# if it's the only authentication method. Prevents the need for the
# user to click the "Login with x" button on the login page.
# Setting this to true enables auto-initiation.
AUTH_AUTO_INITIATE=true

# Set the display name to be shown on the login button.
# (Login with <name>)
OIDC_NAME="SSO"

# Name of the claims(s) to use for the user's display name.
# Can have multiple attributes listed, separated with a '|' in which
# case those values will be joined with a space.
# Example: OIDC_DISPLAY_NAME_CLAIMS=given_name|family_name
OIDC_DISPLAY_NAME_CLAIMS=name

# OAuth Client ID to access the identity provider
OIDC_CLIENT_ID=ClientId

# OAuth Client Secret to access the identity provider
OIDC_CLIENT_SECRET=ClientSecret

# Issuer URL
# Must start with 'https://'
OIDC_ISSUER=https://your-zitadel-instance.example.com

# Enable auto-discovery of endpoints and token keys.
# As per the standard, expects the service to serve a
# `<issuer>/.well-known/openid-configuration` endpoint.
OIDC_ISSUER_DISCOVER=true

# Load custom functions from custom template
APP_THEME=custom
  1. Login with Zitadel SSO to Bookstack
ssddanbrown commented 4 months ago

@baua1310 We do some caching of auto-discovery findings in BookStack which could lead to something like that, especially as it looks like Zitadel has frequent key rotation by default, but our caching is only intended for 15 minutes.

Feel free to raise as a seperate support issue for potential debug/workaround options, as it's something different to what was originally discussed in this closed thread.

baua1310 commented 4 months ago

Hi @ssddanbrown thank you for your message. I created a new issue #5049