diff --git a/AAD.Giraffe/Noop.fs b/AAD.Giraffe/Noop.fs index 5e990ac..f647966 100644 --- a/AAD.Giraffe/Noop.fs +++ b/AAD.Giraffe/Noop.fs @@ -9,16 +9,17 @@ open AAD /// PartProtector implements no-op verification (it always succeeds) for PartProtector interface. [] module PartProtector = - let token = JwtSecurityToken() /// Creates PartProtector instance. - let mkNew () = - { new PartProtector with - member __.Verify (getDemand: HttpContext -> Task) - (onSuccess: JwtSecurityToken -> HttpHandler) = + let mkNew token = + { new PartProtector<'token> with + member _.Verify (getDemand: HttpContext -> Task) + (onSuccess: 'token -> HttpHandler) = onSuccess token - member __.VerifyWith (getDemand: HttpContext -> Task) - (onSuccess: JwtSecurityToken -> HttpHandler) - (onError: JwtSecurityToken option -> WWWAuthenticate -> HttpHandler) = + member _.VerifyWith (getDemand: HttpContext -> Task) + (onSuccess: 'token -> HttpHandler) + (onError: 'token option -> WWWAuthenticate -> HttpHandler) = onSuccess token } + + let mkDefault () = JwtSecurityToken() |> mkNew diff --git a/AAD.Giraffe/PartProtector.fs b/AAD.Giraffe/PartProtector.fs index b657c9d..1eed4dd 100644 --- a/AAD.Giraffe/PartProtector.fs +++ b/AAD.Giraffe/PartProtector.fs @@ -9,15 +9,15 @@ open Microsoft.Extensions.Caching.Memory /// PartProtector is the interface for a stateful protector instance. /// Use PartProtector module to create the instances implementing this interface. -type PartProtector = +type PartProtector<'token> = /// Wraps the verify call abstract Verify: getDemand: (HttpContext -> Task) -> - onSuccess: (JwtSecurityToken -> HttpHandler) -> + onSuccess: ('token -> HttpHandler) -> HttpHandler /// Handling both success and error outcomes abstract VerifyWith: getDemand: (HttpContext -> Task) -> - onSuccess: (JwtSecurityToken -> HttpHandler) -> - onError: (JwtSecurityToken option -> WWWAuthenticate -> HttpHandler) -> + onSuccess: ('token -> HttpHandler) -> + onError: ('token option -> WWWAuthenticate -> HttpHandler) -> HttpHandler /// PartProtector module for working with stateful instances of PartProtector interface. @@ -41,16 +41,14 @@ module PartProtector = onSuccess /// Creates PartProtector instance using the client credentials provided. - let mkNew (introspect: TokenString -> Task>) - (validate: Demand -> JwtSecurityToken -> Result) - (audiences: #seq) - (getConfig: unit -> Task) = + let mkNew (introspect: TokenString -> Task>) + (validate: Demand -> 'token -> Result<'token,string>) = let resourceOwner = - ResourceOwner.mkNew introspect validate audiences getConfig + ResourceOwner.mkNew introspect validate - { new PartProtector with - member __.Verify (getDemand: HttpContext -> Task) - (onSuccess: JwtSecurityToken -> HttpHandler) = + { new PartProtector<'token> with + member _.Verify (getDemand: HttpContext -> Task) + (onSuccess: 'token -> HttpHandler) = fun next (ctx:HttpContext) -> backgroundTask { let handleSuccess,handleMissing,result = @@ -62,9 +60,9 @@ module PartProtector = (Readers.bearerTokenString ctx) return! result next ctx } - member __.VerifyWith (getDemand: HttpContext -> Task) - (onSuccess: JwtSecurityToken -> HttpHandler) - (onError: JwtSecurityToken option -> WWWAuthenticate -> HttpHandler) = + member _.VerifyWith (getDemand: HttpContext -> Task) + (onSuccess: 'token -> HttpHandler) + (onError: 'token option -> WWWAuthenticate -> HttpHandler) = fun next (ctx:HttpContext) -> backgroundTask { let handleSuccess,handleMissing,result = @@ -97,15 +95,16 @@ module PartProtector = let! _ = getConfiguration() // prime the config cache let introspect = - (TokenCache.mkDefault(), audiences, getConfiguration) |||> Introspector.mkNew - let project claim = - seq { - yield! ResourceOwner.ClaimProjection.ofAppRole claim - yield! ResourceOwner.ClaimProjection.ofRole claim - yield! ResourceOwner.ClaimProjection.ofScope claim - } + (TokenCache.mkDefault(), audiences) ||> JwtSecurityTokenIntrospector.mkNew getConfiguration + let project (token: JwtSecurityToken) = + token.Claims + |> Seq.collect (fun claim -> + seq { + yield! ResourceOwner.ClaimProjection.ofAppRole claim + yield! ResourceOwner.ClaimProjection.ofRole claim + yield! ResourceOwner.ClaimProjection.ofScope claim + } + ) return mkNew introspect (ResourceOwner.validate '/' project) - audiences - getConfiguration } \ No newline at end of file diff --git a/AAD.Suave/Noop.fs b/AAD.Suave/Noop.fs index eacc7ef..04a5119 100644 --- a/AAD.Suave/Noop.fs +++ b/AAD.Suave/Noop.fs @@ -2,23 +2,23 @@ namespace AAD.Noop open Suave -open Suave.Operators open AAD open System.IdentityModel.Tokens.Jwt /// PartProtector implements no-op verification (it always succeeds) for PartProtector interface. [] module PartProtector = - let token = JwtSecurityToken() /// Creates PartProtector instance. - let mkNew () = - { new PartProtector with + let mkNew token = + { new PartProtector<'token> with member __.Verify (getDemand: HttpContext -> Async) - (onSuccess: JwtSecurityToken -> WebPart) = + (onSuccess: 'token -> WebPart) = onSuccess token member __.VerifyWith (getDemand: HttpContext -> Async) - (onSuccess: JwtSecurityToken -> WebPart) - (onError: JwtSecurityToken option -> WWWAuthenticate -> WebPart) = + (onSuccess: 'token -> WebPart) + (onError: 'token option -> WWWAuthenticate -> WebPart) = onSuccess token } + + let mkDefault () = JwtSecurityToken() \ No newline at end of file diff --git a/AAD.Suave/PartProtector.fs b/AAD.Suave/PartProtector.fs index 791f147..8508ee4 100644 --- a/AAD.Suave/PartProtector.fs +++ b/AAD.Suave/PartProtector.fs @@ -8,15 +8,15 @@ open Microsoft.Extensions.Caching.Memory /// PartProtector is the interface for a stateful protector instance. /// Use PartProtector module to create the instances implementing this interface. -type PartProtector = +type PartProtector<'token> = /// Wraps the verify call abstract Verify: getDemand: (HttpContext -> Async) -> - onSuccess: (JwtSecurityToken -> WebPart) -> + onSuccess: ('token -> WebPart) -> WebPart /// Handling both success and error outcomes abstract VerifyWith: getDemand: (HttpContext -> Async) -> - onSuccess: (JwtSecurityToken -> WebPart) -> - onError: (JwtSecurityToken option -> WWWAuthenticate -> WebPart) -> + onSuccess: ('token -> WebPart) -> + onError: ('token option -> WWWAuthenticate -> WebPart) -> WebPart /// PartProtector module for working with stateful instances of PartProtector interface. @@ -40,16 +40,14 @@ module PartProtector = onSuccess /// Creates PartProtector instance using the client credentials provided. - let mkNew (introspect: TokenString -> Async>) - (validate: Demand -> JwtSecurityToken -> Result) - (audiences: #seq) - (getConfig: unit -> Async) = + let mkNew (introspect: TokenString -> Async>) + (validate: Demand -> 'token -> Result<'token,string>) = let resourceOwner = - ResourceOwner.mkNew introspect validate audiences getConfig + ResourceOwner.mkNew introspect validate - { new PartProtector with + { new PartProtector<'token> with member __.Verify (getDemand: HttpContext -> Async) - (onSuccess: JwtSecurityToken -> WebPart) = + (onSuccess: 'token -> WebPart) = fun (ctx:HttpContext) -> async { let handleSuccess,handleMissing,result = @@ -62,8 +60,8 @@ module PartProtector = return! result ctx } member __.VerifyWith (getDemand: HttpContext -> Async) - (onSuccess: JwtSecurityToken -> WebPart) - (onError: JwtSecurityToken option -> WWWAuthenticate -> WebPart) = + (onSuccess: 'token -> WebPart) + (onError: 'token option -> WWWAuthenticate -> WebPart) = fun (ctx:HttpContext) -> async { let handleSuccess,handleMissing,result = @@ -96,15 +94,16 @@ module PartProtector = let! _ = getConfiguration() // prime the config cache let introspect = - (TokenCache.mkDefault(), audiences, getConfiguration) |||> Introspector.mkNew - let project claim = - seq { - yield! ResourceOwner.ClaimProjection.ofAppRole claim - yield! ResourceOwner.ClaimProjection.ofRole claim - yield! ResourceOwner.ClaimProjection.ofScope claim - } + (TokenCache.mkDefault(), audiences) ||> JwtSecurityTokenIntrospector.mkNew getConfiguration + let project (token: JwtSecurityToken) = + token.Claims + |> Seq.collect (fun claim -> + seq { + yield! ResourceOwner.ClaimProjection.ofAppRole claim + yield! ResourceOwner.ClaimProjection.ofRole claim + yield! ResourceOwner.ClaimProjection.ofScope claim + } + ) return mkNew introspect (ResourceOwner.validate '/' project) - audiences - getConfiguration } \ No newline at end of file diff --git a/AAD.Test/ResourceOwnerTests.fs b/AAD.Test/ResourceOwnerTests.fs index 05af904..007c4e1 100644 --- a/AAD.Test/ResourceOwnerTests.fs +++ b/AAD.Test/ResourceOwnerTests.fs @@ -16,8 +16,8 @@ module Internals = module Introspection = let audience = ".default" let introspect = - (TokenCache.mkDefault(), [Audience audience], fun _ -> Async.result oidcConfig) - |||> Introspector.mkNew + ((fun _ -> Async.result oidcConfig), TokenCache.mkDefault(), [Audience audience]) + |||> JwtSecurityTokenIntrospector.mkNew [] let Introspects () = @@ -79,12 +79,15 @@ module Internals = } |> Async.RunSynchronously module Validation = + let ofToken p (t: JwtSecurityToken) = + t.Claims |> Seq.collect p + [] let ``Role demand satisfied`` () = let token = Introspection.Introspects() let result = token - |> Result.bind (ResourceOwner.validate '/' ResourceOwner.ClaimProjection.ofRole (Pattern ["Test"; "read"; "A"])) + |> Result.bind (ResourceOwner.validate '/' (ofToken ResourceOwner.ClaimProjection.ofRole) (Pattern ["Test"; "read"; "A"])) true =! Result.isOk result [] @@ -92,7 +95,7 @@ module Internals = let token = Introspection.Introspects() let result = token - |> Result.bind (ResourceOwner.validate '/' ResourceOwner.ClaimProjection.ofScope (Pattern ["Test"; "read"; "A"])) + |> Result.bind (ResourceOwner.validate '/' (ofToken ResourceOwner.ClaimProjection.ofScope) (Pattern ["Test"; "read"; "A"])) true =! Result.isError result [] @@ -100,7 +103,7 @@ module Internals = let claims = Seq.singleton (Claim("scp", "Test.read.A Test.write.B")) let token = JwtSecurityToken(claims = claims) let result = - ResourceOwner.validate '.' ResourceOwner.ClaimProjection.ofScope (Pattern ["Test"; "read"; "A"]) token + ResourceOwner.validate '.' (ofToken ResourceOwner.ClaimProjection.ofScope) (Pattern ["Test"; "read"; "A"]) token true =! Result.isOk result [] @@ -108,7 +111,7 @@ module Internals = let claims = [ Claim("roles", "Test/read/A"); Claim("roles", "Test/write/B") ] let token = JwtSecurityToken(claims = claims) let result = - ResourceOwner.validate '/' ResourceOwner.ClaimProjection.ofAppRole (Pattern ["Test"; "read"; "A"]) token + ResourceOwner.validate '/' (ofToken ResourceOwner.ClaimProjection.ofAppRole) (Pattern ["Test"; "read"; "A"]) token true =! Result.isOk result diff --git a/AAD.fs/ResourceOwner.fs b/AAD.fs/ResourceOwner.fs index a2dd786..9427742 100644 --- a/AAD.fs/ResourceOwner.fs +++ b/AAD.fs/ResourceOwner.fs @@ -31,21 +31,23 @@ module internal TokenCache = [] -module internal Introspector = +module internal JwtSecurityTokenIntrospector = open System.Threading.Tasks open YoLo open Microsoft.IdentityModel.Tokens open Microsoft.IdentityModel.Protocols.OpenIdConnect - let mkNew (cache:string -> (string -> Task<_>) -> Task<_>) - (audiences: #seq) - (getConfig: unit -> Awaitable) = + let mkNew (getConfig: unit -> Awaitable) + (cache:string -> (string -> Task<_>) -> Task<_>) + (audiences: #seq) = let handler = JwtSecurityTokenHandler() let local (jwtEncodedString: string) = backgroundTask { + let mutable issuer = None try let! oidcConfig = getConfig() + issuer <- Some oidcConfig.Issuer let vparams = TokenValidationParameters (ValidIssuer = oidcConfig.Issuer, ValidAudiences = Seq.map Audience.toString audiences, @@ -53,7 +55,15 @@ module internal Introspector = let _,token = handler.ValidateToken(jwtEncodedString,vparams) return Ok (token :?> JwtSecurityToken) with err -> - return Error err.Message + let wwwAuthenticate = + match issuer with + | Some issuer -> + sprintf "Bearer realm=\"%s\", audience=\"%s\", error_description=\"%s\"" + issuer + (audiences |> Seq.map Audience.toString |> String.concat ",") + err.Message + | _ -> sprintf "Bearer error_description=\"Unable to retrieve OIDC configuration: %s\"" err.Message + return Error wwwAuthenticate } let parse s = @@ -69,16 +79,15 @@ module internal Introspector = return r } -type ResourceOwner = +type ResourceOwner<'token> = abstract Validate : demand: Demand -> - onSuccess: (JwtSecurityToken -> unit) -> - onUnauthorized: (JwtSecurityToken option -> WWWAuthenticate -> unit) -> + onSuccess: ('token -> unit) -> + onUnauthorized: ('token option -> WWWAuthenticate -> unit) -> tokenString: TokenString -> Awaitable [] module ResourceOwner = - open Microsoft.IdentityModel.Protocols.OpenIdConnect open System.Security.Claims module ClaimProjection = @@ -94,38 +103,29 @@ module ResourceOwner = if claim.Type = "roles" then Seq.singleton claim.Value else Seq.empty - let mkNew (introspect: TokenString -> Awaitable>) - (validate: Demand -> JwtSecurityToken -> Result) - (audiences: #seq) - (getConfig: unit -> Awaitable) = - { new ResourceOwner with + let mkNew (introspect: TokenString -> Awaitable>) + (validate: Demand -> 'token -> Result<'token,string>) = + { new ResourceOwner<'token> with member __.Validate (demand: Demand) - (onSuccess: JwtSecurityToken -> unit) - (onUnauthorized: JwtSecurityToken option -> WWWAuthenticate -> unit) + (onSuccess: 'token -> unit) + (onUnauthorized: 'token option -> WWWAuthenticate -> unit) (tokenString: TokenString) = awaitable { - let! oidcConfig = getConfig() let! introspected = introspect tokenString let validated = introspected |> Result.bind (validate demand) - let wwwAuthenticate err = - sprintf "Bearer realm=\"%s\", audience=\"%s\", error_description=\"%s\"" - oidcConfig.Issuer - (audiences |> Seq.map Audience.toString |> String.concat ",") - err match validated, introspected with | Ok t,_ -> onSuccess t | Error err, Ok t -> - onUnauthorized (Some t) (wwwAuthenticate err |> WWWAuthenticate) + onUnauthorized (Some t) (WWWAuthenticate err) | Error err, _ -> - onUnauthorized None (wwwAuthenticate err |> WWWAuthenticate) + onUnauthorized None (WWWAuthenticate err) } } - let validate (splitChar: char) (claimsProjection: Claim -> #seq) (demand: Demand) (t: JwtSecurityToken) = + let validate (splitChar: char) (claimsProjection: 'token -> #seq) (demand: Demand) (t: 'token) = let claims = - t.Claims - |> Seq.collect claimsProjection + claimsProjection t |> Seq.map (String.split splitChar) demand |> Demand.eval claims diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 6a4c844..561f144 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,3 +1,7 @@ +### 6.0.0 + +* Breaking: Generalized certain abstractions to make token validation fully pluggable. + ### 5.0.0 * Breaking: dependencies bump and net8.0 target