zalando / skipper

An HTTP router and reverse proxy for service composition, including use cases like Kubernetes Ingress
https://opensource.zalando.com/skipper/
Other
3.11k stars 350 forks source link

OAuth2 Token Exchange Filter Request #805

Open stormmore opened 6 years ago

stormmore commented 6 years ago

Use Case

I am currently looking at a scenario that requires users to authenticate with 3rd party IDP and I need to trust their tokens while passing the services behind Skipper a token from an internal IDP for authz. Similar workflow to:

client ----------------> 1st IDP
   |        authn
   |
   | Authorization: Bearer <access_token from 1st IDP>
   |
   |
skipper --------------> 2nd IDP  -------------> 1st IDP
   |    token-exchange           validate token 
   |
   | Authorization: Bearer <access_token from 2nd IDP>
   |
   |------------------> 2nd IDP
   |       authz (using oauthTokenintrospection filters)
   |
   | X-Tokeninfo-Forward: <tokeninfo from 2nd IDP>
   |            (tokenForward filter)
   |
backend

Background

Looking at both the RFC Draft and Keycloak's implementation, it would be good to have a filter, tokenExchange maybe, that takes the token from the Authorization: Bearer <token> header and requests an exchange with the 2nd IDP used for Authz.

Requirements

According to the Keycloak documentation, the filter should do a POST call to the token endpoint (e.g. http://localhost:8080/auth/realms/myrealm/protocol/openid-connect/token - can be found in the /.well-known/openid-configuration endpoint) with the following fields:

The response from this call should be something like:

{
   "access_token" : "....",
   "refresh_token" : "....",
   "expires_in" : 3600
}

This access token should then replace the one currently in the Authorization: Bear <token> request header and passed on to the next in the chain or the backend.

stormmore commented 6 years ago

There is this note in the Keycloak documentation that I am not 100% of the meaning:

If your requested_token_type parameter is a refresh token type, then the response will contain both an access token, refresh token, and expiration. Here’s an example JSON response you get back from this call.
stormmore commented 6 years ago

It is also worth noting that there are 4 different types of token exchange with slightly different use cases:

  1. Internal to External Token Exchange
  2. External to Internal Token Exchange (The one that I am currently interested in)
  3. Impersonation
  4. Bare-Naked Impersonation

The last 2 I am also super interested in as this will empower support teams to be able to understand a problem. Keycloak's Token Exchange documentation has the details

stormmore commented 6 years ago

OK, I have been "playing" with Keycloak's token-exchange. Given the parameters passed below, the access and refresh tokens that are returned are JWT tokens:

curl -X POST \
    -d "client_id=<CLIENT ID>" \
    -d "client_secret=<CLIENT SECRET> \
    --data-urlencode "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
    -d "subject_token=<3RD PARTY ACCESS_TOKEN>" \
    -d "subject_issuer=<KEYCLOAK IDP ALIAS" \
    --data-urlencode "subject_token_type=urn:ietf:params:oauth:token-type:access_token" \
    https://<KEYCLOAK SERVER>/auth/realms/<REALM>/protocol/openid-connect/token

A redacted response example would be:

{"access_token":"<REDACTED JWT>","expires_in":300,"refresh_expires_in":1800,"refresh_token":"<REDACTED JWT>","token_type":"bearer","not-before-policy":0,"session_state":"cdf4d24f-cf05-4eb3-a1af-62efc5d46cc1","scope":"profile email"}

Example decoded JWT for access_token:

{
  "jti": "9a8613d3-8fb6-4017-9f7c-8f8d578f63b6",
  "exp": 1537820164,
  "nbf": 0,
  "iat": 1537818364,
  "iss": "https://<KEYCLOAK SERVER>/auth/realms/<REALM>",
  "aud": "<CLIENT ID>",
  "sub": "<CLIENT SECRET>",
  "typ": "Bearer",
  "azp": "<CLIENT ID>",
  "auth_time": 0,
  "session_state": "cdf4d24f-cf05-4eb3-a1af-62efc5d46cc1",
  "acr": "1",
  "allowed-origins": [
    "/*"
  ],
  "realm_access": {
    "roles": [
      "user"
    ]
  },
  "resource_access": {
    "account": {
      "roles": [
        "manage-account",
        "manage-account-links",
        "view-profile"
      ]
    }
  },
  "scope": "profile email",
  "email_verified": true,
  "preferred_username": "username",
  "email": "email@test.com"
}

and finally a decoded refresh_token JWT:

{
  "jti": "81fcde30-d66c-4523-b338-3aa6d50e1c9d",
  "exp": 1537818664,
  "nbf": 0,
  "iat": 1537818364,
  "iss": "https://<KEYCLOAK SERVER>/auth/realms/<REALM>",
  "aud": "<CLIENT ID>",
  "sub": "<CLIENT SECRET>",
  "typ": "Refresh",
  "azp": "<CLIENT ID>",
  "auth_time": 0,
  "session_state": "cdf4d24f-cf05-4eb3-a1af-62efc5d46cc1",
  "realm_access": {
    "roles": [
      "gamer"
    ]
  },
  "resource_access": {
    "account": {
      "roles": [
        "manage-account",
        "manage-account-links",
        "view-profile"
      ]
    }
  },
  "scope": "profile email"
}

So knowing this now, it looks like the 2nd call to the tokenInfo / tokenIntrospection endpoints is unnecessary at least for Keycloak's implementation of token exchange.

aryszka commented 6 years ago

then, i would try to go first with a single call to 'IDP2' and see if it works in general. The second call can be added later if another IDP implementation requires it.

szuecs commented 6 years ago

@stormmore can you specify how the filter should be configured?

stormmore commented 6 years ago

In actual fact, since this is hitting the token endpoint, there are many use cases to be able to query that endpoint beyond my current use case. https://www.keycloak.org/docs/latest/securing_apps/#_token-exchange covers them pretty well. So it probably would be better to make it a more general "token" filter that takes a bunch of params that help it form the request to get a new token.

The part I have been thinking about quite a bit is, to accomplish this, Skipper would need to have a client id and client secret and these should probably be passed in through a file and will need a process to make sure if the file is updated it pulls in the update.

Due to the fact that you can do a token-exchange for a 3rd party client, I believe that one client id and client secret should be sufficient.

szuecs commented 6 years ago

ClientID and clientsecret is also required for openID connect in https://github.com/zalando/skipper/pull/743. I am not sure what we do there, now. I think I added this as part of the filter, because some people might want to integrate different token providers and if you pass it as file via flag, then you bind the configuration more hard. On the other hand it leaks the secret to people that have access to the supportListener. Not sure how to satisfy all cases....