diff --git a/LCU.Library.Internal.Security/.gitignore b/LCU.Library.Internal.Security/.gitignore
new file mode 100644
index 0000000..3e759b7
--- /dev/null
+++ b/LCU.Library.Internal.Security/.gitignore
@@ -0,0 +1,330 @@
+## Ignore Visual Studio temporary files, build results, and
+## files generated by popular Visual Studio add-ons.
+##
+## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
+
+# User-specific files
+*.suo
+*.user
+*.userosscache
+*.sln.docstates
+
+# User-specific files (MonoDevelop/Xamarin Studio)
+*.userprefs
+
+# Build results
+[Dd]ebug/
+[Dd]ebugPublic/
+[Rr]elease/
+[Rr]eleases/
+x64/
+x86/
+bld/
+[Bb]in/
+[Oo]bj/
+[Ll]og/
+
+# Visual Studio 2015/2017 cache/options directory
+.vs/
+# Uncomment if you have tasks that create the project's static files in wwwroot
+#wwwroot/
+
+# Visual Studio 2017 auto generated files
+Generated\ Files/
+
+# MSTest test Results
+[Tt]est[Rr]esult*/
+[Bb]uild[Ll]og.*
+
+# NUNIT
+*.VisualState.xml
+TestResult.xml
+
+# Build Results of an ATL Project
+[Dd]ebugPS/
+[Rr]eleasePS/
+dlldata.c
+
+# Benchmark Results
+BenchmarkDotNet.Artifacts/
+
+# .NET Core
+project.lock.json
+project.fragment.lock.json
+artifacts/
+**/Properties/launchSettings.json
+
+# StyleCop
+StyleCopReport.xml
+
+# Files built by Visual Studio
+*_i.c
+*_p.c
+*_i.h
+*.ilk
+*.meta
+*.obj
+*.iobj
+*.pch
+*.pdb
+*.ipdb
+*.pgc
+*.pgd
+*.rsp
+*.sbr
+*.tlb
+*.tli
+*.tlh
+*.tmp
+*.tmp_proj
+*.log
+*.vspscc
+*.vssscc
+.builds
+*.pidb
+*.svclog
+*.scc
+
+# Chutzpah Test files
+_Chutzpah*
+
+# Visual C++ cache files
+ipch/
+*.aps
+*.ncb
+*.opendb
+*.opensdf
+*.sdf
+*.cachefile
+*.VC.db
+*.VC.VC.opendb
+
+# Visual Studio profiler
+*.psess
+*.vsp
+*.vspx
+*.sap
+
+# Visual Studio Trace Files
+*.e2e
+
+# TFS 2012 Local Workspace
+$tf/
+
+# Guidance Automation Toolkit
+*.gpState
+
+# ReSharper is a .NET coding add-in
+_ReSharper*/
+*.[Rr]e[Ss]harper
+*.DotSettings.user
+
+# JustCode is a .NET coding add-in
+.JustCode
+
+# TeamCity is a build add-in
+_TeamCity*
+
+# DotCover is a Code Coverage Tool
+*.dotCover
+
+# AxoCover is a Code Coverage Tool
+.axoCover/*
+!.axoCover/settings.json
+
+# Visual Studio code coverage results
+*.coverage
+*.coveragexml
+
+# NCrunch
+_NCrunch_*
+.*crunch*.local.xml
+nCrunchTemp_*
+
+# MightyMoose
+*.mm.*
+AutoTest.Net/
+
+# Web workbench (sass)
+.sass-cache/
+
+# Installshield output folder
+[Ee]xpress/
+
+# DocProject is a documentation generator add-in
+DocProject/buildhelp/
+DocProject/Help/*.HxT
+DocProject/Help/*.HxC
+DocProject/Help/*.hhc
+DocProject/Help/*.hhk
+DocProject/Help/*.hhp
+DocProject/Help/Html2
+DocProject/Help/html
+
+# Click-Once directory
+publish/
+
+# Publish Web Output
+*.[Pp]ublish.xml
+*.azurePubxml
+# Note: Comment the next line if you want to checkin your web deploy settings,
+# but database connection strings (with potential passwords) will be unencrypted
+*.pubxml
+*.publishproj
+
+# Microsoft Azure Web App publish settings. Comment the next line if you want to
+# checkin your Azure Web App publish settings, but sensitive information contained
+# in these scripts will be unencrypted
+PublishScripts/
+
+# NuGet Packages
+*.nupkg
+# The packages folder can be ignored because of Package Restore
+**/[Pp]ackages/*
+# except build/, which is used as an MSBuild target.
+!**/[Pp]ackages/build/
+# Uncomment if necessary however generally it will be regenerated when needed
+#!**/[Pp]ackages/repositories.config
+# NuGet v3's project.json files produces more ignorable files
+*.nuget.props
+*.nuget.targets
+
+# Microsoft Azure Build Output
+csx/
+*.build.csdef
+
+# Microsoft Azure Emulator
+ecf/
+rcf/
+
+# Windows Store app package directories and files
+AppPackages/
+BundleArtifacts/
+Package.StoreAssociation.xml
+_pkginfo.txt
+*.appx
+
+# Visual Studio cache files
+# files ending in .cache can be ignored
+*.[Cc]ache
+# but keep track of directories ending in .cache
+!*.[Cc]ache/
+
+# Others
+ClientBin/
+~$*
+*~
+*.dbmdl
+*.dbproj.schemaview
+*.jfm
+*.pfx
+*.publishsettings
+orleans.codegen.cs
+
+# Including strong name files can present a security risk
+# (https://github.com/github/gitignore/pull/2483#issue-259490424)
+#*.snk
+
+# Since there are multiple workflows, uncomment next line to ignore bower_components
+# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
+#bower_components/
+
+# RIA/Silverlight projects
+Generated_Code/
+
+# Backup & report files from converting an old project file
+# to a newer Visual Studio version. Backup files are not needed,
+# because we have git ;-)
+_UpgradeReport_Files/
+Backup*/
+UpgradeLog*.XML
+UpgradeLog*.htm
+ServiceFabricBackup/
+*.rptproj.bak
+
+# SQL Server files
+*.mdf
+*.ldf
+*.ndf
+
+# Business Intelligence projects
+*.rdl.data
+*.bim.layout
+*.bim_*.settings
+*.rptproj.rsuser
+
+# Microsoft Fakes
+FakesAssemblies/
+
+# GhostDoc plugin setting file
+*.GhostDoc.xml
+
+# Node.js Tools for Visual Studio
+.ntvs_analysis.dat
+node_modules/
+
+# Visual Studio 6 build log
+*.plg
+
+# Visual Studio 6 workspace options file
+*.opt
+
+# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
+*.vbw
+
+# Visual Studio LightSwitch build output
+**/*.HTMLClient/GeneratedArtifacts
+**/*.DesktopClient/GeneratedArtifacts
+**/*.DesktopClient/ModelManifest.xml
+**/*.Server/GeneratedArtifacts
+**/*.Server/ModelManifest.xml
+_Pvt_Extensions
+
+# Paket dependency manager
+.paket/paket.exe
+paket-files/
+
+# FAKE - F# Make
+.fake/
+
+# JetBrains Rider
+.idea/
+*.sln.iml
+
+# CodeRush
+.cr/
+
+# Python Tools for Visual Studio (PTVS)
+__pycache__/
+*.pyc
+
+# Cake - Uncomment if you are using it
+# tools/**
+# !tools/packages.config
+
+# Tabs Studio
+*.tss
+
+# Telerik's JustMock configuration file
+*.jmconfig
+
+# BizTalk build output
+*.btp.cs
+*.btm.cs
+*.odx.cs
+*.xsd.cs
+
+# OpenCover UI analysis results
+OpenCover/
+
+# Azure Stream Analytics local run output
+ASALocalRun/
+
+# MSBuild Binary and Structured Log
+*.binlog
+
+# NVidia Nsight GPU debugger configuration file
+*.nvuser
+
+# MFractors (Xamarin productivity tool) working folder
+.mfractor/
diff --git a/LCU.Library.Internal.Security/Documentation/OpenIDConnect/ApiCall.png b/LCU.Library.Internal.Security/Documentation/OpenIDConnect/ApiCall.png
new file mode 100644
index 0000000..e12ccd5
Binary files /dev/null and b/LCU.Library.Internal.Security/Documentation/OpenIDConnect/ApiCall.png differ
diff --git a/LCU.Library.Internal.Security/Documentation/OpenIDConnect/app to app oauth.drawio b/LCU.Library.Internal.Security/Documentation/OpenIDConnect/app to app oauth.drawio
new file mode 100644
index 0000000..92aea0b
--- /dev/null
+++ b/LCU.Library.Internal.Security/Documentation/OpenIDConnect/app to app oauth.drawio
@@ -0,0 +1 @@
+7Vxtk6I4EP41flwLCC/6cZzZubuqvbu5mqu63U9biFFzg8SFMOr9+utAQEhQURGYLaempkwbA3Q/6ae7084APa62v4Tuevk7nWF/YGiz7QA9DQxDNw1jwH+12S6VOLYQLEIyE5P2glfyHxZCTUhjMsNRaSKj1GdkXRZ6NAiwx0oyNwzppjxtTv3yVdfuAiuCV8/1Vek/ZMaWqXRkaXv5r5gsltmVdU28s3KzyUIQLd0Z3RRE6PMAPYaUsvTVavuIfa68TC/p554PvJvfWIgDVucDmvf0V7g2Dfzs/fmI/0Av8ev8E0pXeXf9WDywuFm2yzQAq4CyYTCBJ1hzoefTGBadbJaE4de163HhBuwPsiVb+TDS4aVYGocMbw/es55rAiCE6QqzcAdTxAdy5Qn0jMVwszeFPhKyZdEMhi0gIMy/yJfeawheCCWdoTDdUjTmhXgGz0NcPxoYtg+3MJmG8GrBX+0AvIpGZwAxMaQhW9IFDVz/8146CWkczDC/Dw1G+zlfKF0L7f6LGduJ/eLGjJZ1j7eEfS28/saXGiLpR7z5tBUXSga7wuAFhwT0hsNMFoAOvxYH6cKOlY33ayWjbLFUB/zBjyMB9ETj0MPHLCC8gBsuMDs2zzgKrU/a0NBG6Voh9l1G3sv3VgWdZLWHMHR3hQlrSgIWFS72wgX7K+l6GcS6bUswTFeUPp0tT+fzCB5UBm7+RFdgWYFyHIGpZbhm2z5e+Q8eo4CFCd/UBJzkF3eK/RcaEUZoAFOmlDG6Kkx48MmCv8Go5BxozHwS4MfcbWvNeIxRWdf5uOAxUIXDsLUb+QvjtIN1o3WqgjnZ8j1fVJPAFsyzJgPrCSSu0KgHCuH7ckJWCYVN5jRgwh8A9HP5E1kt4M59MoW/7n9xiPkTkcRjsR0HH5d9B8vCBvj+RELMbbwbRu+Lhny4ZBI9W6JgEqfKJOaNTDJWTLLBU36hNWD0uTfe+qhzbtCfmtXWO88nKk4PmfJGHJeXSP23+NQNfJvq3ICmE3QnIST88emCBF2aWxtanRm8EQLVHQtdwqCtktyp7Q44nSSQeMOdoqEcqllH0XAoDmsxDKuLIutK93KVqzcvYN91IeYVuU8hDC5aTqHiPMXjup650TLHBSfnR+rz4AkuikzDcSYTkPs8gJq43tsiwVFhyjz5qeD1CBIuEiz+TuCFCgFAFeGT6Qr+8rAOAtTnKc+Hcdgct+vW6XDLbjPcykLtYnq2dH0fB6CaLl19aXMbx3d3g7v02t1XTe6GlNEgTbLnjcndUK2c+/Rhf8zs1HTienfJtFXTi18bJF7nxVU3/qHYugN7HQjdWrKXWiMDliFzAorvwmQ92GfGddGS8LvAHPrYuSTiPrdmhZBE7HJxWZpvyoHAmfOReILOa2IqdIFTfOK5SYULLI3D94oaWTtR5Fmhn5tWdGZZRQdE0Q//e/oAzYWAaqJvKSEgGg0tNQi0bhYEoj65mR56mUOZfTv0oKt0LkoxGgnmNEnK5WMUEvHnWuLkJn/EOGKcS5RZ/OOGRt8S+AH6AJU07rioV44J6+b1IkfYw0g/AaNkJJ/XNIgtvTa4rsTWRXwl0Yku6lKH6EdJW4+cyDRWiLIV2JdODvsSy9bNWj5pQ81EUkiljz8KTLt1gY6Che4ymkreOuVuPiJrXeRZ8lpV5loc86hrOTH/Rr5lpOCpcNoxdb03lSx7UvYuOJxvhbdO8qNT5sf24FrbwxyomeyPT0zdFhft8fGJoSBLQcu9hN5gCV03RPbSVQk9e4S+9oQhJ88mhcpMXT11aLktzFIr0jz/pj2qUV7d/NUk5aMmqFzttJK415Y3ya3PJdR2yp+uo8qu2GztdlRdcqj7c7VUmUhxgWbHTVWGmtHc2yya8JSjmtEmurbuct2RTwX/rQkIwGP5/TF+ueOqbq7xAYzfaTXDULPP0x75niNcniNUcXCrOQJSt/u9AeOiLY7qfp0h3WRdbXGkJoX3c9Gje1Y+F7X1zs9FTdWIgpEFPwd4y7OpyKNrnAtTHo+wF4oKUy+4u7VuaVS33GecrPdppnZRve/ckrUppWyOqUnY6ai5wlSLiQHe7ANDbQ60LMOwS8SNTFTAnD7UUM2YscwoJ+jk1sWWuq3ah6LIvPtI17PmjhsDeDySAOz0BMBqgacizSlh+p7pNu1o0bXFw+tIVD3G79zSXXWXN8WNcM95AevGfY0ZBWWe5cw+RWl+h55Irbf1osdWaVxqsVrSEM/xL7Zd9LW2s2nOkcCFjoMRWfqx+d2BMfMDBTBCvhCHPIl4ePlNAeQ9K5RD8y6zQrV61z2hdZTroSv57MZNjs64BJ4sz7tpa5Gpfn+2J0TTWQdQfaI5EKnmYY/maGYbTGNLX9gzx8eZRp7viLCpe6ZRE7A701SCy7JbZBoY7v+RVWrr/b8DQ5//Bw==
\ No newline at end of file
diff --git a/LCU.Library.Internal.Security/Documentation/OpenIDConnect/login.png b/LCU.Library.Internal.Security/Documentation/OpenIDConnect/login.png
new file mode 100644
index 0000000..d9527fb
Binary files /dev/null and b/LCU.Library.Internal.Security/Documentation/OpenIDConnect/login.png differ
diff --git a/LCU.Library.Internal.Security/Documentation/OpenIDConnect/readme.md b/LCU.Library.Internal.Security/Documentation/OpenIDConnect/readme.md
new file mode 100644
index 0000000..3412aec
--- /dev/null
+++ b/LCU.Library.Internal.Security/Documentation/OpenIDConnect/readme.md
@@ -0,0 +1,151 @@
+# OpenId Connect (OIDC)
+
+oidc is an extension of OAuth. Where OAuth provides authorization only OIDC extends it to include authentication.
+
+## Javascript Web Token (JWT)
+
+A JWT is a way to transmit OAuth data (not limited to authorization). It has three parts, the header, the payload, and the signature, all base64 encoded.
+
+More detailed information can be found on [jwt.io](https://jwt.io/introduction). There is much more to OAuth than will be included here.
+
+### Header
+
+The header provides metadata information about the token payload and signature. This allows the payload to be decoded and the signature verified
+
+### Payload
+
+The payload contains the user information for the token, also known as claims. There are some known claims and there can also be custom claims.
+
+The information in the payload, while signed, is not private. To exemplify this fact take a token you received from Azure Active Directory and paste it into https://jwt.io/, a public website not linked in any way to the issuing instance of AAD using only the information in the token to decode it and verify the signature.
+
+### Signature
+
+The signature is the result of combining the header and the payload with a secret through the algorithm specified in the header. The signature can be used to verify that the token has not been tampered with between the issuer and the receiver and that the claimed issuer is the actual issuer.
+
+#### Verification
+
+For JWTs signed by an issuer with more than one secret there will be attributes in the header (e.g. kid) that specify which secret was used to sign the token. For symmetrically signed tokens (HS256) the secret is preshared and can be readily used to verify the signature. For assymetrical signed tokens (RS256) the private key is used to sign the token and the public key (readily available from the issuer) is used to verify the signature.
+
+### OIDC extension
+
+"OpenID Connect is a simple identity layer on top of the OAuth protocol"
+
+OAuth provides bearer authorization by virtue of the "bearer" of the token having been authorized by a trusted issuer. In a nutshell, the core of OpenID Connect adds authentication (necessary to perform authorization) on top of the authorization already present in OAuth 2.0.
+
+More detailed information can be found on [OpenID.net](https://openid.net/connect/). There is much more to OpenID Connect than will be included here.
+
+#### Scope
+
+A scope is included in a request of an oidc token and is used to control what the token may be used for.
+
+In Azure Active Directory one application may have many scopes but a scope may only have one application. This is not necessarily true for all oidc providers. Having a scope limited to only belonging to one application is why we must setup our applications and their registrations to request a new token from their current token to call downstream APIs with user information from the original token. If the scope were not limited to only one application it would be possible to request a token at the entry point with a scope that all downstream applications will recognize and accept. This is both more maintenance intensive / less flexible as well as less secure.
+
+## Workflow
+
+Much of computer security is triangles and math; two parties that don't implicitly trust each other agree they trust a third party and they form a triangle using fancy math to ensure that nobody is compromising the triangle they form. In our case our applications are the two parties and Azure Active Directory is the trusted third party. We'll talk about the triangle, not the math.
+
+### Web Application Workflow
+1. a user visits a web page for the first time (therefore they have no cookies)
+1. the server expects a authorization cookie to accompany the request, yet there is none
+1. none is present so the server returns 401: Unauthorized with a security challenge informing the client (the browser) what kind of security it's looking for
+1. the browser uses the challenge information to know to go over to Azure Active Directory to ask for a note (a JWT) to give to the web page's server with a page redirect
+1. Azure Active Directory presents the user with it's own web page asking some questions or presenting information before issuing a note. The questions might include
+ - user credentials, like username and password or SSO information that has already been obtained
+ - information about the requesting party, giving the user a chance to see if something doesn't look right (if they're being tricked)
+ - what information about the user the requestor has asked for e.g. full name, email address. This is more common in public web sites asking for user information from public issuers.
+1. if the user consents Azure Active Directory writes the note and signs it. It also tells the client where to take the note from the preconfigured redirect registration in order to give it back to the requestor
+1. the client follows the redirect and presents the note to the server who requested it in the first place
+1. the server takes the note and brings it back to Azure Active Directory in some way, points at the signature and says "did you write this?"
+1. azure Active Directory says "yes"
+1. the server takes that note, writes it into a cookie or set of cookies, and redirects the client back to the page they started on
+1. now, with a cookie or set of cookies in hand, the browser is back where it started but the server accepts the request and returns a web page... assuming that, after all of that, the user identified by the token is allowed to access the page
+
+
+
+#### Notes
+
+some of the redirect steps have to do with how cookies work. Cookies are sent back and forth with requests and responses to and from the issuing server, meaning that Azure Active Directory, as the issuer of the token, cannot write a cookie on behalf of the web application server. The web application server cannot go to Azure Active Directory to get the token directly because it requires user authentication with Azure Active Directory and user consent and the user is in control of the browser.
+
+this is the workflow for the way _our_ UI applications are set up, with Blazor Server. This process would look a little different and be configured a little differently if we were using a browser-based SPA (e.g. react, Blazor wasm) because preshared secrets should not be trusted from or allowed into the browser.
+
+### API Workflow
+
+now that our web application has a token representing an authenticated user how do we call an API? Remember the API will not respect a token granting access to the web application because they have different scopes by design.
+
+1. Azure Active Directory is configured ahead of time such that the API application won't challenge a valid token asking for user consent before accepting a request.
+1. the web application takes the token that is has and goes to Azure Active Directory with the token and it's own preconfigured secret (we use preshared client secrets, the other option is another triangle with x509 certificates) and asks for a new note
+1. Azure accepts the token and secret and writes a new note for the API application with the information it is configured to get
+ - if the first step were not completed Azure Active Directory would return a response indicating that the web application should challenge the client a second time to ask for additional consent before issuing the new token
+1. the new token is included as an _Authorization: Bearer {token}_ header
+1. the API takes the note and brings it back to Azure Active Directory in some way, points at the signature and says "did you write this?"
+1. azure Active Directory says "yes"
+1. the server takes the note and evaluates what's written on it to decide whether or not to allow the request to proceed
+
+
+
+## More Depth
+
+This has only scratched the surface and has purposefully glossed over many details. Now that you have the basics in hand you should know enough to be able to search Google to get real answers to real problems.
+
+The best way to learn more on this, in my opinion, is to dive in and set it up for yourself, from scratch, with something you can debug and a database you can get at freely. [Duende IdentityServer](https://docs.duendesoftware.com/identityserver/v6) is .net, open source, has good guides, and doesn't add any of the additional restrictions or guard rails Azure Active Directory does; everything is in your hands, for better or worse. It is free for individuals, testing, and small businesses.
+
+There are many other related security practices and technologies that all work together to provide fully secure application use, e.g. SSL. OAuth + oidc is not, in itself, a comprehensive security solution.
+
+One important detail to note that won't be explained is that there are more than one kind of token involved however we have acted as if there is only one. [ID tokens](https://learn.microsoft.com/en-us/azure/active-directory/develop/id-tokens) contain user claims while [access tokens](https://learn.microsoft.com/en-us/azure/active-directory/develop/access-tokens) are what should actually be used to perform authorization.
+
+# Azure
+
+Setting all this up in Azure Active Directory isn't hard... as long as you already know what you're doing. Figuring that out is a little more difficult the first time.
+
+## Application to Secure API
+
+Here are the steps to set up one application to call another, assuming they both already have App Registrations that include at least one scope in AAD
+1. from the upstream caller app registration find the Application (client) ID
+1. go to the downstream called API > Expose an API
+1. under "Authorized client applications" click the "+ Add a client application" button
+1. put the client id of the upstream application into the client ID field and select the scopes you want that upstream caller to be allowed to use (likely there will only be one)
+1. take the scope of the downstream API and go to the code of the upstream application
+1. use that scope where ITokenAcquisition is used to request a new token (very likely the scope should be read from application configuration and the configuration object be used to read the value at this place in the code)
+
+## Additional Configuration for Swagger UI
+
+1. in the App Registration for the application with swagger UI go to Authentication
+1. add Single-page application redirects for
+ - {app base path}/swagger/oauth2-redirect.html
+ - assuming that swagger UI is hosted in the default location
+ - {local development base url}/swagger/oauth2-redirect.html
+ - assuming this is the app registration that corresponds to local development
+1. follow the configuration example of an application (e.g. Document API) already configured with instructions for Swashbuckle and Swagger UI to show the Authorize button and use the OAuth configuration
+ - as new versions of Swashbuckle are released you might find yourself needing to consult Google results on how to translate the old configuration code to the new package version's API
+1. in Swagger UI you should now be able to click the Authorize button on the right, above the list of API routes. You should see a section in the modal the corresponds to the OAuth configuration and, when you click login, it should take you through the OAuth redirect flow. At the end of the redirect flow you should be told that you've been authenticated. You are now ready to call the API through swagger UI!
+
+## Additional Configuration for Postman
+
+1. go to Azure Active Directory > App registrations
+1. find the App Registration for Postman (the current configuration simply names it "postman")
+ - if this needs to be configured follow the normal app registration steps. The desktop application redirect path is https://oauth.pstmn.io/v1/callback and the web app redirect path is https://oauth.pstmn.io/v1/browser-callback, these should be registered as Web Redirect URIs
+1. find the Application (client) ID in the overview (49cbce7d-dcee-4db1-a93e-4b540ee0e578)
+1. in the App Registration for the application to be called by Postman go to Expose an API
+1. under "Authorized client applications" click the "+ Add a client application" button
+1. put the client id of postman into the client ID field and select the scopes you want Postman to be allowed to use (likely there will only be one)
+1. go back to the App Registration for Postman to get the values needed for the following steps
+1. in Postman go to the Authorization tab of a request you have set up
+1. set the type to OAuth 2.0
+1. set Add authorization data to Request Headers
+1. set Access Token to Available Tokens
+1. set Header Prefix to Bearer
+1. set Token Name to whatever you want
+1. set Grant Type to Authorization Code
+1. if using the desktop application check the Authorize using browser checkbox
+1. set Auth URL to https://login.microsoftonline.com/{Directory (tenant) ID}/oauth2/v2.0/authorize
+1. set Access Token URL to https://login.microsoftonline.com/{Directory (tenant) ID}/oauth2/v2.0/token
+1. set Client ID to the Application (client) ID of the Postman App Registration
+1. set Client Secret to a known client secret from the Postman App Registration
+ - if none is known having more than one secret active isn't a problem so long as they don't get compromised, feel free to create or request more
+1. set Scope to the scope you want to use of the App Registration for the application you want to call with Postman
+ - you _can_ set it to the scope of the Postman App Registration however if you don't have permission on your account to give consent for Postman's scope to access the called API's scope you'll hit a roadblock here
+1. set Client Authentication to Send as Basic Auth header
+1. click the Get New Access Token button
+1. if everything worked correctly the browser will open, you'll be taken through the redirect flow, and then you'll be taken back to postman with a modal showing you the token you just received
+ - you might be asked if you want to allow chrome to open links of a type that open Postman. You should say yes, this is the postman website trying to send the token back to postman the application from the browser tab it opened to follow the required redirects
+1. you are now ready to call the API!
\ No newline at end of file
diff --git a/LCU.Library.Internal.Security/IntegrationTestApiOne/Controllers/WeatherForecastController.cs b/LCU.Library.Internal.Security/IntegrationTestApiOne/Controllers/WeatherForecastController.cs
new file mode 100644
index 0000000..e66df02
--- /dev/null
+++ b/LCU.Library.Internal.Security/IntegrationTestApiOne/Controllers/WeatherForecastController.cs
@@ -0,0 +1,72 @@
+using Flurl;
+using Flurl.Http;
+using LCU.Library.Internal.ApiSecurity.Constants;
+using LCU.Library.Internal.ApiSecurity.Models;
+using Microsoft.AspNetCore.Mvc;
+
+namespace IntegrationTestApiOne.Controllers;
+
+[ApiController]
+[Route("[controller]")]
+public class WeatherForecastController : ControllerBase
+{
+ private static readonly string[] Summaries = new[]
+ {
+ "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
+ };
+
+ private readonly ILogger _logger;
+
+ private readonly ApiSecurityConfiguration _apiSecurityConfiguration;
+
+ public WeatherForecastController(ILogger logger, ApiSecurityConfiguration apiSecurityConfiguration)
+ {
+ _logger = logger;
+ _apiSecurityConfiguration = apiSecurityConfiguration;
+ }
+
+ [HttpGet("GetWeatherForecast", Name = "GetWeatherForecast")]
+ public IEnumerable Get()
+ {
+ return Enumerable.Range(1, 5)
+ .Select(index => new WeatherForecast
+ {
+ Date = DateTime.Now.AddDays(index),
+ TemperatureC = Random.Shared.Next(-20, 55),
+ Summary = Summaries[Random.Shared.Next(Summaries.Length)]
+ })
+ .ToArray();
+ }
+
+ [HttpGet("RunTestApiKeyAuthenticatedRequest", Name = "RunTestApiKeyAuthenticatedRequest")]
+ [ProducesResponseType(200)]
+ [ProducesResponseType(500)]
+ public async Task RunTestApiKeyAuthenticatedRequest()
+ {
+ var client = "TestTenantOne";
+ var apiKey = _apiSecurityConfiguration.InternalApiKeySet[client];
+ var result = await "https://localhost:32003"
+ .AppendPathSegment("WeatherForecast")
+ .AppendPathSegment("GetWeatherForecast")
+ .WithHeader(HeaderKeys.ApiKey, apiKey)
+ .WithHeader(HeaderKeys.Client, client)
+ .GetAsync();
+
+ return Ok(result.ResponseMessage.Content.ReadAsStringAsync().Result);
+ }
+
+ [HttpGet("RunTestApiKeyAuthenticatedRequestUsingMadeUpCreds", Name = "RunTestApiKeyAuthenticatedRequestUsingMadeUpCreds")]
+ [ProducesResponseType(200)]
+ [ProducesResponseType(500)]
+ public async Task RunTestApiKeyAuthenticatedRequestUsingMadeUpCreds()
+ {
+ var result = await "https://localhost:32003"
+ .AppendPathSegment("WeatherForecast")
+ .AppendPathSegment("GetWeatherForecast")
+ .WithHeader(HeaderKeys.ApiKey, "MadeUpApiKey")
+ .WithHeader(HeaderKeys.Client, "MadeUpClient")
+ .GetAsync();
+
+ return Ok(result.ResponseMessage.Content.ReadAsStringAsync().Result);
+ }
+}
\ No newline at end of file
diff --git a/LCU.Library.Internal.Security/IntegrationTestApiOne/IntegrationTestApiOne.csproj b/LCU.Library.Internal.Security/IntegrationTestApiOne/IntegrationTestApiOne.csproj
new file mode 100644
index 0000000..9c61b65
--- /dev/null
+++ b/LCU.Library.Internal.Security/IntegrationTestApiOne/IntegrationTestApiOne.csproj
@@ -0,0 +1,19 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LCU.Library.Internal.Security/IntegrationTestApiOne/Program.cs b/LCU.Library.Internal.Security/IntegrationTestApiOne/Program.cs
new file mode 100644
index 0000000..a9be4f9
--- /dev/null
+++ b/LCU.Library.Internal.Security/IntegrationTestApiOne/Program.cs
@@ -0,0 +1,47 @@
+using LCU.Library.Internal.ApiSecurity;
+using Microsoft.OpenApi.Models;
+using Newtonsoft.Json.Converters;
+
+var builder = WebApplication.CreateBuilder(args);
+
+// Add services to the container.
+
+builder.Services.AddControllers()
+ .AddNewtonsoftJson(options =>
+ {
+ options.SerializerSettings.Converters.Add(new StringEnumConverter());
+ options.SerializerSettings.ReferenceLoopHandling = Newtonsoft.Json.ReferenceLoopHandling.Ignore;
+ });
+// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
+builder.Services.AddEndpointsApiExplorer();
+builder.Services.AddSwaggerGen(c =>
+{
+ c.SwaggerDoc("v1", new OpenApiInfo
+ {
+ Title = "IntegrationTestApiOne",
+ Version = "v1"
+ });
+});
+
+builder.Services.AddApiKeySecurity(builder.Configuration);
+
+var app = builder.Build();
+
+// Configure the HTTP request pipeline.
+if (app.Environment.IsDevelopment())
+{
+ app.UseSwagger();
+ app.UseSwaggerUI(c => c.SwaggerEndpoint("v1/swagger.json", "IntegrationTestApiOne"));
+}
+
+app.UseHttpsRedirection();
+
+app.UseRouting();
+
+app.UseAuthorization();
+
+// app.UseMiddleware();
+
+app.UseEndpoints(endpoints => { endpoints.MapControllers(); });
+
+app.Run();
\ No newline at end of file
diff --git a/LCU.Library.Internal.Security/IntegrationTestApiOne/WeatherForecast.cs b/LCU.Library.Internal.Security/IntegrationTestApiOne/WeatherForecast.cs
new file mode 100644
index 0000000..6d1c39f
--- /dev/null
+++ b/LCU.Library.Internal.Security/IntegrationTestApiOne/WeatherForecast.cs
@@ -0,0 +1,12 @@
+namespace IntegrationTestApiOne;
+
+public class WeatherForecast
+{
+ public DateTime Date { get; set; }
+
+ public int TemperatureC { get; set; }
+
+ public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
+
+ public string? Summary { get; set; }
+}
\ No newline at end of file
diff --git a/LCU.Library.Internal.Security/IntegrationTestApiOne/appsettings.Development.json b/LCU.Library.Internal.Security/IntegrationTestApiOne/appsettings.Development.json
new file mode 100644
index 0000000..0c208ae
--- /dev/null
+++ b/LCU.Library.Internal.Security/IntegrationTestApiOne/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/LCU.Library.Internal.Security/IntegrationTestApiOne/appsettings.json b/LCU.Library.Internal.Security/IntegrationTestApiOne/appsettings.json
new file mode 100644
index 0000000..625b546
--- /dev/null
+++ b/LCU.Library.Internal.Security/IntegrationTestApiOne/appsettings.json
@@ -0,0 +1,18 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*",
+ "ApiSecurityConfiguration": {
+ "ApiKeyAuthenticationFailureBehavior": "Error",
+ "ExternalApiKeySet": {
+ "TestTenantTwo": "TestKeyTwo"
+ },
+ "InternalApiKeySet": {
+ "TestTenantOne": "TestKeyOne"
+ }
+ }
+}
diff --git a/LCU.Library.Internal.Security/IntegrationTestApiTwo/Controllers/WeatherForecastController.cs b/LCU.Library.Internal.Security/IntegrationTestApiTwo/Controllers/WeatherForecastController.cs
new file mode 100644
index 0000000..ef6e10c
--- /dev/null
+++ b/LCU.Library.Internal.Security/IntegrationTestApiTwo/Controllers/WeatherForecastController.cs
@@ -0,0 +1,50 @@
+using LCU.Library.Internal.ApiSecurity.Attributes;
+using Microsoft.AspNetCore.Mvc;
+
+namespace IntegrationTestApiTwo.Controllers;
+
+[ApiController]
+[Route("[controller]")]
+public class WeatherForecastController : ControllerBase
+{
+ private static readonly string[] Summaries = new[]
+ {
+ "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
+ };
+
+ private readonly ILogger _logger;
+
+ public WeatherForecastController(ILogger logger)
+ {
+ _logger = logger;
+ }
+
+ [ApiKey]
+ [HttpGet("GetWeatherForecast", Name = "GetWeatherForecast")]
+ public IEnumerable Get()
+ {
+ return Enumerable.Range(1, 5)
+ .Select(index => new WeatherForecast
+ {
+ Date = DateTime.Now.AddDays(index),
+ TemperatureC = Random.Shared.Next(-20, 55),
+ Summary = Summaries[Random.Shared.Next(Summaries.Length)]
+ })
+ .ToArray();
+ }
+
+ [HttpGet("GetWeatherForecastUnauthenticated", Name = "GetWeatherForecastUnauthenticated")]
+ [ProducesResponseType(200)]
+ [ProducesResponseType(500)]
+ public async Task GetWeatherForecastUnauthenticated()
+ {
+ return Ok(Enumerable.Range(1, 5)
+ .Select(index => new WeatherForecast
+ {
+ Date = DateTime.Now.AddDays(index),
+ TemperatureC = Random.Shared.Next(-20, 55),
+ Summary = Summaries[Random.Shared.Next(Summaries.Length)]
+ })
+ .ToArray());
+ }
+}
\ No newline at end of file
diff --git a/LCU.Library.Internal.Security/IntegrationTestApiTwo/IntegrationTestApiTwo.csproj b/LCU.Library.Internal.Security/IntegrationTestApiTwo/IntegrationTestApiTwo.csproj
new file mode 100644
index 0000000..9c61b65
--- /dev/null
+++ b/LCU.Library.Internal.Security/IntegrationTestApiTwo/IntegrationTestApiTwo.csproj
@@ -0,0 +1,19 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LCU.Library.Internal.Security/IntegrationTestApiTwo/Program.cs b/LCU.Library.Internal.Security/IntegrationTestApiTwo/Program.cs
new file mode 100644
index 0000000..32c13bf
--- /dev/null
+++ b/LCU.Library.Internal.Security/IntegrationTestApiTwo/Program.cs
@@ -0,0 +1,102 @@
+using LCU.Library.Internal.ApiSecurity;
+using LCU.Library.Internal.ApiSecurity.Constants;
+using LCU.Library.Internal.ApiSecurity.Middleware;
+using Microsoft.OpenApi.Models;
+using Newtonsoft.Json.Converters;
+
+var builder = WebApplication.CreateBuilder(args);
+
+// Add services to the container.
+
+builder.Services.AddControllers()
+ .AddNewtonsoftJson(options =>
+ {
+ options.SerializerSettings.Converters.Add(new StringEnumConverter());
+ options.SerializerSettings.ReferenceLoopHandling = Newtonsoft.Json.ReferenceLoopHandling.Ignore;
+ });
+// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
+builder.Services.AddEndpointsApiExplorer();
+builder.Services.AddSwaggerGen(c =>
+{
+ c.SwaggerDoc("v1", new OpenApiInfo
+ {
+ Title = "IntegrationTestApiTwo",
+ Version = "v1"
+ });
+
+ c.AddSecurityRequirement(new OpenApiSecurityRequirement
+ {
+ {
+ new OpenApiSecurityScheme
+ {
+ Reference = new OpenApiReference
+ {
+ Id = HeaderKeys.ApiKey, // Must match the "AddSecurityDefinition"
+ Type = ReferenceType.SecurityScheme
+ }
+ },
+ new List()
+ }
+ });
+
+ c.AddSecurityRequirement(new OpenApiSecurityRequirement
+ {
+ {
+ new OpenApiSecurityScheme
+ {
+ Reference = new OpenApiReference
+ {
+ Id = HeaderKeys.Client, // Must match the "AddSecurityDefinition"
+ Type = ReferenceType.SecurityScheme
+ }
+ },
+ new List()
+ }
+ });
+
+ c.AddSecurityDefinition(HeaderKeys.ApiKey,
+ new OpenApiSecurityScheme
+ {
+ Description = HeaderKeys.ApiKey,
+ In = ParameterLocation.Header,
+ Name = HeaderKeys.ApiKey,
+ Type = SecuritySchemeType.ApiKey,
+ Scheme = "apikey"
+ });
+
+ c.AddSecurityDefinition(HeaderKeys.Client,
+ new OpenApiSecurityScheme
+ {
+ Description = HeaderKeys.Client,
+ In = ParameterLocation.Header,
+ Name = HeaderKeys.Client,
+ Type = SecuritySchemeType.ApiKey,
+ Scheme = "apikey"
+ });
+});
+
+builder.Services.AddApiKeySecurity(builder.Configuration);
+
+var app = builder.Build();
+
+// Configure the HTTP request pipeline.
+if (app.Environment.IsDevelopment())
+{
+ if (app.Environment.IsDevelopment())
+ {
+ app.UseSwagger();
+ app.UseSwaggerUI(c => c.SwaggerEndpoint("v1/swagger.json", "IntegrationTestApiTwo"));
+ }
+}
+
+app.UseHttpsRedirection();
+
+app.UseRouting();
+
+app.UseAuthorization();
+
+app.UseMiddleware();
+
+app.UseEndpoints(endpoints => { endpoints.MapControllers(); });
+
+app.Run();
\ No newline at end of file
diff --git a/LCU.Library.Internal.Security/IntegrationTestApiTwo/WeatherForecast.cs b/LCU.Library.Internal.Security/IntegrationTestApiTwo/WeatherForecast.cs
new file mode 100644
index 0000000..3ffcabc
--- /dev/null
+++ b/LCU.Library.Internal.Security/IntegrationTestApiTwo/WeatherForecast.cs
@@ -0,0 +1,12 @@
+namespace IntegrationTestApiTwo;
+
+public class WeatherForecast
+{
+ public DateTime Date { get; set; }
+
+ public int TemperatureC { get; set; }
+
+ public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
+
+ public string? Summary { get; set; }
+}
\ No newline at end of file
diff --git a/LCU.Library.Internal.Security/IntegrationTestApiTwo/appsettings.Development.json b/LCU.Library.Internal.Security/IntegrationTestApiTwo/appsettings.Development.json
new file mode 100644
index 0000000..0c208ae
--- /dev/null
+++ b/LCU.Library.Internal.Security/IntegrationTestApiTwo/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/LCU.Library.Internal.Security/IntegrationTestApiTwo/appsettings.json b/LCU.Library.Internal.Security/IntegrationTestApiTwo/appsettings.json
new file mode 100644
index 0000000..2c8a8f0
--- /dev/null
+++ b/LCU.Library.Internal.Security/IntegrationTestApiTwo/appsettings.json
@@ -0,0 +1,18 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*",
+ "ApiSecurityConfiguration": {
+ "ApiKeyAuthenticationFailureBehavior": "Warn",
+ "ExternalApiKeySet": {
+ "TestTenantOne": "TestKeyOne"
+ },
+ "InternalApiKeySet": {
+ "TestTenantTwo": "TestKeyTwo"
+ }
+ }
+}
diff --git a/LCU.Library.Internal.Security/LCU.Library.Internal.ApiSecurity/ApiSecurityBuilder.cs b/LCU.Library.Internal.Security/LCU.Library.Internal.ApiSecurity/ApiSecurityBuilder.cs
new file mode 100644
index 0000000..7c1ccc3
--- /dev/null
+++ b/LCU.Library.Internal.Security/LCU.Library.Internal.ApiSecurity/ApiSecurityBuilder.cs
@@ -0,0 +1,13 @@
+using Microsoft.Extensions.DependencyInjection;
+
+namespace LCU.Library.Internal.ApiSecurity;
+
+public class ApiSecurityBuilder : IApiSecurityBuilder
+{
+ public ApiSecurityBuilder(IServiceCollection services)
+ {
+ Services = services ?? throw new ArgumentNullException(nameof(services));
+ }
+
+ public IServiceCollection Services { get; }
+}
\ No newline at end of file
diff --git a/LCU.Library.Internal.Security/LCU.Library.Internal.ApiSecurity/Attributes/ApiKeyAttribute.cs b/LCU.Library.Internal.Security/LCU.Library.Internal.ApiSecurity/Attributes/ApiKeyAttribute.cs
new file mode 100644
index 0000000..5d5f1f5
--- /dev/null
+++ b/LCU.Library.Internal.Security/LCU.Library.Internal.ApiSecurity/Attributes/ApiKeyAttribute.cs
@@ -0,0 +1,130 @@
+using LCU.Library.Internal.ApiSecurity.Constants;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.Filters;
+using LCU.Library.Internal.ApiSecurity.Enums;
+using LCU.Library.Internal.ApiSecurity.Models;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+namespace LCU.Library.Internal.ApiSecurity.Attributes;
+
+[AttributeUsage(validOn: AttributeTargets.Method)]
+public class ApiKeyAttribute : Attribute, IAsyncActionFilter
+{
+ public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
+ {
+ var apiSecurityConfiguration = context.HttpContext.RequestServices.GetRequiredService();
+ var logger = context.HttpContext.RequestServices.GetRequiredService().CreateLogger(GetType().Name);
+
+ if (apiSecurityConfiguration.ExternalApiKeySet.Any())
+ {
+ if (!context.HttpContext.Request.Headers.TryGetValue(HeaderKeys.ApiKey, out var extractedApiKey))
+ {
+ switch (apiSecurityConfiguration.ApiKeyAuthenticationFailureBehavior)
+ {
+ case ApiKeyAuthenticationFailureBehavior.Warn:
+ logger.LogWarning(AuthMessages.NotProvidedMessage(HeaderKeys.ApiKey));
+ break;
+ case ApiKeyAuthenticationFailureBehavior.Error:
+ logger.LogError(AuthMessages.NotProvidedMessage(HeaderKeys.ApiKey));
+ context.Result = new ContentResult()
+ {
+ StatusCode = 401,
+ Content = AuthMessages.NotProvidedMessage(HeaderKeys.ApiKey)
+ };
+ return;
+ default:
+ throw new Exception("Invalid ApiKeyAuthenticationFailureBehavior Value");
+ }
+ }
+
+ if (!context.HttpContext.Request.Headers.TryGetValue(HeaderKeys.Client, out var extractedClient))
+ {
+ switch (apiSecurityConfiguration.ApiKeyAuthenticationFailureBehavior)
+ {
+ case ApiKeyAuthenticationFailureBehavior.Warn:
+ logger.LogWarning(AuthMessages.NotProvidedMessage(HeaderKeys.Client));
+ break;
+ case ApiKeyAuthenticationFailureBehavior.Error:
+ logger.LogError(AuthMessages.NotProvidedMessage(HeaderKeys.Client));
+ context.Result = new ContentResult()
+ {
+ StatusCode = 401,
+ Content = AuthMessages.NotProvidedMessage(HeaderKeys.Client)
+ };
+ return;
+ default:
+ throw new Exception("Invalid ApiKeyAuthenticationFailureBehavior Value");
+ }
+ }
+
+ if (!string.IsNullOrWhiteSpace(extractedClient))
+ {
+ if (apiSecurityConfiguration.ExternalApiKeySet.TryGetValue(extractedClient, out var apiKey))
+ {
+ if (!apiKey.Equals(extractedApiKey))
+ {
+ switch (apiSecurityConfiguration.ApiKeyAuthenticationFailureBehavior)
+ {
+ case ApiKeyAuthenticationFailureBehavior.Warn:
+ logger.LogWarning(AuthMessages.AuthFailureMessage(extractedClient));
+ break;
+ case ApiKeyAuthenticationFailureBehavior.Error:
+ logger.LogError(AuthMessages.AuthFailureMessage(extractedClient));
+ context.Result = new ContentResult()
+ {
+ StatusCode = 401,
+ Content = AuthMessages.AuthFailureMessage(extractedClient)
+ };
+ return;
+ default:
+ throw new Exception("Invalid ApiKeyAuthenticationFailureBehavior Value");
+ }
+ }
+
+ logger.LogInformation($"Valid auth received for Client: {extractedClient}");
+ }
+ else
+ {
+ switch (apiSecurityConfiguration.ApiKeyAuthenticationFailureBehavior)
+ {
+ case ApiKeyAuthenticationFailureBehavior.Warn:
+ logger.LogWarning(AuthMessages.ClientNotFoundMessage());
+ break;
+ case ApiKeyAuthenticationFailureBehavior.Error:
+ logger.LogError(AuthMessages.ClientNotFoundMessage());
+ context.Result = new ContentResult()
+ {
+ StatusCode = 401,
+ Content = AuthMessages.ClientNotFoundMessage()
+ };
+ return;
+ default:
+ throw new Exception("Invalid ApiKeyAuthenticationFailureBehavior Value");
+ }
+ }
+ }
+ }
+ else
+ {
+ switch (apiSecurityConfiguration.ApiKeyAuthenticationFailureBehavior)
+ {
+ case ApiKeyAuthenticationFailureBehavior.Warn:
+ logger.LogWarning(AuthMessages.EmptyKeySetMessage("ExternalApiKeySet"));
+ break;
+ case ApiKeyAuthenticationFailureBehavior.Error:
+ logger.LogError(AuthMessages.EmptyKeySetMessage("ExternalApiKeySet"));
+ context.Result = new ContentResult()
+ {
+ StatusCode = 401,
+ Content = AuthMessages.EmptyKeySetMessage("ExternalApiKeySet")
+ };
+ return;
+ default:
+ throw new Exception("Invalid ApiKeyAuthenticationFailureBehavior Value");
+ }
+ }
+
+ await next();
+ }
+}
\ No newline at end of file
diff --git a/LCU.Library.Internal.Security/LCU.Library.Internal.ApiSecurity/Constants/AuthMessages.cs b/LCU.Library.Internal.Security/LCU.Library.Internal.ApiSecurity/Constants/AuthMessages.cs
new file mode 100644
index 0000000..866777b
--- /dev/null
+++ b/LCU.Library.Internal.Security/LCU.Library.Internal.ApiSecurity/Constants/AuthMessages.cs
@@ -0,0 +1,29 @@
+namespace LCU.Library.Internal.ApiSecurity.Constants;
+
+public static class AuthMessages
+{
+ private const string AuthFailureMessageTemplate = "Unauthorized client: ";
+ private const string ClientNotFoundMessageTemplate = "Client not found for extracted API Key";
+ private const string EmptyKeySetMessageTemplate = "Empty Key Set: ";
+ private const string NotProvidedMessageTemplate = "Header Not Provided: ";
+
+ public static string AuthFailureMessage(string input)
+ {
+ return $"{AuthFailureMessageTemplate}{input}";
+ }
+
+ public static string ClientNotFoundMessage()
+ {
+ return $"{ClientNotFoundMessageTemplate}";
+ }
+
+ public static string EmptyKeySetMessage(string input)
+ {
+ return $"{EmptyKeySetMessageTemplate}{input}";
+ }
+
+ public static string NotProvidedMessage(string input)
+ {
+ return $"{NotProvidedMessageTemplate}{input}";
+ }
+}
\ No newline at end of file
diff --git a/LCU.Library.Internal.Security/LCU.Library.Internal.ApiSecurity/Constants/HeaderKeys.cs b/LCU.Library.Internal.Security/LCU.Library.Internal.ApiSecurity/Constants/HeaderKeys.cs
new file mode 100644
index 0000000..3a3522f
--- /dev/null
+++ b/LCU.Library.Internal.Security/LCU.Library.Internal.ApiSecurity/Constants/HeaderKeys.cs
@@ -0,0 +1,7 @@
+namespace LCU.Library.Internal.ApiSecurity.Constants;
+
+public static class HeaderKeys
+{
+ public const string ApiKey = "ApiKey";
+ public const string Client = "Client";
+}
\ No newline at end of file
diff --git a/LCU.Library.Internal.Security/LCU.Library.Internal.ApiSecurity/Enums/ApiKeyAuthenticationFailureBehavior.cs b/LCU.Library.Internal.Security/LCU.Library.Internal.ApiSecurity/Enums/ApiKeyAuthenticationFailureBehavior.cs
new file mode 100644
index 0000000..e353fd6
--- /dev/null
+++ b/LCU.Library.Internal.Security/LCU.Library.Internal.ApiSecurity/Enums/ApiKeyAuthenticationFailureBehavior.cs
@@ -0,0 +1,7 @@
+namespace LCU.Library.Internal.ApiSecurity.Enums;
+
+public enum ApiKeyAuthenticationFailureBehavior
+{
+ Error,
+ Warn
+}
\ No newline at end of file
diff --git a/LCU.Library.Internal.Security/LCU.Library.Internal.ApiSecurity/Enums/ApiKeySetName.cs b/LCU.Library.Internal.Security/LCU.Library.Internal.ApiSecurity/Enums/ApiKeySetName.cs
new file mode 100644
index 0000000..bd90f06
--- /dev/null
+++ b/LCU.Library.Internal.Security/LCU.Library.Internal.ApiSecurity/Enums/ApiKeySetName.cs
@@ -0,0 +1,7 @@
+namespace LCU.Library.Internal.ApiSecurity.Enums;
+
+public enum ApiKeySetName
+{
+ InternalKeySet,
+ ExternalKeySet
+}
\ No newline at end of file
diff --git a/LCU.Library.Internal.Security/LCU.Library.Internal.ApiSecurity/IApiSecurityBuilder.cs b/LCU.Library.Internal.Security/LCU.Library.Internal.ApiSecurity/IApiSecurityBuilder.cs
new file mode 100644
index 0000000..61a7569
--- /dev/null
+++ b/LCU.Library.Internal.Security/LCU.Library.Internal.ApiSecurity/IApiSecurityBuilder.cs
@@ -0,0 +1,11 @@
+using Microsoft.Extensions.DependencyInjection;
+
+namespace LCU.Library.Internal.ApiSecurity;
+
+public interface IApiSecurityBuilder
+{
+ ///
+ /// The application services.
+ ///
+ IServiceCollection Services { get; }
+}
\ No newline at end of file
diff --git a/LCU.Library.Internal.Security/LCU.Library.Internal.ApiSecurity/LCU.Library.Internal.ApiSecurity.csproj b/LCU.Library.Internal.Security/LCU.Library.Internal.ApiSecurity/LCU.Library.Internal.ApiSecurity.csproj
new file mode 100644
index 0000000..c9510c5
--- /dev/null
+++ b/LCU.Library.Internal.Security/LCU.Library.Internal.ApiSecurity/LCU.Library.Internal.ApiSecurity.csproj
@@ -0,0 +1,38 @@
+
+
+
+ enable
+ enable
+ LCU.Library.Internal.ApiSecurity
+ Landmark Credit Union
+ Git
+ Landmark Credit Union
+ 2.0.0
+ 2.0.0
+ 2.0.0
+ LCU.Library.Internal.ApiSecurity
+ latest
+ net8.0
+ true
+ 2.0.0
+
+ Standard LCU Api Security Library
+ 2.0.0 - Updated to .NET 8
+
+
+ Do not throw on empty key sets, account for empty sets in auth
+ 2.0.0 - Updated to .NET 8
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LCU.Library.Internal.Security/LCU.Library.Internal.ApiSecurity/LcuApiSecurity.cs b/LCU.Library.Internal.Security/LCU.Library.Internal.ApiSecurity/LcuApiSecurity.cs
new file mode 100644
index 0000000..b7dee43
--- /dev/null
+++ b/LCU.Library.Internal.Security/LCU.Library.Internal.ApiSecurity/LcuApiSecurity.cs
@@ -0,0 +1,13 @@
+using LCU.Library.Internal.ApiSecurity.Models;
+
+namespace LCU.Library.Internal.ApiSecurity;
+
+public class LcuApiSecurity
+{
+ private readonly ApiSecurityConfiguration _apiSecurityConfiguration;
+
+ public LcuApiSecurity(ApiSecurityConfiguration apiSecurityConfiguration)
+ {
+ _apiSecurityConfiguration = apiSecurityConfiguration;
+ }
+}
\ No newline at end of file
diff --git a/LCU.Library.Internal.Security/LCU.Library.Internal.ApiSecurity/Middleware/ApiKeyMiddleware.cs b/LCU.Library.Internal.Security/LCU.Library.Internal.ApiSecurity/Middleware/ApiKeyMiddleware.cs
new file mode 100644
index 0000000..3d387b1
--- /dev/null
+++ b/LCU.Library.Internal.Security/LCU.Library.Internal.ApiSecurity/Middleware/ApiKeyMiddleware.cs
@@ -0,0 +1,123 @@
+using LCU.Library.Internal.ApiSecurity.Constants;
+using Microsoft.AspNetCore.Http;
+using LCU.Library.Internal.ApiSecurity.Enums;
+using LCU.Library.Internal.ApiSecurity.Models;
+using Microsoft.AspNetCore.Http.Extensions;
+using Microsoft.Extensions.Logging;
+
+namespace LCU.Library.Internal.ApiSecurity.Middleware
+{
+ public class ApiKeyMiddleware
+ {
+ private readonly ApiSecurityConfiguration _apiSecurityConfiguration;
+ private readonly ILogger _logger;
+ private readonly RequestDelegate _next;
+
+ public ApiKeyMiddleware(RequestDelegate next, ApiSecurityConfiguration apiSecurityConfiguration, ILoggerFactory loggerFactory)
+ {
+ _next = next;
+ _apiSecurityConfiguration = apiSecurityConfiguration;
+ _logger = loggerFactory.CreateLogger(GetType().Name);
+ }
+
+ public async Task InvokeAsync(HttpContext context)
+ {
+ var displayUrl = context.Request.GetDisplayUrl();
+ if (_apiSecurityConfiguration.ExternalApiKeySet.Any())
+ {
+ if (!context.Request.Headers.TryGetValue(HeaderKeys.ApiKey, out var extractedApiKey))
+ {
+ switch (_apiSecurityConfiguration.ApiKeyAuthenticationFailureBehavior)
+ {
+ case ApiKeyAuthenticationFailureBehavior.Warn:
+ _logger.LogWarning(AuthMessages.NotProvidedMessage(HeaderKeys.ApiKey));
+ break;
+ case ApiKeyAuthenticationFailureBehavior.Error:
+ _logger.LogError(AuthMessages.NotProvidedMessage(HeaderKeys.ApiKey));
+ context.Response.StatusCode = 401;
+ await context.Response.WriteAsync(AuthMessages.NotProvidedMessage(HeaderKeys.ApiKey));
+ return;
+ default:
+ throw new Exception("Invalid ApiKeyAuthenticationFailureBehavior Value");
+ }
+ }
+
+ if (!context.Request.Headers.TryGetValue(HeaderKeys.Client, out var extractedClient))
+ {
+ switch (_apiSecurityConfiguration.ApiKeyAuthenticationFailureBehavior)
+ {
+ case ApiKeyAuthenticationFailureBehavior.Warn:
+ _logger.LogWarning(AuthMessages.NotProvidedMessage(HeaderKeys.Client));
+ break;
+ case ApiKeyAuthenticationFailureBehavior.Error:
+ _logger.LogError(AuthMessages.NotProvidedMessage(HeaderKeys.Client));
+ context.Response.StatusCode = 401;
+ await context.Response.WriteAsync(AuthMessages.NotProvidedMessage(HeaderKeys.Client));
+ return;
+ default:
+ throw new Exception("Invalid ApiKeyAuthenticationFailureBehavior Value");
+ }
+ }
+
+ if (!string.IsNullOrWhiteSpace(extractedClient))
+ {
+ if (_apiSecurityConfiguration.ExternalApiKeySet.TryGetValue(extractedClient, out var apiKey))
+ {
+ if (!apiKey.Equals(extractedApiKey))
+ {
+ switch (_apiSecurityConfiguration.ApiKeyAuthenticationFailureBehavior)
+ {
+ case ApiKeyAuthenticationFailureBehavior.Warn:
+ _logger.LogWarning(AuthMessages.AuthFailureMessage(extractedClient));
+ break;
+ case ApiKeyAuthenticationFailureBehavior.Error:
+ _logger.LogError(AuthMessages.AuthFailureMessage(extractedClient));
+ context.Response.StatusCode = 401;
+ await context.Response.WriteAsync(AuthMessages.AuthFailureMessage(extractedClient));
+ return;
+ default:
+ throw new Exception("Invalid ApiKeyAuthenticationFailureBehavior Value");
+ }
+ }
+
+ _logger.LogInformation($"Valid auth received for Client: {extractedClient}");
+ }
+ else
+ {
+ switch (_apiSecurityConfiguration.ApiKeyAuthenticationFailureBehavior)
+ {
+ case ApiKeyAuthenticationFailureBehavior.Warn:
+ _logger.LogWarning(AuthMessages.ClientNotFoundMessage());
+ break;
+ case ApiKeyAuthenticationFailureBehavior.Error:
+ _logger.LogError(AuthMessages.ClientNotFoundMessage());
+ context.Response.StatusCode = 401;
+ await context.Response.WriteAsync(AuthMessages.ClientNotFoundMessage());
+ return;
+ default:
+ throw new Exception("Invalid ApiKeyAuthenticationFailureBehavior Value");
+ }
+ }
+ }
+ }
+ else
+ {
+ switch (_apiSecurityConfiguration.ApiKeyAuthenticationFailureBehavior)
+ {
+ case ApiKeyAuthenticationFailureBehavior.Warn:
+ _logger.LogWarning(AuthMessages.EmptyKeySetMessage("ExternalApiKeySet"));
+ break;
+ case ApiKeyAuthenticationFailureBehavior.Error:
+ _logger.LogError(AuthMessages.EmptyKeySetMessage("ExternalApiKeySet"));
+ context.Response.StatusCode = 401;
+ await context.Response.WriteAsync(AuthMessages.EmptyKeySetMessage("ExternalApiKeySet"));
+ return;
+ default:
+ throw new Exception("Invalid ApiKeyAuthenticationFailureBehavior Value");
+ }
+ }
+
+ await _next(context);
+ }
+ }
+}
\ No newline at end of file
diff --git a/LCU.Library.Internal.Security/LCU.Library.Internal.ApiSecurity/Models/ApiSecurityConfiguration.cs b/LCU.Library.Internal.Security/LCU.Library.Internal.ApiSecurity/Models/ApiSecurityConfiguration.cs
new file mode 100644
index 0000000..e54ce05
--- /dev/null
+++ b/LCU.Library.Internal.Security/LCU.Library.Internal.ApiSecurity/Models/ApiSecurityConfiguration.cs
@@ -0,0 +1,39 @@
+using System.ComponentModel.DataAnnotations;
+using LCU.Library.Internal.ApiSecurity.Enums;
+
+namespace LCU.Library.Internal.ApiSecurity.Models;
+
+public class ApiSecurityConfiguration
+{
+ public ApiKeyAuthenticationFailureBehavior ApiKeyAuthenticationFailureBehavior { get; set; }
+
+ [Required]
+ public Dictionary ExternalApiKeySet { get; set; }
+
+ [Required]
+ public Dictionary InternalApiKeySet { get; set; }
+
+ public ApiSecurityConfiguration(ApiKeyAuthenticationFailureBehavior apiKeyAuthenticationFailureBehavior = ApiKeyAuthenticationFailureBehavior.Error)
+ {
+ ApiKeyAuthenticationFailureBehavior = apiKeyAuthenticationFailureBehavior;
+ ExternalApiKeySet = new Dictionary();
+ InternalApiKeySet = new Dictionary();
+ }
+
+ public string ExtractApiKey(string clientKeyValue, ApiKeySetName keySetName)
+ {
+ string extractedApiKey = "";
+
+ if (keySetName == ApiKeySetName.InternalKeySet && !InternalApiKeySet.TryGetValue(clientKeyValue, out extractedApiKey))
+ {
+ throw new Exception("Your Internal Api Key is missing");
+ }
+
+ if (keySetName == ApiKeySetName.ExternalKeySet && !ExternalApiKeySet.TryGetValue(clientKeyValue, out extractedApiKey))
+ {
+ throw new Exception("Your External Api Key is missing");
+ }
+ return extractedApiKey;
+
+ }
+}
\ No newline at end of file
diff --git a/LCU.Library.Internal.Security/LCU.Library.Internal.ApiSecurity/ServiceCollectionExtensions.cs b/LCU.Library.Internal.Security/LCU.Library.Internal.ApiSecurity/ServiceCollectionExtensions.cs
new file mode 100644
index 0000000..3180b09
--- /dev/null
+++ b/LCU.Library.Internal.Security/LCU.Library.Internal.ApiSecurity/ServiceCollectionExtensions.cs
@@ -0,0 +1,26 @@
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using LCU.Library.Internal.ApiSecurity.Models;
+
+namespace LCU.Library.Internal.ApiSecurity;
+
+public static class ServiceCollectionExtensions
+{
+ ///
+ /// Adds required API Security services.
+ ///
+ /// The service collection that API Security services are added to.
+ /// A that can be used to customize API Security functionality.
+ public static IApiSecurityBuilder AddApiKeySecurity(this IServiceCollection services, IConfiguration configuration)
+ {
+ services.AddLogging();
+
+ //TODO: Move this functionality from binding a config to making a call to an API Security API, only hold config in memory instead of file system
+ //This is less of a concern for containerized apps but still not ideal since the credentials will still be on the containers file system
+ var apiSecurityConfiguration = new ApiSecurityConfiguration();
+ configuration.GetSection("ApiSecurityConfiguration").Bind(apiSecurityConfiguration);
+ services.AddSingleton(apiSecurityConfiguration);
+
+ return new ApiSecurityBuilder(services);
+ }
+}
\ No newline at end of file
diff --git a/LCU.Library.Internal.Security/LCU.Library.Internal.Security.Models/AccessPolicy/AccessPolicies.cs b/LCU.Library.Internal.Security/LCU.Library.Internal.Security.Models/AccessPolicy/AccessPolicies.cs
new file mode 100644
index 0000000..000ba67
--- /dev/null
+++ b/LCU.Library.Internal.Security/LCU.Library.Internal.Security.Models/AccessPolicy/AccessPolicies.cs
@@ -0,0 +1,11 @@
+namespace LCU.Library.Internal.Security.Models.AccessPolicy;
+
+public class AccessPolicies
+{
+ public AccessPolicy[] Policies { get; set; }
+
+ public AccessPolicies(AccessPolicy[] policies)
+ {
+ Policies = policies;
+ }
+}
\ No newline at end of file
diff --git a/LCU.Library.Internal.Security/LCU.Library.Internal.Security.Models/AccessPolicy/AccessPolicy.cs b/LCU.Library.Internal.Security/LCU.Library.Internal.Security.Models/AccessPolicy/AccessPolicy.cs
new file mode 100644
index 0000000..2043131
--- /dev/null
+++ b/LCU.Library.Internal.Security/LCU.Library.Internal.Security.Models/AccessPolicy/AccessPolicy.cs
@@ -0,0 +1,15 @@
+namespace LCU.Library.Internal.Security.Models.AccessPolicy;
+
+public class AccessPolicy
+{
+ public string Name { get; set; }
+ public string[] Groups { get; set; }
+ public AccessPolicy[]? Policies { get; set; }
+
+ public AccessPolicy(string name, string[] groups, AccessPolicy[]? policies)
+ {
+ Name = name;
+ Groups = groups;
+ Policies = policies;
+ }
+}
\ No newline at end of file
diff --git a/LCU.Library.Internal.Security/LCU.Library.Internal.Security.Models/AccessPolicy/GroupMembershipRequirement.cs b/LCU.Library.Internal.Security/LCU.Library.Internal.Security.Models/AccessPolicy/GroupMembershipRequirement.cs
new file mode 100644
index 0000000..1bbf0b4
--- /dev/null
+++ b/LCU.Library.Internal.Security/LCU.Library.Internal.Security.Models/AccessPolicy/GroupMembershipRequirement.cs
@@ -0,0 +1,13 @@
+using Microsoft.AspNetCore.Authorization;
+
+namespace LCU.Library.Internal.Security.Models.AccessPolicy;
+
+public class GroupMembershipRequirement : IAuthorizationRequirement
+{
+ public IReadOnlyCollection Groups { get; }
+
+ public GroupMembershipRequirement(params string[] groups)
+ {
+ Groups = groups;
+ }
+}
\ No newline at end of file
diff --git a/LCU.Library.Internal.Security/LCU.Library.Internal.Security.Models/AuthRequest/AuthRequest.cs b/LCU.Library.Internal.Security/LCU.Library.Internal.Security.Models/AuthRequest/AuthRequest.cs
new file mode 100644
index 0000000..0e09359
--- /dev/null
+++ b/LCU.Library.Internal.Security/LCU.Library.Internal.Security.Models/AuthRequest/AuthRequest.cs
@@ -0,0 +1,10 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace LCU.Library.Internal.Security.Models.AuthRequest
+{
+ public class AuthRequest
+ {
+ [Required] public string UserName { get; set; }
+ [Required] public string Password { get; set; }
+ }
+}
\ No newline at end of file
diff --git a/LCU.Library.Internal.Security/LCU.Library.Internal.Security.Models/Configuration/AzureActiveDirectoryOptions.cs b/LCU.Library.Internal.Security/LCU.Library.Internal.Security.Models/Configuration/AzureActiveDirectoryOptions.cs
new file mode 100644
index 0000000..3d6f788
--- /dev/null
+++ b/LCU.Library.Internal.Security/LCU.Library.Internal.Security.Models/Configuration/AzureActiveDirectoryOptions.cs
@@ -0,0 +1,9 @@
+namespace LCU.Library.Internal.Security.Models.Configuration;
+
+public class AzureActiveDirectoryOptions
+{
+ public string ClientId { get; set; }
+ public string Instance { get; set; }
+ public string TenantId { get; set; }
+ public string ValidAudience { get; set; }
+}
\ No newline at end of file
diff --git a/LCU.Library.Internal.Security/LCU.Library.Internal.Security.Models/Configuration/AzureAdJwtRetrievalConfiguration.cs b/LCU.Library.Internal.Security/LCU.Library.Internal.Security.Models/Configuration/AzureAdJwtRetrievalConfiguration.cs
new file mode 100644
index 0000000..a2a8ccc
--- /dev/null
+++ b/LCU.Library.Internal.Security/LCU.Library.Internal.Security.Models/Configuration/AzureAdJwtRetrievalConfiguration.cs
@@ -0,0 +1,10 @@
+namespace LCU.Library.Internal.Security.Models.Configuration;
+
+public class AzureAdJwtRetrievalConfiguration
+{
+ public string AuthenticationUrl { get; set; }
+ public string Role { get; set; }
+ public string Scope { get; set; }
+ public string ClientId { get; set; }
+ public string ClientSecret { get; set; }
+}
\ No newline at end of file
diff --git a/LCU.Library.Internal.Security/LCU.Library.Internal.Security.Models/LCU.Library.Internal.Security.Models.csproj b/LCU.Library.Internal.Security/LCU.Library.Internal.Security.Models/LCU.Library.Internal.Security.Models.csproj
new file mode 100644
index 0000000..e0e60d8
--- /dev/null
+++ b/LCU.Library.Internal.Security/LCU.Library.Internal.Security.Models/LCU.Library.Internal.Security.Models.csproj
@@ -0,0 +1,35 @@
+
+
+
+ LCU.Library.Internal.Security
+ Landmark Credit Union
+ https://landmarkcu.visualstudio.com/Platform/_git/LCU.Library.Internal.Security
+ Git
+ Landmark Credit Union
+ enable
+ enable
+ netstandard2.0
+ true
+ latest
+ 1.2.3
+ 1.2.3
+ 1.2.3
+
+ 1.2.3 - Model Lib for LCU API Security Library
+
+
+ 1.2.3 - Model Lib for LCU API Security Library
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LCU.Library.Internal.Security/LCU.Library.Internal.Security.Models/Response/AzureAdAuthorizationResponse.cs b/LCU.Library.Internal.Security/LCU.Library.Internal.Security.Models/Response/AzureAdAuthorizationResponse.cs
new file mode 100644
index 0000000..b32e371
--- /dev/null
+++ b/LCU.Library.Internal.Security/LCU.Library.Internal.Security.Models/Response/AzureAdAuthorizationResponse.cs
@@ -0,0 +1,21 @@
+using System.Text.Json.Serialization;
+
+namespace LCU.Library.Internal.Security.Models.Response;
+
+public class AzureAdAuthorizationResponse
+{
+ [JsonPropertyName("token_type")]
+ public string TokenType { get; set; }
+
+ [JsonPropertyName("scope")]
+ public string Scope { get; set; }
+
+ [JsonPropertyName("expires_in")]
+ public long ExpiresIn { get; set; }
+
+ [JsonPropertyName("ext_expires_in")]
+ public long ExtExpiresIn { get; set; }
+
+ [JsonPropertyName("access_token")]
+ public string AccessToken { get; set; }
+}
\ No newline at end of file
diff --git a/LCU.Library.Internal.Security/LCU.Library.Internal.Security.sln b/LCU.Library.Internal.Security/LCU.Library.Internal.Security.sln
new file mode 100644
index 0000000..40e9d04
--- /dev/null
+++ b/LCU.Library.Internal.Security/LCU.Library.Internal.Security.sln
@@ -0,0 +1,55 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.3.32819.101
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LCU.Library.Internal.Security", "LCU.Library.Internal.Security\LCU.Library.Internal.Security.csproj", "{6FA91785-9CD2-4F33-8033-47B5C0EA999F}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LCU.Library.Internal.ApiSecurity", "LCU.Library.Internal.ApiSecurity\LCU.Library.Internal.ApiSecurity.csproj", "{04108305-5D6F-476E-9FAB-C3F73121D2B9}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "IntegrationTestApiTwo", "IntegrationTestApiTwo\IntegrationTestApiTwo.csproj", "{11ECE8E1-6BA4-4D22-B9B0-3F9B2D1C5312}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "IntegrationTestApiOne", "IntegrationTestApiOne\IntegrationTestApiOne.csproj", "{843CF1F6-F431-4C6C-BB0D-0ED1D401AEC4}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LCU.Library.Internal.Security.Models", "LCU.Library.Internal.Security.Models\LCU.Library.Internal.Security.Models.csproj", "{5E617270-D0C8-4DE3-AD8C-426F7D96247F}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LCU.Library.Internal.SecurityTests", "LCU.Library.Internal.SecurityTests\LCU.Library.Internal.SecurityTests.csproj", "{1F0C4895-3EAD-43D2-BB8F-26EAD7F65EEA}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {6FA91785-9CD2-4F33-8033-47B5C0EA999F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {6FA91785-9CD2-4F33-8033-47B5C0EA999F}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {6FA91785-9CD2-4F33-8033-47B5C0EA999F}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {6FA91785-9CD2-4F33-8033-47B5C0EA999F}.Release|Any CPU.Build.0 = Release|Any CPU
+ {04108305-5D6F-476E-9FAB-C3F73121D2B9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {04108305-5D6F-476E-9FAB-C3F73121D2B9}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {04108305-5D6F-476E-9FAB-C3F73121D2B9}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {04108305-5D6F-476E-9FAB-C3F73121D2B9}.Release|Any CPU.Build.0 = Release|Any CPU
+ {11ECE8E1-6BA4-4D22-B9B0-3F9B2D1C5312}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {11ECE8E1-6BA4-4D22-B9B0-3F9B2D1C5312}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {11ECE8E1-6BA4-4D22-B9B0-3F9B2D1C5312}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {11ECE8E1-6BA4-4D22-B9B0-3F9B2D1C5312}.Release|Any CPU.Build.0 = Release|Any CPU
+ {843CF1F6-F431-4C6C-BB0D-0ED1D401AEC4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {843CF1F6-F431-4C6C-BB0D-0ED1D401AEC4}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {843CF1F6-F431-4C6C-BB0D-0ED1D401AEC4}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {843CF1F6-F431-4C6C-BB0D-0ED1D401AEC4}.Release|Any CPU.Build.0 = Release|Any CPU
+ {5E617270-D0C8-4DE3-AD8C-426F7D96247F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {5E617270-D0C8-4DE3-AD8C-426F7D96247F}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {5E617270-D0C8-4DE3-AD8C-426F7D96247F}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {5E617270-D0C8-4DE3-AD8C-426F7D96247F}.Release|Any CPU.Build.0 = Release|Any CPU
+ {1F0C4895-3EAD-43D2-BB8F-26EAD7F65EEA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {1F0C4895-3EAD-43D2-BB8F-26EAD7F65EEA}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {1F0C4895-3EAD-43D2-BB8F-26EAD7F65EEA}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {1F0C4895-3EAD-43D2-BB8F-26EAD7F65EEA}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(ExtensibilityGlobals) = postSolution
+ SolutionGuid = {408FAB2F-8E04-41E3-AA7C-19C85FFE28CA}
+ EndGlobalSection
+EndGlobal
diff --git a/LCU.Library.Internal.Security/LCU.Library.Internal.Security/AccessPolicyFactory.cs b/LCU.Library.Internal.Security/LCU.Library.Internal.Security/AccessPolicyFactory.cs
new file mode 100644
index 0000000..90b3a39
--- /dev/null
+++ b/LCU.Library.Internal.Security/LCU.Library.Internal.Security/AccessPolicyFactory.cs
@@ -0,0 +1,45 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using LCU.Library.Internal.Security.Models.AccessPolicy;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.Extensions.Configuration;
+
+namespace LCU.Library.Internal.Security;
+
+public static class AccessPolicyFactory
+{
+ public static IEnumerable<(string Name, Action factory)> HandlePoliciesFromConfiguration(AccessPolicy policyConfiguration, string? parentName = null)
+ {
+ (string Name, Action) policy = (
+ $"{(parentName is not null ? $"{parentName}." : string.Empty)}{policyConfiguration.Name}",
+ b => b
+ .RequireAuthenticatedUser()
+ .AddRequirements(new GroupMembershipRequirement(policyConfiguration.Groups))
+ );
+ var childPolicies = policyConfiguration.Policies?.SelectMany(p => HandlePoliciesFromConfiguration(p, policy.Name)) ??
+ Enumerable.Empty<(string Name, Action factory)>();
+
+ return new[] { policy }.Concat(childPolicies);
+ }
+
+ public static AccessPolicies BuildPoliciesFromConfiguration(IConfigurationSection configuration)
+ {
+ var policies = configuration
+ .GetSection("AccessPolicies")
+ .GetChildren()
+ .Select(PolicyFactory);
+
+ return new AccessPolicies(policies.ToArray());
+ }
+
+ private static AccessPolicy PolicyFactory(IConfigurationSection configuration)
+ {
+ return new AccessPolicy
+ (
+ configuration.Key,
+ configuration.GetValue(nameof(AccessPolicy.Groups))?.Split(",").Select(g => g.Trim()).ToArray(),
+ configuration.GetChildren().Where(c => c[nameof(AccessPolicy.Groups)] is not null).Select(PolicyFactory).ToArray()
+ );
+ }
+}
\ No newline at end of file
diff --git a/LCU.Library.Internal.Security/LCU.Library.Internal.Security/AddSecurityServiceCollectionExtension.cs b/LCU.Library.Internal.Security/LCU.Library.Internal.Security/AddSecurityServiceCollectionExtension.cs
new file mode 100644
index 0000000..c1a410c
--- /dev/null
+++ b/LCU.Library.Internal.Security/LCU.Library.Internal.Security/AddSecurityServiceCollectionExtension.cs
@@ -0,0 +1,13 @@
+using LCU.Library.Internal.Security.Dao.Client;
+using LCU.Library.Internal.Security.Service;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace LCU.Library.Internal.Security;
+
+public static class AddSecurityServiceCollectionExtension
+{
+ public static IServiceCollection AddSecurityServices(this IServiceCollection services) => services
+ .AddScoped()
+ .AddScoped()
+ .AddScoped();
+}
\ No newline at end of file
diff --git a/LCU.Library.Internal.Security/LCU.Library.Internal.Security/AzureAdGroupPolicyServiceCollectionExtension.cs b/LCU.Library.Internal.Security/LCU.Library.Internal.Security/AzureAdGroupPolicyServiceCollectionExtension.cs
new file mode 100644
index 0000000..8ba4ca1
--- /dev/null
+++ b/LCU.Library.Internal.Security/LCU.Library.Internal.Security/AzureAdGroupPolicyServiceCollectionExtension.cs
@@ -0,0 +1,39 @@
+using System.Linq;
+using LCU.Library.Internal.Security.Service;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Identity.Web;
+
+namespace LCU.Library.Internal.Security;
+
+public static class AzureAdGroupPolicyServiceCollectionExtension
+{
+ private const string DefaultScheme = "OpenIdConnect";
+
+ public static IServiceCollection AddAzureAdGroupPolicyServices(this IServiceCollection services,
+ IConfigurationSection azureAdConfiguration,
+ IConfigurationSection securityConfiguration,
+ string defaultScheme = DefaultScheme)
+ {
+ services.AddAuthentication(defaultScheme)
+ .AddMicrosoftIdentityWebApp(azureAdConfiguration);
+
+ // Azure AD groups policies
+ var security = AccessPolicyFactory.BuildPoliciesFromConfiguration(securityConfiguration);
+ services.AddAuthorization(options =>
+ {
+ options.FallbackPolicy = options.DefaultPolicy;
+
+ foreach (var (name, policy) in security.Policies.SelectMany(p => AccessPolicyFactory.HandlePoliciesFromConfiguration(p)))
+ {
+ options.AddPolicy(name, policy);
+ }
+ });
+
+ services.AddScoped();
+ services.AddSingleton();
+
+ return services;
+ }
+}
\ No newline at end of file
diff --git a/LCU.Library.Internal.Security/LCU.Library.Internal.Security/AzureAdServiceCollectionExtensions.cs b/LCU.Library.Internal.Security/LCU.Library.Internal.Security/AzureAdServiceCollectionExtensions.cs
new file mode 100644
index 0000000..cbd0305
--- /dev/null
+++ b/LCU.Library.Internal.Security/LCU.Library.Internal.Security/AzureAdServiceCollectionExtensions.cs
@@ -0,0 +1,49 @@
+using System;
+using LCU.Library.Internal.Security.Models.Configuration;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using Microsoft.IdentityModel.Tokens;
+
+namespace LCU.Library.Internal.Security;
+
+public static class AzureAdServiceCollectionExtensions
+{
+ public static AuthenticationBuilder AddAzureAdBearer(this AuthenticationBuilder builder)
+ => builder.AddAzureAdBearer(_ => { });
+
+ public static AuthenticationBuilder AddAzureAdBearer(this AuthenticationBuilder builder, Action configureOptions)
+ {
+ builder.Services.Configure(configureOptions);
+ builder.Services.AddSingleton, ConfigureAzureOptions>();
+ builder.AddJwtBearer();
+ return builder;
+ }
+
+ private class ConfigureAzureOptions : IConfigureNamedOptions
+ {
+ private readonly AzureActiveDirectoryOptions _azureOptions;
+
+ public ConfigureAzureOptions(IOptions azureOptions)
+ {
+ _azureOptions = azureOptions.Value;
+ }
+
+ public void Configure(string name, JwtBearerOptions options)
+ {
+ options.Audience = _azureOptions.ClientId;
+ options.Authority = $"{_azureOptions.Instance}{_azureOptions.TenantId}";
+
+ options.TokenValidationParameters = new TokenValidationParameters
+ {
+ ValidAudience = _azureOptions.ValidAudience
+ };
+ }
+
+ public void Configure(JwtBearerOptions options)
+ {
+ Configure(Options.DefaultName, options);
+ }
+ }
+}
\ No newline at end of file
diff --git a/LCU.Library.Internal.Security/LCU.Library.Internal.Security/Dao/Client/DaoClient.cs b/LCU.Library.Internal.Security/LCU.Library.Internal.Security/Dao/Client/DaoClient.cs
new file mode 100644
index 0000000..d554827
--- /dev/null
+++ b/LCU.Library.Internal.Security/LCU.Library.Internal.Security/Dao/Client/DaoClient.cs
@@ -0,0 +1,25 @@
+#nullable enable
+using System.Net.Http;
+using System.Text;
+using System.Threading.Tasks;
+using LCU.Library.Internal.Exceptions;
+using LCU.Library.Internal.Security.HttpsClient;
+using JsonSerializer = System.Text.Json.JsonSerializer;
+
+namespace LCU.Library.Internal.Security.Dao.Client;
+
+public class DaoClient : IDaoClient
+{
+ public async Task Post(string url, string uri, string body)
+ {
+ var client = TrustingHttpClient.GetClient(url);
+ var stringContent = new StringContent(body, Encoding.UTF8, "application/x-www-form-urlencoded");
+ var response = await client.PostAsync(uri, stringContent);
+ if (!response.IsSuccessStatusCode)
+ {
+ throw new ServiceException($"StatusCode:{response.StatusCode}, Content: {response.Content.ReadAsStringAsync().Result}");
+ }
+ var json = await response.Content.ReadAsStringAsync();
+ return JsonSerializer.Deserialize(json);
+ }
+}
\ No newline at end of file
diff --git a/LCU.Library.Internal.Security/LCU.Library.Internal.Security/Dao/Client/IDaoClient.cs b/LCU.Library.Internal.Security/LCU.Library.Internal.Security/Dao/Client/IDaoClient.cs
new file mode 100644
index 0000000..4b4925d
--- /dev/null
+++ b/LCU.Library.Internal.Security/LCU.Library.Internal.Security/Dao/Client/IDaoClient.cs
@@ -0,0 +1,9 @@
+#nullable enable
+using System.Threading.Tasks;
+
+namespace LCU.Library.Internal.Security.Dao.Client;
+
+public interface IDaoClient
+{
+ public Task Post(string url, string uri, string body);
+}
\ No newline at end of file
diff --git a/LCU.Library.Internal.Security/LCU.Library.Internal.Security/GroupMembershipRequirementHandler.cs b/LCU.Library.Internal.Security/LCU.Library.Internal.Security/GroupMembershipRequirementHandler.cs
new file mode 100644
index 0000000..a42c9cc
--- /dev/null
+++ b/LCU.Library.Internal.Security/LCU.Library.Internal.Security/GroupMembershipRequirementHandler.cs
@@ -0,0 +1,23 @@
+using System.Linq;
+using System.Threading.Tasks;
+using LCU.Library.Internal.Security.Models.AccessPolicy;
+using Microsoft.AspNetCore.Authorization;
+
+namespace LCU.Library.Internal.Security;
+
+public class GroupMembershipRequirementHandler : AuthorizationHandler
+{
+ protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, GroupMembershipRequirement requirement)
+ {
+ var groups = context.User.Claims.Where(c => c.Type == "groups")
+ .Concat(context.User.Claims.Where(c => c.Type == "roles"))
+ .Select(c => c.Value);
+
+ if (groups.Any(requirement.Groups.Contains))
+ {
+ context.Succeed(requirement);
+ }
+
+ return Task.CompletedTask;
+ }
+}
\ No newline at end of file
diff --git a/LCU.Library.Internal.Security/LCU.Library.Internal.Security/HttpsClient/TrustingHttpClient.cs b/LCU.Library.Internal.Security/LCU.Library.Internal.Security/HttpsClient/TrustingHttpClient.cs
new file mode 100644
index 0000000..133dc7c
--- /dev/null
+++ b/LCU.Library.Internal.Security/LCU.Library.Internal.Security/HttpsClient/TrustingHttpClient.cs
@@ -0,0 +1,25 @@
+using System;
+using System.Net.Http;
+using System.Security.Authentication;
+
+namespace LCU.Library.Internal.Security.HttpsClient;
+
+public static class TrustingHttpClient
+{
+ public static HttpClient GetClient(string endPoint)
+ {
+ var handler = new HttpClientHandler
+ {
+ SslProtocols = SslProtocols.Tls12 |
+ SslProtocols.Tls11 |
+ SslProtocols.Tls,
+ ServerCertificateCustomValidationCallback = (_, _, _, _) => true
+ };
+ var httpClient = new HttpClient(handler)
+ {
+ Timeout = TimeSpan.FromSeconds(60),
+ BaseAddress = new Uri(endPoint)
+ };
+ return httpClient;
+ }
+}
\ No newline at end of file
diff --git a/LCU.Library.Internal.Security/LCU.Library.Internal.Security/LCU.Library.Internal.Security.csproj b/LCU.Library.Internal.Security/LCU.Library.Internal.Security/LCU.Library.Internal.Security.csproj
new file mode 100644
index 0000000..317a755
--- /dev/null
+++ b/LCU.Library.Internal.Security/LCU.Library.Internal.Security/LCU.Library.Internal.Security.csproj
@@ -0,0 +1,38 @@
+
+
+
+ LCU.Library.Internal.Security
+ Landmark Credit Union
+ https://landmarkcu.visualstudio.com/Platform/_git/LCU.Library.Internal.Security
+ Git
+ Landmark Credit Union
+ Properties
+ 2.0.0
+ 2.0.0
+ net8.0
+ true
+ 2.0.0
+ latest
+
+ Security Lib for LCU API authentication using Azure AD
+ 2.0.0 - Updated to .NET 8
+
+
+ Security Lib for LCU API authentication using Azure AD
+ 2.0.0 - Updated to .NET 8
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LCU.Library.Internal.Security/LCU.Library.Internal.Security/Properties/AssemblyInfo.cs b/LCU.Library.Internal.Security/LCU.Library.Internal.Security/Properties/AssemblyInfo.cs
new file mode 100644
index 0000000..da3e779
--- /dev/null
+++ b/LCU.Library.Internal.Security/LCU.Library.Internal.Security/Properties/AssemblyInfo.cs
@@ -0,0 +1,3 @@
+using System.Runtime.CompilerServices;
+
+[assembly: InternalsVisibleTo("LCU.Library.Internal.SecurityTests")]
\ No newline at end of file
diff --git a/LCU.Library.Internal.Security/LCU.Library.Internal.Security/Service/AzureAdAuthorizationService.cs b/LCU.Library.Internal.Security/LCU.Library.Internal.Security/Service/AzureAdAuthorizationService.cs
new file mode 100644
index 0000000..177b1bb
--- /dev/null
+++ b/LCU.Library.Internal.Security/LCU.Library.Internal.Security/Service/AzureAdAuthorizationService.cs
@@ -0,0 +1,70 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Components.Authorization;
+using Serilog;
+
+namespace LCU.Library.Internal.Security.Service;
+
+public class AzureAdAuthorizationService : IAzureAdAuthorizationService
+{
+ private readonly AuthenticationStateProvider _authenticationStateProvider;
+ private readonly IAuthorizationService _authorizationService;
+
+ public AzureAdAuthorizationService
+ (
+ AuthenticationStateProvider authenticationStateProvider,
+ IAuthorizationService authorizationService
+ )
+ {
+ _authenticationStateProvider = authenticationStateProvider;
+ _authorizationService = authorizationService;
+ }
+
+ public string GetUserName()
+ {
+ try
+ {
+ return _authenticationStateProvider.GetAuthenticationStateAsync()
+ .Result.User.Claims.FirstOrDefault(x => x.Type == "name")?.Value ?? string.Empty;
+ }
+ catch (Exception ex)
+ {
+ Log.Error(ex, "Failure to authenticate current user.");
+ throw new Exception("Failure to authenticate current user.");
+ }
+ }
+
+ public bool IsInGroup(string group)
+ {
+ return GetAuthorizedGroups().Contains(group);
+ }
+
+ public List GetAuthorizedGroups()
+ {
+ try
+ {
+ var groups = _authenticationStateProvider.GetAuthenticationStateAsync().Result.User.Claims
+ .Where(x => x.Type.Contains("groups"))
+ .Select(x => x.Value).ToList();
+
+ if (!groups.Any())
+ {
+ throw new Exception("No groups were found for current user.");
+ }
+
+ return groups;
+ }
+ catch (Exception ex)
+ {
+ Log.Error(ex, "Failure to get authenticated groups for the current user.");
+ throw new Exception("Failure to get authenticated groups for the current user.");
+ }
+ }
+
+ public bool PolicySatisfied(string policy)
+ => _authorizationService
+ .AuthorizeAsync(_authenticationStateProvider.GetAuthenticationStateAsync().Result.User, policy)
+ .Result.Succeeded;
+}
\ No newline at end of file
diff --git a/LCU.Library.Internal.Security/LCU.Library.Internal.Security/Service/AzureAdVerificationService.cs b/LCU.Library.Internal.Security/LCU.Library.Internal.Security/Service/AzureAdVerificationService.cs
new file mode 100644
index 0000000..b5427b7
--- /dev/null
+++ b/LCU.Library.Internal.Security/LCU.Library.Internal.Security/Service/AzureAdVerificationService.cs
@@ -0,0 +1,109 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.IdentityModel.Tokens.Jwt;
+using System.Linq;
+using System.Text;
+using System.Text.Json;
+using System.Threading.Tasks;
+using System.Web;
+using LCU.Library.Internal.Exceptions;
+using LCU.Library.Internal.Security.Dao.Client;
+using LCU.Library.Internal.Security.Models.Configuration;
+using LCU.Library.Internal.Security.Models.Response;
+
+namespace LCU.Library.Internal.Security.Service;
+
+public class AzureAdVerificationService : IAzureAdVerificationService
+{
+ private const string ClientId = "client_id";
+ private const string Scope = "scope";
+ private const string Username = "username";
+ private const string Password = "password";
+ private const string GrantType = "grant_type";
+ private const string State = "state";
+ private const string ClientSecret = "client_secret";
+ private const string GrantTypePassword = "password";
+
+ private readonly AzureAdJwtRetrievalConfiguration _configuration;
+ private readonly IDaoClient _daoClient;
+
+ public AzureAdVerificationService(IDaoClient daoClient, AzureAdJwtRetrievalConfiguration configuration)
+ {
+ _daoClient = daoClient;
+ _configuration = configuration;
+ }
+
+ public async Task GetTokenForWebApiUsingUsernamePasswordAsync(string userName, string password)
+ {
+ try{
+ var handler = new JwtSecurityTokenHandler();
+ var stateIdentifier = Guid.NewGuid().ToString();
+
+ var queryString = HttpUtility.ParseQueryString(string.Empty, Encoding.UTF8);
+ queryString.Add(ClientId, _configuration.ClientId);
+ queryString.Add(Scope, _configuration.Scope);
+ queryString.Add(Username, userName);
+ queryString.Add(Password, password);
+ queryString.Add(GrantType, GrantTypePassword);
+ queryString.Add(State, stateIdentifier);
+ queryString.Add(ClientSecret, _configuration.ClientSecret);
+
+ var result = await _daoClient.Post(_configuration.AuthenticationUrl, "", HttpUtility.UrlDecode(queryString.ToString()));
+
+ if (result == null || handler.ReadToken(result.AccessToken) is not JwtSecurityToken jsonToken){
+ throw new ServiceException($"Authentication for Username: {userName} could not be performed, a null JWT was returned, or the JWT could not be parsed.");
+ }
+
+ return jsonToken;
+ } catch (Exception exception){
+ throw new ServiceException($"Failure to get authentication for username: {userName}. Exception message: {exception.Message}");
+ }
+ }
+
+ public bool ValidateJwtHasAuthorizedRole(JwtSecurityToken jsonToken)
+ {
+ try
+ {
+ IEnumerable RolesFromClaim(string claimName)
+ {
+ if (jsonToken.Payload.GetValueOrDefault(claimName)?.ToString() is { Length: > 0 } claims)
+ {
+ return JsonSerializer.Deserialize>(claims)!;
+ }
+
+ return Enumerable.Empty();
+ }
+
+ var roles = RolesFromClaim("roles");
+ var groups = RolesFromClaim("groups");
+
+ return roles.Concat(groups).Any(x => _configuration.Role.Split("|").Any(y => y == x));
+ } catch (Exception exception){
+ throw new ServiceException($"Failure to validate roles for JwtSecurityToken. Exception message: {exception.Message}");
+ }
+ }
+
+ public bool ValidateJwtHasAuthorizedRole(JwtSecurityToken jsonToken, List approvedRoles)
+ {
+ try
+ {
+ IEnumerable RolesFromClaim(string claimName)
+ {
+ if (jsonToken.Payload.GetValueOrDefault(claimName)?.ToString() is { Length: > 0 } claims)
+ {
+ return JsonSerializer.Deserialize>(claims)!;
+ }
+
+ return Enumerable.Empty();
+ }
+
+ var roles = RolesFromClaim("roles");
+ var groups = RolesFromClaim("groups");
+
+ return roles.Concat(groups).Any(x => approvedRoles.Any(y => y == x));
+ } catch (Exception exception){
+ throw new ServiceException($"Failure to validate roles for JwtSecurityToken. Exception message: {exception.Message}");
+ }
+ }
+}
\ No newline at end of file
diff --git a/LCU.Library.Internal.Security/LCU.Library.Internal.Security/Service/IAzureAdAuthorizationService.cs b/LCU.Library.Internal.Security/LCU.Library.Internal.Security/Service/IAzureAdAuthorizationService.cs
new file mode 100644
index 0000000..1b2dcca
--- /dev/null
+++ b/LCU.Library.Internal.Security/LCU.Library.Internal.Security/Service/IAzureAdAuthorizationService.cs
@@ -0,0 +1,16 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Components.Authorization;
+using Serilog;
+
+namespace LCU.Library.Internal.Security.Service;
+
+public interface IAzureAdAuthorizationService
+{
+ public string GetUserName();
+ public bool IsInGroup(string group);
+ public List GetAuthorizedGroups();
+ public bool PolicySatisfied(string policy);
+}
\ No newline at end of file
diff --git a/LCU.Library.Internal.Security/LCU.Library.Internal.Security/Service/IAzureAdVerificationService.cs b/LCU.Library.Internal.Security/LCU.Library.Internal.Security/Service/IAzureAdVerificationService.cs
new file mode 100644
index 0000000..0145cc0
--- /dev/null
+++ b/LCU.Library.Internal.Security/LCU.Library.Internal.Security/Service/IAzureAdVerificationService.cs
@@ -0,0 +1,13 @@
+#nullable enable
+using System.Collections.Generic;
+using System.IdentityModel.Tokens.Jwt;
+using System.Threading.Tasks;
+
+namespace LCU.Library.Internal.Security.Service;
+
+public interface IAzureAdVerificationService
+{
+ Task GetTokenForWebApiUsingUsernamePasswordAsync(string userName, string password);
+ public bool ValidateJwtHasAuthorizedRole(JwtSecurityToken jsonToken);
+ public bool ValidateJwtHasAuthorizedRole(JwtSecurityToken jsonToken, List approvedRoles);
+}
\ No newline at end of file
diff --git a/LCU.Library.Internal.Security/LCU.Library.Internal.Security/Service/IUsernamePasswordAuthorizationService.cs b/LCU.Library.Internal.Security/LCU.Library.Internal.Security/Service/IUsernamePasswordAuthorizationService.cs
new file mode 100644
index 0000000..9332dc7
--- /dev/null
+++ b/LCU.Library.Internal.Security/LCU.Library.Internal.Security/Service/IUsernamePasswordAuthorizationService.cs
@@ -0,0 +1,10 @@
+using System.Threading.Tasks;
+using LCU.Library.Internal.Security.Models.AuthRequest;
+using Microsoft.AspNetCore.Mvc;
+
+namespace LCU.Library.Internal.Security.Service;
+
+public interface IUsernamePasswordAuthorizationService
+{
+ Task OnAuthorizationAsync(AuthRequest authRequest);
+}
\ No newline at end of file
diff --git a/LCU.Library.Internal.Security/LCU.Library.Internal.Security/Service/UsernamePasswordAuthorizationService.cs b/LCU.Library.Internal.Security/LCU.Library.Internal.Security/Service/UsernamePasswordAuthorizationService.cs
new file mode 100644
index 0000000..ae7ce8e
--- /dev/null
+++ b/LCU.Library.Internal.Security/LCU.Library.Internal.Security/Service/UsernamePasswordAuthorizationService.cs
@@ -0,0 +1,34 @@
+using System.Threading.Tasks;
+using LCU.Library.Internal.Security.Models.AuthRequest;
+using Microsoft.AspNetCore.Mvc;
+
+namespace LCU.Library.Internal.Security.Service;
+
+internal class UsernamePasswordAuthorizationService : IUsernamePasswordAuthorizationService
+{
+ private readonly IAzureAdVerificationService _azureAdVerificationService;
+
+ public UsernamePasswordAuthorizationService(IAzureAdVerificationService azureAdVerificationService)
+ {
+ _azureAdVerificationService = azureAdVerificationService;
+ }
+
+ public async Task OnAuthorizationAsync(AuthRequest authRequest)
+ {
+ var jwtToken = await _azureAdVerificationService.GetTokenForWebApiUsingUsernamePasswordAsync(authRequest.UserName, authRequest.Password);
+
+ if (jwtToken is null)
+ {
+ return new UnauthorizedResult();
+ }
+
+ var authorized = _azureAdVerificationService.ValidateJwtHasAuthorizedRole(jwtToken);
+
+ if (!authorized)
+ {
+ return new ForbidResult();
+ }
+
+ return null;
+ }
+}
\ No newline at end of file
diff --git a/LCU.Library.Internal.Security/LCU.Library.Internal.SecurityTests/LCU.Library.Internal.SecurityTests.csproj b/LCU.Library.Internal.Security/LCU.Library.Internal.SecurityTests/LCU.Library.Internal.SecurityTests.csproj
new file mode 100644
index 0000000..562c4f9
--- /dev/null
+++ b/LCU.Library.Internal.Security/LCU.Library.Internal.SecurityTests/LCU.Library.Internal.SecurityTests.csproj
@@ -0,0 +1,27 @@
+
+
+
+ net8.0
+
+ false
+
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LCU.Library.Internal.Security/LCU.Library.Internal.SecurityTests/Service/AzureAdVerificationServiceTest.cs b/LCU.Library.Internal.Security/LCU.Library.Internal.SecurityTests/Service/AzureAdVerificationServiceTest.cs
new file mode 100644
index 0000000..324e17f
--- /dev/null
+++ b/LCU.Library.Internal.Security/LCU.Library.Internal.SecurityTests/Service/AzureAdVerificationServiceTest.cs
@@ -0,0 +1,70 @@
+using System;
+using System.Collections.Generic;
+using System.IdentityModel.Tokens.Jwt;
+using System.Security.Claims;
+using LCU.Library.Internal.Security.Models.Configuration;
+using LCU.Library.Internal.Security.Service;
+using Newtonsoft.Json;
+using NUnit.Framework;
+
+namespace LCU.Library.Internal.SecurityTests.Service
+{
+ public class AzureAdVerificationServiceTest
+ {
+ private readonly IAzureAdVerificationService _adVerification;
+
+ private readonly AzureAdJwtRetrievalConfiguration _configuration = new AzureAdJwtRetrievalConfiguration
+ {
+ Role = Guid.NewGuid().ToString()
+ };
+
+ public AzureAdVerificationServiceTest()
+ {
+ _adVerification = new AzureAdVerificationService(null!, _configuration);
+ }
+
+ [TestCase(null, null)]
+ [TestCase("", null)]
+ [TestCase(null, "")]
+ [TestCase("", "")]
+ public void ValidateJwtHasAuthorizedRole_MissingOrEmptyRoleAndGroupClaims_ReturnsFalse(string? roleClaims, string? groupClaims)
+ {
+ var claims = new List();
+ if(roleClaims is {}) claims.Add(new Claim("roles", roleClaims));
+ if(groupClaims is {}) claims.Add(new Claim("groups", groupClaims));
+ var token = new JwtSecurityToken(claims: claims);
+
+ var authorized = _adVerification.ValidateJwtHasAuthorizedRole(token);
+
+ Assert.That(authorized, Is.False);
+ }
+
+ [Test]
+ public void ValidateJwtHasAuthorizedRole_RoleClaimContainsConfiguredRole_ReturnsTrue()
+ {
+ var claims = new List
+ {
+ new Claim("roles", JsonConvert.SerializeObject(new[] { _configuration.Role }))
+ };
+ var token = new JwtSecurityToken(claims: claims);
+
+ var authorized = _adVerification.ValidateJwtHasAuthorizedRole(token);
+
+ Assert.That(authorized, Is.True);
+ }
+
+ [Test]
+ public void ValidateJwtHasAuthorizedRole_GroupClaimContainsConfiguredRole_ReturnsTrue()
+ {
+ var claims = new List
+ {
+ new Claim("groups", JsonConvert.SerializeObject(new[] { _configuration.Role }))
+ };
+ var token = new JwtSecurityToken(claims: claims);
+
+ var authorized = _adVerification.ValidateJwtHasAuthorizedRole(token);
+
+ Assert.That(authorized, Is.True);
+ }
+ }
+}
\ No newline at end of file
diff --git a/LCU.Library.Internal.Security/LCU.Library.Internal.SecurityTests/Service/UsernamePasswordAuthorizationServiceTest.cs b/LCU.Library.Internal.Security/LCU.Library.Internal.SecurityTests/Service/UsernamePasswordAuthorizationServiceTest.cs
new file mode 100644
index 0000000..5d3b4d3
--- /dev/null
+++ b/LCU.Library.Internal.Security/LCU.Library.Internal.SecurityTests/Service/UsernamePasswordAuthorizationServiceTest.cs
@@ -0,0 +1,110 @@
+using System;
+using System.Collections.Generic;
+using System.IdentityModel.Tokens.Jwt;
+using System.Threading.Tasks;
+using LCU.Library.Internal.Security.Models.AuthRequest;
+using LCU.Library.Internal.Security.Service;
+using Microsoft.AspNetCore.Mvc;
+using NUnit.Framework;
+
+namespace LCU.Library.Internal.SecurityTests.Service
+{
+ public class UsernamePasswordAuthorizationServiceTest
+ {
+ private readonly IUsernamePasswordAuthorizationService _service;
+
+ private readonly FakeAzureAdVerificationService _activeDirectoryService = new FakeAzureAdVerificationService();
+
+ public UsernamePasswordAuthorizationServiceTest()
+ {
+ _service = new UsernamePasswordAuthorizationService(_activeDirectoryService);
+ }
+
+ [Test]
+ public async Task OnAuthorizationAsync_NullJWTFromAd_ReturnsUnauthorized()
+ {
+ var authResult = await _service.OnAuthorizationAsync(AuthRequestFactory());
+
+ Assert.IsInstanceOf(authResult);
+ }
+
+ [Test]
+ public async Task OnAuthorizationAsync_JWTNotAuthorizedByRole_ReturnsForbidden()
+ {
+ var request = AuthRequestFactory();
+ _activeDirectoryService.SetAuthenticable(request.UserName, request.Password);
+
+ var authResult = await _service.OnAuthorizationAsync(request);
+
+ Assert.IsInstanceOf(authResult);
+ }
+
+ [Test]
+ public async Task OnAuthorizationAsync_JWTAuthorized_ReturnsNull()
+ {
+ var request = AuthRequestFactory();
+ _activeDirectoryService.SetAuthenticable(request.UserName, request.Password);
+ _activeDirectoryService.SetAuthorized(request.UserName, request.Password);
+
+ var authResult = await _service.OnAuthorizationAsync(request);
+
+ Assert.IsNull(authResult);
+ }
+
+ private static AuthRequest AuthRequestFactory() => new AuthRequest
+ {
+ UserName = Guid.NewGuid().ToString(),
+ Password = Guid.NewGuid().ToString()
+ };
+
+ private class FakeAzureAdVerificationService : IAzureAdVerificationService
+ {
+ private readonly IDictionary> _tokens =
+ new Dictionary>();
+
+ private readonly IList _authorizedTokens = new List();
+
+ public void SetAuthenticable(string userName, string password)
+ {
+ if (_tokens.TryGetValue(userName, out var userPasswords))
+ {
+ userPasswords[password] = new JwtSecurityToken();
+ }
+ else
+ {
+ _tokens[userName] = new Dictionary
+ {
+ [password] = new JwtSecurityToken()
+ };
+ }
+ }
+
+ public void SetAuthorized(string userName, string password)
+ {
+ _authorizedTokens.Add(_tokens[userName][password]);
+ }
+
+ public Task GetTokenForWebApiUsingUsernamePasswordAsync(string userName, string password)
+ {
+ return Task.FromResult
+ (
+ _tokens.TryGetValue(userName, out var userPassword)
+ ? userPassword.TryGetValue(password, out var token)
+ ? token
+ : null
+ : null
+ );
+ }
+
+ public bool ValidateJwtHasAuthorizedRole(JwtSecurityToken jsonToken)
+ {
+ return _authorizedTokens.Contains(jsonToken);
+ }
+
+ public bool ValidateJwtHasAuthorizedRole(JwtSecurityToken jsonToken, List approvedRoles)
+ {
+ return _authorizedTokens.Contains(jsonToken);
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/LCU.Library.Internal.Security/README.md b/LCU.Library.Internal.Security/README.md
new file mode 100644
index 0000000..5491d07
--- /dev/null
+++ b/LCU.Library.Internal.Security/README.md
@@ -0,0 +1,210 @@
+# Introduction
+This repo is used to store the collection of standardized security packages for LCU
+___
+
+# LCU.Library.Internal.ApiSecurity
+Currently you can add API Key security to you API using this nuget package
+The following sample code will run the configuration code and add the required
+services to the applications service collection on startup
+```C#
+builder.Services.AddEndpointsApiExplorer();
+builder.Services.AddSwaggerGen(c =>
+{
+ c.SwaggerDoc("v1", new OpenApiInfo
+ {
+ Title = "IntegrationTestApiTwo",
+ Version = "v1"
+ });
+
+ //Add Security Entry Fields In Swagger
+ c.AddSecurityRequirement(new OpenApiSecurityRequirement
+ {
+ {
+ new OpenApiSecurityScheme
+ {
+ Reference = new OpenApiReference
+ {
+ Id = HeaderKeys.ApiKey, // Must match the "AddSecurityDefinition"
+ Type = ReferenceType.SecurityScheme
+ }
+ },
+ new List()
+ }
+ });
+
+ c.AddSecurityRequirement(new OpenApiSecurityRequirement
+ {
+ {
+ new OpenApiSecurityScheme
+ {
+ Reference = new OpenApiReference
+ {
+ Id = HeaderKeys.Client, // Must match the "AddSecurityDefinition"
+ Type = ReferenceType.SecurityScheme
+ }
+ },
+ new List()
+ }
+ });
+
+ c.AddSecurityDefinition(HeaderKeys.ApiKey,
+ new OpenApiSecurityScheme
+ {
+ Description = HeaderKeys.ApiKey,
+ In = ParameterLocation.Header,
+ Name = HeaderKeys.ApiKey,
+ Type = SecuritySchemeType.ApiKey,
+ Scheme = "apikey"
+ });
+
+ c.AddSecurityDefinition(HeaderKeys.Client,
+ new OpenApiSecurityScheme
+ {
+ Description = HeaderKeys.Client,
+ In = ParameterLocation.Header,
+ Name = HeaderKeys.Client,
+ Type = SecuritySchemeType.ApiKey,
+ Scheme = "apikey"
+ });
+});
+
+builder.Services.AddApiKeySecurity(builder.Configuration);
+```
+
+There are two ways to implement API Key auth in a Web API
+
+- Attribute: A more precise implementation that will only run the
+authentication against requests that hit endpoints tagged with the
+'ApiKey' attribute within your controllers
+```C#
+[ApiKey]
+public async Task Get()
+{
+ return Ok();
+}
+```
+
+- Middleware: A blanket authentication solution that will authenticate
+all incoming requests
+```C#
+app.UseAuthorization();
+
+app.UseMiddleware();
+
+app.UseEndpoints(endpoints =>
+{
+ endpoints.MapControllers();
+});
+```
+Extracting the auth logic from the Attribute & Middleware to a service class
+was explored and is possible but was rolled back to allow for custom logic updates
+to each respective authentication strategy
+
+## ApiSecurityConfiguration
+
+### ApiKeyAuthenticationFailureBehavior [ Default = Error ]
+Authentication failure behavior enum
+- Error: Log error on failure, return error to requesting client
+- Warn: Log warning on failure, process request normally afterwards
+
+### ExternalApiKeySet [ Default = Null ]
+Collection of authorized client-key combinations to authenticate
+incoming requests against
+
+### InternalApiKeySet [ Default = Null ]
+Collection of authorized client-key combinations to be added to outbound
+requests requiring authentication
+
+### Sample Configuration
+
+```C#
+"ApiSecurityConfiguration": {
+ "ApiKeyAuthenticationFailureBehavior": "Warn",
+ "ExternalApiKeySet": {
+ "TestTenantTwo": "TestKeyTwo"
+ },
+ "InternalApiKeySet": {
+ "TestTenantOne": "TestKeyOne"
+ }
+ }
+```
+### Azure AD Authentication
+Open TopDesk ticket in Other Request category. Within ticket, request service account and group creation for each environment such as:
+Group: temenos_action_handler_qa
+User: svc_temenosaction_q
+Also ask that each group be placed into the Groups Organization unit (if this is not requested, the user name and password may inadvertently change).
+
+In appsettings, add the following AzureAd node
+```C#
+"AzureAd": {
+ "Instance": "https://login.microsoftonline.com/",
+ "Domain": "landmarkcu.onmicrosoft.com",
+ "TenantId": "acquireFromDevOps",
+ "CallbackPath": "/signin-oidc",
+ "ClientId": "acquireFromDevOps",
+ "ClientSecret": "acquireFromDevOps",
+ "AzureKeyVaultUrl": "qa-ApiName-kv",
+ "Role": "Api_Name_QA",
+ "AuthenticationUrl": "https://login.microsoftonline.com/landmarkcu.onmicrosoft.com/oauth2/v2.0/token",
+ "Scope": "acquireFromDevOps"
+ }
+```
+In Program class, add configurations and services
+```C#
+using Azure.Identity;
+using LCU.Library.Internal.Security.Service;
+using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.Identity.Web;
+
+var settings = builder.Configuration.GetSection("AzureAd");
+var clientSecretCredentials = new ClientSecretCredential(
+ settings["TenantId"],
+ settings["ClientId"],
+ settings["ClientSecret"]);
+builder.Configuration.AddAzureKeyVault(
+ new Uri($"https://{settings["AzureKeyVaultUrl"]}.vault.azure.net/"), clientSecretCredentials);
+
+configuration.Bind(services, "AzureAd");
+
+services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
+ .AddMicrosoftIdentityWebApi(configuration.GetSection("AzureAd"))
+ .EnableTokenAcquisitionToCallDownstreamApi()
+ .AddInMemoryTokenCaches();
+
+services.AddScoped();
+```
+
+### Adding Policy Authentication from Azure AD Groups
+
+Here is an example of using the Authentication portion of the library to add Azure AD Group-based authentication to your blazor application, by converting them
+to policies.
+
+Program.cs
+
+```C#
+services.AddAzureAdGroupPolicyServices(configuration.GetSection("AzureAd"),
+ configuration.GetSection("Security"));
+```
+appsettings.json
+
+```json
+"AzureAd": {
+ "TenantId": "GET FROM AZURE AD",
+ "ClientId": "GET FROM AZURE AD",
+ "ClientSecret": "GET FROM AZURE AD",
+ "Instance": "https://login.microsoftonline.com/",
+ "Domain": "landmarkcu.onmicrosoft.com",
+ "CallbackPath": "/signin-oidc",
+ "AuthenticationUrl": "https://login.microsoftonline.com/landmarkcu.onmicrosoft.com/oauth2/v2.0/token"
+}
+```
+
+```json
+"Security": {
+ "AccessPolicies": {
+ "Admin": {
+ "Groups": "IT Development"
+ }
+ }
+ }
+```
\ No newline at end of file
diff --git a/LCU.Library.Internal.Security/azure-pipelines.yml b/LCU.Library.Internal.Security/azure-pipelines.yml
new file mode 100644
index 0000000..79e6a2f
--- /dev/null
+++ b/LCU.Library.Internal.Security/azure-pipelines.yml
@@ -0,0 +1,53 @@
+# ASP.NET Core
+
+name: 'LCU.Library.Internal.Security.$(Build.SourceBranchName).$(Date:yyyyMMdd)$(Rev:.r)'
+trigger:
+ branches:
+ include:
+ - '*'
+
+pool:
+ name: LCUOnPrem
+ demands: MSBuild
+
+variables:
+ buildConfiguration: 'Release'
+
+steps:
+- task: DotNetCoreCLI@2
+ inputs:
+ command: 'restore'
+ feedsToUse: 'select'
+ vstsFeed: '58ebbac4-8e98-4281-bb3f-58003852f4a7'
+
+- task: DotNetCoreCLI@2
+ inputs:
+ command: 'build'
+ projects: '**/*.csproj'
+ arguments: '--configuration $(buildConfiguration)'
+
+- task: DotNetCoreCLI@2
+ displayName: 'dotnet publish $(buildConfiguration)'
+ inputs:
+ command: 'publish'
+ publishWebProjects: false
+ projects: '**/*.csproj'
+ arguments: '--configuration $(BuildConfiguration) --output $(Build.ArtifactStagingDirectory)'
+
+- task: NuGetCommand@2
+ condition: and (succeeded(), eq(variables['Build.SourceBranchName'], 'master') )
+ displayName: 'NuGet push'
+ inputs:
+ command: push
+ packagesToPush: '**/*.nupkg'
+ publishVstsFeed: '58ebbac4-8e98-4281-bb3f-58003852f4a7'
+ allowPackageConflicts: true
+
+- task: PublishBuildArtifacts@1
+ condition: and (succeeded(), eq(variables['Build.SourceBranchName'], 'master') )
+ displayName: 'Publish Artifact: dist'
+ inputs:
+ PathtoPublish: '$(build.artifactstagingdirectory)'
+ ArtifactName: 'dist'
+
+