papsign/Ktor-OpenAPI-Generator

Basic authorization

christiangroth opened this issue · 20 comments

Hi,

I'm trying to get a basic auth to work, unfortunately I'm not successful. Here is what I've done so far:

Installed Ktor Authorization feature:

    install(Authentication) {
        basic {
            realm = BuildProperties.application
            validate { credential ->
                if(credential.name == credential.password) {
                    UserIdPrincipal(credential.name)
                } else {
                    null
                }
            }
        }
    }

Implemented my AuthProvider:

object BasicAuthProvider : AuthProvider<UserIdPrincipal> {
    override val security =
        listOf(
            listOf(
                AuthProvider.Security(
                    SecuritySchemeModel(
                        name = "basicAuth",
                        type = SecuritySchemeType.http,
                        scheme = HttpSecurityScheme.basic,
                    ), emptyList<Scopes>()
                )
            )
        )

    override suspend fun getAuth(pipeline: PipelineContext<Unit, ApplicationCall>): UserIdPrincipal {
        return pipeline.context.authentication.principal() ?: throw RuntimeException("No UserIdPrincipal")
    }

    override fun apply(route: NormalOpenAPIRoute): OpenAPIAuthenticatedRoute<UserIdPrincipal> {
        val authenticatedKtorRoute = route.ktorRoute.authenticate { }
        return OpenAPIAuthenticatedRoute(authenticatedKtorRoute, authProvider = this)
    }
}

enum class Scopes(override val description: String) : Described {
    Profile("Some scope")
}

Implemented shortcut extension function for routing definitions:

inline fun NormalOpenAPIRoute.auth(route: OpenAPIAuthenticatedRoute<UserIdPrincipal>.() -> Unit): OpenAPIAuthenticatedRoute<UserIdPrincipal> {
    return BasicAuthProvider.apply(this).apply { route() }
}

Enhanced an existing route to use basic auth:

fun NormalOpenAPIRoute.versionApi() {
    route("version") {
        auth {
            get<Unit, VersionResponse>(
                EndpointInfo("Version info", "Returns information about the current version this service runs in."),
                tags(Metadata),
            ) {
                pipeline.call.respond(VersionResponse())
            }
        }
    }
}

The result is, that my /version route exists, is listed in swagger-ui / openapi.json but is not authenticated. Also I don't get auth information in swagger-ui / openapi.json. I'm quite sure I'm close, but I don't get what I'm missing right now? Docs and examples are not that helpful at that point, neither existing issues ... or I just don't get it.

Thanks for your help, Chris

Not sure if the output of /openapi.json is also helpful:

// 20210316155318
// http://localhost:8080/openapi.json

{
  "components": {
    "schemas": {
      "de.espirit.todoapp.VersionResponse": {
        "nullable": false,
        "properties": {
          "gitBranch": {
            "nullable": false,
            "type": "string"
          },
          "gitHash": {
            "nullable": false,
            "type": "string"
          },
          "version": {
            "nullable": false,
            "type": "string"
          }
        },
        "required": [
          "gitBranch",
          "gitHash",
          "version"
        ],
        "type": "object"
      }
    }
  },
  "info": {
    "description": "This API allows to manage simple todo items. \nAll items are kept in memory only, so restarting the service will result in data loss.\n\nPlease do not hesitate to contact the team in case of issues or questions.",
    "title": "HTTP Service Template",
    "version": "PX-48-SNAPSHOT"
  },
  "openapi": "3.0.0",
  "paths": {
    "/api/todos": {
      ...
    },
    "/api/todos/{id}": {
      ...
    },
    "/version": {
      "get": {
        "description": "Returns information about the current version this service runs in.",
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/de.espirit.todoapp.VersionResponse"
                }
              }
            },
            "description": "OK"
          }
        },
        "summary": "Version info",
        "tags": [
          "Metadata"
        ]
      }
    }
  },
  "tags": [
    {
      "description": "An API to manage simple todo items.",
      "name": "TODOs API"
    },
    {
      "description": "Several endpoints for requesting service metadata.",
      "name": "Metadata"
    }
  ]
}

maybe you need to inherit the route provider as well like this:

OpenAPIAuthenticatedRoute(this.ktorRoute.authenticate(authName) {}, this.provider.child(), this).throws(
    APIException.apiException<BadPrincipalException>(HttpStatusCode.Unauthorized)
)

Or use a named authenication.

you could also change .apply { route() } with .apply(route) but in theory it means the same.

Ah wait: you used route.ktorroute instead of this.ktorroute

Hi @Wicpar , thanks for your quick reply!

Ah wait: you used route.ktorroute instead of this.ktorroute

Yeah, because I'm inside AuthProvider and don't have this.ktorRoute there, right?
Edit: I think "your this" is "my route" ... ;) Because I use it that way

inline fun NormalOpenAPIRoute.auth(route: OpenAPIAuthenticatedRoute<UserIdPrincipal>.() -> Unit): OpenAPIAuthenticatedRoute<UserIdPrincipal> {
    return BasicAuthProvider.apply(this).apply { route() }
}

maybe you need to inherit the route provider as well like this:

OpenAPIAuthenticatedRoute(this.ktorRoute.authenticate(authName) {}, this.provider.child(), this).throws(
    APIException.apiException<BadPrincipalException>(HttpStatusCode.Unauthorized)
)

Or use a named authenication.

you could also change .apply { route() } with .apply(route) but in theory it means the same.

Not 100% sure I already tried these, but I'll double check it in the office tomorrow.

So, unfortunately neither the named authentication nor the inherited provider did change anything. Also changing .apply { route() } to .apply(route) did change anything.

A last suggestion that came to my mind is regarding the Ktor version. I saw you use 1.3.2 (https://github.com/papsign/Ktor-OpenAPI-Generator/blob/master/gradle.properties). I'm on 1.5.2. Are there any known incompatibilities?

Not to my knowledge

I have it setup like this:

class OAuth2Provider(scopes: List<T>) : AuthProvider<A> {
override suspend fun getAuth(pipeline: PipelineContext<Unit, ApplicationCall>): A =
    this@OAuth2Handler.getAuth(pipeline.call.principal()!!)

override fun apply(route: NormalOpenAPIRoute): OpenAPIAuthenticatedRoute<A> =
    OpenAPIAuthenticatedRoute(route.ktorRoute.authenticate(authName) {}, route.provider.child(), this).throws(
        APIException.apiException<BadPrincipalException>(HttpStatusCode.Unauthorized)
    )

  override val security: Iterable<Iterable<AuthProvider.Security<*>>> =
      listOf(listOf(AuthProvider.Security(scheme, scopes)))
}

fun auth(apiRoute: NormalOpenAPIRoute, scopes: List<T>): OpenAPIAuthenticatedRoute<A> {
  val authProvider = OAuth2Provider(scopes)
  return authProvider.apply(apiRoute)
}

Found the error! It wasn't on the side of implementing AuthHandler, but on the usage side. Don't want to be too harsh here, but that's kind of bad API or at least really easy to mess up.

Here's the situation before:
image

And that's the fixed one:
image

So the issue was, the Import of the get method still pointed to normal package instead of auth. I would have expected an compile error, because inside the auth-Block this points to OpenAPIAuthenticatedRoute and not to NormalOpenAPIRoute.

Please don't get me wrong, you're still doing a great job and I like the project very much! Thank you very much for your quick feedback and help. I will now see that I implement my usecases, but I think nothing stands in the way now. :)

Edit: Of course the third generic parameter was also missing, but especially if you have normal and auth routes in one file, you need both imports and I bet at leat I will mess it up :D Using explicit this.get<....>(...) will lead to expected compile error, checked that, but implicit this won't.

Got some last minor questions, not sure if I may open separate issues for them, please just let me know:

  1. the route is now properly intercepted and authorized, also swagger-ui shows my BasicAuth as part of Available authorizations in the top. However, regarding the route, I see th symbol that it's authorized, but if I click it, the Available authorizations are empty.

Global:
image

Route:
image

  1. When declaring example for API throws, they are not used in OpanAPI as I would have expected.

Code :

@Serializable
data class ResponseError(val code: Int, val description: String, val message: String? = null) {
    constructor(statusCode: HttpStatusCode, message: String? = null) : this(statusCode.value, statusCode.description, message)
}

object BasicAuthProvider : AuthProvider<UserIdPrincipal> {

    [...]

    override fun apply(route: NormalOpenAPIRoute): OpenAPIAuthenticatedRoute<UserIdPrincipal> {
        return OpenAPIAuthenticatedRoute(route.ktorRoute.authenticate { }, route.provider.child(), this)
            .throws(
                status = HttpStatusCode.Unauthorized,
                // TODO not used in openApi.json
                example = ResponseError(HttpStatusCode.Unauthorized, "Missing authorization to access this route."),
                gen = { e: UnauthorizedException -> return@throws ResponseError(HttpStatusCode.Unauthorized, e.message) }
            )
            .throws(
                status = HttpStatusCode.Forbidden,
                example = ResponseError(HttpStatusCode.Forbidden, "Insufficient access permissions for this route."),
                gen = { e: ForbiddenException -> return@throws ResponseError(HttpStatusCode.Forbidden, e.message) }
            )
    }
}

Result in Swagger-UI:
image

I would have expected the default values of created ResponseError instanced for example parameters to be present in the UI as well.

  1. Regarding the thows-Information from 2). Is it possible to define a custom description? Right now only the name of the Status Code is shown. For 401 and 403 it is quite obvious, but if one introduces let's say a HTTP 409 Conflict a more detailed description would be helpful for the API users.

EDIT: Added openapi.json

// 20210318092817
// http://localhost:8080/openapi.json

{
  "components": {
    "schemas": {
      "de.espirit.todoapp.ResponseError": {
        "nullable": false,
        "properties": {
          "code": {
            "format": "int32",
            "nullable": false,
            "type": "integer"
          },
          "description": {
            "nullable": false,
            "type": "string"
          },
          "message": {
            "nullable": true,
            "type": "string"
          }
        },
        "required": [
          "code",
          "description"
        ],
        "type": "object"
      },
      "de.espirit.todoapp.Todo": {
        "nullable": false,
        "properties": {
          "id": {
            "nullable": false,
            "type": "string"
          },
          "text": {
            "nullable": true,
            "type": "string"
          },
          "title": {
            "nullable": false,
            "type": "string"
          }
        },
        "required": [
          "id",
          "title"
        ],
        "type": "object"
      },
      "de.espirit.todoapp.TodoRequestData": {
        "nullable": false,
        "properties": {
          "text": {
            "nullable": true,
            "type": "string"
          },
          "title": {
            "nullable": false,
            "type": "string"
          }
        },
        "required": [
          "title"
        ],
        "type": "object"
      },
      "de.espirit.todoapp.VersionResponse": {
        "nullable": false,
        "properties": {
          "gitBranch": {
            "nullable": false,
            "type": "string"
          },
          "gitHash": {
            "nullable": false,
            "type": "string"
          },
          "version": {
            "nullable": false,
            "type": "string"
          }
        },
        "required": [
          "gitBranch",
          "gitHash",
          "version"
        ],
        "type": "object"
      }
    },
    "securitySchemes": {
      "basicAuth": {
        "name": "basicAuth",
        "scheme": "basic",
        "type": "http"
      }
    }
  },
  "info": {
    "contact": {
      "email": "team-delivery_platform@e-spirit.com",
      "name": "Team Delivery Platform"
    },
    "description": "This API allows to manage simple todo items. \nAll items are kept in memory only, so restarting the service will result in data loss.\n\nPlease do not hesitate to contact the team in case of issues or questions.",
    "title": "HTTP Service Template",
    "version": "PX-48-SNAPSHOT"
  },
  "openapi": "3.0.0",
  "paths": {
    "/api/todos": {
      "post": {
        "description": "Creates a new todo with given data.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/de.espirit.todoapp.TodoRequestData"
              }
            }
          }
        },
        "responses": {
          "201": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/de.espirit.todoapp.Todo"
                }
              }
            },
            "description": "Created"
          }
        },
        "summary": "Create todos",
        "tags": [
          "TODOs API"
        ]
      },
      "get": {
        "description": "Lists all todos.",
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "items": {
                    "$ref": "#/components/schemas/de.espirit.todoapp.Todo"
                  },
                  "nullable": false,
                  "type": "array"
                }
              }
            },
            "description": "OK"
          }
        },
        "summary": "List todos",
        "tags": [
          "TODOs API"
        ]
      }
    },
    "/api/todos/{id}": {
      "get": {
        "description": "Retrieve the todo identified by path parameter, or HTTP 404 Not Found.",
        "parameters": [
          {
            "deprecated": false,
            "description": "Todo id path parameter",
            "explode": false,
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "nullable": false,
              "type": "string"
            },
            "style": "simple"
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/de.espirit.todoapp.Todo"
                }
              }
            },
            "description": "OK"
          },
          "404": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/de.espirit.todoapp.ResponseError"
                }
              }
            },
            "description": "Not Found"
          }
        },
        "summary": "Retrieve todo",
        "tags": [
          "TODOs API"
        ]
      },
      "delete": {
        "description": "Deleted the todo identified by path parameter, or HTTP 404 Not Found.",
        "parameters": [
          {
            "deprecated": false,
            "description": "Todo id path parameter",
            "explode": false,
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "nullable": false,
              "type": "string"
            },
            "style": "simple"
          }
        ],
        "responses": {
          "204": {
            "description": "No Content"
          },
          "404": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/de.espirit.todoapp.ResponseError"
                }
              }
            },
            "description": "Not Found"
          }
        },
        "summary": "Delete todo",
        "tags": [
          "TODOs API"
        ]
      }
    },
    "/version": {
      "get": {
        "description": "Returns information about the current version this service runs in.",
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/de.espirit.todoapp.VersionResponse"
                }
              }
            },
            "description": "OK"
          },
          "401": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/de.espirit.todoapp.ResponseError"
                }
              }
            },
            "description": "Unauthorized"
          },
          "403": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/de.espirit.todoapp.ResponseError"
                }
              }
            },
            "description": "Forbidden"
          }
        },
        "security": [
          {
            "entries": [
              null
            ],
            "keys": [
              "basicAuth"
            ],
            "size": 1,
            "values": [
              [
                
              ]
            ]
          }
        ],
        "summary": "Version info",
        "tags": [
          "Metadata"
        ]
      }
    }
  },
  "tags": [
    {
      "description": "An API to manage simple todo items.",
      "name": "TODOs API"
    },
    {
      "description": "Several endpoints for requesting service metadata.",
      "name": "Metadata"
    }
  ]
}

Ah yes Indeed...
There is no way around that in ktor sadly as you cannot stop scope inheritance.
The only way to change that is to change the syntax entirely to use an objection based method instead of fixed type parameters.

For your questions:
1: iirc it worked for oauth, maybe extra definitions are needed for basic auth
2: it could be a regression, can you try an earlier version from a few monts ago ?

  1. Regarding to https://swagger.io/docs/specification/authentication/basic-authentication/ three things are needed:
  • an entry in components.securitySchemes (here the name attribute is generated, which should be the key of the entry, see screenshot)
  • an entry (empty array) in security with the same name (that's missing)
  • an entry in the security array under path.http-verb (the entry is there, but looks wrong, see screenshot)

image

  1. Tried 0.2-beta.8, 0.2-beta.10, 0.2-beta.13 .. does not work in any of these

Do you have any feedback on 3)?

Didn't see 3 you can simply edit the description in the status code class.

1 seems like a regression, i'll look into it
2 is an oversight then i'll look into it as well

@christiangroth 2 works properly when i copy your code, the one difference is that i don' t have a @serialize annotation so I removed it.
image

@christiangroth what is your jackson configuration for the server ?

install(io.ktor.features.ContentNegotiation) {
            jackson {
                enable(
                    com.fasterxml.jackson.databind.DeserializationFeature.WRAP_EXCEPTIONS,
                    com.fasterxml.jackson.databind.DeserializationFeature.USE_BIG_INTEGER_FOR_INTS,
                    com.fasterxml.jackson.databind.DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS
                )

                enable(com.fasterxml.jackson.databind.SerializationFeature.WRAP_EXCEPTIONS, com.fasterxml.jackson.databind.SerializationFeature.INDENT_OUTPUT)

                setSerializationInclusion(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL)

                setDefaultPrettyPrinter(com.fasterxml.jackson.core.util.DefaultPrettyPrinter().apply {
                    indentArraysWith(com.fasterxml.jackson.core.util.DefaultPrettyPrinter.FixedSpaceIndenter.instance)
                    indentObjectsWith(com.fasterxml.jackson.core.util.DefaultIndenter("  ", "\n"))
                })

                registerModule(com.fasterxml.jackson.datatype.jsr310.JavaTimeModule())
            }
        }

We don't use Jackson, but kotlinx-serialization instead. I took the code snippet from #42 to manage DataModel serialization.

It looks like the origin of your issue, you'll have to debug that on your own as i know nothing of kotlinx/serialization.

Yeah, I also thought that. I'll try to finde some time next week and come back to you / keep this issue updated.

So I solved 2 and 3 by fixing my models. The DataModel inheritance was missing, so obviously I did not work for custom types.

If you have any updates on the possible regression regarding 1) within the next days, that would be fine. Thx :)

@christiangroth hey, i am also trying to figure out this issue. Is it possible to provide your full example? Thanks

@hmmeral I'm not sure what you're missing, so I just copy the complete code again (just removed some internal details) :) Hope that helps.

Definition side:

import com.papsign.ktor.openapigen.model.Described
import com.papsign.ktor.openapigen.model.security.HttpSecurityScheme
import com.papsign.ktor.openapigen.model.security.SecuritySchemeModel
import com.papsign.ktor.openapigen.model.security.SecuritySchemeType
import com.papsign.ktor.openapigen.modules.providers.AuthProvider
import com.papsign.ktor.openapigen.route.path.auth.OpenAPIAuthenticatedRoute
import com.papsign.ktor.openapigen.route.path.normal.NormalOpenAPIRoute
import com.papsign.ktor.openapigen.route.throws
import io.ktor.application.*
import io.ktor.auth.*
import io.ktor.features.*
import io.ktor.http.*
import io.ktor.util.pipeline.*

const val BASIC_AUTHENTICATION_METRICS = "metrics"

fun Application.installAuthenticationFeature() {
    install(Authentication) {

        val metricsUser = environment.config.property(ConfigKey.METRICS_BASIC_AUTH_USER.path)?.getString()
        val metricsPassword = environment.config.property(ConfigKey.METRICS_BASIC_AUTH_PASSWORD.path)?.getString()
        basic(BASIC_AUTHENTICATION_METRICS) {
            realm = BuildProperties.application
            validate { credential ->
                if (credential.name == metricsUser && credential.password == metricsPassword) {
                    UserIdPrincipal(credential.name)
                } else {
                    null
                }
            }
        }
    }
}

inline fun NormalOpenAPIRoute.auth(route: OpenAPIAuthenticatedRoute<UserIdPrincipal>.() -> Unit): OpenAPIAuthenticatedRoute<UserIdPrincipal> {
    return BasicAuthProvider.apply(this).apply(route)
}

class UnauthorizedException(message: String) : RuntimeException(message)
class ForbiddenException(message: String) : RuntimeException(message)

// even if we don't need scopes at all, an empty enum has to be there, see https://github.com/papsign/Ktor-OpenAPI-Generator/issues/65
enum class Scopes : Described

object BasicAuthProvider : AuthProvider<UserIdPrincipal> {

    // description for OpenAPI model
    override val security =
        listOf(
            listOf(
                AuthProvider.Security(
                    SecuritySchemeModel(
                        name = "basicAuth",
                        type = SecuritySchemeType.http,
                        scheme = HttpSecurityScheme.basic,
                    ), emptyList<Scopes>()
                )
            )
        )

    // gets auth information at runtime
    override suspend fun getAuth(pipeline: PipelineContext<Unit, ApplicationCall>): UserIdPrincipal {
        return pipeline.context.authentication.principal()
            ?: throw UnauthorizedException("Unable to verify given credentials, or credentials are missing.")
    }

    // convert normal route to authenticated route including OpenAPI meta information
    // TODO OpenAPI: Not listed as available auths at path level
    override fun apply(route: NormalOpenAPIRoute): OpenAPIAuthenticatedRoute<UserIdPrincipal> {
        return OpenAPIAuthenticatedRoute(route.ktorRoute.authenticate(BASIC_AUTHENTICATION_METRICS) { }, route.provider.child(), this)
            .throws(
                status = HttpStatusCode.Unauthorized.description("Your identity could not be verified."),
                example = ResponseError(HttpStatusCode.Unauthorized, "Missing authorization to access this route."),
                gen = { e: UnauthorizedException -> return@throws ResponseError(HttpStatusCode.Unauthorized, e.message) }
            )
            .throws(
                status = HttpStatusCode.Forbidden.description("Your access rights are insufficient."),
                example = ResponseError(HttpStatusCode.Forbidden, "Insufficient access permissions for this route."),
                gen = { e: ForbiddenException -> return@throws ResponseError(HttpStatusCode.Forbidden, e.message) }
            )
    }
}

And the usage side:

import com.papsign.ktor.openapigen.route.EndpointInfo
import com.papsign.ktor.openapigen.route.path.auth.get
import com.papsign.ktor.openapigen.route.path.normal.NormalOpenAPIRoute
import com.papsign.ktor.openapigen.route.response.respond
import com.papsign.ktor.openapigen.route.route
import com.papsign.ktor.openapigen.route.tags
import io.ktor.application.*
import io.ktor.auth.*
import io.ktor.metrics.micrometer.*
import io.micrometer.core.instrument.ImmutableTag
import io.micrometer.prometheus.PrometheusConfig
import io.micrometer.prometheus.PrometheusMeterRegistry
import io.micrometer.prometheus.PrometheusRenameFilter

val appMicrometerRegistry = PrometheusMeterRegistry(PrometheusConfig.DEFAULT)

fun Application.installMetricsFeature() {
    install(MicrometerMetrics) {
        registry = appMicrometerRegistry

        // we also want the build information to be part of the metrics, i.e. this helps showing versions on dashboards
        // see https://www.robustperception.io/exposing-the-software-version-to-prometheus
        registry.gauge("build_info", listOf(
            ImmutableTag("application", BuildProperties.application),
            ImmutableTag("gitBranch", BuildProperties.gitBranch),
            ImmutableTag("gitHash", BuildProperties.gitHash),
            ImmutableTag("version", BuildProperties.version),
        ), 1)
    }
}

fun NormalOpenAPIRoute.metricsApi() {
    route("metrics") {
        auth {
            get<Unit, String, UserIdPrincipal>(
                EndpointInfo("Metrics", "Returns information about the current metrics for this service instance."),
                tags(Metadata),
            ) {
                respond(appMicrometerRegistry.scrape())
            }
        }
    }
}