@@ -12,7 +12,10 @@ import io.ktor.client.request.forms.submitForm
12
12
import io.ktor.http.HttpStatusCode
13
13
import io.ktor.http.parameters
14
14
import io.ktor.server.application.ApplicationCall
15
- import io.ktor.server.application.createRouteScopedPlugin
15
+ import io.ktor.server.auth.AuthenticationConfig
16
+ import io.ktor.server.auth.AuthenticationContext
17
+ import io.ktor.server.auth.AuthenticationFailedCause
18
+ import io.ktor.server.auth.AuthenticationProvider
16
19
import io.ktor.server.request.host
17
20
import io.ktor.server.request.uri
18
21
import io.ktor.server.response.respondRedirect
@@ -57,8 +60,8 @@ data class TokenErrorResponse(
57
60
*
58
61
* The `other` field is a generic map that contains an arbitrary combination of additional claims contained in the token.
59
62
*
60
- * If you know the exact claims to expect, you should instead explicitly define these as fields on the
61
- * data class itself, for example:
63
+ * TODO(user): If you know the exact claims to expect, you should instead explicitly define these as fields on the
64
+ * data class itself and ignore everything else , for example:
62
65
*
63
66
* ```kotlin
64
67
* data class TokenIntrospectionResponse(
@@ -79,6 +82,9 @@ data class TokenIntrospectionResponse(
79
82
val other : Map <String , Any ?> = mutableMapOf(),
80
83
)
81
84
85
+ /* *
86
+ * AuthClient is a client that interacts with Texas.
87
+ */
82
88
class AuthClient (
83
89
private val config : Config .Auth ,
84
90
private val provider : IdentityProvider ,
@@ -150,66 +156,97 @@ class AuthClient(
150
156
}
151
157
}
152
158
153
- class AuthPluginConfiguration (
154
- var client : AuthClient ? = null ,
155
- var ingress : String? = null ,
156
- var logger : Logger = LoggerFactory .getLogger("io.nais.common.ktor.NaisAuth "),
157
- )
159
+ fun AuthenticationConfig.texas (
160
+ name : String? = null,
161
+ configure : TexasAuthenticationProvider .Config .() -> Unit ,
162
+ ) {
163
+ register(TexasAuthenticationProvider .Config (name).apply (configure).build())
164
+ }
158
165
159
- val NaisAuth =
160
- createRouteScopedPlugin(
161
- name = " NaisAuth" ,
162
- createConfiguration = ::AuthPluginConfiguration ,
163
- ) {
164
- val logger = pluginConfig.logger
165
- val client = pluginConfig.client ? : throw IllegalArgumentException (" NaisAuth plugin: client must be set" )
166
- val ingress = pluginConfig.ingress ? : " "
167
-
168
- val challenge: suspend (ApplicationCall ) -> Unit = { call ->
169
- val target = call.loginUrl(ingress)
170
- logger.info(" unauthenticated: redirecting to '$target '" )
171
- call.respondRedirect(target)
172
- }
166
+ /* *
167
+ * TexasAuthenticationProvider is an [io.ktor.server.auth.AuthenticationProvider] that validates tokens by using Texas's introspection endpoint.
168
+ */
169
+ class TexasAuthenticationProvider (
170
+ config : Config ,
171
+ ) : AuthenticationProvider(config) {
172
+ class Config internal constructor(
173
+ name : String? ,
174
+ ) : AuthenticationProvider.Config(name) {
175
+ lateinit var client: AuthClient
176
+ var logger: Logger = LoggerFactory .getLogger(" io.nais.common.TexasAuthenticationProvider" )
177
+ var ingress: String = " "
178
+
179
+ internal fun build () = TexasAuthenticationProvider (this )
180
+ }
173
181
174
- pluginConfig.apply {
175
- onCall { call ->
176
- val token = call.bearerToken()
177
- if (token == null ) {
178
- logger.warn(" unauthenticated: no Bearer token found in Authorization header" )
179
- challenge(call)
180
- return @onCall
181
- }
182
+ private val client = config.client
183
+ private val logger = config.logger
184
+ private val ingress = config.ingress
182
185
183
- val introspectResponse =
184
- try {
185
- client.introspect(token)
186
- } catch (e: Exception ) {
187
- logger.error(" unauthenticated: introspect request failed: ${e.message} " )
188
- challenge(call)
189
- return @onCall
190
- }
191
-
192
- if (introspectResponse.active) {
193
- logger.info(" authenticated - claims='${introspectResponse.other} '" )
194
- return @onCall
195
- }
186
+ override suspend fun onAuthenticate (context : AuthenticationContext ) {
187
+ val applicationCall = context.call
188
+ val token = applicationCall.bearerToken()
196
189
197
- logger.warn(" unauthenticated: ${introspectResponse.error} " )
198
- challenge(call)
199
- return @onCall
190
+ if (token == null ) {
191
+ logger.warn(" unauthenticated: no Bearer token found in Authorization header" )
192
+ context.loginChallenge(AuthenticationFailedCause .NoCredentials )
193
+ return
194
+ }
195
+
196
+ val introspectResponse =
197
+ try {
198
+ client.introspect(token)
199
+ } catch (e: Exception ) {
200
+ // TODO(user): You should handle the specific exceptions that can be thrown by the HTTP client, e.g. retry on network errors and so on
201
+ logger.error(" unauthenticated: introspect request failed: ${e.message} " )
202
+ context.loginChallenge(AuthenticationFailedCause .Error (e.message ? : " introspect request failed" ))
203
+ return
200
204
}
205
+
206
+ if (! introspectResponse.active) {
207
+ logger.warn(" unauthenticated: ${introspectResponse.error} " )
208
+ context.loginChallenge(AuthenticationFailedCause .InvalidCredentials )
209
+ return
201
210
}
202
211
203
- logger.info(" NaisAuth plugin loaded." )
212
+ logger.info(" authenticated - claims='${introspectResponse.other} '" )
213
+ context.principal(
214
+ TexasPrincipal (
215
+ claims = introspectResponse.other,
216
+ token = token,
217
+ ),
218
+ )
204
219
}
205
220
206
- // loginUrl constructs a URL string that points to the login endpoint (Wonderwall) for redirecting a request.
207
- // It also indicates that the user should be redirected back to the original request path after authentication
208
- private fun ApplicationCall.loginUrl (defaultHost : String ): String {
209
- val host =
210
- defaultHost.ifEmpty(defaultValue = {
211
- " ${this .request.local.scheme} ://${this .request.host()} "
212
- })
221
+ private fun AuthenticationContext.loginChallenge (cause : AuthenticationFailedCause ) {
222
+ challenge(" Texas" , cause) { authenticationProcedureChallenge, call ->
223
+ val target = call.loginUrl()
224
+ logger.info(" unauthenticated: redirecting to '$target '" )
225
+ call.respondRedirect(target)
226
+ authenticationProcedureChallenge.complete()
227
+ }
228
+ }
213
229
214
- return " $host /oauth2/login?redirect=${this .request.uri} "
230
+ /* *
231
+ * loginUrl constructs a URL string that points to the login endpoint (Wonderwall) for redirecting a request.
232
+ * It also indicates that the user should be redirected back to the original request path after authentication
233
+ */
234
+ private fun ApplicationCall.loginUrl (): String {
235
+ val host =
236
+ ingress.ifEmpty(defaultValue = {
237
+ " ${this .request.local.scheme} ://${this .request.host()} "
238
+ })
239
+
240
+ return " $host /oauth2/login?redirect=${this .request.uri} "
241
+ }
215
242
}
243
+
244
+ /* *
245
+ * TexasPrincipal represents the authenticated principal.
246
+ * The `claims` field is a map of arbitrary claims from the [TokenIntrospectionResponse].
247
+ * TODO(user): You should explicitly define expected claims as fields on the data class itself instead of using a generic map.
248
+ */
249
+ data class TexasPrincipal (
250
+ val claims : Map <String , Any ?>,
251
+ val token : String ,
252
+ )
0 commit comments