diff --git a/CHANGELOG.md b/CHANGELOG.md index c335c1da6..410ec5fb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,13 @@ ## 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 ### Added - Ktor 2 Support 🎉 diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 03c867f95..1bfc42fab 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -49,6 +49,12 @@ dependencies { testFixturesApi("io.ktor:ktor-serialization-gson:$ktorVersion") testFixturesApi("io.ktor:ktor-serialization-kotlinx-json:$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") } diff --git a/core/src/main/kotlin/io/bkbn/kompendium/core/plugin/NotarizedRoute.kt b/core/src/main/kotlin/io/bkbn/kompendium/core/plugin/NotarizedRoute.kt index fd9455f4b..6b9456e67 100644 --- a/core/src/main/kotlin/io/bkbn/kompendium/core/plugin/NotarizedRoute.kt +++ b/core/src/main/kotlin/io/bkbn/kompendium/core/plugin/NotarizedRoute.kt @@ -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.SpecConfig import io.bkbn.kompendium.oas.path.Path +import io.bkbn.kompendium.oas.path.PathOperation import io.bkbn.kompendium.oas.payload.Parameter import io.ktor.server.application.ApplicationCallPipeline import io.ktor.server.application.Hook @@ -44,11 +45,13 @@ object NotarizedRoute { 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) { val route = it as? Route ?: return@on val spec = application.attributes[KompendiumAttributes.openApiSpec] val routePath = route.calculateRoutePath() + val authMethods = route.collectAuthMethods() + pluginConfig.path?.addDefaultAuthMethods(authMethods) require(spec.paths[routePath] == null) { """ 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.collectAuthMethods() = toString() + .split("/") + .filter { it.contains(Regex("\\(authenticate .*\\)")) } + .map { it.replace("(authenticate ", "").replace(")", "") } + .map { it.split(", ") } + .flatten() + + private fun Path.addDefaultAuthMethods(methods: List) { + 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) { + 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())) + } + } + } + } } diff --git a/core/src/main/kotlin/io/bkbn/kompendium/core/util/Helpers.kt b/core/src/main/kotlin/io/bkbn/kompendium/core/util/Helpers.kt index 7a3e61ec8..f41c1fe31 100644 --- a/core/src/main/kotlin/io/bkbn/kompendium/core/util/Helpers.kt +++ b/core/src/main/kotlin/io/bkbn/kompendium/core/util/Helpers.kt @@ -70,7 +70,7 @@ object Helpers { security = config.security ?.map { (k, v) -> k to v } ?.map { listOf(it).toMap() } - ?.toList(), + ?.toMutableList(), requestBody = when (this) { is MethodInfoWithRequest -> Request( description = this.request.description, diff --git a/core/src/test/kotlin/io/bkbn/kompendium/core/KompendiumTest.kt b/core/src/test/kotlin/io/bkbn/kompendium/core/KompendiumTest.kt index 4245e766b..86d9eba15 100644 --- a/core/src/test/kotlin/io/bkbn/kompendium/core/KompendiumTest.kt +++ b/core/src/test/kotlin/io/bkbn/kompendium/core/KompendiumTest.kt @@ -1,8 +1,11 @@ package io.bkbn.kompendium.core +import dev.forst.ktor.apikey.apiKey import io.bkbn.kompendium.core.fixtures.TestHelpers.openApiTestAllSerializers 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.defaultAuthConfig import io.bkbn.kompendium.core.util.TestModules.defaultField import io.bkbn.kompendium.core.util.TestModules.defaultParameter 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.gnarlyGenericResponse 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.nestedGenericCollection 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.withOperationId 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.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 java.time.Instant @@ -190,7 +209,7 @@ class KompendiumTest : DescribeSpec({ // TODO Assess strategies here } 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") { openApiTestAllSerializers("T0036__nullable_fields.json") { nullableNestedObject() } @@ -219,4 +238,100 @@ class KompendiumTest : DescribeSpec({ describe("Free Form") { // 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() } + } + } }) diff --git a/core/src/test/kotlin/io/bkbn/kompendium/core/util/TestModules.kt b/core/src/test/kotlin/io/bkbn/kompendium/core/util/TestModules.kt index 710a23ca0..ad88a99dc 100644 --- a/core/src/test/kotlin/io/bkbn/kompendium/core/util/TestModules.kt +++ b/core/src/test/kotlin/io/bkbn/kompendium/core/util/TestModules.kt @@ -35,8 +35,10 @@ import io.bkbn.kompendium.oas.payload.Parameter import io.ktor.http.HttpStatusCode import io.ktor.server.application.call import io.ktor.server.application.install +import io.ktor.server.auth.authenticate import io.ktor.server.response.respond import io.ktor.server.response.respondText +import io.ktor.server.routing.Route import io.ktor.server.routing.Routing import io.ktor.server.routing.delete import io.ktor.server.routing.get @@ -604,24 +606,68 @@ object TestModules { fun Routing.simpleRecursive() = basicGetGenerator() - private inline fun Routing.basicGetGenerator( - params: List = emptyList(), - operationId: String? = null - ) { - route(rootPath) { - install(NotarizedRoute()) { - get = GetInfo.builder { - summary(defaultPathSummary) - description(defaultPathDescription) - operationId?.let { operationId(it) } - parameters = params - response { - description(defaultResponseDescription) - responseCode(HttpStatusCode.OK) - responseType() + fun Routing.defaultAuthConfig() { + authenticate("basic") { + route(rootPath) { + basicGetGenerator() + } + } + } + + fun Routing.customAuthConfig() { + authenticate("auth-oauth-google") { + route(rootPath) { + install(NotarizedRoute()) { + get = GetInfo.builder { + summary(defaultPathSummary) + description(defaultPathDescription) + response { + description(defaultResponseDescription) + responseCode(HttpStatusCode.OK) + responseType() + } + security = mapOf( + "auth-oauth-google" to listOf("read:pets") + ) } } } } } + + fun Routing.multipleAuthStrategies() { + authenticate("jwt", "api-key") { + route(rootPath) { + basicGetGenerator() + } + } + } + + private inline fun Routing.basicGetGenerator( + params: List = emptyList(), + operationId: String? = null + ) { + route(rootPath) { + basicGetGenerator(params, operationId) + } + } + + private inline fun Route.basicGetGenerator( + params: List = 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() + } + } + } + } } diff --git a/core/src/test/resources/T0045__default_auth_config.json b/core/src/test/resources/T0045__default_auth_config.json new file mode 100644 index 000000000..56028b4a2 --- /dev/null +++ b/core/src/test/resources/T0045__default_auth_config.json @@ -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": [] +} diff --git a/core/src/test/resources/T0046__custom_auth_config.json b/core/src/test/resources/T0046__custom_auth_config.json new file mode 100644 index 000000000..b27a7d213 --- /dev/null +++ b/core/src/test/resources/T0046__custom_auth_config.json @@ -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": [] +} diff --git a/core/src/test/resources/T0047__multiple_auth_strategies.json b/core/src/test/resources/T0047__multiple_auth_strategies.json new file mode 100644 index 000000000..1406dd399 --- /dev/null +++ b/core/src/test/resources/T0047__multiple_auth_strategies.json @@ -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": [] +} diff --git a/core/src/testFixtures/kotlin/io/bkbn/kompendium/core/fixtures/TestHelpers.kt b/core/src/testFixtures/kotlin/io/bkbn/kompendium/core/fixtures/TestHelpers.kt index 7df68f93d..5a0abc145 100644 --- a/core/src/testFixtures/kotlin/io/bkbn/kompendium/core/fixtures/TestHelpers.kt +++ b/core/src/testFixtures/kotlin/io/bkbn/kompendium/core/fixtures/TestHelpers.kt @@ -59,17 +59,17 @@ object TestHelpers { * 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 * @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( snapshotName: String, customTypes: Map = emptyMap(), applicationSetup: Application.() -> Unit = { }, + specOverrides: OpenApiSpec.() -> OpenApiSpec = { this }, routeUnderTest: Routing.() -> Unit ) { - openApiTest(snapshotName, SupportedSerializer.KOTLINX, routeUnderTest, applicationSetup, customTypes) - openApiTest(snapshotName, SupportedSerializer.JACKSON, routeUnderTest, applicationSetup, customTypes) - openApiTest(snapshotName, SupportedSerializer.GSON, routeUnderTest, applicationSetup, customTypes) + openApiTest(snapshotName, SupportedSerializer.KOTLINX, routeUnderTest, applicationSetup, specOverrides, customTypes) + openApiTest(snapshotName, SupportedSerializer.JACKSON, routeUnderTest, applicationSetup, specOverrides, customTypes) + openApiTest(snapshotName, SupportedSerializer.GSON, routeUnderTest, applicationSetup, specOverrides, customTypes) } private fun openApiTest( @@ -77,11 +77,12 @@ object TestHelpers { serializer: SupportedSerializer, routeUnderTest: Routing.() -> Unit, applicationSetup: Application.() -> Unit, + specOverrides: OpenApiSpec.() -> OpenApiSpec, typeOverrides: Map = emptyMap() ) = testApplication { install(NotarizedApplication()) { customTypes = typeOverrides - spec = defaultSpec() + spec = defaultSpec().specOverrides() } install(ContentNegotiation) { when (serializer) { diff --git a/gradle.properties b/gradle.properties index c21857bdb..63af2c522 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,5 @@ # Kompendium -project.version=3.0.0 +project.version=3.1.0 # Kotlin kotlin.code.style=official # Gradle diff --git a/oas/src/main/kotlin/io/bkbn/kompendium/oas/path/PathOperation.kt b/oas/src/main/kotlin/io/bkbn/kompendium/oas/path/PathOperation.kt index 6a0a7aa7f..cd87bffe7 100644 --- a/oas/src/main/kotlin/io/bkbn/kompendium/oas/path/PathOperation.kt +++ b/oas/src/main/kotlin/io/bkbn/kompendium/oas/path/PathOperation.kt @@ -39,6 +39,6 @@ data class PathOperation( var responses: Map? = null, var callbacks: Map? = null, var deprecated: Boolean = false, - var security: List>>? = null, + var security: MutableList>>? = null, var servers: List? = null, ) diff --git a/playground/src/main/kotlin/io/bkbn/kompendium/playground/AuthPlayground.kt b/playground/src/main/kotlin/io/bkbn/kompendium/playground/AuthPlayground.kt index 1a26c0885..83784e32f 100644 --- a/playground/src/main/kotlin/io/bkbn/kompendium/playground/AuthPlayground.kt +++ b/playground/src/main/kotlin/io/bkbn/kompendium/playground/AuthPlayground.kt @@ -92,9 +92,6 @@ private fun Route.locationDocumentation() { get = GetInfo.builder { summary("Get user by id") description("A very neat endpoint!") - security = mapOf( - "basic" to emptyList() - ) response { responseCode(HttpStatusCode.OK) responseType()