feat: auto auth detect (#299)

This commit is contained in:
Ryan Brink
2022-08-19 09:46:41 -06:00
committed by GitHub
parent 196fba790a
commit e589d917c8
13 changed files with 497 additions and 28 deletions

View File

@ -15,6 +15,13 @@
## Released ## Released
## [3.1.0] - August 18th, 2022
### Added
- Ability to automatically detect authentication via route
### Fixed
- Improved stack trace output
## [3.0.0] - August 16th, 2022 ## [3.0.0] - August 16th, 2022
### Added ### Added
- Ktor 2 Support 🎉 - Ktor 2 Support 🎉

View File

@ -49,6 +49,12 @@ dependencies {
testFixturesApi("io.ktor:ktor-serialization-gson:$ktorVersion") testFixturesApi("io.ktor:ktor-serialization-gson:$ktorVersion")
testFixturesApi("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion") testFixturesApi("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion")
testFixturesApi("io.ktor:ktor-server-content-negotiation:$ktorVersion") testFixturesApi("io.ktor:ktor-server-content-negotiation:$ktorVersion")
testFixturesApi("io.ktor:ktor-server-auth:$ktorVersion")
testFixturesApi("io.ktor:ktor-server-auth-jwt:$ktorVersion")
testFixturesApi("io.ktor:ktor-client:$ktorVersion")
testFixturesApi("io.ktor:ktor-client-cio:$ktorVersion")
testFixturesApi("dev.forst:ktor-api-key:2.1.0")
testFixturesApi("org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.0") testFixturesApi("org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.0")
} }

View File

@ -11,6 +11,7 @@ import io.bkbn.kompendium.core.metadata.PutInfo
import io.bkbn.kompendium.core.util.Helpers.addToSpec import io.bkbn.kompendium.core.util.Helpers.addToSpec
import io.bkbn.kompendium.core.util.SpecConfig import io.bkbn.kompendium.core.util.SpecConfig
import io.bkbn.kompendium.oas.path.Path import io.bkbn.kompendium.oas.path.Path
import io.bkbn.kompendium.oas.path.PathOperation
import io.bkbn.kompendium.oas.payload.Parameter import io.bkbn.kompendium.oas.payload.Parameter
import io.ktor.server.application.ApplicationCallPipeline import io.ktor.server.application.ApplicationCallPipeline
import io.ktor.server.application.Hook import io.ktor.server.application.Hook
@ -44,11 +45,13 @@ object NotarizedRoute {
createConfiguration = ::Config createConfiguration = ::Config
) { ) {
// This is required in order to introspect the route path // This is required in order to introspect the route path and authentication
on(InstallHook) { on(InstallHook) {
val route = it as? Route ?: return@on val route = it as? Route ?: return@on
val spec = application.attributes[KompendiumAttributes.openApiSpec] val spec = application.attributes[KompendiumAttributes.openApiSpec]
val routePath = route.calculateRoutePath() val routePath = route.calculateRoutePath()
val authMethods = route.collectAuthMethods()
pluginConfig.path?.addDefaultAuthMethods(authMethods)
require(spec.paths[routePath] == null) { require(spec.paths[routePath] == null) {
""" """
The specified path ${Parameter.Location.path} has already been documented! The specified path ${Parameter.Location.path} has already been documented!
@ -76,4 +79,33 @@ object NotarizedRoute {
} }
private fun Route.calculateRoutePath() = toString().replace(Regex("/\\(.+\\)"), "") private fun Route.calculateRoutePath() = toString().replace(Regex("/\\(.+\\)"), "")
private fun Route.collectAuthMethods() = toString()
.split("/")
.filter { it.contains(Regex("\\(authenticate .*\\)")) }
.map { it.replace("(authenticate ", "").replace(")", "") }
.map { it.split(", ") }
.flatten()
private fun Path.addDefaultAuthMethods(methods: List<String>) {
get?.addDefaultAuthMethods(methods)
put?.addDefaultAuthMethods(methods)
post?.addDefaultAuthMethods(methods)
delete?.addDefaultAuthMethods(methods)
options?.addDefaultAuthMethods(methods)
head?.addDefaultAuthMethods(methods)
patch?.addDefaultAuthMethods(methods)
trace?.addDefaultAuthMethods(methods)
}
private fun PathOperation.addDefaultAuthMethods(methods: List<String>) {
methods.forEach { m ->
if (security == null || security?.all { s -> !s.containsKey(m) } == true) {
if (security == null) {
security = mutableListOf(mapOf(m to emptyList()))
} else {
security?.add(mapOf(m to emptyList()))
}
}
}
}
} }

View File

@ -70,7 +70,7 @@ object Helpers {
security = config.security security = config.security
?.map { (k, v) -> k to v } ?.map { (k, v) -> k to v }
?.map { listOf(it).toMap() } ?.map { listOf(it).toMap() }
?.toList(), ?.toMutableList(),
requestBody = when (this) { requestBody = when (this) {
is MethodInfoWithRequest -> Request( is MethodInfoWithRequest -> Request(
description = this.request.description, description = this.request.description,

View File

@ -1,8 +1,11 @@
package io.bkbn.kompendium.core package io.bkbn.kompendium.core
import dev.forst.ktor.apikey.apiKey
import io.bkbn.kompendium.core.fixtures.TestHelpers.openApiTestAllSerializers import io.bkbn.kompendium.core.fixtures.TestHelpers.openApiTestAllSerializers
import io.bkbn.kompendium.core.util.TestModules.complexRequest import io.bkbn.kompendium.core.util.TestModules.complexRequest
import io.bkbn.kompendium.core.util.TestModules.customAuthConfig
import io.bkbn.kompendium.core.util.TestModules.dateTimeString import io.bkbn.kompendium.core.util.TestModules.dateTimeString
import io.bkbn.kompendium.core.util.TestModules.defaultAuthConfig
import io.bkbn.kompendium.core.util.TestModules.defaultField import io.bkbn.kompendium.core.util.TestModules.defaultField
import io.bkbn.kompendium.core.util.TestModules.defaultParameter import io.bkbn.kompendium.core.util.TestModules.defaultParameter
import io.bkbn.kompendium.core.util.TestModules.exampleParams import io.bkbn.kompendium.core.util.TestModules.exampleParams
@ -16,6 +19,7 @@ import io.bkbn.kompendium.core.util.TestModules.genericPolymorphicResponse
import io.bkbn.kompendium.core.util.TestModules.genericPolymorphicResponseMultipleImpls import io.bkbn.kompendium.core.util.TestModules.genericPolymorphicResponseMultipleImpls
import io.bkbn.kompendium.core.util.TestModules.gnarlyGenericResponse import io.bkbn.kompendium.core.util.TestModules.gnarlyGenericResponse
import io.bkbn.kompendium.core.util.TestModules.headerParameter import io.bkbn.kompendium.core.util.TestModules.headerParameter
import io.bkbn.kompendium.core.util.TestModules.multipleAuthStrategies
import io.bkbn.kompendium.core.util.TestModules.multipleExceptions import io.bkbn.kompendium.core.util.TestModules.multipleExceptions
import io.bkbn.kompendium.core.util.TestModules.nestedGenericCollection import io.bkbn.kompendium.core.util.TestModules.nestedGenericCollection
import io.bkbn.kompendium.core.util.TestModules.nestedGenericMultipleParamsCollection import io.bkbn.kompendium.core.util.TestModules.nestedGenericMultipleParamsCollection
@ -46,7 +50,22 @@ import io.bkbn.kompendium.core.util.TestModules.simpleRecursive
import io.bkbn.kompendium.core.util.TestModules.trailingSlash import io.bkbn.kompendium.core.util.TestModules.trailingSlash
import io.bkbn.kompendium.core.util.TestModules.withOperationId import io.bkbn.kompendium.core.util.TestModules.withOperationId
import io.bkbn.kompendium.json.schema.definition.TypeDefinition import io.bkbn.kompendium.json.schema.definition.TypeDefinition
import io.bkbn.kompendium.oas.component.Components
import io.bkbn.kompendium.oas.security.ApiKeyAuth
import io.bkbn.kompendium.oas.security.BasicAuth
import io.bkbn.kompendium.oas.security.BearerAuth
import io.bkbn.kompendium.oas.security.OAuth
import io.kotest.core.spec.style.DescribeSpec import io.kotest.core.spec.style.DescribeSpec
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.http.HttpMethod
import io.ktor.server.application.install
import io.ktor.server.auth.Authentication
import io.ktor.server.auth.OAuthServerSettings
import io.ktor.server.auth.UserIdPrincipal
import io.ktor.server.auth.basic
import io.ktor.server.auth.jwt.jwt
import io.ktor.server.auth.oauth
import kotlin.reflect.typeOf import kotlin.reflect.typeOf
import java.time.Instant import java.time.Instant
@ -190,7 +209,7 @@ class KompendiumTest : DescribeSpec({
// TODO Assess strategies here // TODO Assess strategies here
} }
it("Can serialize a recursive type") { it("Can serialize a recursive type") {
openApiTestAllSerializers("T0042__simple_recursive.json") { simpleRecursive() } openApiTestAllSerializers("T0042__simple_recursive.json") { simpleRecursive() }
} }
it("Nullable fields do not lead to doom") { it("Nullable fields do not lead to doom") {
openApiTestAllSerializers("T0036__nullable_fields.json") { nullableNestedObject() } openApiTestAllSerializers("T0036__nullable_fields.json") { nullableNestedObject() }
@ -219,4 +238,100 @@ class KompendiumTest : DescribeSpec({
describe("Free Form") { describe("Free Form") {
// todo Assess strategies here // todo Assess strategies here
} }
describe("Authentication") {
it("Can add a default auth config by default") {
openApiTestAllSerializers(
snapshotName = "T0045__default_auth_config.json",
applicationSetup = {
install(Authentication) {
basic("basic") {
realm = "Ktor Server"
validate { UserIdPrincipal("Placeholder") }
}
}
},
specOverrides = {
this.copy(
components = Components(
securitySchemes = mutableMapOf(
"basic" to BasicAuth()
)
)
)
}
) { defaultAuthConfig() }
}
it("Can provide custom auth config with proper scopes") {
openApiTestAllSerializers(
snapshotName = "T0046__custom_auth_config.json",
applicationSetup = {
install(Authentication) {
oauth("auth-oauth-google") {
urlProvider = { "http://localhost:8080/callback" }
providerLookup = {
OAuthServerSettings.OAuth2ServerSettings(
name = "google",
authorizeUrl = "https://accounts.google.com/o/oauth2/auth",
accessTokenUrl = "https://accounts.google.com/o/oauth2/token",
requestMethod = HttpMethod.Post,
clientId = "DUMMY_VAL",
clientSecret = "DUMMY_VAL",
defaultScopes = listOf("https://www.googleapis.com/auth/userinfo.profile"),
extraTokenParameters = listOf("access_type" to "offline")
)
}
client = HttpClient(CIO)
}
}
},
specOverrides = {
this.copy(
components = Components(
securitySchemes = mutableMapOf(
"auth-oauth-google" to OAuth(
flows = OAuth.Flows(
implicit = OAuth.Flows.Implicit(
authorizationUrl = "https://accounts.google.com/o/oauth2/auth",
scopes = mapOf(
"write:pets" to "modify pets in your account",
"read:pets" to "read your pets"
)
)
)
)
)
)
)
}
) { customAuthConfig() }
}
it("Can provide multiple authentication strategies") {
openApiTestAllSerializers(
snapshotName = "T0047__multiple_auth_strategies.json",
applicationSetup = {
install(Authentication) {
apiKey("api-key") {
headerName = "X-API-KEY"
validate {
UserIdPrincipal("Placeholder")
}
}
jwt("jwt") {
realm = "Server"
}
}
},
specOverrides = {
this.copy(
components = Components(
securitySchemes = mutableMapOf(
"jwt" to BearerAuth("JWT"),
"api-key" to ApiKeyAuth(ApiKeyAuth.ApiKeyLocation.HEADER, "X-API-KEY")
)
)
)
}
) { multipleAuthStrategies() }
}
}
}) })

View File

@ -35,8 +35,10 @@ import io.bkbn.kompendium.oas.payload.Parameter
import io.ktor.http.HttpStatusCode import io.ktor.http.HttpStatusCode
import io.ktor.server.application.call import io.ktor.server.application.call
import io.ktor.server.application.install import io.ktor.server.application.install
import io.ktor.server.auth.authenticate
import io.ktor.server.response.respond import io.ktor.server.response.respond
import io.ktor.server.response.respondText import io.ktor.server.response.respondText
import io.ktor.server.routing.Route
import io.ktor.server.routing.Routing import io.ktor.server.routing.Routing
import io.ktor.server.routing.delete import io.ktor.server.routing.delete
import io.ktor.server.routing.get import io.ktor.server.routing.get
@ -604,24 +606,68 @@ object TestModules {
fun Routing.simpleRecursive() = basicGetGenerator<ColumnSchema>() fun Routing.simpleRecursive() = basicGetGenerator<ColumnSchema>()
private inline fun <reified T> Routing.basicGetGenerator( fun Routing.defaultAuthConfig() {
params: List<Parameter> = emptyList(), authenticate("basic") {
operationId: String? = null route(rootPath) {
) { basicGetGenerator<TestResponse>()
route(rootPath) { }
install(NotarizedRoute()) { }
get = GetInfo.builder { }
summary(defaultPathSummary)
description(defaultPathDescription) fun Routing.customAuthConfig() {
operationId?.let { operationId(it) } authenticate("auth-oauth-google") {
parameters = params route(rootPath) {
response { install(NotarizedRoute()) {
description(defaultResponseDescription) get = GetInfo.builder {
responseCode(HttpStatusCode.OK) summary(defaultPathSummary)
responseType<T>() description(defaultPathDescription)
response {
description(defaultResponseDescription)
responseCode(HttpStatusCode.OK)
responseType<TestResponse>()
}
security = mapOf(
"auth-oauth-google" to listOf("read:pets")
)
} }
} }
} }
} }
} }
fun Routing.multipleAuthStrategies() {
authenticate("jwt", "api-key") {
route(rootPath) {
basicGetGenerator<TestResponse>()
}
}
}
private inline fun <reified T> Routing.basicGetGenerator(
params: List<Parameter> = emptyList(),
operationId: String? = null
) {
route(rootPath) {
basicGetGenerator<T>(params, operationId)
}
}
private inline fun <reified T> Route.basicGetGenerator(
params: List<Parameter> = emptyList(),
operationId: String? = null
) {
install(NotarizedRoute()) {
get = GetInfo.builder {
summary(defaultPathSummary)
description(defaultPathDescription)
operationId?.let { operationId(it) }
parameters = params
response {
description(defaultResponseDescription)
responseCode(HttpStatusCode.OK)
responseType<T>()
}
}
}
}
} }

View File

@ -0,0 +1,82 @@
{
"openapi": "3.1.0",
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "Test API",
"version": "1.33.7",
"description": "An amazing, fully-ish 😉 generated API spec",
"termsOfService": "https://example.com",
"contact": {
"name": "Homer Simpson",
"url": "https://gph.is/1NPUDiM",
"email": "chunkylover53@aol.com"
},
"license": {
"name": "MIT",
"url": "https://github.com/bkbnio/kompendium/blob/main/LICENSE"
}
},
"servers": [
{
"url": "https://myawesomeapi.com",
"description": "Production instance of my API"
},
{
"url": "https://staging.myawesomeapi.com",
"description": "Where the fun stuff happens"
}
],
"paths": {
"/": {
"get": {
"tags": [],
"summary": "Great Summary!",
"description": "testing more",
"parameters": [],
"responses": {
"200": {
"description": "A Successful Endeavor",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TestResponse"
}
}
}
}
},
"deprecated": false,
"security": [
{
"basic": []
}
]
},
"parameters": []
}
},
"webhooks": {},
"components": {
"schemas": {
"TestResponse": {
"type": "object",
"properties": {
"c": {
"type": "string"
}
},
"required": [
"c"
]
}
},
"securitySchemes": {
"basic": {
"type": "http",
"scheme": "basic"
}
}
},
"security": [],
"tags": []
}

View File

@ -0,0 +1,92 @@
{
"openapi": "3.1.0",
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "Test API",
"version": "1.33.7",
"description": "An amazing, fully-ish 😉 generated API spec",
"termsOfService": "https://example.com",
"contact": {
"name": "Homer Simpson",
"url": "https://gph.is/1NPUDiM",
"email": "chunkylover53@aol.com"
},
"license": {
"name": "MIT",
"url": "https://github.com/bkbnio/kompendium/blob/main/LICENSE"
}
},
"servers": [
{
"url": "https://myawesomeapi.com",
"description": "Production instance of my API"
},
{
"url": "https://staging.myawesomeapi.com",
"description": "Where the fun stuff happens"
}
],
"paths": {
"/": {
"get": {
"tags": [],
"summary": "Great Summary!",
"description": "testing more",
"parameters": [],
"responses": {
"200": {
"description": "A Successful Endeavor",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TestResponse"
}
}
}
}
},
"deprecated": false,
"security": [
{
"auth-oauth-google": [
"read:pets"
]
}
]
},
"parameters": []
}
},
"webhooks": {},
"components": {
"schemas": {
"TestResponse": {
"type": "object",
"properties": {
"c": {
"type": "string"
}
},
"required": [
"c"
]
}
},
"securitySchemes": {
"auth-oauth-google": {
"flows": {
"implicit": {
"authorizationUrl": "https://accounts.google.com/o/oauth2/auth",
"scopes": {
"write:pets": "modify pets in your account",
"read:pets": "read your pets"
}
}
},
"type": "oauth2"
}
}
},
"security": [],
"tags": []
}

View File

@ -0,0 +1,91 @@
{
"openapi": "3.1.0",
"jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema",
"info": {
"title": "Test API",
"version": "1.33.7",
"description": "An amazing, fully-ish 😉 generated API spec",
"termsOfService": "https://example.com",
"contact": {
"name": "Homer Simpson",
"url": "https://gph.is/1NPUDiM",
"email": "chunkylover53@aol.com"
},
"license": {
"name": "MIT",
"url": "https://github.com/bkbnio/kompendium/blob/main/LICENSE"
}
},
"servers": [
{
"url": "https://myawesomeapi.com",
"description": "Production instance of my API"
},
{
"url": "https://staging.myawesomeapi.com",
"description": "Where the fun stuff happens"
}
],
"paths": {
"/": {
"get": {
"tags": [],
"summary": "Great Summary!",
"description": "testing more",
"parameters": [],
"responses": {
"200": {
"description": "A Successful Endeavor",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TestResponse"
}
}
}
}
},
"deprecated": false,
"security": [
{
"jwt": []
},
{
"api-key": []
}
]
},
"parameters": []
}
},
"webhooks": {},
"components": {
"schemas": {
"TestResponse": {
"type": "object",
"properties": {
"c": {
"type": "string"
}
},
"required": [
"c"
]
}
},
"securitySchemes": {
"jwt": {
"bearerFormat": "JWT",
"type": "http",
"scheme": "bearer"
},
"api-key": {
"in": "header",
"name": "X-API-KEY",
"type": "apiKey"
}
}
},
"security": [],
"tags": []
}

View File

@ -59,17 +59,17 @@ object TestHelpers {
* and build a test ktor server to compare the expected output with the output found in the default * and build a test ktor server to compare the expected output with the output found in the default
* OpenAPI json endpoint. By default, this will run the same test with Gson, Kotlinx, and Jackson serializers * OpenAPI json endpoint. By default, this will run the same test with Gson, Kotlinx, and Jackson serializers
* @param snapshotName The snapshot file to retrieve from the resources folder * @param snapshotName The snapshot file to retrieve from the resources folder
* @param moduleFunction Initializer for the application to allow tests to pass the required Ktor modules
*/ */
fun openApiTestAllSerializers( fun openApiTestAllSerializers(
snapshotName: String, snapshotName: String,
customTypes: Map<KType, JsonSchema> = emptyMap(), customTypes: Map<KType, JsonSchema> = emptyMap(),
applicationSetup: Application.() -> Unit = { }, applicationSetup: Application.() -> Unit = { },
specOverrides: OpenApiSpec.() -> OpenApiSpec = { this },
routeUnderTest: Routing.() -> Unit routeUnderTest: Routing.() -> Unit
) { ) {
openApiTest(snapshotName, SupportedSerializer.KOTLINX, routeUnderTest, applicationSetup, customTypes) openApiTest(snapshotName, SupportedSerializer.KOTLINX, routeUnderTest, applicationSetup, specOverrides, customTypes)
openApiTest(snapshotName, SupportedSerializer.JACKSON, routeUnderTest, applicationSetup, customTypes) openApiTest(snapshotName, SupportedSerializer.JACKSON, routeUnderTest, applicationSetup, specOverrides, customTypes)
openApiTest(snapshotName, SupportedSerializer.GSON, routeUnderTest, applicationSetup, customTypes) openApiTest(snapshotName, SupportedSerializer.GSON, routeUnderTest, applicationSetup, specOverrides, customTypes)
} }
private fun openApiTest( private fun openApiTest(
@ -77,11 +77,12 @@ object TestHelpers {
serializer: SupportedSerializer, serializer: SupportedSerializer,
routeUnderTest: Routing.() -> Unit, routeUnderTest: Routing.() -> Unit,
applicationSetup: Application.() -> Unit, applicationSetup: Application.() -> Unit,
specOverrides: OpenApiSpec.() -> OpenApiSpec,
typeOverrides: Map<KType, JsonSchema> = emptyMap() typeOverrides: Map<KType, JsonSchema> = emptyMap()
) = testApplication { ) = testApplication {
install(NotarizedApplication()) { install(NotarizedApplication()) {
customTypes = typeOverrides customTypes = typeOverrides
spec = defaultSpec() spec = defaultSpec().specOverrides()
} }
install(ContentNegotiation) { install(ContentNegotiation) {
when (serializer) { when (serializer) {

View File

@ -1,5 +1,5 @@
# Kompendium # Kompendium
project.version=3.0.0 project.version=3.1.0
# Kotlin # Kotlin
kotlin.code.style=official kotlin.code.style=official
# Gradle # Gradle

View File

@ -39,6 +39,6 @@ data class PathOperation(
var responses: Map<Int, Response>? = null, var responses: Map<Int, Response>? = null,
var callbacks: Map<String, PathOperation>? = null, var callbacks: Map<String, PathOperation>? = null,
var deprecated: Boolean = false, var deprecated: Boolean = false,
var security: List<Map<String, List<String>>>? = null, var security: MutableList<Map<String, List<String>>>? = null,
var servers: List<Server>? = null, var servers: List<Server>? = null,
) )

View File

@ -92,9 +92,6 @@ private fun Route.locationDocumentation() {
get = GetInfo.builder { get = GetInfo.builder {
summary("Get user by id") summary("Get user by id")
description("A very neat endpoint!") description("A very neat endpoint!")
security = mapOf(
"basic" to emptyList()
)
response { response {
responseCode(HttpStatusCode.OK) responseCode(HttpStatusCode.OK)
responseType<ExampleResponse>() responseType<ExampleResponse>()