From c227bca56a3fc00229287caeb4c56db5370b0133 Mon Sep 17 00:00:00 2001 From: Declan McAleese <21492+djmcaleese@users.noreply.github.com> Date: Tue, 10 Sep 2024 17:01:27 +0100 Subject: [PATCH] Keycloak implementation of SPI (#5) * Create a new Keycloak implementation as a copy of Cognito * Strip out Cognito specifics * Create a new client using DCR * Reimplement create without DCR; implement delete; document auth * Additional test for deletes * Get tokens from a helper function * Enable authorization services on new clients * Get tokens with middleware * Use management client as resource server and discover endpoints * Authorise API products via resources and permissions * Document Keycloak setup and policy enforcement * Tests for permissions management * Remove reference to Keycloak resource server * fix dev release * fix dev release - add fallback for release tag * add keycloak variables in values template * add id to keycloak oauth credential creation, cognito TODO * update openapi definition * add id to e2e tests * fix unit tests * remove name from client generation * fix tests using new id field * Return created client's name for id * add GetAPIProducts endpoint + Keycloak implementation * add GetAPIProducts handler tests * fix github linter problem * github review comment fixes * update Keycloak's UpdateAppAPIProducts method to CRUD as-needed * update README wording * update example scope --------- Co-authored-by: Fabian Gonzalez --- .github/workflows/ci-release-dev.yaml | 8 +- .gitignore | 3 + DEVELOPMENT.md | 103 +++- README.md | 24 +- api/README.md | 2 +- api/v1/openapi.yaml | 65 ++- cmd/idp-connect.go | 2 + docs/configuring-gloo-gateway.md | 427 +++++++++++++++++ docs/configuring-gloo-platform.md | 206 -------- docs/usage.md | 64 ++- go.mod | 10 +- go.sum | 46 ++ helm/Chart-template.yaml | 2 +- helm/templates/_helpers.tpl | 6 + helm/values-template.yaml | 10 +- internal/cognito/server/handler.go | 19 +- internal/cognito/server/handler_test.go | 18 +- .../cognito/server/mock/cognito_client.go | 35 +- internal/keycloak/keycloak.go | 27 ++ internal/keycloak/server/handler.go | 445 ++++++++++++++++++ internal/keycloak/server/handler_test.go | 348 ++++++++++++++ internal/keycloak/server/server.go | 125 +++++ internal/keycloak/server/server_suite_test.go | 23 + internal/keycloak/server/util.go | 30 ++ pkg/api/v1/server.gen.go | 70 ++- pkg/api/v1/spec.gen.go | 50 +- pkg/api/v1/types.gen.go | 9 +- test/e2e/e2e_test.go | 20 +- 28 files changed, 1877 insertions(+), 320 deletions(-) create mode 100644 docs/configuring-gloo-gateway.md delete mode 100644 docs/configuring-gloo-platform.md create mode 100644 internal/keycloak/keycloak.go create mode 100644 internal/keycloak/server/handler.go create mode 100644 internal/keycloak/server/handler_test.go create mode 100644 internal/keycloak/server/server.go create mode 100644 internal/keycloak/server/server_suite_test.go create mode 100644 internal/keycloak/server/util.go diff --git a/.github/workflows/ci-release-dev.yaml b/.github/workflows/ci-release-dev.yaml index 517ffe2..664743b 100644 --- a/.github/workflows/ci-release-dev.yaml +++ b/.github/workflows/ci-release-dev.yaml @@ -15,8 +15,14 @@ jobs: - uses: actions/checkout@v4 - id: set_version run: | + # In order to publish Helm charts we need valid semantic version, so we get the latest release tag to prefix the version with. + git fetch --tags + + # Try to get the latest tag, fallback to 0.0.0 if no tag is found + LATEST_RELEASE=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") + BRANCH=$(echo $(git rev-parse --abbrev-ref HEAD) | tr -d '0123456789/.') - VERSION=dev-$BRANCH-$(git rev-parse --short HEAD) + VERSION=$LATEST_RELEASE-dev-$BRANCH-$(git rev-parse --short HEAD) echo "version=$VERSION" >> $GITHUB_OUTPUT echo "Set version to $VERSION" docker-release: diff --git a/.gitignore b/.gitignore index 2c09ac2..9965167 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,6 @@ _helm_sync_dir/ helm/Chart.yaml helm/values.yaml + +# Built binaries +cmd/idp-connect diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 56dc682..9252192 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -1,4 +1,4 @@ -## Development +# Development This is a development guide for users wanting to help contribute to the project. @@ -6,12 +6,107 @@ This is a development guide for users wanting to help contribute to the project. Before you begin, make sure you have all of the required tools installed: -``` +```sh ./env/validate-env.sh ``` -## TODO: +Add any new connector implementations to `cmd/idp-connect.go` so that they can become valid server options to start. + +## Keycloak + +You can test the manipulation of self-service clients using a dedicated realm in a Keycloak instance. Create a new realm using curl and the admin credentials using the examples below. + +First, create a token to access the Keycloak REST API. This is a short-lived token, so you may need to repeat this step later on: + +```sh +KEYCLOAK_URL=http://$(kubectl --context mgmt -n keycloak get service keycloak -o jsonpath='{.status.loadBalancer.ingress[0].*}'):8080 + +KEYCLOAK_TOKEN=$(curl -Ssm 10 --fail-with-body \ + -d "client_id=admin-cli" \ + -d "username=admin" \ + -d "password=admin" \ + -d "grant_type=password" \ + "$KEYCLOAK_URL/realms/master/protocol/openid-connect/token" | + jq -r .access_token) +``` + +Create the new realm: + +```sh +REALM=my-realm + +curl -Ssm 10 --fail-with-body -H "Authorization: Bearer ${KEYCLOAK_TOKEN}" -H "Content-Type: application/json" \ + -d '{ "realm": "'${REALM}'", "enabled": true }' \ + $KEYCLOAK_URL/admin/realms +``` + +You'll need to provision a client in this realm that permits service accounts and has permissions to manipulate self-service clients. For convenience, we'll also treat this client as a _resource server_ in which we will store API products as _resources_. You can create such a client like this: + +```sh +KEYCLOAK_CLIENT=gloo-portal + +# Create initial token to register the client +INITIAL_TOKEN=$(curl -Ssm 10 --fail-with-body -H "Authorization: Bearer ${KEYCLOAK_TOKEN}" -H "Content-Type: application/json" \ + -d '{ "expiration": 0, "count": 1 }' \ + $KEYCLOAK_URL/admin/realms/${REALM}/clients-initial-access | + jq -r .token) + +# Register the client +read -r KEYCLOAK_CLIENT_INTERNAL_ID KEYCLOAK_SECRET <<<$(curl -Ssm 10 --fail-with-body -H "Authorization: bearer ${INITIAL_TOKEN}" -H "Content-Type: application/json" \ + -d '{ "clientId": "'${KEYCLOAK_CLIENT}'", "name": "Solo.io Gloo Portal Resource Server" }' \ + ${KEYCLOAK_URL}/realms/${REALM}/clients-registrations/default | + jq -r '[.id, .secret] | @tsv') + +echo "Management client ID: ${KEYCLOAK_CLIENT}" +echo "Management client secret: ${KEYCLOAK_SECRET}" + +# Set up the client as we need +curl -Ssm 10 --fail-with-body -H "Authorization: Bearer ${KEYCLOAK_TOKEN}" -H "Content-Type: application/json" \ + -X PUT -d '{ "serviceAccountsEnabled": true, "authorizationServicesEnabled": true }' \ + ${KEYCLOAK_URL}/admin/realms/${REALM}/clients/${KEYCLOAK_CLIENT_INTERNAL_ID} + +# Get the internal ID of the client's service account user +SA_USER_ID=$(curl -Ssm 10 --fail-with-body -H "Authorization: Bearer ${KEYCLOAK_TOKEN}" -H "Content-Type: application/json" \ + ${KEYCLOAK_URL}/admin/realms/${REALM}/clients/${KEYCLOAK_CLIENT_INTERNAL_ID}/service-account-user | + jq -r .id) + +# Get the ID of the 'realm-management' client +REALM_MGMT_CLIENT_ID=$(curl -Ssm 10 --fail-with-body -H "Authorization: Bearer ${KEYCLOAK_TOKEN}" \ + "${KEYCLOAK_URL}/admin/realms/${REALM}/clients?clientId=realm-management" | + jq -r '.[].id') + +# Get the ID of the 'manage-clients' role +ROLE_ID=$(curl -Ssm 10 --fail-with-body -H "Authorization: Bearer ${KEYCLOAK_TOKEN}" -H "Content-Type: application/json" \ + ${KEYCLOAK_URL}/admin/realms/${REALM}/users/${SA_USER_ID}/role-mappings/clients/${REALM_MGMT_CLIENT_ID}/available | jq -r '.[] | select(.name=="manage-clients") | .id') + +# Add the 'manage-clients' role to the service account user +curl -Ssm 10 --fail-with-body -H "Authorization: Bearer ${KEYCLOAK_TOKEN}" -H "Content-Type: application/json" \ + -d '[ { "id": "'${ROLE_ID}'", "name": "manage-clients", "composite": false, "clientRole": true, "containerId": "'${REALM_MGMT_CLIENT_ID}'" } ]' \ + ${KEYCLOAK_URL}/admin/realms/${REALM}/users/${SA_USER_ID}/role-mappings/clients/${REALM_MGMT_CLIENT_ID} +``` + +The values of `KEYCLOAK_CLIENT` and `KEYCLOAK_SECRET` should be supplied to the Keycloak flavour of `idp-connect` at runtime (via `--client-id` and `--client-secret`) so that the service can obtain tokens and manipulate self-service clients on behalf of this management client. In the example used so far, you can start the service like this: + +```sh +./idp-connect keycloak --issuer ${KEYCLOAK_URL}/realms/${REALM} --client-id ${KEYCLOAK_CLIENT} --client-secret ${KEYCLOAK_SECRET} + ``` + +IDP Connect will use the token endpoint to obtain a token for the management client. You can replicate this for testing purposes like this: + +```sh +MGMT_TOKEN=$(curl -Ssm 10 --fail-with-body \ + -u ${KEYCLOAK_CLIENT}:${KEYCLOAK_SECRET} \ + -d "grant_type=urn:ietf:params:oauth:grant-type:uma-ticket" \ + -d "audience=${KEYCLOAK_CLIENT}" \ + ${KEYCLOAK_URL}/realms/${REALM}/protocol/openid-connect/token | + jq -r .access_token) + +# Test the token by listing the clients in the realm +curl -Ssm 10 --fail-with-body -H "Authorization: Bearer ${MGMT_TOKEN}" ${KEYCLOAK_URL}/admin/realms/${REALM}/clients | jq . +``` + +## TODO * Create middleware to handle login requests and responses and exposing those metrics via Prometheus metrics * Cognito - * Develop auth mechanism when Cognito is running in EKS, taking advantage of AWS IAM Role for Service Accounts \ No newline at end of file + * Develop auth mechanism when Cognito is running in EKS, taking advantage of AWS IAM Role for Service Accounts diff --git a/README.md b/README.md index d1e426b..563e599 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,30 @@ # IDP Connect -IDP Connect is an implementation of the Service Programming Interface Gloo Platform Portal uses in order to manage client credentials for accessing services in your Kubernetes Cluster. In Gloo Platform Portal, we use the concept of "Applications" to refer to the external applications accessing the API Products exposed via your Gloo Portal. When a user registers an application as an OAuth client, -it is the responsibility of the SPI to create the credential associated with that application. For more information, and to review key terms associated with Gloo Platform Portal, checkout out our documentation: [Gloo Portal Documentation](https://docs.solo.io/gloo-portal/latest/). +IDP Connect is an implementation of the Service Programming Interface Gloo Gateway Portal uses in order to manage client credentials for accessing services in your Kubernetes Cluster. In Gloo Gateway Portal, we use the concept of "Applications" to refer to the external applications accessing the API Products exposed via your Gloo Portal. When a user registers an application as an OAuth client, +it is the responsibility of the SPI to create the credential associated with that application. For more information, and to review key terms associated with Gloo Gateway Portal, checkout out our documentation: [Gloo Portal Documentation](https://docs.solo.io/gloo-portal/latest/). ## Supported Identity Providers Here is a list of Identity Providers that we currently support: * Amazon Cognito +* Keycloak + +## Configuration Instructions + +### Keycloak + +A Keycloak client must be created for the Keycloak IDP Connect service to use. Provide the ID and secret of this client in the `--client-id` and `--client-secret` IDP Connect arguments respectively. This client must meet some requirements: + +* The client must have the `manage-client` permission needed for IDP Connect to be able to manipulate self-service clients. +* **Authorization** must be enabled on this client, as this client will also act as an OAuth2 [resource server](https://www.keycloak.org/docs/latest/authorization_services/index.html#_resource_server_overview). +* **Service accounts roles** (or OAuth2 _client credentials_) must be enabled, to allow IDP Connect to use this client directly to manage other clients and resources. + +#### Related documentation + +* Keycloak's support for client registration: +* Resource authorization in Keycloak: +* IDP Connect will manipulate resources using Keycloak's Authorization Services, which is based on [User-Managed Access (UMA)](https://docs.kantarainitiative.org/uma/rec-uma-core.html) ## Production @@ -15,5 +32,6 @@ IDP Connect provides a straightforward and easy-to-setup way of configuring cred we expect that the needs of your system are and will evolve beyond the scope of this simple implementation. The SPI we provide provides a hook on top of which you can build a customizable system to service any number of more advanced use cases. TODO: Add information for devs + * Install tools -* (Potential) Allow for AWS IAM Roles for service accounts as cognito auth method. \ No newline at end of file +* (Potential) Allow for AWS IAM Roles for service accounts as cognito auth method. diff --git a/api/README.md b/api/README.md index fb107c5..ee7e48f 100644 --- a/api/README.md +++ b/api/README.md @@ -1,6 +1,6 @@ # Overview -This directory contains the API for Gloo Platform Portal IDP Connect. +This directory contains the API for Gloo Gateway Portal IDP Connect. ## Generation diff --git a/api/v1/openapi.yaml b/api/v1/openapi.yaml index bfddf71..f625b57 100644 --- a/api/v1/openapi.yaml +++ b/api/v1/openapi.yaml @@ -1,7 +1,7 @@ info: - title: 'Gloo Platform Portal IDP Connect API' + title: 'Gloo Gateway Portal IDP Connect API' version: 1.0.0 - description: Before you begin, set up Gloo Platform Portal with your OpenID Connect (OIDC) provider. Then with this IDP Connect API, you can manage the clients and API Products in your OIDC provider that get associated with your Portal applications. For more information, see the [Gloo Platform Portal docs](https://docs.solo.io/gloo-portal/latest/). + description: Before you begin, set up Gloo Gateway Portal with your OpenID Connect (OIDC) provider. Then with this IDP Connect API, you can manage the clients and API Products in your OIDC provider that get associated with your Portal applications. For more information, see the [Gloo Gateway Portal docs](https://docs.solo.io/gloo-portal/latest/). openapi: 3.0.0 servers: - url: https://api.gloo-platform-portal.com/v1 @@ -11,34 +11,24 @@ paths: description: Creates an application of type oauth2. This is intended to be integrated with an Open Id Connect Provider that the IDP Connect implementation integrates with. Note that the `clientSecret` is never stored in the database and is shown to the user only once. Keep this secret to make future requests to the API products in the Portal. operationId: CreateOAuthApplication requestBody: - description: (Required) name for creating name of the client. + description: (Required) Unique identifier for creating client. required: true content: application/json: schema: type: object required: - - name + - id properties: - name: + id: type: string - example: "example-user-pool-developer-1" + example: "a0897e6d0ea94f589c38278bca4e9342" responses: '201': content: application/json: schema: - type: object - properties: - clientId: - type: string - example: a0897e6d0ea94f589c38278bca4e9342 - clientSecret: - type: string - example: c94dbd582d594e8aa04934f9c7ef0f52 - clientName: - type: string - example: "example-user-pool-developer-1" + $ref: '#/components/schemas/OAuthApplication' description: Successfully created client. '400': description: Invalid input. @@ -74,7 +64,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/Error' + $ref: '#/components/schemas/Error' '500': description: Unexpected error deleting application. content: @@ -108,8 +98,8 @@ paths: apiProducts: type: array items: - type: string - example: "example-api-product" + type: string + example: "example-api-product" responses: '204': description: Successfully added API Product to application. @@ -174,6 +164,27 @@ paths: summary: Creates API Product in the OpenID Connect Provider. Then, you can add this API Product to the application for your Portal applications with the `PUT /applications/{id}/api-products` API request. tags: - API Products + get: + description: Get all API Products in the Open Id Connect Provider. The Portal uses the results to keep the API Products in the IdP in sync with Portal by creating and deleting as needed. + operationId: GetAPIProducts + responses: + '200': + description: Successfully retrieved API Products. + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ApiProduct' + '500': + description: Unexpected error retrieving API Products. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + summary: Get all API Products in the OpenID Connect Provider. + tags: + - API Products /api-products/{name}: delete: description: Deletes API Product in the Open Id Connect Provider for a given unique identifier. @@ -215,6 +226,20 @@ components: description: type: string example: "example API Product description" + OAuthApplication: + required: + - clientId + - clientSecret + properties: + clientId: + type: string + example: a0897e6d0ea94f589c38278bca4e9342 + clientSecret: + type: string + example: c94dbd582d594e8aa04934f9c7ef0f52 + clientName: + type: string + example: "example-user-pool-developer-1" Error: required: - code diff --git a/cmd/idp-connect.go b/cmd/idp-connect.go index 0ba497e..f5ef5e7 100644 --- a/cmd/idp-connect.go +++ b/cmd/idp-connect.go @@ -7,6 +7,7 @@ import ( "github.com/spf13/cobra" "github.com/solo-io/gloo-portal-idp-connect/internal/cognito" + "github.com/solo-io/gloo-portal-idp-connect/internal/keycloak" "github.com/solo-io/gloo-portal-idp-connect/internal/version" ) @@ -28,6 +29,7 @@ func rootCommand(ctx context.Context) *cobra.Command { cmd.AddCommand( cognito.Command(), + keycloak.Command(), ) return cmd diff --git a/docs/configuring-gloo-gateway.md b/docs/configuring-gloo-gateway.md new file mode 100644 index 0000000..c58bd46 --- /dev/null +++ b/docs/configuring-gloo-gateway.md @@ -0,0 +1,427 @@ +# OPA-based API Product Authorization with OAuth + +## AWS Cognito + +Below are the instructions for using AWS Cognito to manage client credentials and access tokens for your API Products and configure Gloo Gateway to authorize them. + +### Manual Bootstrap + +If not using the SPI and wanting to manually bootstrap AWS Cognito, follow these steps: + +1. Create a UserPool +1. Within the UserPool, go to “App Integration” +1. Create one or more “Resource Servers”, and for these Resource Servers, create scopes that map to the “apiProductId” (in this example, `tracks-rest-api` and `catstronauts-api`). +1. Create an “App client”, and configure it so it: + 1. Has a client-id and client-secret + 1. Can be used for “client credentials grant” OAuth flows. + 1. This can be configured in the “OAuth 2 Grant Types” section in app-client’s “Edit Hosted UI” submenu. + 1. In the same “Edit Hosted UI” submenu, give the app client access to one or more of the scopes we’ve configured earlier. + +### Obtaining an access-token + +To obtain an OAuth access-token from Cognito, we first need to determine its “token endpoint”. We can fetch that information from Cognito’s `.well-known/openid-configuration` endpoint: + +```sh +export AWS_COGNITO_REGION={your cognito region} +export AWS_COGNITO_USER_POOL_ID={your cognito user-pool id} + +curl https://cognito-idp.$AWS_COGNITO_REGION.amazonaws.com/$AWS_COGNITO_USER_POOL_ID/.well-known/openid-configuration +``` + +From the response, find the “token_endpoint”. We can now fetch a new access-token for our service account by using the client-credentials grant flow (note that in this example, we’re asking for 2 scopes, `access/catstronauts-api` and `access/tracks-rest-api` ) + +```yaml +export TOKEN_ENDPOINT={your cognito token endpoint} +export CLIENT_ID={your service account’s client-id} +export CLIENT_SECRET={your service account’s client-secret} + +curl -X POST -H "Accept: application/json" -H "Content-Type: application/x-www-form-urlencoded" \ +--data-urlencode "client_id=$CLIENT_ID" \ +--data-urlencode "client_secret=$CLIENT_SECRET" \ +--data-urlencode "grant_type=client_credentials" \ +--data-urlencode "scope=access/catstronauts-api access/tracks-rest-api" \ +$TOKEN_ENDPOINT +``` + +You will be granted an access-token (JWT), which decoded should look something like this: + +```json +[ + { + "kid": "toHqwIJt3ahSc6BbWdpabY6Han4psIwSbVfrI1Jod6I=", + "alg": "RS256" + }, + { + "sub": "s2ai3kk4j6vfun6po9747bi1g", + "token_use": "access", + "scope": "access/catstronauts-api access/tracks-rest-api", + "auth_time": 1699993949, + "iss": "https://cognito-idp.eu-north-1.amazonaws.com/eu-north-1_6GQrqVZAY", + "exp": 1699997549, + "iat": 1699993949, + "version": 2, + "jti": "44da9501-26b1-4dc7-8efb-235df5fc281d", + "client_id": "s1ai3kk5j6vfqn6pf9835bi1r" + } +] +``` + +## Keycloak + +### Set up the resource server, resources, clients and permissions + +The base requirement for API Product Authorization with Keycloak is a realm and a resource server. In the Keycloak administration UI, perform the following steps to create these: + +1. In the realm drop-down click **Create realm**. Give your realm a name and make sure it is **Enabled** +1. Within the realm, select _Clients_ and create a new client for the Gloo Portal. This will be the _resource server_ with which we associate API products as _resources_ + * Enable **Authorization** on this client, which allows it to act as a resource server + +If not using the SPI and wanting to manually bootstrap Keycloak, follow these additional steps: + +1. Under the client's _Authorization_ tab, select _Resources_ and then **Create resource** + * Give the resource a **Name** that exactly matches the API Product ID it represents + * Make sure **User-Managed access enabled** is selected + * Create a resource for each API product being protected +1. Create a new _Client_ for each application + * Enable **Direct access grants** so that the client can obtain tokens for users with their username and password + * Enable **Client authentication** so that you may retrieve the client secret for use in generating access tokens. +1. Back in the Gloo Portal client (resource server), authorise a test application (client) to one of the API products (resources) + 1. Click into the client's _Authorization_ tab, select _Policies_ and then **Create client policy** + 1. Select a policy type of "Client", give it the same name as the test application, and select the test application client from the list in the **Clients** field + 1. Save the new policy + 1. Return to the resource server's _Authorization_ tab, select _Resources_, find the API product you want to allow access to and click **Create permission** next to it + 1. Give the new permission a name, then select the policy you created in the previous step in the **Policies** field + 1. Save the new permission +1. Create a new _User_ for testing, and give it a password + +### Obtaining an access token + +To obtain an OAuth access token from Keycloak, we first need to determine its “token endpoint”. We can fetch that information from Keycloak's `.well-known/openid-configuration` endpoint: + +```sh +KEYCLOAK_URL= +REALM= + +TOKEN_ENDPOINT=$(curl ${KEYCLOAK_URL}/realms/${REALM}/.well-known/openid-configuration | jq -r .token_endpoint) +``` + +We can now fetch a new access token for our test user and one of the client applications: + +```sh +CLIENT_ID= +CLIENT_SECRET= +USER_NAME= +PASSWORD= + +USER_TOKEN=$(curl ${TOKEN_ENDPOINT} \ + -d "client_id=${CLIENT_ID}" -d "client_secret=${CLIENT_SECRET}" \ + -d "username=${USER_NAME}" -d "password=${PASSWORD}" \ + -d "grant_type=password" | + jq -r .access_token) +``` + +You will be granted an access token (JWT), which decoded should look something like this: + +```json +$ echo $USER_TOKEN | jwt decode - + +Token header +------------ +{ + "typ": "JWT", + "alg": "RS256", + "kid": "nZr3uOYfZT1tsdPqWYSfpJPykrlU6RMZNLcpGqH15DA" +} + +Token claims +------------ +{ + "acr": "1", + "aud": "account", + "azp": "${CLIENT_ID}", + "email": "user1@solo.io", + "email_verified": false, + "exp": 1713443354, + "family_name": "One", + "given_name": "User", + "iat": 1713443054, + "iss": "${KEYCLOAK_URL}/realms/${REALM}", + "jti": "e6c8494a-618a-44f5-9c40-503c503c42bd", + "name": "User One", + "preferred_username": "${USER_NAME}", + "realm_access": { + "roles": [ + "offline_access", + "uma_authorization", + "default-roles-my-realm" + ] + }, + "resource_access": { + "account": { + "roles": [ + "manage-account", + "manage-account-links", + "view-profile" + ] + } + }, + "scope": "profile email", + "session_state": "2dd78f5d-172c-47a5-b131-2e562d7a6533", + "sid": "2dd78f5d-172c-47a5-b131-2e562d7a6533", + "sub": "521f0859-1353-4d4f-b4a2-4c2d4aa654f4", + "typ": "Bearer" +} +``` + +Note that there are no scopes or permissions for the protected API products in this token. We'll be using Keycloak's Authorization Services, based on [User-Managed Access (UMA)](https://docs.kantarainitiative.org/uma/rec-uma-core.html), to subsequently authorise requests to API products. If you wish to obtain a _requesting party token_ (RPT) for this user to send with API calls, you can get one from the same endpoint using the given access token: + +```sh +RESOURCE_SERVER_ID= + +USER1_RPT=$(curl ${TOKEN_ENDPOINT} \ + -H "Authorization: Bearer ${USER_TOKEN}" \ + -d "grant_type=urn:ietf:params:oauth:grant-type:uma-ticket" \ + -d "audience=${RESOURCE_SERVER_ID}" | + jq -r .access_token) +``` + +## Configuring Gloo’s ExtAuthPolicy for your APIProduct/RouteTable + +In Gloo Gateway, your RouteTable defines your API Product. Below is an example of such an API Product for the Tracks API: + +```yaml +apiVersion: networking.gloo.solo.io/v2 +kind: RouteTable +metadata: + name: v1-tracks-rt + namespace: istio-gateway-ns + annotations: + cluster.solo.io/cluster: cluster-1 + labels: + portal: tracks-portal + api: tracks +spec: + hosts: + - "*" + virtualGateways: + - name: cluster-1-north-south-gw-443 + namespace: istio-gateway-ns + portalMetadata: + apiProductDisplayName: tracks REST API + apiProductId: tracks-rest-api + apiVersion: v1 + title: tracks v1 REST API + description: V1 REST API for tracks to retrieve data for tracks, authors and modules. + contact: example@solo.io + license: MIT + termsOfService: sample terms of service + lifecycle: development + customMetadata: + compatibility: backwards + http: + - name: tracks-api-v1 + labels: + apiProduct: tracks-v1 + matchers: + - uri: + prefix: /trackapi/v1/ + forwardTo: + pathRewrite: / + destinations: + - ref: + name: tracks-rest-api + namespace: tracks + port: + number: 5000 +``` + +In this RouteTable, notice the “apiProductId” in the Portal metadata which matches the name of one of our scopes (i.e. access/tracks-rest-api). This facilitates authorization using OPA as this will be available to the rules when they are evaluated. Note that this label is just an example and can be named anything. + +> **Note**: In order for the OPA policy to work, a dev portal must be enabled and an API Doc for the configured API Product must be generated. This is what triggers Gloo Gateway to add the necessary context to allow the OPA policy to validate which API Product the request is targeting. + +We can now create a ConfigMap with our OPA policies to only grant access to API Products when the access token corresponds to permissions to access the ApiProductId. How to implement this policy differs depending on the IDP in use: + +### Cognito + +Create an OPA policy config map as follows: + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: oauth-scope-apiproduct-opa-cm + namespace: gloo-mesh-addons + annotations: + cluster.solo.io/cluster: cluster-1 +data: + policy.rego: |- + package test + + default allow = false + + allow { + # Get the accessed ApiProductId from the metadata + filter_metadata := input.check_request.attributes.metadata_context.filter_metadata + apimanagement_metadata := filter_metadata["io.solo.gloo.apimanagement"] + api_product_id := apimanagement_metadata.api_product_id + + # Get the scopes from the access-token + scopes := split(input.state.jwtAccessToken.scope, " ") + # Split scopes to remove resource server prefix + scopeComponents := split(scopes[_], "/") + + scope := scopeComponents[1] + + # Ensure apiproduct and scopes are not empty + api_product_id != "" + scope != "" + + # Validate that we have a scope for this API Product + scope == api_product_id + } +``` + +Finally, we can apply the ExtAuthPolicy to our ApiProduct route(s) that performs JWT validation using the Cognito’s JSON Web Key Set (JWKS), which contains the public keys used to verify the validity of the token, and applies to OPA policy to perform Authorization checks: + +```yaml +apiVersion: security.policy.gloo.solo.io/v2 +kind: ExtAuthPolicy +metadata: + annotations: + cluster.solo.io/cluster: cluster-1 + name: tracks-v2-oauth + namespace: gloo-mesh-addons +spec: + applyToRoutes: + - route: + labels: + apiProduct: tracks-v1 + config: + server: + name: ext-auth-server + namespace: gloo-mesh-addons + cluster: cluster-1 + glooAuth: + configs: + - oauth2: + accessTokenValidation: + jwt: + remote_jwks: + url: https://cognito-idp.us-west-2.amazonaws.com/us-west-2_CngONp9kI/.well-known/jwks.json + - opaAuth: + modules: + - name: oauth-scope-apiproduct-opa-cm + namespace: gloo-mesh-addons + query: "data.test.allow == true" +``` + +Note that the url for remoteJwks url might be different for your Cognito instances. The location can also be found from Cognito’s `.well-known/openid-configuration` endpoint, from which we also fetched the token-endpoint earlier. + +### Keycloak + +Create a new OPA policy and store it in a ConfigMap. The policy below will check whether the access token is a _requesting party token_ (RPT) and, if it is, try to match the permissions against the requested API product. If the access token is not an RPT then the policy will call Keycloak directly to check if the user and client have permission to access the requested API product: + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: oauth-scope-apiproduct-opa-cm + namespace: gloo-mesh-addons +data: + policy.rego: |- + package test + + import future.keywords.if + import future.keywords.in + + resource_server_id := "${RESOURCE_SERVER_ID}" + + # Get the requested API product from the metadata + filter_metadata := input.check_request.attributes.metadata_context.filter_metadata + api_product_id := filter_metadata["io.solo.gloo.apimanagement"].api_product_id + + default allow := false + + allow if not api_product_id + + allow if api_product_id == "" + + allow if authorised_by_rpt + + allow if authorised_by_keycloak + + # Check if the token is an RPT and includes a permission to the requested API product + authorised_by_rpt if { + input.state.jwtAccessToken.aud == resource_server_id + some permission in input.state.jwtAccessToken.authorization.permissions + permission.rsname == api_product_id + } + + # Check if the user and client can access the API product with the authorisation server directly + authorised_by_keycloak if { + discovered_config := http.send({ + "url": concat("", [input.state.jwtAccessToken.iss, "/.well-known/uma2-configuration"]), + "method": "GET", + "force_cache": true, + "force_cache_duration_seconds": 86400, # Cache response for 24 hours + }).body + + authorisation_response := http.send({ + "url": discovered_config.token_endpoint, + "method": "POST", + "headers": { + "Authorization": input.http_request.headers["authorization"], + "Content-Type": "application/x-www-form-urlencoded", + }, + "raw_body": sprintf("grant_type=urn:ietf:params:oauth:grant-type:uma-ticket&audience=%v&response_mode=decision&permission=%v", [resource_server_id, api_product_id]), + }) + + authorisation_response.body.result + } +``` + +Finally, we can apply the ExtAuthPolicy to our ApiProduct route(s) that performs JWT validation using Keycloak’s JSON Web Key Set (JWKS), which contains the public keys used to verify the validity of the token, and applies to OPA policy to perform Authorization checks. Substitute the Keycloak URL and realm as needed: + +```yaml +apiVersion: security.policy.gloo.solo.io/v2 +kind: ExtAuthPolicy +metadata: + annotations: + cluster.solo.io/cluster: cluster-1 + name: tracks-v2-oauth + namespace: gloo-mesh-addons +spec: + applyToRoutes: + - route: + labels: + apiProduct: tracks-v1 + config: + server: + name: ext-auth-server + namespace: gloo-mesh-addons + cluster: cluster-1 + glooAuth: + configs: + - oauth2: + accessTokenValidation: + jwt: + remote_jwks: + url: ${KEYCLOAK_URL}/realms/${REALM}/protocol/openid-connect/certs + - opaAuth: + modules: + - name: oauth-scope-apiproduct-opa-cm + namespace: gloo-mesh-addons + query: "data.test.allow == true" +``` + +## Validation + +When we now call our service with the access-token we fetched earlier, we can see that we can access our service: + +```sh +export ACCESS_TOKEN={your access-token} + +curl -v -H "Authorization: Bearer $ACCESS_TOKEN" http://api.example.com/trackapi/v1/tracks +``` + +You can validate that the OPA authorization works as expected by fetching a new access token that does not contain the scope or permission needed to access this service, and try to access the service with that token. diff --git a/docs/configuring-gloo-platform.md b/docs/configuring-gloo-platform.md deleted file mode 100644 index 09c6626..0000000 --- a/docs/configuring-gloo-platform.md +++ /dev/null @@ -1,206 +0,0 @@ -# OPA-based API Product Authorization with OAuth - -## AWS Cognito - -Below are the instructions for using AWS Cognito to manage client credentials and access tokens for your API Products and configure Gloo Platform to authorize them. - -### Manual Bootstrap - -If not using the SPI and wanting to manually bootstrap AWS Cognito, follow these steps: - -1. Create a UserPool -1. Within the UserPool, go to “App Integration” -1. Create one or more “Resource Servers”, and for these Resource Servers, create scopes that map to the “apiProductId” (in this example, `tracks-rest-api` and `catstronauts-api`). -1. Create an “App client”, and configure it so it: - 1. Has a client-id and client-secret - 1. Can be used for “client credentials grant” OAuth flows. - 1. This can be configured in the “OAuth 2 Grant Types” section in app-client’s “Edit Hosted UI” submenu. - 1. In the same “Edit Hosted UI” submenu, give the app client access to one or more of the scopes we’ve configured earlier. - - -### Obtaining an access-token -To obtain an OAuth access-token from Cognito, we first need to determine it’s “token endpoint”. We can fetch that information from Cognito’s “.well-known/openid-configuration” endpoint: - -``` -export AWS_COGNITO_REGION={your cognito region} -export AWS_COGNITO_USER_POOL_ID={your cognito user-pool id} - -curl https://cognito-idp.$AWS_COGNITO_REGION.amazonaws.com/$AWS_COGNITO_USER_POOL_ID/.well-known/openid-configuration -``` - -From the response, find the “token_endpoint”. We can now fetch a new access-token for our service account by using the client-credentials grant flow (note that in this example, we’re asking for 2 scopes, `access/catstronauts-api` and `access/tracks-rest-api` ) - -``` -export TOKEN_ENDPOINT={your cognito token endpoint} -export CLIENT_ID={your service account’s client-id} -export CLIENT_SECRET={your service account’s client-secret} - -curl -X POST -H "Accept: application/json" -H "Content-Type: application/x-www-form-urlencoded" \ ---data-urlencode "client_id=$CLIENT_ID" \ ---data-urlencode "client_secret=$CLIENT_SECRET" \ ---data-urlencode "grant_type=client_credentials" \ ---data-urlencode "scope=access/catstronauts-api access/tracks-rest-api" \ -$TOKEN_ENDPOINT -``` - -You will be granted an access-token (JWT), which decoded should look something like this: - -``` -[ - { - "kid": "toHqwIJt3ahSc6BbWdpabY6Han4psIwSbVfrI1Jod6I=", - "alg": "RS256" - }, - { - "sub": "s2ai3kk4j6vfun6po9747bi1g", - "token_use": "access", - "scope": "access/catstronauts-api tracks-rest-api", - "auth_time": 1699993949, - "iss": "https://cognito-idp.eu-north-1.amazonaws.com/eu-north-1_6GQrqVZAY", - "exp": 1699997549, - "iat": 1699993949, - "version": 2, - "jti": "44da9501-26b1-4dc7-8efb-235df5fc281d", - "client_id": "s1ai3kk5j6vfqn6pf9835bi1r" - } -] -``` - -Configuring the Gloo’s ExtAuthPolicy for your APIProduct/RouteTable - -In Gloo Gateway, your RouteTable defines your API Product. Below is an example of such an API Product for the Tracks API: - -``` -apiVersion: networking.gloo.solo.io/v2 -kind: RouteTable -metadata: - name: v1-tracks-rt - namespace: istio-gateway-ns - annotations: - cluster.solo.io/cluster: cluster-1 - labels: - portal: tracks-portal - api: tracks -spec: - hosts: - - "*" - virtualGateways: - - name: cluster-1-north-south-gw-443 - namespace: istio-gateway-ns - portalMetadata: - apiProductDisplayName: tracks REST API - apiProductId: tracks-rest-api - apiVersion: v1 - title: tracks v1 REST API - description: V1 REST API for tracks to retrieve data for tracks, authors and modules. - contact: example@solo.io - license: MIT - termsOfService: sample terms of service - lifecycle: development - customMetadata: - compatibility: backwards - http: - - name: tracks-api-v1 - labels: - apiProduct: tracks-v1 - matchers: - - uri: - prefix: /trackapi/v1/ - forwardTo: - pathRewrite: / - destinations: - - ref: - name: tracks-rest-api - namespace: tracks - port: - number: 5000 -``` - - -In this RouteTable, notice the “apiProductId” in the Portal metadata which matches the name of one of our scopes (i.e. access/tracks-rest-api). This enables the authorization based on scopes using OPA. Note that this label is just an example and can be named anything. - -We can now create a ConfigMap with our OPA policies to only grant access to API Products when the access-token has a scope that matches the ApiProductId: - -``` -apiVersion: v1 -kind: ConfigMap -metadata: - name: oauth-scope-apiproduct-opa-cm - namespace: gloo-mesh-addons - annotations: - cluster.solo.io/cluster: cluster-1 -data: - policy.rego: |- - package test - - default allow = false - - allow { - # Get the accessed ApiProductId from the metadata - filter_metadata := input.check_request.attributes.metadata_context.filter_metadata - apimanagement_metadata := filter_metadata["io.solo.gloo.apimanagement"] - api_product_id := apimanagement_metadata.api_product_id - - # Get the scopes from the access-token - scopes := split(input.state.jwtAccessToken.scope, " ") - # Split scopes to remove resource server prefix - scopeComponents := split(scopes[_], "/") - - scope := scopeComponents[1] - - # Ensure apiproduct and scopes are not empty - api_product_id != "" - scope != "" - - # Validate that we have a scope for this API Product - scope == api_product_id - } -``` - -**Note**: In order for the OPA policy to work, a dev portal must be enabled and an API Doc for the configured API Product must be generated. This is what triggers Gloo Platform to add the necessary context to allow the OPA policy to validate which API Product the request is targeting. - -Finally, we can apply the ExtAuthPolicy to our ApiProduct route(s) that performs JWT validation using the Cognito’s JSON Web Key Set (JWKS), which contains the public keys used to verify the validity of the token, and applies to OPA policy to perform Authorization checks: - -``` -apiVersion: security.policy.gloo.solo.io/v2 -kind: ExtAuthPolicy -metadata: - annotations: - cluster.solo.io/cluster: cluster-1 - name: tracks-v2-oauth - namespace: gloo-mesh-addons -spec: - applyToRoutes: - - route: - labels: - apiProduct: tracks-v1 - config: - server: - name: ext-auth-server - namespace: gloo-mesh-addons - cluster: cluster-1 - glooAuth: - configs: - - oauth2: - accessTokenValidation: - jwt: - remote_jwks: - url: https://cognito-idp.us-west-2.amazonaws.com/us-west-2_CngONp9kI/.well-known/jwks.json - - opaAuth: - modules: - - name: oauth-scope-apiproduct-opa-cm - namespace: gloo-mesh-addons - query: "data.test.allow == true" -``` - -Note that the url for remoteJwks url might be different for your Cognito instances. The location can also be found from Cognito’s `.well-known/openid-configuration` endpoint, from which we also fetched the token-endpoint earlier. - -When we now call our service with the access-token we fetched earlier from Cognito, we can see that we can access our service: - -``` -export ACCESS_TOKEN={your cognito access-token} - -curl -v -H "Authorization: Bearer $ACCESS_TOKEN" http://api.example.com/trackapi/v1/tracks -``` - -You can validate that the OPA authorization works as expected by fetching a new access-token from Cognito that does not contain the scope needed to access this service and try to access the service with that token. diff --git a/docs/usage.md b/docs/usage.md index 5016683..1106d66 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -1,10 +1,11 @@ -## Use with Gloo Platform +# Use with Gloo Gateway -The idea behind the Service Programming Interface is to provide an interopability layer between Gloo Platform Portal and the IDP that the customer wants to use. In order to get the desired functionality, there are two flows that need to be implemented: IDP Configuration and Data Path Authorization. +The idea behind the Service Programming Interface is to provide an interopability layer between Gloo Gateway Portal and the IDP that the customer wants to use. In order to get the desired functionality, there are two flows that need to be implemented: IDP Configuration and Data Path Authorization. -### AWS Cognito Example +## AWS Cognito Example + +### IDP Configuration -#### IDP Configuration ![Create Client Flow](./images/create-client-flow.png) When an application is created in Gloo Portal, the SPI will be called to create the client representation in the SPI. The Client ID and Secret can then be used to create the access_token via Cognito's token endpoint. @@ -29,13 +30,62 @@ Once an Application and API Product is in the system, you can begin to authorize Once the API Product has been given access to the application, you can see that the custom scope is included in the client's "Hosted UI" section. -#### Data Path Authorization +### Data Path Authorization ![Obtaining Access Token](./images/retrieve-credentials.png) -Cognito's token endpoint can be used to retrieve the access token. See [Configuring Gloo Platform](./configuring-gloo-platform.md) for more information on how to use the access token to authorize requests to your API Products. +Cognito's token endpoint can be used to retrieve the access token. See [Configuring Gloo Gateway](./configuring-gloo-gateway.md) for more information on how to use the access token to authorize requests to your API Products. ![Data Path](./images/data-path.png) -The Access token can be used to authorize requests via ext-auth. The most convenient method would be to use OPA in order to match the scope of the access token against the `apiProductId` of the API Product. See [Configuring Gloo Platform](./configuring-gloo-platform.md) for more information on how to use the access token to authorize requests to your API Products. +The Access token can be used to authorize requests via ext-auth. The most convenient method would be to use OPA in order to match the scope of the access token against the `apiProductId` of the API Product. See [Configuring Gloo Gateway](./configuring-gloo-gateway.md) for more information on how to use the access token to authorize requests to your API Products. + +## Keycloak Example + +### IDP Configuration + +When an application is created in Gloo Portal, the SPI will be called to create the client representation in the SPI. This will result in a new _client_ created in the Keycloak realm, with an auto-generated client ID and secret returned to the caller. + +> **Note:** Clients created by the SPI use Keycloak's default settings, and no assumptions are made about how the client will manage and distribute tokens. It is left to the customer to decide how the created clients should be configured. + +When an API Product is created in Gloo Portal, the SPI will be called to create the representation in the IDP. For Keycloak, this would most likely be represented as a _resource_ managed by the _resource server_ client. For convenience, the default implementation assumes that the client being used by the SPI to manage applications is also the resource server that will manage API products. + +With at least one Application (client) and one API Product (resource) registered in Keycloak, you can begin to authorize API Products for particular applications. This can be represented in Keycloak as a _permission_ granted on the API product resource to the client application. + +Once the application has been given access to the API Product, the application can obtain a Requesting Party Token (RPT) from Keycloak's token endpoint, or the policy enforcement point (e.g. Gloo ext-auth) can interrogate Keycloak directly to validate that the user and application are authorised to the API Product. + +### Data Path Authorization + +Policy enforcement points (such as Gloo ext-auth) have at least two options for checking authorised access to protected API Products with the representations described above: + +* Validating a Requesting Party Token (RPT) obtained by the client, possibly via a [UMA Grant Flow](https://www.keycloak.org/docs/latest/authorization_services/#_service_uma_authorization_process) +* Directly checking permissions in Keycloak + +In either case, requests can be authorised via Gloo ext-auth. The most convenient method is to use OPA in order to match permissions against the `apiProductId` of the API Product. See [Configuring Gloo Gateway](./configuring-gloo-gateway.md) for an example of how to use the access token to authorize requests to your API Products. + +#### Requesting Party Token (RPT) + +Whether following the UMA Grant Flow or otherwise, the client will need to use Keycloak's token endpoint to obtain a RPT on the user's behalf. + +> **Note:** The resource server client ID (i.e. the client ID used by the SPI itself) must be specified as the `audience` in the token request for the correct permissions to be evaluated. This will be automatic if an RPT is obtained using a permission ticket in the UMA Grant Flow. + +The access token returned will contain a new `authorization` claim with permissions for the permitted API products: + +```json +"authorization": { + "permissions": [ + { + "rsid": "aa6edf59-a4b7-4532-b6b1-a5b423da7809", + "rsname": "tracks-rest-api" + } + ] +} +``` + +Requests to the API Product can then be (re-)tried using this new access token. + +See for more details on obtaining an RPT. + +## Direct permission check +Rather than having the client obtain an RPT with the authorised permissions, the policy enforcement point can request a decision from Keycloak based on the access token presented by the client. This approach is useful when clients do not support the UMA Grant Flow but has the downside of introducing an HTTP call to Keycloak as part of the authorisation process. diff --git a/go.mod b/go.mod index e2e875a..5075716 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/cognitoidentityprovider v1.28.0 github.com/aws/smithy-go v1.19.0 github.com/getkin/kin-openapi v0.118.0 + github.com/go-resty/resty/v2 v2.12.0 github.com/golang/mock v1.6.0 github.com/labstack/echo/v4 v4.11.4 github.com/oapi-codegen/echo-middleware v1.0.1 @@ -54,6 +55,7 @@ require ( github.com/imdario/mergo v0.3.6 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/invopop/yaml v0.2.0 // indirect + github.com/jarcoal/httpmock v1.3.1 github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/labstack/gommon v0.4.2 // indirect @@ -68,11 +70,11 @@ require ( github.com/pkg/errors v0.9.1 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect - golang.org/x/crypto v0.17.0 // indirect - golang.org/x/net v0.19.0 // indirect + golang.org/x/crypto v0.21.0 // indirect + golang.org/x/net v0.22.0 // indirect golang.org/x/oauth2 v0.12.0 // indirect - golang.org/x/sys v0.16.0 // indirect - golang.org/x/term v0.15.0 // indirect + golang.org/x/sys v0.18.0 // indirect + golang.org/x/term v0.18.0 // indirect golang.org/x/text v0.14.0 // indirect golang.org/x/time v0.5.0 // indirect golang.org/x/tools v0.16.1 // indirect diff --git a/go.sum b/go.sum index ba0287f..e9c962a 100644 --- a/go.sum +++ b/go.sum @@ -64,6 +64,9 @@ github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= github.com/go-openapi/swag v0.22.4 h1:QLMzNJnMGPRNDCbySlcj1x01tzU8/9LTTL9hZZZogBU= github.com/go-openapi/swag v0.22.4/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-resty/resty/v2 v2.11.0/go.mod h1:iiP/OpA0CkcL3IGt1O0+/SIItFUbkkyw5BGXiVdTu+A= +github.com/go-resty/resty/v2 v2.12.0 h1:rsVL8P90LFvkUYq/V5BTVe203WfRIU4gvcf+yfzJzGA= +github.com/go-resty/resty/v2 v2.12.0/go.mod h1:o0yGPrkS3lOe1+eFajk6kBW8ScXzwU3hD69/gt2yB/0= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= @@ -102,6 +105,8 @@ github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLf github.com/invopop/yaml v0.1.0/go.mod h1:2XuRLgs/ouIrW3XNzuNj7J3Nvu/Dig5MXvbCEdiBN3Q= github.com/invopop/yaml v0.2.0 h1:7zky/qH+O0DwAyoobXUqvVBwgBFRxKoQ/3FjcVpjTMY= github.com/invopop/yaml v0.2.0/go.mod h1:2XuRLgs/ouIrW3XNzuNj7J3Nvu/Dig5MXvbCEdiBN3Q= +github.com/jarcoal/httpmock v1.3.1 h1:iUx3whfZWVf3jT01hQTO/Eo5sAYtB2/rqaUuOtpInww= +github.com/jarcoal/httpmock v1.3.1/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg= github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= @@ -197,6 +202,7 @@ github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.25.0 h1:4Hvk6GtkucQ790dqmj7l1eEnRdKm3k3ZUrUMS2d5+5c= @@ -204,27 +210,44 @@ go.uber.org/zap v1.25.0/go.mod h1:JIAUzQIH94IC4fOJQm7gMmBJP5k7wQfdcnYdPoEXJYk= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= +golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e h1:+WEEuIdZHnUeJJmEUjyYC2gfUMj69yZXw17EnHg/otA= golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e/go.mod h1:Kr81I6Kryrl9sr8s2FK3vxD90NdsKWRuOIl2O4CvYbA= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc= +golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= golang.org/x/oauth2 v0.12.0 h1:smVPGxink+n1ZI5pkQa8y6fZT0RW0MgCO5bFpepy4B4= golang.org/x/oauth2 v0.12.0/go.mod h1:A74bZ3aGXgCY0qaIC9Ahg6Lglin4AMAco8cIv9baba4= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -232,18 +255,39 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4= golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8= +golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -251,6 +295,8 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.16.1 h1:TLyB3WofjdOEepBHAU20JdNC1Zbg87elYofWYAY5oZA= golang.org/x/tools v0.16.1/go.mod h1:kYVVN6I1mBNoB1OX+noeBjbRk4IUEPa7JJ+TJMEooJ0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/helm/Chart-template.yaml b/helm/Chart-template.yaml index 0a8cebc..1e49d3d 100644 --- a/helm/Chart-template.yaml +++ b/helm/Chart-template.yaml @@ -1,5 +1,5 @@ apiVersion: v1 name: gloo-portal-idp-connect -description: Gloo Platform Portal IDP Connect sample implementation +description: Gloo Gateway Portal IDP Connect sample implementation type: application version: %version% diff --git a/helm/templates/_helpers.tpl b/helm/templates/_helpers.tpl index c19c538..7e6e7de 100644 --- a/helm/templates/_helpers.tpl +++ b/helm/templates/_helpers.tpl @@ -15,5 +15,11 @@ gloo-portal-idp-connect args command - --port=8080 - --user-pool-id={{ .Values.cognito.userPoolId }} - --resource-server={{ .Values.cognito.resourceServer }} +{{- else if eq .Values.connector "keycloak"}} + - keycloak + - --port=8080 + - --issuer={{ .Values.keycloak.realm }} + - --client-id={{ .Values.keycloak.mgmtClientId }} + - --client-secret={{ .Values.keycloak.mgmtClientSecret }} {{- end }} {{- end }} diff --git a/helm/values-template.yaml b/helm/values-template.yaml index 41b445b..b4e4c2f 100644 --- a/helm/values-template.yaml +++ b/helm/values-template.yaml @@ -7,7 +7,7 @@ image: service: # Port for IDP Connect service to listen on. This is also the port the service will be configured to listen on. port: 80 -# Connector to use in IDP connect sample. Supported connectors are: 'cognito' +# Connector to use in IDP connect sample. Supported connectors are: 'cognito' and 'keycloak' connector: cognito # Configuration for the cognito connector cognito: @@ -26,6 +26,14 @@ cognito: secretAccessKey: "" # AWS session token sessionToken: "" +# Configuration for the keycloak connector +keycloak: + # (Required) Keycloak issuer URL (e.g. https://keycloak.example.com/realms/my-org) + realm: "" + # (Required) ID of the Keycloak client that is authorised to manage app clients + mgmtClientId: "" + # (Required) Secret of the Keycloak client that is authorised to manage app clients + mgmtClientSecret: "" resources: container: limit: diff --git a/internal/cognito/server/handler.go b/internal/cognito/server/handler.go index 8e5a3d0..fd6b632 100644 --- a/internal/cognito/server/handler.go +++ b/internal/cognito/server/handler.go @@ -97,13 +97,13 @@ func (s *StrictServerHandler) CreateOAuthApplication( ctx context.Context, request portalv1.CreateOAuthApplicationRequestObject, ) (portalv1.CreateOAuthApplicationResponseObject, error) { - if request.Body == nil || len(request.Body.Name) == 0 { - return portalv1.CreateOAuthApplication400JSONResponse(newPortal400Error("client name is required")), nil + if request.Body == nil || len(request.Body.Id) == 0 { + return portalv1.CreateOAuthApplication400JSONResponse(newPortal400Error("unique id is required")), nil } out, err := s.cognitoClient.CreateUserPoolClient(ctx, &cognito.CreateUserPoolClientInput{ UserPoolId: &s.userPool, - ClientName: aws.String(request.Body.Name), + ClientName: aws.String(request.Body.Id), GenerateSecret: true, }) @@ -112,9 +112,9 @@ func (s *StrictServerHandler) CreateOAuthApplication( } return portalv1.CreateOAuthApplication201JSONResponse{ - ClientId: out.UserPoolClient.ClientId, - ClientSecret: out.UserPoolClient.ClientSecret, - ClientName: aws.String(request.Body.Name), + ClientId: *out.UserPoolClient.ClientId, + ClientSecret: *out.UserPoolClient.ClientSecret, + ClientName: aws.String(request.Body.Id), }, nil } @@ -266,6 +266,13 @@ func (s *StrictServerHandler) CreateAPIProduct( return portalv1.CreateAPIProduct201Response{}, nil } +func (s *StrictServerHandler) GetAPIProducts( + _ context.Context, + _ portalv1.GetAPIProductsRequestObject, +) (portalv1.GetAPIProductsResponseObject, error) { + panic("implement me") +} + func unwrapCognitoError(err error) portalv1.Error { var notFoundErr *types.ResourceNotFoundException if ok := errors.As(err, ¬FoundErr); ok { diff --git a/internal/cognito/server/handler_test.go b/internal/cognito/server/handler_test.go index bdf01b2..2e8dcb5 100644 --- a/internal/cognito/server/handler_test.go +++ b/internal/cognito/server/handler_test.go @@ -26,10 +26,11 @@ const ( var _ = Describe("Server", func() { var ( - s *server.StrictServerHandler - mockCtrl *gomock.Controller - mockCognitoClient *mock_server.MockCognitoClient - ctx context.Context + s *server.StrictServerHandler + mockCtrl *gomock.Controller + mockCognitoClient *mock_server.MockCognitoClient + ctx context.Context + applicationClientId = "client-internal-id" ) BeforeEach(func() { @@ -81,16 +82,15 @@ var _ = Describe("Server", func() { ) }) It("can create a client", func() { - client := "test-client" resp, err := s.CreateOAuthApplication(ctx, portalv1.CreateOAuthApplicationRequestObject{ Body: &portalv1.CreateOAuthApplicationJSONRequestBody{ - Name: client, + Id: applicationClientId, }, }) Expect(err).NotTo(HaveOccurred()) Expect(resp).To(BeAssignableToTypeOf(portalv1.CreateOAuthApplication201JSONResponse{})) resp200 := resp.(portalv1.CreateOAuthApplication201JSONResponse) - Expect(*resp200.ClientName).To(Equal(client)) + Expect(*resp200.ClientName).To(Equal(applicationClientId)) Expect(resp200.ClientId).NotTo(BeNil()) Expect(resp200.ClientSecret).NotTo(BeNil()) }) @@ -101,10 +101,10 @@ var _ = Describe("Server", func() { Expect(resp).To(BeAssignableToTypeOf(portalv1.CreateOAuthApplication400JSONResponse{})) }) - It("returns error code on empty client name", func() { + It("returns an error code on empty client id", func() { resp, err := s.CreateOAuthApplication(ctx, portalv1.CreateOAuthApplicationRequestObject{ Body: &portalv1.CreateOAuthApplicationJSONRequestBody{ - Name: "", + Id: "", }, }) Expect(err).NotTo(HaveOccurred()) diff --git a/internal/cognito/server/mock/cognito_client.go b/internal/cognito/server/mock/cognito_client.go index 84616b8..2fd2db8 100644 --- a/internal/cognito/server/mock/cognito_client.go +++ b/internal/cognito/server/mock/cognito_client.go @@ -6,36 +6,35 @@ package mock_server import ( context "context" - reflect "reflect" - cognitoidentityprovider "github.com/aws/aws-sdk-go-v2/service/cognitoidentityprovider" gomock "github.com/golang/mock/gomock" + reflect "reflect" ) -// MockCognitoClient is a mock of CognitoClient interface. +// MockCognitoClient is a mock of CognitoClient interface type MockCognitoClient struct { ctrl *gomock.Controller recorder *MockCognitoClientMockRecorder } -// MockCognitoClientMockRecorder is the mock recorder for MockCognitoClient. +// MockCognitoClientMockRecorder is the mock recorder for MockCognitoClient type MockCognitoClientMockRecorder struct { mock *MockCognitoClient } -// NewMockCognitoClient creates a new mock instance. +// NewMockCognitoClient creates a new mock instance func NewMockCognitoClient(ctrl *gomock.Controller) *MockCognitoClient { mock := &MockCognitoClient{ctrl: ctrl} mock.recorder = &MockCognitoClientMockRecorder{mock} return mock } -// EXPECT returns an object that allows the caller to indicate expected use. +// EXPECT returns an object that allows the caller to indicate expected use func (m *MockCognitoClient) EXPECT() *MockCognitoClientMockRecorder { return m.recorder } -// CreateResourceServer mocks base method. +// CreateResourceServer mocks base method func (m *MockCognitoClient) CreateResourceServer(arg0 context.Context, arg1 *cognitoidentityprovider.CreateResourceServerInput, arg2 ...func(*cognitoidentityprovider.Options)) (*cognitoidentityprovider.CreateResourceServerOutput, error) { m.ctrl.T.Helper() varargs := []interface{}{arg0, arg1} @@ -48,14 +47,14 @@ func (m *MockCognitoClient) CreateResourceServer(arg0 context.Context, arg1 *cog return ret0, ret1 } -// CreateResourceServer indicates an expected call of CreateResourceServer. +// CreateResourceServer indicates an expected call of CreateResourceServer func (mr *MockCognitoClientMockRecorder) CreateResourceServer(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0, arg1}, arg2...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateResourceServer", reflect.TypeOf((*MockCognitoClient)(nil).CreateResourceServer), varargs...) } -// CreateUserPoolClient mocks base method. +// CreateUserPoolClient mocks base method func (m *MockCognitoClient) CreateUserPoolClient(arg0 context.Context, arg1 *cognitoidentityprovider.CreateUserPoolClientInput, arg2 ...func(*cognitoidentityprovider.Options)) (*cognitoidentityprovider.CreateUserPoolClientOutput, error) { m.ctrl.T.Helper() varargs := []interface{}{arg0, arg1} @@ -68,14 +67,14 @@ func (m *MockCognitoClient) CreateUserPoolClient(arg0 context.Context, arg1 *cog return ret0, ret1 } -// CreateUserPoolClient indicates an expected call of CreateUserPoolClient. +// CreateUserPoolClient indicates an expected call of CreateUserPoolClient func (mr *MockCognitoClientMockRecorder) CreateUserPoolClient(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0, arg1}, arg2...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateUserPoolClient", reflect.TypeOf((*MockCognitoClient)(nil).CreateUserPoolClient), varargs...) } -// DeleteUserPoolClient mocks base method. +// DeleteUserPoolClient mocks base method func (m *MockCognitoClient) DeleteUserPoolClient(arg0 context.Context, arg1 *cognitoidentityprovider.DeleteUserPoolClientInput, arg2 ...func(*cognitoidentityprovider.Options)) (*cognitoidentityprovider.DeleteUserPoolClientOutput, error) { m.ctrl.T.Helper() varargs := []interface{}{arg0, arg1} @@ -88,14 +87,14 @@ func (m *MockCognitoClient) DeleteUserPoolClient(arg0 context.Context, arg1 *cog return ret0, ret1 } -// DeleteUserPoolClient indicates an expected call of DeleteUserPoolClient. +// DeleteUserPoolClient indicates an expected call of DeleteUserPoolClient func (mr *MockCognitoClientMockRecorder) DeleteUserPoolClient(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0, arg1}, arg2...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteUserPoolClient", reflect.TypeOf((*MockCognitoClient)(nil).DeleteUserPoolClient), varargs...) } -// DescribeResourceServer mocks base method. +// DescribeResourceServer mocks base method func (m *MockCognitoClient) DescribeResourceServer(arg0 context.Context, arg1 *cognitoidentityprovider.DescribeResourceServerInput, arg2 ...func(*cognitoidentityprovider.Options)) (*cognitoidentityprovider.DescribeResourceServerOutput, error) { m.ctrl.T.Helper() varargs := []interface{}{arg0, arg1} @@ -108,14 +107,14 @@ func (m *MockCognitoClient) DescribeResourceServer(arg0 context.Context, arg1 *c return ret0, ret1 } -// DescribeResourceServer indicates an expected call of DescribeResourceServer. +// DescribeResourceServer indicates an expected call of DescribeResourceServer func (mr *MockCognitoClientMockRecorder) DescribeResourceServer(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0, arg1}, arg2...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DescribeResourceServer", reflect.TypeOf((*MockCognitoClient)(nil).DescribeResourceServer), varargs...) } -// UpdateResourceServer mocks base method. +// UpdateResourceServer mocks base method func (m *MockCognitoClient) UpdateResourceServer(arg0 context.Context, arg1 *cognitoidentityprovider.UpdateResourceServerInput, arg2 ...func(*cognitoidentityprovider.Options)) (*cognitoidentityprovider.UpdateResourceServerOutput, error) { m.ctrl.T.Helper() varargs := []interface{}{arg0, arg1} @@ -128,14 +127,14 @@ func (m *MockCognitoClient) UpdateResourceServer(arg0 context.Context, arg1 *cog return ret0, ret1 } -// UpdateResourceServer indicates an expected call of UpdateResourceServer. +// UpdateResourceServer indicates an expected call of UpdateResourceServer func (mr *MockCognitoClientMockRecorder) UpdateResourceServer(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0, arg1}, arg2...) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateResourceServer", reflect.TypeOf((*MockCognitoClient)(nil).UpdateResourceServer), varargs...) } -// UpdateUserPoolClient mocks base method. +// UpdateUserPoolClient mocks base method func (m *MockCognitoClient) UpdateUserPoolClient(arg0 context.Context, arg1 *cognitoidentityprovider.UpdateUserPoolClientInput, arg2 ...func(*cognitoidentityprovider.Options)) (*cognitoidentityprovider.UpdateUserPoolClientOutput, error) { m.ctrl.T.Helper() varargs := []interface{}{arg0, arg1} @@ -148,7 +147,7 @@ func (m *MockCognitoClient) UpdateUserPoolClient(arg0 context.Context, arg1 *cog return ret0, ret1 } -// UpdateUserPoolClient indicates an expected call of UpdateUserPoolClient. +// UpdateUserPoolClient indicates an expected call of UpdateUserPoolClient func (mr *MockCognitoClientMockRecorder) UpdateUserPoolClient(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() varargs := append([]interface{}{arg0, arg1}, arg2...) diff --git a/internal/keycloak/keycloak.go b/internal/keycloak/keycloak.go new file mode 100644 index 0000000..3b3fcd6 --- /dev/null +++ b/internal/keycloak/keycloak.go @@ -0,0 +1,27 @@ +package keycloak + +import ( + "context" + + "github.com/spf13/cobra" + + "github.com/solo-io/gloo-portal-idp-connect/internal/keycloak/server" +) + +func Command() *cobra.Command { + serverOpts := &server.Options{} + + cmd := &cobra.Command{ + Short: "Start the Keycloak IDP connector", + Use: "keycloak", + RunE: func(cmd *cobra.Command, args []string) error { + return server.ListenAndServe(context.Background(), serverOpts) + }, + // option to silence usage when an error occurs + SilenceUsage: true, + } + + serverOpts.AddToFlags(cmd.Flags()) + + return cmd +} diff --git a/internal/keycloak/server/handler.go b/internal/keycloak/server/handler.go new file mode 100644 index 0000000..5917ec1 --- /dev/null +++ b/internal/keycloak/server/handler.go @@ -0,0 +1,445 @@ +package server + +import ( + "context" + "errors" + "fmt" + "regexp" + "slices" + "strings" + "time" + + resty "github.com/go-resty/resty/v2" + portalv1 "github.com/solo-io/gloo-portal-idp-connect/pkg/api/v1" +) + +type StrictServerHandler struct { + restClient resty.Client + issuer string + discoveredEndpoints DiscoveredEndpoints + adminRoot string + mgmtClientId string + mgmtClientSecret string +} + +type KeycloakToken struct { + AccessToken string `json:"access_token"` +} + +type KeycloakClient struct { + Id string `json:"id"` + Name string `json:"name"` + Secret string `json:"secret"` +} + +type Permission struct { + Id string `json:"id"` + Name string `json:"name"` + Clients []string `json:"clients"` +} + +type KeycloakError struct { + Error string `json:"error"` + Description string `json:"error_description"` +} + +func NewStrictServerHandler(opts *Options, restyClient *resty.Client, discoveredEndpoints DiscoveredEndpoints) *StrictServerHandler { + r := regexp.MustCompile("^(https?:.*?)/realms/(.[^/]*)/?$") + adminRoot := r.ReplaceAllString(opts.Issuer, "$1/admin/realms/$2") + + var token *KeycloakToken + var tokenRefreshed time.Time + + restyClient.OnBeforeRequest(func(c *resty.Client, r *resty.Request) error { + // If we already have user info, assume this is a request to fetch a token + if r.UserInfo != nil { + return nil + } + + // Reuse the last token if we got it less than a minute ago + if token == nil || time.Since(tokenRefreshed).Seconds() > 60 { + tokenResponse, err := c.R(). + SetBasicAuth(opts.MgmtClientId, opts.MgmtClientSecret). + SetFormData(map[string]string{ + "grant_type": "urn:ietf:params:oauth:grant-type:uma-ticket", + "audience": opts.MgmtClientId, + }). + SetResult(&token). + SetError(&KeycloakError{}). + Post(discoveredEndpoints.Tokens) + + tokenRefreshed = time.Now() + + if err != nil { + return err + } + + if tokenResponse.IsError() { + error := tokenResponse.Error().(*KeycloakError) + return fmt.Errorf("could not obtain token for client %s: [%s] %s", opts.MgmtClientId, error.Error, error.Description) + } + } + + r.SetAuthToken(token.AccessToken) + r.SetError(&KeycloakError{}) + + return nil + }) + + return &StrictServerHandler{ + restClient: *restyClient, + issuer: opts.Issuer, + discoveredEndpoints: discoveredEndpoints, + adminRoot: adminRoot, + mgmtClientId: opts.MgmtClientId, + mgmtClientSecret: opts.MgmtClientSecret, + } +} + +// CreateOAuthApplication creates a client in Keycloak +func (s *StrictServerHandler) CreateOAuthApplication( + _ context.Context, + request portalv1.CreateOAuthApplicationRequestObject, +) (portalv1.CreateOAuthApplicationResponseObject, error) { + if request.Body == nil || len(request.Body.Id) == 0 { + return portalv1.CreateOAuthApplication400JSONResponse(newPortal400Error("unique id is required")), nil + } + + var createdClient KeycloakClient + + resp, err := s.restClient.R(). + SetBody(map[string]interface{}{ + "clientId": request.Body.Id, + "name": request.Body.Id, + }). + SetResult(&createdClient). + Post(s.issuer + "/clients-registrations/default") + + if err != nil || resp.IsError() { + return portalv1.CreateOAuthApplication500JSONResponse(unwrapError(resp, err)), nil + } + + return portalv1.CreateOAuthApplication201JSONResponse{ + ClientId: createdClient.Name, + ClientName: &createdClient.Name, + ClientSecret: createdClient.Secret, + }, nil +} + +// DeleteApplication deletes a client by ID. +func (s *StrictServerHandler) DeleteApplication( + _ context.Context, + request portalv1.DeleteApplicationRequestObject, +) (portalv1.DeleteApplicationResponseObject, error) { + if len(request.Id) == 0 { + return portalv1.DeleteApplication404JSONResponse(newPortal400Error("client ID is required")), nil + } + + // Get the Keycloak internal ID of the client + var clients []KeycloakClient + getId, err := s.restClient.R(). + SetQueryParams(map[string]string{ + "clientId": request.Id, + }). + SetResult(&clients). + Get(s.adminRoot + "/clients") + + if err != nil || getId.IsError() { + return portalv1.DeleteApplication500JSONResponse(unwrapError(getId, err)), nil + } + + if len(clients) == 0 { + return portalv1.DeleteApplication404JSONResponse(newPortal400Error("no client matches name [" + request.Id + "]")), nil + } + + if len(clients) > 1 { + // If we get this then we're not looking up the ID properly + return portalv1.DeleteApplication500JSONResponse(newPortal500Error("more than one matching client found for [" + request.Id + "]")), nil + } + + // Delete the client with the single ID we located + resp, err := s.restClient.R(). + Delete(s.adminRoot + "/clients/" + clients[0].Id) + + if err != nil || resp.IsError() { + switch portalErr := unwrapError(resp, err); portalErr.Code { + case 404: + return portalv1.DeleteApplication404JSONResponse(portalErr), nil + default: + return portalv1.DeleteApplication500JSONResponse(portalErr), nil + } + } + + return portalv1.DeleteApplication204Response{}, nil +} + +// UpdateAppAPIProducts updates resources for a client in Keycloak. +func (s *StrictServerHandler) UpdateAppAPIProducts( + ctx context.Context, + request portalv1.UpdateAppAPIProductsRequestObject, +) (portalv1.UpdateAppAPIProductsResponseObject, error) { + if len(request.Id) == 0 { + return portalv1.UpdateAppAPIProducts400JSONResponse(newPortal400Error("client ID is required")), nil + } + if request.Body == nil { + return portalv1.UpdateAppAPIProducts400JSONResponse(newPortal400Error("request body is required")), nil + } + + // Make sure the client exists + var clients []KeycloakClient + getClient, err := s.restClient.R(). + SetQueryParams(map[string]string{ + "clientId": request.Id, + }). + SetResult(&clients). + Get(s.adminRoot + "/clients") + + if err != nil || getClient.IsError() { + return portalv1.UpdateAppAPIProducts500JSONResponse(unwrapError(getClient, err)), nil + } + + if len(clients) == 0 { + return portalv1.UpdateAppAPIProducts404JSONResponse(newPortal400Error("no client matches name [" + request.Id + "]")), nil + } + + if len(clients) > 1 { + // If we get this then we're not looking up the ID properly + return portalv1.UpdateAppAPIProducts500JSONResponse(newPortal500Error("more than one matching client found for [" + request.Id + "]")), nil + } + + // We need the internal IDs of the API resources before we can associate them with permissions + var resourceIds = make(map[string]string) + + for _, api := range request.Body.ApiProducts { + var matchingResourceIds []string + + getId, err := s.restClient.R(). + SetQueryParams(map[string]string{ + "name": api, + "exactName": "true", + }). + SetResult(&matchingResourceIds). + Get(s.discoveredEndpoints.ResourceRegistration) + + if err != nil || getId.IsError() { + return portalv1.UpdateAppAPIProducts500JSONResponse(unwrapError(getId, err)), nil + } + + if len(matchingResourceIds) == 0 { + return portalv1.UpdateAppAPIProducts400JSONResponse(newPortal400Error("no resource matches name [" + api + "]")), nil + } + + if len(matchingResourceIds) > 1 { + // Keycloak enforces unique names, so if we get this then we're not looking up the ID properly + return portalv1.UpdateAppAPIProducts500JSONResponse(newPortal500Error("more than one matching resource found for [" + api + "]")), nil + } + + resourceIds[api] = matchingResourceIds[0] + } + + // Get all the existing permissions, so we can filter by those that are just for the given client + var allPermissions []Permission + getPermissions, err := s.restClient.R(). + SetResult(&allPermissions). + Get(s.discoveredEndpoints.Policy) + + if err != nil || getPermissions.IsError() { + return portalv1.UpdateAppAPIProducts500JSONResponse(unwrapError(getPermissions, err)), nil + } + + // Filter the permissions to only those that are for the client + clientPermissions := make(map[string]Permission) + for _, permission := range allPermissions { + // We only care for permissions that are for the client. + if slices.Contains(permission.Clients, request.Id) { + clientPermissions[permission.Name] = permission + } + } + + permissionsToCreate := make(map[string]string) + var permissionsToDelete []string + + // Get all the permissions to delete. + // We know which permissions need to be deleted if they exist in the clientPermissions map but not found in the passed in resourceIds, which should have the complete list of resources the client should have access to. + for _, permission := range clientPermissions { + // The permission ID is in the format of "/", so we extract the apiName by removing the `/` from the beginning of the string. + // To avoid any possibility of null-pointer hits, we check if the string starts with the client ID before removing it. + if strings.HasPrefix(permission.Name, request.Id+"/") { + // ResourceIds is a map of apiName -> resourceId, so we get the api name from the permission ID and check if it exists in the resourceIds map. If not, we add it to the list of permissions to delete. + if _, ok := resourceIds[GetApiNameFromPermission(permission.Name, request.Id)]; !ok { + permissionsToDelete = append(permissionsToDelete, permission.Id) + } + } + } + + // Get all the permissions to create. + // We know which permissions need to be created if they exist in the passed in resourceIds but not found in the clientPermissions map. + for resourceName, resourceId := range resourceIds { + // If the client doesn't have a permission for the resource, we need to create one. + // Permission IDs are in the format of "/", so we check if the client has a permission for the resource by checking if the permission ID exists in the clientPermissions map. If not, we add it to the list of permissions to create. + if _, ok := clientPermissions[PermissionName(request.Id, resourceName)]; !ok { + permissionsToCreate[resourceName] = resourceId + } + } + + // Delete the permissions that are no longer needed + for _, permissionId := range permissionsToDelete { + deletePermission, err := s.restClient.R(). + Delete(s.discoveredEndpoints.Policy + "/" + permissionId) + + if err != nil || deletePermission.IsError() { + return portalv1.UpdateAppAPIProducts500JSONResponse(unwrapError(deletePermission, err)), nil + } + } + + // Create the new permissions that are needed + for resourceName, resourceId := range permissionsToCreate { + newPermission, err := s.restClient.R(). + SetBody(map[string]interface{}{ + "name": PermissionName(request.Id, resourceName), + "description": resourceName + " access for client " + request.Id, + "clients": [1]string{request.Id}, + }). + Post(s.discoveredEndpoints.Policy + "/" + resourceId) + + if err != nil || newPermission.IsError() { + return portalv1.UpdateAppAPIProducts500JSONResponse(unwrapError(newPermission, err)), nil + } + } + + return portalv1.UpdateAppAPIProducts204Response{}, nil +} + +// CreateAPIProduct creates resources in Keycloak +func (s *StrictServerHandler) CreateAPIProduct( + ctx context.Context, + request portalv1.CreateAPIProductRequestObject, +) (portalv1.CreateAPIProductResponseObject, error) { + if request.Body == nil { + return portalv1.CreateAPIProduct400JSONResponse(newPortal400Error("request body is required")), nil + } + + resp, err := s.restClient.R(). + SetBody(map[string]interface{}{ + "name": request.Body.ApiProduct.Name, + "displayName": request.Body.ApiProduct.Description, + "ownerManagedAccess": "true", + }). + Post(s.discoveredEndpoints.ResourceRegistration) + + if err != nil || resp.IsError() { + switch portalErr := unwrapError(resp, err); portalErr.Code { + case 409: + return portalv1.CreateAPIProduct409JSONResponse(portalErr), nil + default: + return portalv1.CreateAPIProduct500JSONResponse(portalErr), nil + } + } + + return portalv1.CreateAPIProduct201Response{}, nil +} + +// GetAPIProducts retrieves the list of ApiProduct resources from Keycloak +func (s *StrictServerHandler) GetAPIProducts( + _ context.Context, + _ portalv1.GetAPIProductsRequestObject, +) (portalv1.GetAPIProductsResponseObject, error) { + var resourceIds []string + resp, err := s.restClient.R(). + SetResult(&resourceIds). + Get(s.discoveredEndpoints.ResourceRegistration) + + if err != nil || resp.IsError() { + return portalv1.GetAPIProducts500JSONResponse(unwrapError(resp, err)), nil + } + + var apiProducts []portalv1.ApiProduct + + // from the resourceIds, we need to get the names of the resources + for _, resourceId := range resourceIds { + var resource map[string]interface{} + + getResource, err := s.restClient.R(). + SetResult(&resource). + Get(s.discoveredEndpoints.ResourceRegistration + "/" + resourceId) + + if err != nil || getResource.IsError() { + return portalv1.GetAPIProducts500JSONResponse(unwrapError(getResource, err)), nil + } + + // We don't want to return the default resource to not risk deleting it when reconciling + if resource["name"] == "Default Resource" { + continue + } + + apiProducts = append(apiProducts, portalv1.ApiProduct{ + Name: resource["name"].(string), + }) + } + + return portalv1.GetAPIProducts200JSONResponse(apiProducts), nil +} + +// DeleteAPIProduct deletes resources in Keycloak +func (s *StrictServerHandler) DeleteAPIProduct( + ctx context.Context, + request portalv1.DeleteAPIProductRequestObject, +) (portalv1.DeleteAPIProductResponseObject, error) { + if len(request.Name) == 0 { + return portalv1.DeleteAPIProduct404JSONResponse(newPortal400Error("name is required")), nil + } + + // We need the internal ID of the resource before we can delete it + var resourceIds []string + getId, err := s.restClient.R(). + SetQueryParams(map[string]string{ + "name": request.Name, + "exactName": "true", + }). + SetResult(&resourceIds). + Get(s.discoveredEndpoints.ResourceRegistration) + + if err != nil || getId.IsError() { + return portalv1.DeleteAPIProduct500JSONResponse(unwrapError(getId, err)), nil + } + + if len(resourceIds) == 0 { + return portalv1.DeleteAPIProduct404JSONResponse(newPortal400Error("no resource matches this name")), nil + } + + if len(resourceIds) > 1 { + // Keycloak enforces unique names, so if we get this then we're not looking up the ID properly + return portalv1.DeleteAPIProduct500JSONResponse(newPortal500Error("more than one matching resource found")), nil + } + + resp, err := s.restClient.R(). + Delete(s.discoveredEndpoints.ResourceRegistration + "/" + resourceIds[0]) + + if err != nil || resp.IsError() { + return portalv1.DeleteAPIProduct500JSONResponse(unwrapError(resp, err)), nil + } + + return portalv1.DeleteAPIProduct204Response{}, nil +} + +func unwrapError(resp *resty.Response, err error) portalv1.Error { + if err == nil { + error := resp.Error().(*KeycloakError) + return portalv1.Error{ + Code: resp.StatusCode(), + Message: error.Error, + Reason: error.Description, + } + } + + var respErr *resty.ResponseError + if ok := errors.As(err, &respErr); ok { + return portalv1.Error{ + Code: respErr.Response.StatusCode(), + Message: respErr.Response.Status(), + Reason: respErr.Error(), + } + } + + return newPortal500Error(err.Error()) +} diff --git a/internal/keycloak/server/handler_test.go b/internal/keycloak/server/handler_test.go new file mode 100644 index 0000000..1e50056 --- /dev/null +++ b/internal/keycloak/server/handler_test.go @@ -0,0 +1,348 @@ +package server_test + +import ( + "context" + resty "github.com/go-resty/resty/v2" + _ "github.com/golang/mock/mockgen/model" + "github.com/jarcoal/httpmock" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/solo-io/gloo-portal-idp-connect/internal/keycloak/server" + portalv1 "github.com/solo-io/gloo-portal-idp-connect/pkg/api/v1" +) + +var _ = Describe("Server", func() { + + const ( + issuer = "https://keycloak.example.com/realms/my-org" + mgmtClientId = "client-id" + mgmtClientSecret = "client-secret" + + fakeAdminEndpoint = "https://keycloak.example.com/admin/realms/my-org" + + applicationClientId = "client-internal-id" + applicationClientSecret = "client-secret" + ) + + var ( + s *server.StrictServerHandler + ctx context.Context + + endpoints = server.DiscoveredEndpoints{ + Tokens: issuer + "/protocol/openid-connect/token", + ResourceRegistration: issuer + "/authz/protection/resource_set", + } + + dummyClient = server.KeycloakClient{ + Id: applicationClientId, + Name: applicationClientId, + Secret: applicationClientSecret, + } + ) + + BeforeEach(func() { + ctx = context.Background() + + var restyClient = resty.New() + httpmock.ActivateNonDefault(restyClient.GetClient()) + + s = server.NewStrictServerHandler(&server.Options{ + Issuer: issuer, + MgmtClientId: mgmtClientId, + MgmtClientSecret: mgmtClientSecret, + }, + restyClient, + endpoints) + + dummyToken := &server.KeycloakToken{ + AccessToken: "access-token", + } + + newTokenResponder, _ := httpmock.NewJsonResponder(200, dummyToken) + httpmock.RegisterResponder("POST", endpoints.Tokens, newTokenResponder) + }) + + Context("Application", func() { + + When("no client exists", func() { + + BeforeEach(func() { + newClientResponder, _ := httpmock.NewJsonResponder(200, dummyClient) + httpmock.RegisterResponder("POST", issuer+"/clients-registrations/default", newClientResponder) + + getClientResponder, _ := httpmock.NewJsonResponder(200, []string{}) + httpmock.RegisterResponder("GET", fakeAdminEndpoint+"/clients?clientId=non-existing-client", getClientResponder) + }) + + It("can create a client", func() { + resp, err := s.CreateOAuthApplication(ctx, portalv1.CreateOAuthApplicationRequestObject{ + Body: &portalv1.CreateOAuthApplicationJSONRequestBody{ + Id: applicationClientId, + }, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(resp).To(BeAssignableToTypeOf(portalv1.CreateOAuthApplication201JSONResponse{})) + resp200 := resp.(portalv1.CreateOAuthApplication201JSONResponse) + Expect(*resp200.ClientName).To(Equal(applicationClientId)) + Expect(resp200.ClientId).To(Equal(applicationClientId)) + Expect(resp200.ClientSecret).To(Equal(applicationClientSecret)) + }) + + It("returns error code on nil body", func() { + resp, err := s.CreateOAuthApplication(ctx, portalv1.CreateOAuthApplicationRequestObject{}) + Expect(err).NotTo(HaveOccurred()) + Expect(resp).To(BeAssignableToTypeOf(portalv1.CreateOAuthApplication400JSONResponse{})) + }) + + It("returns error code on empty client id", func() { + resp, err := s.CreateOAuthApplication(ctx, portalv1.CreateOAuthApplicationRequestObject{ + Body: &portalv1.CreateOAuthApplicationJSONRequestBody{ + Id: "", + }, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(resp).To(BeAssignableToTypeOf(portalv1.CreateOAuthApplication400JSONResponse{})) + }) + + It("returns not found code on deletion", func() { + resp, err := s.DeleteApplication(ctx, portalv1.DeleteApplicationRequestObject{ + Id: "non-existing-client", + }) + Expect(err).NotTo(HaveOccurred()) + Expect(resp).To(BeAssignableToTypeOf(portalv1.DeleteApplication404JSONResponse{})) + resp404 := resp.(portalv1.DeleteApplication404JSONResponse) + Expect(resp404.Code).To(Equal(400)) + }) + }) + + When("client exists", func() { + BeforeEach(func() { + getClientIdResponder, _ := httpmock.NewJsonResponder(200, [1]server.KeycloakClient{dummyClient}) + httpmock.RegisterResponder("GET", fakeAdminEndpoint+"/clients?clientId="+applicationClientId, getClientIdResponder) + + deleteClientResponder, _ := httpmock.NewJsonResponder(204, nil) + httpmock.RegisterResponder("DELETE", fakeAdminEndpoint+"/clients/"+applicationClientId, deleteClientResponder) + }) + + It("can delete the client", func() { + resp, err := s.DeleteApplication(ctx, portalv1.DeleteApplicationRequestObject{ + Id: applicationClientId, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(resp).To(BeAssignableToTypeOf(portalv1.DeleteApplication204Response{})) + }) + + }) + }) + + Context("API Products", func() { + + apiProduct := "new-API-Product" + apiProductDescription := "test description" + + When("resource doesn't exist", func() { + + nonExistingApiProduct := "non-existent-API-Product" + + BeforeEach(func() { + newResourceResponder, _ := httpmock.NewJsonResponder(201, nil) + httpmock.RegisterResponder("POST", endpoints.ResourceRegistration, newResourceResponder) + + getResourcesResponder, _ := httpmock.NewJsonResponder(200, []string{}) + httpmock.RegisterResponder("GET", endpoints.ResourceRegistration, getResourcesResponder) + + resourceIdLookupResponder, _ := httpmock.NewJsonResponder(200, []string{}) + httpmock.RegisterResponder("GET", endpoints.ResourceRegistration+"?exactName=true&name="+nonExistingApiProduct, resourceIdLookupResponder) + }) + + It("can create an API Product", func() { + resp, err := s.CreateAPIProduct(ctx, portalv1.CreateAPIProductRequestObject{ + Body: &portalv1.CreateAPIProductJSONRequestBody{ + ApiProduct: portalv1.ApiProduct{ + Name: apiProduct, + Description: &apiProductDescription, + }, + }, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(resp).To(BeAssignableToTypeOf(portalv1.CreateAPIProduct201Response{})) + }) + + It("returns not found if deleting API Product not present", func() { + resp, err := s.DeleteAPIProduct(ctx, portalv1.DeleteAPIProductRequestObject{ + Name: nonExistingApiProduct, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(resp).To(BeAssignableToTypeOf(portalv1.DeleteAPIProduct404JSONResponse{})) + }) + + It("returns an empty list if no API Products are found", func() { + resp, err := s.GetAPIProducts(ctx, portalv1.GetAPIProductsRequestObject{}) + Expect(err).NotTo(HaveOccurred()) + Expect(resp).To(BeAssignableToTypeOf(portalv1.GetAPIProducts200JSONResponse{})) + resp200 := resp.(portalv1.GetAPIProducts200JSONResponse) + Expect(resp200).To(BeEmpty()) + }) + }) + + When("resource exists", func() { + + resourceId := "3ccd99e8-846b-4b0b-8d75-7ff5ee86cb24" + + BeforeEach(func() { + newResourceResponder, _ := httpmock.NewJsonResponder(409, nil) + httpmock.RegisterResponder("POST", endpoints.ResourceRegistration, newResourceResponder) + + getResourcesResponder, _ := httpmock.NewJsonResponder(200, [1]string{resourceId}) + httpmock.RegisterResponder("GET", endpoints.ResourceRegistration, getResourcesResponder) + + getResourceResponser, _ := httpmock.NewJsonResponder(200, map[string]any{"name": apiProduct}) + httpmock.RegisterResponder("GET", endpoints.ResourceRegistration+"/"+resourceId, getResourceResponser) + + resourceIdLookupResponder, _ := httpmock.NewJsonResponder(200, []string{resourceId}) + httpmock.RegisterResponder("GET", endpoints.ResourceRegistration+"?exactName=true&name="+apiProduct, resourceIdLookupResponder) + + deleteResourceResponder, _ := httpmock.NewJsonResponder(204, nil) + httpmock.RegisterResponder("DELETE", endpoints.ResourceRegistration+"/"+resourceId, deleteResourceResponder) + }) + + It("can get API Products", func() { + resp, err := s.GetAPIProducts(ctx, portalv1.GetAPIProductsRequestObject{}) + Expect(err).NotTo(HaveOccurred()) + Expect(resp).To(BeAssignableToTypeOf(portalv1.GetAPIProducts200JSONResponse{})) + resp200 := resp.(portalv1.GetAPIProducts200JSONResponse) + Expect(resp200).To(HaveLen(1)) + Expect(resp200).To(ContainElement(portalv1.ApiProduct{ + Name: apiProduct, + })) + }) + + It("can delete the APIProduct", func() { + resp, err := s.DeleteAPIProduct(ctx, portalv1.DeleteAPIProductRequestObject{ + Name: apiProduct, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(resp).To(BeAssignableToTypeOf(portalv1.DeleteAPIProduct204Response{})) + }) + + It("returns that there is a resource conflict", func() { + resp, err := s.CreateAPIProduct(ctx, portalv1.CreateAPIProductRequestObject{ + Body: &portalv1.CreateAPIProductJSONRequestBody{ + ApiProduct: portalv1.ApiProduct{ + Name: apiProduct, + Description: &apiProductDescription, + }, + }, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(resp).To(BeAssignableToTypeOf(portalv1.CreateAPIProduct409JSONResponse{})) + }) + }) + }) + + Context("Client <-> API Products authorisation", func() { + + apiProducts := []string{"api-product-1", "api-product-2"} + + When("client does not exist", func() { + + BeforeEach(func() { + getClientResponder, _ := httpmock.NewJsonResponder(200, []string{}) + httpmock.RegisterResponder("GET", fakeAdminEndpoint+"/clients?clientId="+applicationClientId, getClientResponder) + }) + + It("returns not found on update", func() { + resp, err := s.UpdateAppAPIProducts(ctx, portalv1.UpdateAppAPIProductsRequestObject{ + Id: applicationClientId, + Body: &portalv1.UpdateAppAPIProductsJSONRequestBody{ + ApiProducts: apiProducts, + }, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(resp).To(BeAssignableToTypeOf(portalv1.UpdateAppAPIProducts404JSONResponse{})) + }) + }) + + When("referencing client that does exist", func() { + + newResourceName := "new-api-product" + newResourceId := "new-resource-id" + + existingResourceName := "existing-api-product" + existingResourceId := "existing-resource-id" + existingPermissionId := "existing-permission-id" + + BeforeEach(func() { + getClientResponder, _ := httpmock.NewJsonResponder(200, [1]server.KeycloakClient{dummyClient}) + httpmock.RegisterResponder("GET", fakeAdminEndpoint+"/clients?clientId="+applicationClientId, getClientResponder) + + getPermissionResponder, _ := httpmock.NewJsonResponder(200, []server.Permission{{ + Id: existingPermissionId, + Name: server.PermissionName(applicationClientId, existingResourceName), + Clients: []string{applicationClientId}, + }}) + httpmock.RegisterResponder("GET", endpoints.Policy, getPermissionResponder) + + newResourceIdLookupResponder, _ := httpmock.NewJsonResponder(200, []string{newResourceId}) + httpmock.RegisterResponder("GET", endpoints.ResourceRegistration+"?exactName=true&name="+newResourceName, newResourceIdLookupResponder) + + existingResourceIdLookupResponder, _ := httpmock.NewJsonResponder(200, []string{existingResourceId}) + httpmock.RegisterResponder("GET", endpoints.ResourceRegistration+"?exactName=true&name="+existingResourceName, existingResourceIdLookupResponder) + + deletePermissionResponder, _ := httpmock.NewJsonResponder(204, nil) + httpmock.RegisterResponder("DELETE", endpoints.Policy+"/"+existingPermissionId, deletePermissionResponder) + + newPermissionResponder, _ := httpmock.NewJsonResponder(200, nil) + httpmock.RegisterResponder("POST", endpoints.Policy+"/"+newResourceId, newPermissionResponder) + }) + + It("does not re-create permissions for existing Api Products", func() { + resp, err := s.UpdateAppAPIProducts(ctx, portalv1.UpdateAppAPIProductsRequestObject{ + Id: applicationClientId, + Body: &portalv1.UpdateAppAPIProductsJSONRequestBody{ + ApiProducts: []string{existingResourceName}, + }, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(resp).To(BeAssignableToTypeOf(portalv1.UpdateAppAPIProducts204Response{})) + + info := httpmock.GetCallCountInfo() + Expect(info["POST "+endpoints.Policy+"/"+existingResourceId]).To(Equal(0)) + Expect(info["DELETE "+endpoints.Policy+"/"+existingPermissionId]).To(Equal(0)) + }) + + It("adds permissions for new API Products", func() { + resp, err := s.UpdateAppAPIProducts(ctx, portalv1.UpdateAppAPIProductsRequestObject{ + Id: applicationClientId, + Body: &portalv1.UpdateAppAPIProductsJSONRequestBody{ + // here we pass the existing resource, whose permissions should not be recreated, and a new one which should + ApiProducts: []string{existingResourceName, newResourceName}, + }, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(resp).To(BeAssignableToTypeOf(portalv1.UpdateAppAPIProducts204Response{})) + + info := httpmock.GetCallCountInfo() + Expect(info["POST "+endpoints.Policy+"/"+newResourceId]).To(Equal(1)) + // the existing resource + its permission should not be created nor deleted + Expect(info["POST "+endpoints.Policy+"/"+existingResourceId]).To(Equal(0)) + Expect(info["DELETE "+endpoints.Policy+"/"+existingPermissionId]).To(Equal(0)) + }) + + It("deletes permissions for removed API Products", func() { + resp, err := s.UpdateAppAPIProducts(ctx, portalv1.UpdateAppAPIProductsRequestObject{ + Id: applicationClientId, + Body: &portalv1.UpdateAppAPIProductsJSONRequestBody{ + ApiProducts: []string{}, // because we don't pass the existingResourceName but it is an existing permissions, it should be deleted + }, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(resp).To(BeAssignableToTypeOf(portalv1.UpdateAppAPIProducts204Response{})) + + info := httpmock.GetCallCountInfo() + Expect(info["DELETE "+endpoints.Policy+"/"+existingPermissionId]).To(Equal(1)) + }) + }) + }) +}) diff --git a/internal/keycloak/server/server.go b/internal/keycloak/server/server.go new file mode 100644 index 0000000..743c08b --- /dev/null +++ b/internal/keycloak/server/server.go @@ -0,0 +1,125 @@ +package server + +import ( + "context" + "log" + "net" + "net/http" + + "github.com/getkin/kin-openapi/openapi3filter" + resty "github.com/go-resty/resty/v2" + "github.com/labstack/echo/v4" + echomiddleware "github.com/labstack/echo/v4/middleware" + middleware "github.com/oapi-codegen/echo-middleware" + "github.com/rotisserie/eris" + "github.com/spf13/pflag" + + portalv1 "github.com/solo-io/gloo-portal-idp-connect/pkg/api/v1" +) + +const wellKnownUmaConfigPath = "/.well-known/uma2-configuration" + +type Options struct { + Port string + Issuer string + MgmtClientId string + MgmtClientSecret string +} + +type DiscoveredEndpoints struct { + Policy string + ResourceRegistration string + Tokens string +} + +func (o *Options) AddToFlags(flag *pflag.FlagSet) { + flag.StringVar(&o.Port, "port", "8080", "Port for HTTP server") + flag.StringVar(&o.Issuer, "issuer", "", "Keycloak issuer URL (e.g. https://keycloak.example.com/realms/my-org)") + flag.StringVar(&o.MgmtClientId, "client-id", "", "ID of the Keycloak client that is authorised to manage app clients") + flag.StringVar(&o.MgmtClientSecret, "client-secret", "", "Secret of the Keycloak client that is authorised to manage app clients") +} + +func (o *Options) Validate() error { + if o.Issuer == "" { + return eris.New("Issuer is required") + } + return nil +} + +func ListenAndServe(ctx context.Context, opts *Options) error { + type UmaConfiguration struct { + PolicyEndpoint string `json:"policy_endpoint"` + ResourceRegistrationEndpoint string `json:"resource_registration_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + } + + if err := opts.Validate(); err != nil { + return err + } + + client := resty.New() + + umaConfiguration, err := client.R(). + SetResult(UmaConfiguration{}). + Get(opts.Issuer + wellKnownUmaConfigPath) + if err != nil { + return eris.Wrap(err, "UMA configuration could not be discovered") + } + + policyEndpoint := umaConfiguration.Result().(*UmaConfiguration).PolicyEndpoint + if len(policyEndpoint) == 0 { + return eris.New("Policy endpoint was not provided by the issuer") + } + + resourceRegistrationEndpoint := umaConfiguration.Result().(*UmaConfiguration).ResourceRegistrationEndpoint + if len(resourceRegistrationEndpoint) == 0 { + return eris.New("Resource registration endpoint was not provided by the issuer") + } + + tokenEndpoint := umaConfiguration.Result().(*UmaConfiguration).TokenEndpoint + if len(tokenEndpoint) == 0 { + return eris.New("Token endpoint was not provided by the issuer") + } + + discoveredEndpoints := DiscoveredEndpoints{ + Policy: policyEndpoint, + ResourceRegistration: resourceRegistrationEndpoint, + Tokens: tokenEndpoint, + } + + swagger, err := portalv1.GetSwagger() + if err != nil { + return eris.Wrap(err, "could not load swagger spec") + } + + // Clear out the servers array in the swagger spec, that skips validating + // that server names match. We don't know how this thing will be run. + swagger.Servers = nil + + // Create an instance of our handler which satisfies the generated interface + keycloakHandler := NewStrictServerHandler(opts, client, discoveredEndpoints) + portalHandler := portalv1.NewStrictHandler(keycloakHandler, nil) + + e := echo.New() + // Log all requests + e.Use(echomiddleware.Logger()) + // Use our validation middleware to check all requests against the + // OpenAPI schema. + e.Use(middleware.OapiRequestValidatorWithOptions(swagger, &middleware.Options{ + Options: openapi3filter.Options{ + AuthenticationFunc: openapi3filter.NoopAuthenticationFunc, + }}, + )) + + // We now register our portal handler above as the handler for the interface + portalv1.RegisterHandlers(e, portalHandler) + + // And we serve HTTP until the world ends. + s := &http.Server{ + Handler: e, + Addr: net.JoinHostPort("0.0.0.0", opts.Port), + } + + log.Printf("Starting server on port %v\n", opts.Port) + return s.ListenAndServe() +} diff --git a/internal/keycloak/server/server_suite_test.go b/internal/keycloak/server/server_suite_test.go new file mode 100644 index 0000000..0b9bde3 --- /dev/null +++ b/internal/keycloak/server/server_suite_test.go @@ -0,0 +1,23 @@ +package server_test + +import ( + "testing" + + "github.com/jarcoal/httpmock" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = BeforeEach(func() { + // remove any mocks + httpmock.Reset() +}) + +var _ = AfterSuite(func() { + httpmock.DeactivateAndReset() +}) + +func TestServer(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Server Suite") +} diff --git a/internal/keycloak/server/util.go b/internal/keycloak/server/util.go new file mode 100644 index 0000000..164637a --- /dev/null +++ b/internal/keycloak/server/util.go @@ -0,0 +1,30 @@ +package server + +import ( + portalv1 "github.com/solo-io/gloo-portal-idp-connect/pkg/api/v1" +) + +func newPortalError(code int, msg, reason string) portalv1.Error { + return portalv1.Error{ + Code: code, + Message: msg, + Reason: reason, + } +} + +func newPortal400Error(reason string) portalv1.Error { + return newPortalError(400, "Bad Request", reason) +} + +func newPortal500Error(reason string) portalv1.Error { + return newPortalError(500, "Internal Server Error", reason) +} + +func PermissionName(clientId, apiName string) string { + return clientId + "/" + apiName +} + +func GetApiNameFromPermission(permissionId, clientId string) string { + // we extract the api platform id from the permission ID, by removing the ClientID + '/' from the beginning + return permissionId[len(clientId)+1:] +} diff --git a/pkg/api/v1/server.gen.go b/pkg/api/v1/server.gen.go index a5b06ff..4c19f51 100644 --- a/pkg/api/v1/server.gen.go +++ b/pkg/api/v1/server.gen.go @@ -16,6 +16,9 @@ import ( // ServerInterface represents all server handlers. type ServerInterface interface { + // Get all API Products in the OpenID Connect Provider. + // (GET /api-products) + GetAPIProducts(ctx echo.Context) error // Creates API Product in the OpenID Connect Provider. Then, you can add this API Product to the application for your Portal applications with the `PUT /applications/{id}/api-products` API request. // (POST /api-products) CreateAPIProduct(ctx echo.Context) error @@ -38,6 +41,15 @@ type ServerInterfaceWrapper struct { Handler ServerInterface } +// GetAPIProducts converts echo context to params. +func (w *ServerInterfaceWrapper) GetAPIProducts(ctx echo.Context) error { + var err error + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.GetAPIProducts(ctx) + return err +} + // CreateAPIProduct converts echo context to params. func (w *ServerInterfaceWrapper) CreateAPIProduct(ctx echo.Context) error { var err error @@ -132,6 +144,7 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL Handler: si, } + router.GET(baseURL+"/api-products", wrapper.GetAPIProducts) router.POST(baseURL+"/api-products", wrapper.CreateAPIProduct) router.DELETE(baseURL+"/api-products/:name", wrapper.DeleteAPIProduct) router.POST(baseURL+"/applications/oauth2", wrapper.CreateOAuthApplication) @@ -140,6 +153,31 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL } +type GetAPIProductsRequestObject struct { +} + +type GetAPIProductsResponseObject interface { + VisitGetAPIProductsResponse(w http.ResponseWriter) error +} + +type GetAPIProducts200JSONResponse []ApiProduct + +func (response GetAPIProducts200JSONResponse) VisitGetAPIProductsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetAPIProducts500JSONResponse Error + +func (response GetAPIProducts500JSONResponse) VisitGetAPIProductsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + type CreateAPIProductRequestObject struct { Body *CreateAPIProductJSONRequestBody } @@ -225,11 +263,7 @@ type CreateOAuthApplicationResponseObject interface { VisitCreateOAuthApplicationResponse(w http.ResponseWriter) error } -type CreateOAuthApplication201JSONResponse struct { - ClientId *string `json:"clientId,omitempty"` - ClientName *string `json:"clientName,omitempty"` - ClientSecret *string `json:"clientSecret,omitempty"` -} +type CreateOAuthApplication201JSONResponse OAuthApplication func (response CreateOAuthApplication201JSONResponse) VisitCreateOAuthApplicationResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") @@ -336,6 +370,9 @@ func (response UpdateAppAPIProducts500JSONResponse) VisitUpdateAppAPIProductsRes // StrictServerInterface represents all server handlers. type StrictServerInterface interface { + // Get all API Products in the OpenID Connect Provider. + // (GET /api-products) + GetAPIProducts(ctx context.Context, request GetAPIProductsRequestObject) (GetAPIProductsResponseObject, error) // Creates API Product in the OpenID Connect Provider. Then, you can add this API Product to the application for your Portal applications with the `PUT /applications/{id}/api-products` API request. // (POST /api-products) CreateAPIProduct(ctx context.Context, request CreateAPIProductRequestObject) (CreateAPIProductResponseObject, error) @@ -365,6 +402,29 @@ type strictHandler struct { middlewares []StrictMiddlewareFunc } +// GetAPIProducts operation middleware +func (sh *strictHandler) GetAPIProducts(ctx echo.Context) error { + var request GetAPIProductsRequestObject + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetAPIProducts(ctx.Request().Context(), request.(GetAPIProductsRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetAPIProducts") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(GetAPIProductsResponseObject); ok { + return validResponse.VisitGetAPIProductsResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} + // CreateAPIProduct operation middleware func (sh *strictHandler) CreateAPIProduct(ctx echo.Context) error { var request CreateAPIProductRequestObject diff --git a/pkg/api/v1/spec.gen.go b/pkg/api/v1/spec.gen.go index 337e064..0f75715 100644 --- a/pkg/api/v1/spec.gen.go +++ b/pkg/api/v1/spec.gen.go @@ -18,30 +18,32 @@ import ( // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "H4sIAAAAAAAC/+RY7W7bNhR9lQtuQFvAsZzEWWP/c+NtMIa1xtr+KgKEEa9sthLJkpRbI/C7DyQlm5KV", - "2um6ZMCAAo1k8n6fw0PdkVQWSgoU1pDxHTHpEgvq/5woPteSlal1T0pLhdpy9L8xNKnmynIp3CN+pYXK", - "kYzrv2Ayn0G1G+LFPWLXyi00VnOxIJseEbTATiMnVPETVYWwt3HTIxo/l1wjI+MPwcr1pkd+1Vrq/YhT", - "ybyXygoXFheonf8CjaGLVghvLbWlgSvJEP6sFnTErpGadgl8AFD9cihsH9Yuhq3F641byUUm98pNXmEm", - "NcJalnCLCy56YNBCqeD3XEqY59RmUhcwl9rSHL5wu3RrNbxRKGZTuJJCYGrh+ZvZ9OoFKC1XnKHuw7sl", - "irDcLrmB2XS+XTuZz3reYUoFFFTQBYJdIqQ5d5MDVLC45Qa4qHzOpldbF2CX1MICLVBjZMqpRRbFVwVM", - "lcp5Sl2ypg+/SQ2FS9fVQhf+tUs4+P/QmTKTqbl+vrRWmXGSuKe+kbnsc5kscilPlF+W5NSiscmLvusS", - "t755nfZalSA9skJtQjNO+4P+wI2CVCio4mRMzv2rHlHULv3sJdEg+xdKGrvf1yuNLqIGdrjwebrWwYxt", - "o5jXTSPesfZlmbGtkcl8Nt8Cx80bGvtKsnVAgrAovP+o1MnHapADA+wDiDbo4GeNGRmTn5IdfSQVdyQR", - "cbTHPTJyvYWGvP2Ifu2m11mRRkEyqbunpzE2JHZrdYk+DqOkMCGbs8HpfgPelmmKxmRlnq8h9b4bY913", - "fR4OBg+q4rcqFbiqI/GZWNGcM+BClbXb0b/vNi40zTVStgb8yo01PoaLx0j9vcCvClNXevRM6hvBxaLV", - "iU2PmLIoqF4fhk7EevMG3e1IjTIWaC82YaU3EWXp5+8+tqrJE+Fm/v4dJPFvyR1nmwYR3HhXFTg9B9GF", - "cSCJidQdBL0mgSR37qzbhPHN0eL+IE/9+wcxSQAWLPgKBZSCfy4ROENheca7eCa4aPCMopoWaFG7LNoh", - "Pf+rwuMLeE0LBJn5cOIIv+AzBjn/hK7uITXnl7vtjktJrRXCf22I96LJax+613vwHx6Af/DfBf/h4+JQ", - "SEd6pWBPCEFfjAMQPDBzXRA8NPMReiQt7fLs8NnpkByh1U3ZWiGE7Q7z3ID758rHkLlJu0X/uNC7A4WK", - "+3HiNYzLKlYF3Gm/AoUNbrf2AiX04bW0uNt5E2TTW0w12hsXj8AVajBWamR11Ri19JYa9OKKGzBL+UXU", - "lFQa1CBFvgYpUuzDH4gq8JfxVt26gn5CyEpbaqxpxtT7Xb1VJNbcu8Bo90mKN5PSLie72v4wYXG//ndJ", - "nigp8xOGK8zdppPTI28CR6iLiJLcHs+A28NGRCQV2nW0qPjOOgQvruJxLejgcvQSf2EDpKNhdnE5Ss8v", - "z15e3qZ0iKPz4VnXrSRYev1P61obCnPaNJWOhuyWXVyesYvREC8pHQxH58NslL7EbJBdnHV26WBHOgVY", - "Xf2n015PrHu2BeiUPN9mvJhiIz7tolgnUA6LitjZvaqgwRLHyoLZNGiQKJlDOoCzR1IBjaQfTQVElfhP", - "qYBmNTpVQGsqHyAEjpjS/ft02SEJ3itGm/P6rKFOzP74hi0TpXa61jxgguOGzaZuet2tonmh+IGT/GNv", - "9f6RWyzMd3yM276gWtP1N6795qiTedIqW7ibGIUpz3japqBDh/IhkFPGsN2mDsQ/0bX/f0g0lLEWzbRw", - "2+ScgFrjGcagdUdg41vkVnfHBpfUAPVD4Lr9/fzkIkG9qrmh1DkZk/rTI1W8H744Vl8Uq0+P/VQWyeqU", - "bK43fwcAAP//n2nwxoEXAAA=", + "H4sIAAAAAAAC/+RYf2/bNhD9KgdtQFvAsZzEWeP85yZbYQxrjbX5qwgQWjzZbCWSJSmnRuDvPpCUbOqH", + "Y2fLkgEDAkSSyePx7r13R95Hicil4MiNji7uI50sMCfucSzZVAlaJMa+SSUkKsPQ/UZRJ4pJwwS3r/iD", + "5DLD6KJ6gvF0AuVsCAf3IrOSdqA2ivF5tO5FnOTYaeSISHYkSxdaE9e9SOH3gimk0cUXb+Vm3Yt+VUqo", + "tseJoG6V0grjBueo7Po5ak3mDRc+GWIKDZeCIvxRDujwXSHRzRA4B6D8ZZ/bzq2tDxuLdicfx4VZjKXM", + "WEKqSDc2lTHkZkLrDpDB+egt/kIHSEbD9Ox8lJyen7w9nyVkiKPT4UnXRrylDztTUWhUR1KI7IjiEjPr", + "xNHxbkOfMFFo6qaS0ZDO6Nn5CT0bDfGckMFwdDpMR8lbTAfp2cn+WFW7baxys7YjGU9FC5rRO0yFQliJ", + "AmY4Z7wHGg0UEt5nQsB7YvCOrGAqlCEZ3DGzsEMVfJTIJ1dwKTjHxMDrj5OryzcglVgyiqoPnxfI/XCz", + "YBomV9PN2PF00nPrJYRDTjiZI5gFgndZA+E0ZIcGxss1J1eXmyXALIiBORogWouEEYM08K90mGzBofvw", + "m1CQ293aUKjcfbb79et/6doxFYm+eb0wRuqLOLZvfS0y0WcinmdCHEk3LM6IQW3iN32bI2ZcOrvMNeIQ", + "9aIlKu0zcdwf9AcWIUIiJ5JFF9Gp+9SLJDELh+c4YLz7MPcgquf0vY1KlrWiaHdpEwcTuvFiGqascrPQ", + "qN1ohbrIjAYj4BuidN+6rE7o1D7qFU98EkpDsxUkColhfO7ySjFD/6KBI1KkNmKWLC4ZlqjW+/F0Uq3g", + "GK+l4Noz+mQw8GrFDXK39yDH8ddSbLxK2ydmMHcTf1aYRhfRT/FWz+NSzONAydcbkhGlyCpy1KmH91OR", + "JKh1WmTZChQaxXCJdcz2rZ2zR7r6kIdetDucueb4Q2Ji0Y+lrjqHbJAbHq17kS7ynKjVARgJyL2BiAU3", + "mWsrNeE0p8VS6A4kXtrko65Vu31AbAHCG9liIvKqh9q8E3T1qBDXqwOpFfBDAdIQ3cDIzQY7YvYV3dhW", + "vvxmagFJheoWsZp6ReGyRhW4bjHjuJ2AGlYdFetIdUAdPgdQJ3xJMkaBcVlUy47+/WXDQJNMIaErwB9M", + "vyhHN5pYz0SNoHuo08VPV3W3tZVQ6qtvaMIIZyLYpcPfrqJZ1XCE2+n1Z4jD3+J7Rte1inTrlirJ+YBc", + "rHv1Shbf2+507eGbocE2kK/c90cpiScWzNkSORScfS8QGEVuWMq6dMYvUdMZSRTJ0aCyu2i69PrPko9v", + "wPaFINJmfYQ7fEUhY9/Qxt1vza7L7HRb1KOqu/f/mhTvBchrtn43LfoP99Dfr99F/+Hz8pALK3oFpy9I", + "wU0n8gAF92DucSXSYz5gjyCFWZy4qvRg7bRMDthqUbaSCH665TzTYP9s+ChSi7QZute52hYUwnfzxLXS", + "ro0L2lNmTyU5cuOX3djzktCHD8LgduZteOC4tf5wXKICbYRCWkWNEkNmRKPrBZkGvRB3vJIke4ICwbMV", + "CJ5gH373Dacd5qzacTn5hpAWplBYyYyu5tt4y0Yn4xVtV0vROj8+VWPB/vGBs9FkMHpQcxEo0nVT75wa", + "bgqPT9fBTcWTkLMV7n29ddWvVM6+XKvywm3CJgCdHcLDAhEqUiA/XYpk6/n+GhwutrOI1kh1aBWdXPmS", + "HWxmX9lk9JmKZm3Tz1Y0g0j8p4pmPRqdRbOBykfUzQNQ2roHkUVHBb2WlNTx+ko3DsVN+PopYynrVxCH", + "IjhM2OTKotc24fX++wmR/LSHYF27L3nkbXPr2mTXKVkfVMnGjbD5Vl5LTFjKkqYE7ath+0hOKMVmmjoY", + "/0Kn5P+h0BBKGzLz4GWWZ62/stRobAms3Wtt2tTQ4IJoIA4ENtt/X5+sJ6iWlTYUKosuourGmEjW9xfF", + "GTGpUHl5Y9xPRB4vj6P1zfqvAAAA//+PrzBnYhoAAA==", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/pkg/api/v1/types.gen.go b/pkg/api/v1/types.gen.go index 59aefc4..16896cf 100644 --- a/pkg/api/v1/types.gen.go +++ b/pkg/api/v1/types.gen.go @@ -16,6 +16,13 @@ type Error struct { Reason string `json:"reason"` } +// OAuthApplication defines model for OAuthApplication. +type OAuthApplication struct { + ClientId string `json:"clientId"` + ClientName *string `json:"clientName,omitempty"` + ClientSecret string `json:"clientSecret"` +} + // CreateAPIProductJSONBody defines parameters for CreateAPIProduct. type CreateAPIProductJSONBody struct { ApiProduct ApiProduct `json:"apiProduct"` @@ -23,7 +30,7 @@ type CreateAPIProductJSONBody struct { // CreateOAuthApplicationJSONBody defines parameters for CreateOAuthApplication. type CreateOAuthApplicationJSONBody struct { - Name string `json:"name"` + Id string `json:"id"` } // UpdateAppAPIProductsJSONBody defines parameters for UpdateAppAPIProducts. diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go index 1346b6c..0064efe 100644 --- a/test/e2e/e2e_test.go +++ b/test/e2e/e2e_test.go @@ -14,14 +14,16 @@ import ( "github.com/solo-io/gloo-portal-idp-connect/test" ) +// Note: This test uses Cognito as the identity provider. Cognito's ClientId is generated by cognito, so we store it in the clientId variable and use that in subsequent tests. +// If these were in Keycloak, because the ClientID is provided by the user, we would not need to store it in a variable and could simply use the 'internalClientId' variable instead. var _ = Describe("E2e", Ordered, func() { - // clientId is the ID of the client we will create var ( + // clientId is the ID of the client we create clientId string // testApiProduct is the name of the scope we will create. testApiProduct string - // clientName is the name of the client we will create. - appName string + // internalClientId is the user-defined unique Id. In Portal's case, it will be the ID of the oauth credential table entry. + internalClientId string ) BeforeAll(func() { @@ -29,9 +31,9 @@ var _ = Describe("E2e", Ordered, func() { // Unique scope string to make sure there are no conflicts between runs of e2e tests and so that, // should things go wrong, we can identify when the scope was created. testApiProduct = fmt.Sprintf("e2e-test-api-product-%s", nowString) - // Unique name dated with current time so that we can get a gauge on the client we are creating when, - // and to avoid conflicts. This will also be the client name created in AWS Cognito. - appName = fmt.Sprintf("e2e-test-client-%s", nowString) + // Unique ID dated with current time so that we can get a gauge on the client we are creating when, + // and to avoid conflicts. + internalClientId = fmt.Sprintf("id-%s", nowString) }) It("can can create client", func() { @@ -39,7 +41,7 @@ var _ = Describe("E2e", Ordered, func() { Url: "idp-connect/applications/oauth2", Cluster: env, Method: "POST", - Data: fmt.Sprintf(`{"name": "%s"}`, appName), + Data: fmt.Sprintf(`{"id": "%s"}`, internalClientId), App: "curl", Headers: []string{"Content-Type: application/json"}, } @@ -51,9 +53,9 @@ var _ = Describe("E2e", Ordered, func() { // If the response was made correctly, we should be able to unmarshal it Expect(json.Unmarshal([]byte(out), &createObj)).To(Succeed()) Expect(createObj.ClientName).ToNot(BeNil()) - Expect(*createObj.ClientName).To(Equal(appName)) + Expect(*createObj.ClientName).To(Equal(internalClientId)) Expect(createObj.ClientId).ToNot(BeNil()) - clientId = *createObj.ClientId + clientId = createObj.ClientId }) It("can create API Products", func() {