Skip to content

Commit

Permalink
Abstract away 'token'
Browse files Browse the repository at this point in the history
  • Loading branch information
et1975 committed May 23, 2024
1 parent 0df0fd0 commit 1fbe91f
Show file tree
Hide file tree
Showing 7 changed files with 100 additions and 94 deletions.
17 changes: 9 additions & 8 deletions AAD.Giraffe/Noop.fs
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,17 @@ open AAD
/// PartProtector implements no-op verification (it always succeeds) for PartProtector interface.
[<RequireQualifiedAccess>]
module PartProtector =
let token = JwtSecurityToken()
/// Creates PartProtector instance.
let mkNew () =
{ new PartProtector with
member __.Verify (getDemand: HttpContext -> Task<Demand>)
(onSuccess: JwtSecurityToken -> HttpHandler) =
let mkNew token =
{ new PartProtector<'token> with
member _.Verify (getDemand: HttpContext -> Task<Demand>)
(onSuccess: 'token -> HttpHandler) =
onSuccess token

member __.VerifyWith (getDemand: HttpContext -> Task<Demand>)
(onSuccess: JwtSecurityToken -> HttpHandler)
(onError: JwtSecurityToken option -> WWWAuthenticate -> HttpHandler) =
member _.VerifyWith (getDemand: HttpContext -> Task<Demand>)
(onSuccess: 'token -> HttpHandler)
(onError: 'token option -> WWWAuthenticate -> HttpHandler) =
onSuccess token
}

let mkDefault () = JwtSecurityToken() |> mkNew
47 changes: 23 additions & 24 deletions AAD.Giraffe/PartProtector.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Demand>) ->
onSuccess: (JwtSecurityToken -> HttpHandler) ->
onSuccess: ('token -> HttpHandler) ->
HttpHandler
/// Handling both success and error outcomes
abstract VerifyWith: getDemand: (HttpContext -> Task<Demand>) ->
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.
Expand All @@ -41,16 +41,14 @@ module PartProtector =
onSuccess

/// Creates PartProtector instance using the client credentials provided.
let mkNew (introspect: TokenString -> Task<Result<JwtSecurityToken,string>>)
(validate: Demand -> JwtSecurityToken -> Result<JwtSecurityToken,string>)
(audiences: #seq<Audience>)
(getConfig: unit -> Task<OpenIdConnectConfiguration>) =
let mkNew (introspect: TokenString -> Task<Result<'token,string>>)
(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<Demand>)
(onSuccess: JwtSecurityToken -> HttpHandler) =
{ new PartProtector<'token> with
member _.Verify (getDemand: HttpContext -> Task<Demand>)
(onSuccess: 'token -> HttpHandler) =
fun next (ctx:HttpContext) ->
backgroundTask {
let handleSuccess,handleMissing,result =
Expand All @@ -62,9 +60,9 @@ module PartProtector =
(Readers.bearerTokenString ctx)
return! result next ctx
}
member __.VerifyWith (getDemand: HttpContext -> Task<Demand>)
(onSuccess: JwtSecurityToken -> HttpHandler)
(onError: JwtSecurityToken option -> WWWAuthenticate -> HttpHandler) =
member _.VerifyWith (getDemand: HttpContext -> Task<Demand>)
(onSuccess: 'token -> HttpHandler)
(onError: 'token option -> WWWAuthenticate -> HttpHandler) =
fun next (ctx:HttpContext) ->
backgroundTask {
let handleSuccess,handleMissing,result =
Expand Down Expand Up @@ -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
}
14 changes: 7 additions & 7 deletions AAD.Suave/Noop.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
[<RequireQualifiedAccess>]
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<Demand>)
(onSuccess: JwtSecurityToken -> WebPart) =
(onSuccess: 'token -> WebPart) =
onSuccess token

member __.VerifyWith (getDemand: HttpContext -> Async<Demand>)
(onSuccess: JwtSecurityToken -> WebPart)
(onError: JwtSecurityToken option -> WWWAuthenticate -> WebPart) =
(onSuccess: 'token -> WebPart)
(onError: 'token option -> WWWAuthenticate -> WebPart) =
onSuccess token
}

let mkDefault () = JwtSecurityToken()
43 changes: 21 additions & 22 deletions AAD.Suave/PartProtector.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Demand>) ->
onSuccess: (JwtSecurityToken -> WebPart) ->
onSuccess: ('token -> WebPart) ->
WebPart
/// Handling both success and error outcomes
abstract VerifyWith: getDemand: (HttpContext -> Async<Demand>) ->
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.
Expand All @@ -40,16 +40,14 @@ module PartProtector =
onSuccess

/// Creates PartProtector instance using the client credentials provided.
let mkNew (introspect: TokenString -> Async<Result<JwtSecurityToken,string>>)
(validate: Demand -> JwtSecurityToken -> Result<JwtSecurityToken,string>)
(audiences: #seq<Audience>)
(getConfig: unit -> Async<OpenIdConnectConfiguration>) =
let mkNew (introspect: TokenString -> Async<Result<'token,string>>)
(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<Demand>)
(onSuccess: JwtSecurityToken -> WebPart) =
(onSuccess: 'token -> WebPart) =
fun (ctx:HttpContext) ->
async {
let handleSuccess,handleMissing,result =
Expand All @@ -62,8 +60,8 @@ module PartProtector =
return! result ctx
}
member __.VerifyWith (getDemand: HttpContext -> Async<Demand>)
(onSuccess: JwtSecurityToken -> WebPart)
(onError: JwtSecurityToken option -> WWWAuthenticate -> WebPart) =
(onSuccess: 'token -> WebPart)
(onError: 'token option -> WWWAuthenticate -> WebPart) =
fun (ctx:HttpContext) ->
async {
let handleSuccess,handleMissing,result =
Expand Down Expand Up @@ -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
}
15 changes: 9 additions & 6 deletions AAD.Test/ResourceOwnerTests.fs
Original file line number Diff line number Diff line change
Expand Up @@ -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

[<Fact>]
let Introspects () =
Expand Down Expand Up @@ -79,36 +79,39 @@ module Internals =
} |> Async.RunSynchronously

module Validation =
let ofToken p (t: JwtSecurityToken) =
t.Claims |> Seq.collect p

[<Fact>]
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

[<Fact>]
let ``Scope demand is not satisfied`` () =
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

[<Fact>]
let ``Scope demand is satisfied`` () =
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

[<Fact>]
let ``AppRole demand is satisfied`` () =
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


Expand Down
54 changes: 27 additions & 27 deletions AAD.fs/ResourceOwner.fs
Original file line number Diff line number Diff line change
Expand Up @@ -31,29 +31,39 @@ module internal TokenCache =


[<RequireQualifiedAccess>]
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<Audience>)
(getConfig: unit -> Awaitable<OpenIdConnectConfiguration>) =
let mkNew (getConfig: unit -> Awaitable<OpenIdConnectConfiguration>)
(cache:string -> (string -> Task<_>) -> Task<_>)
(audiences: #seq<Audience>) =
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,
IssuerSigningKeys = oidcConfig.SigningKeys)
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 =
Expand All @@ -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<unit>

[<RequireQualifiedAccess>]
module ResourceOwner =
open Microsoft.IdentityModel.Protocols.OpenIdConnect
open System.Security.Claims

module ClaimProjection =
Expand All @@ -94,38 +103,29 @@ module ResourceOwner =
if claim.Type = "roles" then Seq.singleton claim.Value
else Seq.empty

let mkNew (introspect: TokenString -> Awaitable<Result<JwtSecurityToken,string>>)
(validate: Demand -> JwtSecurityToken -> Result<JwtSecurityToken,string>)
(audiences: #seq<Audience>)
(getConfig: unit -> Awaitable<OpenIdConnectConfiguration>) =
{ new ResourceOwner with
let mkNew (introspect: TokenString -> Awaitable<Result<'token,string>>)
(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<string>) (demand: Demand) (t: JwtSecurityToken) =
let validate (splitChar: char) (claimsProjection: 'token -> #seq<string>) (demand: Demand) (t: 'token) =
let claims =
t.Claims
|> Seq.collect claimsProjection
claimsProjection t
|> Seq.map (String.split splitChar)
demand
|> Demand.eval claims
Expand Down
4 changes: 4 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down

0 comments on commit 1fbe91f

Please sign in to comment.