directus icon indicating copy to clipboard operation
directus copied to clipboard

LinkedIn SSO not working

Open mahendraHegde opened this issue 1 year ago • 4 comments

Describe the Bug

I'm trying to implement linkedIn oauth, but i get below error on auth callback, i tried both hosted and localhost no luck

http://localhost:8088/auth/login/linkedin/callback?code=xxxxx&state=yyyyy

error

{
    "errors": [
        {
            "message": "Service \"oauth2\" is unavailable. Service returned unexpected response: A required parameter \"client_id\" is missing.",
            "extensions": {
                "service": "oauth2",
                "reason": "Service returned unexpected response: A required parameter \"client_id\" is missing",
                "code": "SERVICE_UNAVAILABLE"
            }
        }
    ]
}

here's the .env

AUTH_LINKEDIN_DRIVER="oauth2"
AUTH_LINKEDIN_CLIENT_ID="xxx"
AUTH_LINKEDIN_CLIENT_SECRET="xxx"
AUTH_LINKEDIN_AUTHORIZE_URL="https://www.linkedin.com/oauth/v2/authorization"
AUTH_LINKEDIN_ACCESS_URL="https://www.linkedin.com/oauth/v2/accessToken"
AUTH_LINKEDIN_PROFILE_URL="https://api.linkedin.com/v2/me"
AUTH_LINKEDIN_ALLOW_PUBLIC_REGISTRATION=true
AUTH_LINKEDIN_DEFAULT_ROLE_ID=xxxx
AUTH_LINKEDIN_REDIRECT_ALLOW_LIST=https://client.domain.com/auth/callback/,http://localhost:3000/auth/callback/

Upon investigating further it seems like accessToken api called from openid-client isnt passing client_id and client_secret as request body, I made below change to /api/auth/drivers/oauth2/.js and it seems to work, but I dint investigate how this change would impact other providers.

from

tokenSet = await this.client.oauthCallback(this.redirectUrl, { code: payload['code'], state: payload['state'] }, { code_verifier: payload['codeVerifier'], state: codeChallenge });

to

tokenSet = await this.client.oauthCallback(this.redirectUrl, { code: payload['code'], state: payload['state'] }, {  state: codeChallenge},{exchangeBody:{client_id:this.fulConf.clientId,client_secret:this.fulConf.clientSecret}});

I'd be happy to send a fix if you agree with the change or have any suggestions.

To Reproduce

use above config and and try to login using linkedIn

Directus Version

v11.1.0

Hosting Strategy

Self-Hosted

Database

sqlite

mahendraHegde avatar Sep 12 '24 00:09 mahendraHegde

Can you check whether https://github.com/directus/directus/issues/9521#issuecomment-962502798 helps? Cause it might be the same problem as in the linked issue, where the value of AUTH_LINKEDIN_CLIENT_ID is mistakenly interpreted as a number and thus ignored by the openid-client library.

paescuj avatar Sep 12 '24 09:09 paescuj

@paescuj thanks for the response, but linkedIn clientId is not in numeric format, I anyway tried casting to string in .env still no luck!!

mahendraHegde avatar Sep 12 '24 13:09 mahendraHegde

I wanted to share that I'm having the same issue. According to these LinkedIn docs, client_id and client_secret are required parameters, among others.

If we plan to use LinkedIn SSO, how does one advise I make the changes to the library?

@mahendraHegde - how did you update that file if you're using a docker image?

am17torres avatar Oct 07 '24 17:10 am17torres

@am17torres I tried the approach out locally by settings up directus project

mahendraHegde avatar Oct 07 '24 19:10 mahendraHegde

According to this issue it is possible to send the client_id and client_secret in the body by setting token_endpoint_auth_method to client_secret_post. We pass down option overrides to the underlying library using the AUTH_<PROVIDER>_CLIENT_* variables in your env.

That being said, adding the following to your config should resolve the issue

AUTH_LINKEDIN_CLIENT_TOKEN_ENDPOINT_AUTH_METHOD="client_secret_post"

As this issue was due to a misconfiguration I will proceed to close this, happy to discuss further and reopen if this does not resolve the problem.

ComfortablyCoding avatar Mar 09 '25 06:03 ComfortablyCoding

@mahendraHegde Sorry for commenting on this closed issue, but did you in the end get this to work without changing the code in the OAuth2 driver? If I add:

AUTH_LINKEDIN_CLIENT_TOKEN_ENDPOINT_AUTH_METHOD: "client_secret_post"

to my .env file as suggested by @ComfortablyCoding, it does indeed change the response, but it still does not work. Instead of the error that you mentioned initially in this thread:

{"errors":[{"message":"Service \"oauth2\" is unavailable. Service returned unexpected response: A required parameter \"client_id\" is missing.","extensions":{"service":"oauth2","reason":"Service returned unexpected response: A required parameter \"client_id\" is missing","code":"SERVICE_UNAVAILABLE"}}]}

I now instead get this error:

{"errors":[{"message":"Service \"oauth2\" is unavailable. Service returned unexpected response: Client authentication failed.","extensions":{"service":"oauth2","reason":"Service returned unexpected response: Client authentication failed","code":"SERVICE_UNAVAILABLE"}}]}

The relevant part of my .env:

AUTH_PROVIDERS: "linkedin"
AUTH_LINKEDIN_DRIVER: "oauth2"
AUTH_LINKEDIN_CLIENT_ID: "xxxxxxxxxx"
AUTH_LINKEDIN_CLIENT_SECRET: "yyyyyyyyy"
AUTH_LINKEDIN_AUTHORIZE_URL: "https://www.linkedin.com/oauth/v2/authorization"
AUTH_LINKEDIN_ACCESS_URL: "https://www.linkedin.com/oauth/v2/accessToken"
AUTH_LINKEDIN_PROFILE_URL: "https://api.linkedin.com/v2/userinfo"
AUTH_LINKEDIN_ALLOW_PUBLIC_REGISTRATION: "true"
AUTH_LINKEDIN_DEFAULT_ROLE_ID: "xxx-yyy-zzz"
AUTH_LINKEDIN_REDIRECT_ALLOW_LIST: "http://localhost:4321/loggedin"
AUTH_LINKEDIN_CLIENT_TOKEN_ENDPOINT_AUTH_METHOD: "client_secret_post"

If I take the code returned from LinkedIn and use it in Postman to send a POST request to the accessToken endpoint (using the request format detailed here), I get the access token without problems.

If I put a wrong client_secret into Postman, I get "Client authentication failed". So, it looks like the Directus problem also has to do with the client_secret (same error message). But I have checked and double and triple checked the AUTH_LINKEDIN_CLIENT_SECRET, and it is correct to the best of my knowledge (the secrets from LinkedIn end in "==" - could this be an encoding issue? It seems not, since urlencoding it makes no difference).

Looking into the oauth2.js driver inside Directus, the "token_endpoint_auth_method" is passed on to the openid-client by setting AUTH_LINKEDIN_CLIENT_TOKEN_ENDPOINT_AUTH_METHOD in .env. The following is from my docker-compose log where I console log the object put into the new issuer.Client() constructor:

directus_1  | {
directus_1  |   client_id: 'xxxxxxxxx',
directus_1  |   client_secret: 'yyyyyyy==',
directus_1  |   redirect_uris: [ 'http://localhost:4321/loggedin' ],
directus_1  |   response_types: [ 'code' ],
directus_1  |   token_endpoint_auth_method: 'client_secret_post'
directus_1  | }

But looking at openid-client (v5.7.0 which I believe is used in latest version of Directus), specifically this file, I can't seem to find a place where client_secret is merged into exchangeBody when token_endpoint_auth_method is set to "client_secret_post" - as @mahendraHegde notes above, he adds the client_secret manually into exchangeBody to make it work.

Any help much appreciated.

cjsinnbeck avatar Apr 29 '25 20:04 cjsinnbeck

Interesting. From what I understand, the authentication flow proceeds as follows:

  1. The login process starts with a call to authenticationServer.login: Directus – authentication.ts#L53
  2. This eventually triggers a call to this.client.oauthCallback via getUserID: Directus – oauth2.ts#L167
  3. Inside oauthCallback, the this.grant method is invoked: openid-client – client.js#L647
  4. grant internally calls authenticatedPost: openid-client – client.js#L157
  5. authenticatedPost then uses the authFor function: openid-client – client.js#L78
  6. Finally, authFor is responsible for adding client_id and client_secret to the payload when token_endpoint_auth_method is client_secret_post: openid-client – client.js#L85

From my own testing of this, I can indeed see the client_id and client_secret in the payload when the request is sent. I do receive the same error as you in the response. From what I can tell we are assuming the OAuth implementation is PKCE which linkedin does not seem to be? Manually running the failed request without code_verifier in the payload succeeds.

Have you tried to alternatively use linkedin as an openid provider?

ComfortablyCoding avatar Apr 30 '25 17:04 ComfortablyCoding

@ComfortablyCoding Thanks a lot for your detailed reply. You are right, client_id and client_secret are indeed passed on to the payload when AUTH_LINKEDIN_CLIENT_TOKEN_ENDPOINT_AUTH_METHOD = "client_secret_post", I was mistaken about that part. Had been staring at it for too long.

Did some more testing, and TLDR, using openid instead of oauth2 and changing:

https://github.com/directus/directus/blob/a6b840b08e802ae61e031701b4ed49879bd15b76/api/src/auth/drivers/openid.ts#L187-L191

to:

 tokenSet = await client.callback( 
 	this.redirectUrl, 
 	{ code: payload['code'], state: payload['state'], iss: payload['iss'] }, 
 	{ state: codeChallenge }, 
 ); 

seems to make LinkedIn SSO work.

My (limited) understanding is that the nonce in the JWT is a security measure to make sure that the request has not been tampered with on the way from client to ID provider. However, if LinkedIn (and possibly others) do not support the JWT nonce, I guess a workaround could be to add a AUTH_<PROVIDER>_CLIENT_NO_NONCE .env variable that could be set to true and would remove the code_verifier + nonce from the client.callback call in openid.js?


Some observations/steps that lead me to the above result:

It seems that PKCE can be enabled for LinkedIn apps, but it is on a per-app basis (one needs to manually contact LinkedIn support). I have not tried that route yet. Currently, I just get "Not enough permissions to access Native PKCE protocol" if I try to use the dedicated authorization endpoint for that.

As you also notice, omitting the code_verifier makes the token request succeed, this is why it worked in Postman. If I comment out the code_verifier in the payload - openid-client/lib/client.js#L653 - and then console.log tokenset, I get a TokenSet back with the correct structure:

{
  access_token: 'AQUT...',
  expires_at: 1751225249,  
  scope: 'email,profile'
}

However, when client.userinfo is then called afterwards with the token, it returns:

{
    "status": 403,
    "serviceErrorCode": 100,
    "code": "ACCESS_DENIED",
    "message": "Not enough permissions to access: userinfo.GET.NO_VERSION"
}

Googling a bit, this seems to be related to the absence of the openid scope. When I now add that scope to AUTH_LINKEDIN_SCOPE in my .env, I get a TokenSet back on this format:

{
  access_token: 'AQXp...',
  expires_at: 1751227158,
  scope: 'email,openid,profile',
  token_type: 'Bearer',
  id_token: 'eyJ6a...'
}

But this then causes the error id_token detected in the response, you must use client.callback() instead of client.oauthCallback() - in other words, it says "use the OpenID driver instead of the OAuth2 driver".

So, this brings me to your question about OpenID :-) Yes, I have tried to set it up, but it also fails initially. I have this in .env:

AUTH_LINKEDIN_DRIVER: "openid"
AUTH_LINKEDIN_CLIENT_ID: "xxx" 
AUTH_LINKEDIN_CLIENT_SECRET: "yyy"
AUTH_LINKEDIN_ISSUER_URL: "https://www.linkedin.com/oauth/.well-known/openid-configuration"
AUTH_LINKEDIN_SCOPE: "profile openid email"
AUTH_LINKEDIN_ALLOW_PUBLIC_REGISTRATION: "true"
AUTH_LINKEDIN_DEFAULT_ROLE_ID: "xxx-xxx-xxx"
AUTH_LINKEDIN_REDIRECT_ALLOW_LIST: "http://localhost:4321/logged-in"
AUTH_LINKEDIN_IDENTIFIER_KEY: "email"
AUTH_LINKEDIN_CLIENT_TOKEN_ENDPOINT_AUTH_METHOD: "client_secret_post"

Initially, it gives the same "Client authentication failed" error as with OAuth2. If I remove the code_verifier from the "checks" in client.callback, it does indeed return a tokenset with an JWT id_token (that decodes to the correct LinkedIn info). But the flow now fails with an error nonce mismatch, expected WHv....., got: undefined from client.validateIdToken. This is because the JWT in id_token does not contain a nonce. It seems that LinkedIn does not add the nonce to the JWT (see i.e. here)

If I now remove the nonce from the "checks" part of client.callback in openid.ts#L190 I seem to get past this problem, the login flow finishes, and I finally get the access_token and refresh_token returned from Directus. The user is also correctly created in Directus with "LinkedIn" as Provider value, etc.

cjsinnbeck avatar Apr 30 '25 21:04 cjsinnbeck