eclipse-vertx / vertx-auth

Apache License 2.0
162 stars 154 forks source link

Azure AD accessToken never valid-looking for key in user.attributes instead of user.principal #450

Closed photomorre closed 3 years ago

photomorre commented 3 years ago

Version

4.0.0 but I saw it in 3.9.5 as well

Context

After retrieving a valid accessToken from Azure AD from my registered app, and I found that OAuth2AuthProviderImpl jumps to Introspection even though the token is not opaque.

Reason: After the user object is created, the token is placed in user.principal but later the validation of not null and containing key "access_token" look for the token in user.attributes.

https://github.com/vert-x3/vertx-auth/blame/b9bfc619a320bfeb3053ac0513ffbf68f97a3fb1/vertx-auth-oauth2/src/main/java/io/vertx/ext/auth/oauth2/impl/OAuth2AuthProviderImpl.java#L178

The problematic row is 178:

if (user.attributes().containsKey("accessToken") && !jwt.isUnsecure())

and should be (if I understand things correctly):

if (user.principal().containsKey("accessToken") && !jwt.isUnsecure())

A confusing part is that the key set in user.principal is "access_token" and the lookup is for "accessToken". The result is that a valid Azure AD access token is is then looked for with introspection, something that Azure AD (currently) does not support -> an access token is always consider non-valid.

Things works with id tokens.

Do you have a reproducer?

No.

Steps to reproduce

  1. Register your API in Azure AD App Registration and configure your Scope(s) for Authorization Code flow.
  2. Request a token with Postman. Remember to use Scope including api://XXXX(app ID - that will show up in the token as Audience (aud)). Example: Scope: api://0874d758-25d2-4abc-9b99-xxxxxxxxxxxx/Users.Read.All
  3. Configure your openapi.json for oauth2:
    "securitySchemes": {
      "OAuth2": {
        "type": "oauth2",
        "flows": {
          "authorizationCode": {
            "authorizationUrl": "https://login.microsoftonline.com/c1061658-0240-4cbc-8da5-xxxxxxxxxxxx/oauth2/v2.0/authorize",
            "tokenUrl": "https://login.microsoftonline.com/c1061658-0240-4cbc-8da5-xxxxxxxxxxxx/oauth2/v2.0/token",
            "scopes": {
              "Users.Read.All": "To be able to read user data",
              "Users.Write.All": "To manipulate users.",
            }
          }
        }
      }
    }

    and secure your REST operation:

    "paths": {
    "/api/v1/user": {
      "get": {
        "summary": "Get some user data",
        "operationId": "getHello",
        "x-vertx-event-bus": "user.service",
        "security": [
          {
            "OAuth2": [
              "Users.Read.All"
            ]
          }
        ],
  4. Configure API Verticle with OAuth2

    OAuth2Auth oAuth2Auth = OAuth2Auth.create(vertx, new OAuth2Options()
        .setClientID(CLIENT_ID)
        .setClientSecret(CLIENT_SECRET)
        .setFlow(OAuth2FlowType.AUTH_CODE)
        .setSite(AZ_ENDPOINT)
        .setTokenPath(AZ_TOKEN_PATH)
        .setAuthorizationPath(AZ_AUTHZ_PATH)
    );
    
    OAuth2AuthHandler oAuth2AuthHandler = OAuth2AuthHandler
        .create(vertx, oAuth2Auth)
        .withScope("Users.Read.All")
        .withScope("Users.Write.All")
  5. Start your Verticle with Vert.x magic connecting your OpenAPI to the message bus with service proxy.
    routerBuilder
       .mountServicesFromExtensions()
       .securityHandler("OAuth2", oAuth2AuthHandler)
  6. Send in your api request with Postman configured with the retrieved access token from above.
  7. Get the reply from the API: Unauthorized
  8. Debug and find that Vert.x gives the error "Can't authenticate access_token: Provider doesn't support token introspection" from line 201.

Disclaimer

It is not very well-documented how all settings should be done in Azure AD and Vert.x regarding this use of tokens and I will continue testing and if requested from you (or having more time) I will test how things work with Keycloak. In short, there are many different settings for just handling the scopes (should scope with audience be set in Vert.x and openapi.json or just one of them or without audience in both or one of them. I know though to get a valid token, one has have the Scope with audience to get an access token from Azure AD using Postman. (The scope has bo be globally unique for Azure AD so they use api:// as aud.)

Extra

I have not found that Vert.x handle/use the audience (aud) claim anywhere.
I will gladly help with testing more and supply feedback. Thank you for an awesome project!

/Morre

pmlopes commented 3 years ago

Hi @photomorre could you share the discovery json with the uuid's redacted? It may be that we're using some wrong url to validate the token. Also if we don't have the information about where to load the keys, jwt's will always be assumed to be opaque and rely on introspection because, no keys are loaded. So this could be a misconfiguration bug we could try to improve.

photomorre commented 3 years ago

Hi @pmlopes! Nice to hear from you! Are we talking about the config settings for OAuth2 or a JsonObject from debugging? Here are the configs:

 private static final String CLIENT_ID = "0874d758-xxxx-4abc-9b99-xxxxxxxxxxxxx";
  private static final String CLIENT_SECRET = "somethingverysecret";
  private static final String AZ_LOGIN_URL = "https://login.microsoftonline.com";
  private static final String AZ_TENANT_ID = "c1061658-xxxx-4cbc-8da5-xxxxxxxxxxxxx";
  private static final String AZ_ENDPOINT = AZ_LOGIN_URL + "/" + AZ_TENANT_ID;
  private static final String AZ_TOKEN_PATH = AZ_ENDPOINT + "/oauth2/v2.0/token";
  private static final String AZ_AUTHZ_PATH = AZ_ENDPOINT + "/oauth2/v2.0/authorize";

these are concatenated what is also set in openapi.json: ´´´ "authorizationUrl": "https://login.microsoftonline.com/c1061658-xxxx-4cbc-8da5-xxxxxxxxxxxxx/oauth2/v2.0/authorize", "tokenUrl": "https://login.microsoftonline.com/c1061658-xxxx-4cbc-8da5-xxxxxxxxxxxxx/oauth2/v2.0/token", ´´´ From https://docs.microsoft.com/en-us/azure/databricks/dev-tools/api/latest/aad/troubleshoot-aad-token: You should verify that the following fields match the record:

aud: The Azure Databricks resource ID: 2ff814a6-3304-4ab8-85cb-cd0e6f879c1d iss: Should be https://sts.windows.net// tid: Should be the tenant of the workspace (look this up either by org ID or workspace appliance ID) nbf/exp: Current time should fall between nbf and exp unique_name: Should be a user that exists in the Databricks workspace, unless the user is a contributor on the workspace appliance resource Validate the signature of the token using the public certs from the OIDC endpoints: https://login.microsoftonline.com/common/.well-known/openid-configuration (contains URLs to call to)

And here is an example of of a a retrieved access token decoded via https://jwt.ms:

{
  "typ": "JWT",
  "alg": "RS256",
  "x5t": "nOo3ZDrODXEK1jKWhXslHR_KXEg",
  "kid": "nOo3ZDrODXEK1jKWhXslHR_KXEg"
}.{
  "aud": "api://0874d758-xxxx-4abc-9b99-xxxxxxxxxxxx",
  "iss": "https://sts.windows.net/c1061658-0240-4cbc-8da5-165a9caa30a3/",
  "iat": 1610555409,
  "nbf": 1610555409,
  "exp": 1610559309,
  "acr": "1",
  "ageGroup": "3",
  "aio": "AUQAu/8SAAAAxLiVT6MN+1JNTYw3bXCr8jN6Vdv+nJDiKJVIvEhnL8HPdYV8u5OP/3nomn87olBn6m+kKUISsGVH8SYEht9bJQ==",
  "amr": [
    "pwd",
    "mfa"
  ],
  "appid": "0874d758-xxxx-4abc-9b99-xxxxxxxxxxxx",
  "appidacr": "1",
  "family_name": "Mårelius",
  "given_name": "Lars",
  "ipaddr": "xxx.208.221.xxx",
  "name": "Morre",
  "oid": "5a776681-xxxx-4028-897a-xxxxxxx",
  "rh": "0.AAAAWBYGwUACvEyNpRZanKowo1jXdAjSJbxKm5m3XLvAekBIAL0.",
  "scp": "Use.Api Users.Read.All",
  "sub": "8pHqB2Pqqi62GX5Qn-wTxBFdBQHfHFLbow8KzAqOmQQ",
  "tid": "c1061658-xxxx-4cbc-8da5-xxxxxxxxxxxx",
  "unique_name": "morre@tentixo.com",
  "upn": "morre@tentixo.com",
  "uti": "UGGWr-uG0UubcCChq0wkAA",
  "ver": "1.0"
}.[Signature]

This Azure AD is blocker for my PoC for my customer so I have a lot of time helping out. We could do a Teams session debugging if needed. I am also curious if the Audience [aud] is actually validated with Vert.x. To not be able to use another token from Azure AD that have the same Scope [scp] - a problem one would not have using Keycloak locally: If I get a token from App A from the same Azure Tentant I could use it for getting access to App B if they have set the same name on the Scopes. Request strings from A and B:

App A: "scp": "api://0874d758-xxxx-4abc-9b99-xxxxxxxxxxxx/Users.Read.All"
App B "scp": "api://something-xxxx-else-from-xxxxxxxxxxxx/Users.Read.All"

The prefix "api://" is needed to get the token from Azure AD and that will then end up in "aud".

photomorre commented 3 years ago

This is the URL I found from https://login.microsoftonline.com/common/.well-known/openid-configuration that will load the keys: https://login.microsoftonline.com/common/discovery/keys So I would guess that one has to have a specific Azure AD setting for OpenID Connect to work - set the /openid-configuration URL and have a call retrieving the URLs, parse out the /discovery/keys URL and validate there. Edited: Or to avoid hard-coding something Microsoft might change and/or avoid call-n-parse: have a setting for setAzureKeyDiscovery where one can set the valid key URL.

pmlopes commented 3 years ago

I see a couple of things here, this token is using x5t certificate thumbprint which we currently don't implement, which is ok, because we try all the certificates instead.

Second, loading of jwk's on the oauth2 module is an async operation that is not performed at the constructor level, so I'm almost certain that those keys are not being loaded (need to review the openapi module), if you're testing with oauth directly this means we need to call:

.jWKSet()
  .onSucess(ok -> {})
  .onFailure(err -> {})

But it also means that the openapi module must pass the right url:

https://login.microsoftonline.com/common/discovery/keys

Then the issue you noticed with the validation of the audience. Apparently Azure AD is drifting from the standard by changing the protocol to api://... instead of being the origin.

I think if you could help me by creating a simple hello world without openapi (for now), we could then check which validations are failing. Once this is solved we can see how to add that to openapi too.

photomorre commented 3 years ago

aud: I think MS had to choose a naming convention (as far as I know, MS does not use the api:// as something implemented as a protocol) since any Tenant use the same endpoints and the actual URL of the registered app might changed, so they add api:// as a prefix to the App ID -> problem solved.

HelloToken: Wilco. I will use my current code base and make something with both Azure AD and Keycloak and some scenarios.

The PoC scenario that I'm implementing is a "rabbit hole" one where app with logged in user calls and API that calls Azure Services and do callbacks to the original requester: Service -> [token] -> API -> method -> [token] -> Azure Service and I want to first check that the Service is allowed to talk to the API (audience) and then check if the on-behalf-of user is allowed to activate the requested method [scope].

I hope to have something up on Github next week.

pmlopes commented 3 years ago

@photomorre I've prepared a basic unit test to get this tested and we can start figuring out what's going wrong.

https://github.com/vert-x3/vertx-auth/pull/452

If you could checkout that branch, switch the variables in the test with your stuff and we could continue from there. Maybe you could join our official discord chat and we can continue chatting there to avoid disclosing any secrets/tokens etc...

Here's the discord link: https://discord.com/invite/KzEMwP2

pmlopes commented 3 years ago

@slinkydeveloper I've a question regarding openapi. I did a quick look at the schemas, and it seems that for openapi oauth2 is assuming that the application initiates the authentication. From the config I see the flows and URLs are there to enable the application to start the process, and keep the token in the session.

I don't see the case when the application is receiving a JWT because there's no way to configure keys/certificates/etc... Am I reading the schemas correctly?

For complex scenarios like the one on this example, could we do something like?

routerBuilder.securityHandler(
  "oauth",
  Oauth2AuthHandler.create(Oauth2Auth.create(...))
);
photomorre commented 3 years ago

I'll join the chat!

Yes, things are complex - a lot of moving parts/configs. I tested with many different settings and the service proxy::OpenAPI parts did not enjoy if I had one-sided config, for example just OAuth2 settings in the OpenAPI config.

I also tested to add OAuth2 handler to the router after it was created by

routerBuilder
  .mountServicesFromExtensions()
  .securityHandler("OAuth2", oAuth2AuthHandler);

One was to add the

router.route()
  .handler(oAuth2AuthHandler);

both with ´´´route("/")androute("/api/") but it looks like the .mountServicesFromExtensions() does the all the work and if OAuth2 is present in the OpenAPI config it says "missing security handler".

I will get my mind around your suggestion and do some testing. There is one other thing I would like to try and that is to add the callback URL in the Oauth2AuthHandler. Now I cannot add it since it's a catch 22 when the service mounting happens. Maybe your suggestion is an idea that solves that too.

I hope to be working on Sunday next time.

pmlopes commented 3 years ago

I'll close this issue as we can now verify ADD tokens correctly as tested. For OpenAPI related tasks a new issue is created in vertx-web to track the work needed to be performed there.