diff --git a/.github/workflows/commands.yaml b/.github/workflows/commands.yaml index b69e4d4..272ff58 100644 --- a/.github/workflows/commands.yaml +++ b/.github/workflows/commands.yaml @@ -12,6 +12,8 @@ jobs: install: name: Run kuadrantctl install runs-on: ubuntu-latest + env: + KIND_CLUSTER_NAME: kuadrantctl-local steps: - name: Set up Go 1.21.x uses: actions/setup-go@v4 @@ -21,7 +23,7 @@ jobs: - name: Check out code uses: actions/checkout@v3 - name: Create k8s Kind Cluster - uses: helm/kind-action@v1.2.0 + uses: helm/kind-action@v1.8.0 with: version: v0.20.0 config: utils/kind-cluster.yaml diff --git a/.github/workflows/testing.yaml b/.github/workflows/testing.yaml index ce4a31d..394bb75 100644 --- a/.github/workflows/testing.yaml +++ b/.github/workflows/testing.yaml @@ -28,7 +28,7 @@ jobs: name: Run tests runs-on: ubuntu-latest env: - KIND_CLUSTER_NAME: kuadrant-local + KIND_CLUSTER_NAME: kuadrantctl-local steps: - name: Set up Go 1.21.x uses: actions/setup-go@v4 @@ -38,7 +38,7 @@ jobs: - name: Check out code uses: actions/checkout@v3 - name: Create k8s Kind Cluster - uses: helm/kind-action@v1.2.0 + uses: helm/kind-action@v1.8.0 with: version: v0.20.0 config: utils/kind-cluster.yaml diff --git a/doc/generate-kuadrant-auth-policy.md b/doc/generate-kuadrant-auth-policy.md index 39e99a6..732bb5d 100644 --- a/doc/generate-kuadrant-auth-policy.md +++ b/doc/generate-kuadrant-auth-policy.md @@ -12,13 +12,18 @@ OpenAPI document resource can be provided by one of the following channels: * URL format (supported schemes are HTTP and HTTPS). The CLI will try to download from the given address. * Read from stdin standard input stream. -#### openIdConnect type -This initial version of the command only generates AuhPolicy when there is at least one security requirement referencing the -[Security Scheme Object](https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#security-scheme-object) which type is `openIdConnect`. +OpenAPI [Security Scheme Object](https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#security-scheme-object) types -### Description +| Types | Implemented | +| --- | --- | +| `openIdConnect` | **YES** | +| `apiKey` | **YES** | +| `http` | NO | +| `oauth2` | NO | + +### `openIdConnect` Type Description -The following OAS example has one protected endpoint `GET /dog` with OIDC sec scheme. +The following OAS example has one protected endpoint `GET /dog` with `openIdConnect` security scheme type. ```yaml paths: @@ -61,7 +66,7 @@ spec: method: GET rules: authentication: - getDog: + getDog_securedDog: credentials: {} jwt: issuerUrl: https://example.com/.well-known/openid-configuration @@ -73,6 +78,91 @@ spec: method: GET ``` +### `apiKey` Type Description + +The following OAS example has one protected endpoint `GET /dog` with `apiKey` security scheme type. + +```yaml +paths: + /dog: + get: + operationId: "getDog" + security: + - securedDog: [] + responses: + 405: + description: "invalid input" +components: + securitySchemes: + securedDog: + type: apiKey + name: dog_token + in: query +``` + +Running the command + +``` +kuadrantctl generate kuadrant authpolicy --oas ./petstore-openapi.yaml | yq -P +``` + +The generated authpolicy (only relevan fields shown here): + +```yaml +kind: AuthPolicy +apiVersion: kuadrant.io/v1beta2 +metadata: + name: petstore + namespace: petstore + creationTimestamp: null +spec: + routeSelectors: + - matches: + - path: + type: Exact + value: /dog + method: GET + rules: + authentication: + getDog_securedDog: + credentials: + queryString: + name: dog_token + apiKey: + selector: + matchLabels: + kuadrant.io/apikeys-by: securedDog + routeSelectors: + - matches: + - path: + type: Exact + value: /dog + method: GET +``` + +In this particular example, the endpoint `GET /dog` will be protected. +The token needs to be in the query string of the request included in a parameter named `dog_token`. +Kuadrant will validate received tokens against tokens found in kubernetes secrets with label `kuadrant.io/apikeys-by: ${sec scheme name}`. +In this particular example the label selector will be: `kuadrant.io/apikeys-by: securedDog`. + +Like the following example: + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: api-key-1 + labels: + authorino.kuadrant.io/managed-by: authorino + kuadrant.io/apikeys-by: securedDog +stringData: + api_key: MYSECRETTOKENVALUE +type: Opaque +``` +> **Note**: Kuadrant validates tokens against api keys found in secrets. The label selector format `kuadrant.io/apikeys-by: ${sec scheme name}` is arbitrary and designed for this CLI command. + +For more information about Kuadrant auth based on api key: https://docs.kuadrant.io/authorino/docs/user-guides/api-key-authentication/ + ### Usage ```shell @@ -93,6 +183,16 @@ Global Flags: ### User Guide +The verification steps will lead you to the process of deploying and testing the following api with +endpoints protected using different security schemes: + +| Operation | Security Scheme | +| --- | --- | +| `GET /api/v1/cat` | public (not auth) | +| `POST /api/v1/cat` | ApiKey in header | +| `GET /api/v1/dog` | OpenIdConnect | +| `GET /api/v1/snake` | OpenIdConnect **OR** ApiKey in query string | + * [Optional] Setup SSO service supporting OIDC. For this example, we will be using [keycloak](https://www.keycloak.org). * Create a new realm `petstore` * Create a client `petstore`. In the Client Protocol field, select `openid-connect`. @@ -143,7 +243,7 @@ kubectl apply -n petstore -f examples/petstore/petstore.yaml ```yaml cat <petstore-openapi.yaml --- -openapi: "3.0.3" +openapi: "3.1.0" info: title: "Pet Store API" version: "1.0.0" @@ -165,46 +265,112 @@ paths: - name: petstore port: 80 namespace: petstore - get: # public (not auth) + get: # No sec requirements operationId: "getCat" responses: 405: description: "invalid input" + post: # API key + operationId: "postCat" + security: + - cat_api_key: [] + responses: + 405: + description: "invalid input" /dog: x-kuadrant: backendRefs: - name: petstore port: 80 namespace: petstore - get: # secured + get: # OIDC operationId: "getDog" security: - - openIdConnect: [] + - oidc: + - read:dogs + responses: + 405: + description: "invalid input" + /snake: + x-kuadrant: + backendRefs: + - name: petstore + port: 80 + namespace: petstore + get: # OIDC or API key + operationId: "getSnake" + security: + - oidc: ["read:snakes"] + - snakes_api_key: [] responses: 405: description: "invalid input" components: securitySchemes: - openIdConnect: + cat_api_key: + type: apiKey + name: api_key + in: header + oidc: type: openIdConnect openIdConnectUrl: https://${KEYCLOAK_PUBLIC_DOMAIN}/auth/realms/petstore + snakes_api_key: + type: apiKey + name: snake_token + in: query EOF ``` + > Replace `${KEYCLOAK_PUBLIC_DOMAIN}` with your SSO instance domain -| Operation | Applied config | -| --- | --- | -| `GET /api/v1/cat` | public (not auth) | -| `GET /api/v1/dog` | OIDC authenticatred | +* Create an API key only valid for `POST /api/v1/cat` endpoint +```yaml +kubectl apply -f -< **Note**: the label's value of `kuadrant.io/apikeys-by: cat_api_key` is the name of the sec scheme of the OpenAPI spec. + +* Create an API key only valid for `GET /api/v1/snake` endpoint + +```yaml +kubectl apply -f -< **Note**: the label's value of `kuadrant.io/apikeys-by: snakes_api_key` is the name of the sec scheme of the OpenAPI spec. * Create the HTTPRoute using the CLI + ```bash bin/kuadrantctl generate gatewayapi httproute --oas petstore-openapi.yaml | kubectl apply -n petstore -f - ``` * Create Kuadrant's Auth Policy + ```bash bin/kuadrantctl generate kuadrant authpolicy --oas petstore-openapi.yaml | kubectl apply -n petstore -f - ``` @@ -212,22 +378,76 @@ bin/kuadrantctl generate kuadrant authpolicy --oas petstore-openapi.yaml | kubec Now, we are ready to test OpenAPI endpoints :exclamation: - `GET /api/v1/cat` -> It's a public endpoint, hence should return 200 Ok + ```bash curl -H "Host: example.com" -i "http://127.0.0.1:9080/api/v1/cat" ``` -- `GET /api/v1/dog` -> It's a secured endpoint, hence, without credentials, it should return 401 + +- `POST /api/v1/cat` -> It's a protected endpoint with apikey + +Without any credentials, it should return `401 Unauthorized` + ```bash -curl -H "Host: example.com" -i "http://127.0.0.1:9080/api/v1/dog" +curl -H "Host: example.com" -X POST -i "http://127.0.0.1:9080/api/v1/cat" ``` + ``` HTTP/1.1 401 Unauthorized -www-authenticate: Bearer realm="getDog" -x-ext-auth-reason: credential not found -date: Tue, 28 Nov 2023 09:38:26 GMT +www-authenticate: Bearer realm="getDog_oidc" +www-authenticate: Bearer realm="getSnake_oidc" +www-authenticate: snake_token realm="getSnake_snakes_api_key" +www-authenticate: api_key realm="postCat_cat_api_key" +x-ext-auth-reason: {"postCat_cat_api_key":"credential not found"} +date: Tue, 28 Nov 2023 22:28:44 GMT +server: istio-envoy +content-length: 0 +``` + +The *reason* headers tell that `credential not found`. +Credentials satisfying `postCat_cat_api_key` authentication is needed. + +According to the OpenAPI spec, it should be a header named `api_key`. +What if we try a wrong token? one token assigned to other endpoint, +i.e. `I_LIKE_SNAKES` instead of the valid one `I_LIKE_CATS`. It should return `401 Unauthorized`. + +```bash +curl -H "Host: example.com" -H "api_key: I_LIKE_SNAKES" -X POST -i "http://127.0.0.1:9080/api/v1/cat" +``` + +``` +TTP/1.1 401 Unauthorized +www-authenticate: Bearer realm="getDog_oidc" +www-authenticate: Bearer realm="getSnake_oidc" +www-authenticate: snake_token realm="getSnake_snakes_api_key" +www-authenticate: api_key realm="postCat_cat_api_key" +x-ext-auth-reason: {"postCat_cat_api_key":"the API Key provided is invalid"} +date: Tue, 28 Nov 2023 22:32:55 GMT server: istio-envoy content-length: 0 ``` -- Get authentication token. This example is using Direct Access Grants oauth2 grant type (also known as Client Credentials grant type). When configuring the Keycloak (OIDC provider) client settings, we enabled Direct Access Grants to enable this procedure. We will be authenticating as `bob` user with `p` password. We previously created `bob` user in Keycloak in the `petstore` realm. + +The *reason* headers tell that `the API Key provided is invalid`. +Using valid token (from the secret `cat-api-key-1` assigned to `POST /api/v1/cats`) +in the `api_key` header should return 200 Ok + +``` +curl -H "Host: example.com" -H "api_key: I_LIKE_CATS" -X POST -i "http://127.0.0.1:9080/api/v1/cat" +``` + +- `GET /api/v1/dog` -> It's a protected endpoint with oidc (assigned to our keycloak instance and `petstore` realm) + +without credentials, it should return `401 Unauthorized` + +```bash +curl -H "Host: example.com" -i "http://127.0.0.1:9080/api/v1/dog" +``` + +To get the authentication token, this example is using Direct Access Grants oauth2 grant type +(also known as Client Credentials grant type). When configuring the Keycloak (OIDC provider) client +settings, we enabled Direct Access Grants to enable this procedure. +We will be authenticating as `bob` user with `p` password. +We previously created `bob` user in Keycloak in the `petstore` realm. + ``` export ACCESS_TOKEN=$(curl -k -H "Content-Type: application/x-www-form-urlencoded" \ -d 'grant_type=password' \ @@ -236,14 +456,42 @@ export ACCESS_TOKEN=$(curl -k -H "Content-Type: application/x-www-form-urlencode -d 'username=bob' \ -d 'password=p' "https://${KEYCLOAK_PUBLIC_DOMAIN}/auth/realms/petstore/protocol/openid-connect/token" | jq -r '.access_token') ``` + > Replace `${KEYCLOAK_PUBLIC_DOMAIN}` with your SSO instance domain + With the access token in place, let's try to get those puppies ```bash curl -H "Authorization: Bearer $ACCESS_TOKEN" -H 'Host: example.com' http://127.0.0.1:9080/api/v1/dog -i ``` -should return 200 Ok + +it should return 200 OK + +- `GET /api/v1/snake` -> It's a protected endpoint with oidc (assigned to our keycloak instance and `petstore` realm) **OR** with apiKey + +This example is to show that multiple security requirements (with *OR* semantics) can be specified +for an OpenAPI operation. + +Without credentials, it should return `401 Unauthorized` + +```bash +curl -H "Host: example.com" -i "http://127.0.0.1:9080/api/v1/snake" +``` + +With the access token in place, it should return 200 OK (unless the token has expired). + +```bash +curl -H "Authorization: Bearer $ACCESS_TOKEN" -H 'Host: example.com' http://127.0.0.1:9080/api/v1/snake -i +``` + +With apiKey it should also work. According to the OpenAPI spec security scheme, +it should be a query string named `snake_token` and the token needs to be valid token +(from the secret `snake-api-key-1` assigned to `GET /api/v1/snake`) + +```bash +curl -H 'Host: example.com' -i "http://127.0.0.1:9080/api/v1/snake?snake_token=I_LIKE_SNAKES" +``` * Clean environment ```bash diff --git a/examples/oas3/petstore-multiple-sec-requirements.yaml b/examples/oas3/petstore-multiple-sec-requirements.yaml index 7afe0ff..91b2184 100644 --- a/examples/oas3/petstore-multiple-sec-requirements.yaml +++ b/examples/oas3/petstore-multiple-sec-requirements.yaml @@ -3,6 +3,15 @@ openapi: "3.1.0" info: title: "Pet Store API" version: "1.0.0" + x-kuadrant: + route: + name: "petstore" + namespace: "petstore" + hostnames: + - example.com + parentRefs: + - name: istio-ingressgateway + namespace: istio-system servers: - url: https://toplevel.example.io/v1 paths: @@ -15,7 +24,7 @@ paths: post: # API key operationId: "postCat" security: - - petstore_api_key: [] + - cat_api_key: [] responses: 405: description: "invalid input" @@ -23,17 +32,30 @@ paths: get: # OIDC operationId: "getDog" security: - - petstore_oidc: + - oidc: - read:dogs responses: 405: description: "invalid input" + /snake: + get: # OIDC or API key + operationId: "getSnake" + security: + - oidc: ["read:snakes"] + - snakes_api_key: [] + responses: + 405: + description: "invalid input" components: securitySchemes: - petstore_api_key: + cat_api_key: type: apiKey name: api_key in: header - petstore_oidc: + oidc: type: openIdConnect - openIdConnectUrl: http://example.org/auth/realms/myrealm + openIdConnectUrl: https://example.com/.well-known/openid-configuration + snakes_api_key: + type: apiKey + name: snake_token + in: query diff --git a/pkg/kuadrantapi/authpolicy.go b/pkg/kuadrantapi/authpolicy.go index b302edd..0a87a46 100644 --- a/pkg/kuadrantapi/authpolicy.go +++ b/pkg/kuadrantapi/authpolicy.go @@ -1,6 +1,9 @@ package kuadrantapi import ( + "errors" + "fmt" + "github.com/getkin/kin-openapi/openapi3" authorinoapi "github.com/kuadrant/authorino/api/v1beta2" kuadrantapiv1beta2 "github.com/kuadrant/kuadrant-operator/api/v1beta2" @@ -12,6 +15,10 @@ import ( "github.com/kuadrant/kuadrantctl/pkg/utils" ) +const ( + APIKeySecretLabel = "kuadrant.io/apikeys-by" +) + func AuthPolicyObjectMetaFromOAS(doc *openapi3.T) metav1.ObjectMeta { return gatewayapi.HTTPRouteObjectMetaFromOAS(doc) } @@ -122,27 +129,10 @@ func AuthPolicyAuthenticationSchemeFromOAS(doc *openapi3.T) map[string]kuadranta kuadrantPathExtension.GetPathMatchType(), ) - oidcScheme := findOIDCSecuritySchemesFromRequirements(doc, secRequirements) - - if oidcScheme == nil { - // no oidc sec scheme found - continue - } - - authName := utils.OpenAPIOperationName(path, verb, operation) + operationAuthentication := buildOperationAuthentication(doc, basePath, path, pathItem, verb, operation, pathMatchType, secRequirements) - authentication[authName] = kuadrantapiv1beta2.AuthenticationSpec{ - CommonAuthRuleSpec: kuadrantapiv1beta2.CommonAuthRuleSpec{ - RouteSelectors: buildAuthPolicyRouteSelectors(basePath, path, pathItem, verb, operation, pathMatchType), - }, - AuthenticationSpec: authorinoapi.AuthenticationSpec{ - AuthenticationMethodSpec: authorinoapi.AuthenticationMethodSpec{ - Jwt: &authorinoapi.JwtAuthenticationSpec{ - IssuerUrl: oidcScheme.OpenIdConnectUrl, - }, - }, - }, - } + // Aggregate auth methods per operation + authentication = utils.MergeMaps(authentication, operationAuthentication) } } @@ -153,23 +143,112 @@ func AuthPolicyAuthenticationSchemeFromOAS(doc *openapi3.T) map[string]kuadranta return authentication } -func findOIDCSecuritySchemesFromRequirements(doc *openapi3.T, secRequirements openapi3.SecurityRequirements) *openapi3.SecurityScheme { +func buildOperationAuthentication(doc *openapi3.T, basePath, path string, pathItem *openapi3.PathItem, verb string, op *openapi3.Operation, pathMatchType gatewayapiv1beta1.PathMatchType, secRequirements openapi3.SecurityRequirements) map[string]kuadrantapiv1beta2.AuthenticationSpec { + // OpenAPI supports as security requirement to have multiple security schemes and ALL + // of the must be satisfied. + // From https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#security-requirement-object + // Kuadrant does not support it yet: https://github.com/Kuadrant/authorino/issues/112 + // not supported (AND'ed) + // security: + // - petstore_api_key: [] + // petstore_oidc: [] + // supported (OR'ed) + // security: + // - petstore_api_key: [] + // - petstore_oidc: [] + + opAuth := make(map[string]kuadrantapiv1beta2.AuthenticationSpec, 0) for _, secReq := range secRequirements { - for secReqItemName := range secReq { - secScheme, ok := doc.Components.SecuritySchemes[secReqItemName] - if !ok { - // should never happen. OpenAPI validation should detect this issue - continue - } - if secScheme == nil || secScheme.Value == nil { - continue - } - // Ref https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#fixed-fields-23 - if secScheme.Value.Type == "openIdConnect" { - return secScheme.Value + if len(secReq) > 1 { + panic(errors.New("multiple schemes that require ALL must be satisfied, currently not supported")) + } + + extractSecReqItemName := func(sr openapi3.SecurityRequirement) string { + for secReqItemName := range sr { + return secReqItemName } + + return "" + } + + secReqItemName := extractSecReqItemName(secReq) + + secScheme, ok := doc.Components.SecuritySchemes[secReqItemName] + if !ok { + // should never happen. OpenAPI validation should detect this issue + continue + } + + if secScheme == nil || secScheme.Value == nil { + continue } + + authName := fmt.Sprintf("%s_%s", utils.OpenAPIOperationName(path, verb, op), secReqItemName) + + // Ref https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#fixed-fields-23 + switch secScheme.Value.Type { + case "openIdConnect": + opAuth[authName] = openIDAuthenticationSpec(basePath, path, pathItem, verb, op, pathMatchType, *secScheme.Value) + case "apiKey": + opAuth[authName] = apiKeyAuthenticationSpec(basePath, path, pathItem, verb, op, pathMatchType, secReqItemName, *secScheme.Value) + } + } + + if len(opAuth) == 0 { + return nil } - return nil + return opAuth +} + +func apiKeyAuthenticationSpec(basePath, path string, pathItem *openapi3.PathItem, verb string, op *openapi3.Operation, pathMatchType gatewayapiv1beta1.PathMatchType, secSchemeName string, secScheme openapi3.SecurityScheme) kuadrantapiv1beta2.AuthenticationSpec { + // From https://github.com/Kuadrant/kuadrantctl/pull/46#issuecomment-1830278191 + // secScheme.In is required + // secScheme.Name is required + credentials := authorinoapi.Credentials{} + switch secScheme.In { + case "query": + credentials.QueryString = &authorinoapi.Named{Name: secScheme.Name} + case "header": + credentials.CustomHeader = &authorinoapi.CustomHeader{ + Named: authorinoapi.Named{Name: secScheme.Name}, + } + case "cookie": + credentials.Cookie = &authorinoapi.Named{Name: secScheme.Name} + } + + return kuadrantapiv1beta2.AuthenticationSpec{ + CommonAuthRuleSpec: kuadrantapiv1beta2.CommonAuthRuleSpec{ + RouteSelectors: buildAuthPolicyRouteSelectors(basePath, path, pathItem, verb, op, pathMatchType), + }, + AuthenticationSpec: authorinoapi.AuthenticationSpec{ + Credentials: credentials, + AuthenticationMethodSpec: authorinoapi.AuthenticationMethodSpec{ + ApiKey: &authorinoapi.ApiKeyAuthenticationSpec{ + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + // label selector be like + // kuadrant.io/apikeys-by: ${SecuritySchemeName} + APIKeySecretLabel: secSchemeName, + }, + }, + }, + }, + }, + } +} + +func openIDAuthenticationSpec(basePath, path string, pathItem *openapi3.PathItem, verb string, op *openapi3.Operation, pathMatchType gatewayapiv1beta1.PathMatchType, secScheme openapi3.SecurityScheme) kuadrantapiv1beta2.AuthenticationSpec { + return kuadrantapiv1beta2.AuthenticationSpec{ + CommonAuthRuleSpec: kuadrantapiv1beta2.CommonAuthRuleSpec{ + RouteSelectors: buildAuthPolicyRouteSelectors(basePath, path, pathItem, verb, op, pathMatchType), + }, + AuthenticationSpec: authorinoapi.AuthenticationSpec{ + AuthenticationMethodSpec: authorinoapi.AuthenticationMethodSpec{ + Jwt: &authorinoapi.JwtAuthenticationSpec{ + IssuerUrl: secScheme.OpenIdConnectUrl, + }, + }, + }, + } } diff --git a/pkg/utils/maps.go b/pkg/utils/maps.go new file mode 100644 index 0000000..c423dc4 --- /dev/null +++ b/pkg/utils/maps.go @@ -0,0 +1,12 @@ +package utils + +func MergeMaps[K comparable, V any](MyMap1 map[K]V, MyMap2 map[K]V) map[K]V { + merged := make(map[K]V) + for key, val := range MyMap1 { + merged[key] = val + } + for key, val := range MyMap2 { + merged[key] = val + } + return merged +}