├── .editorconfig ├── .gitignore ├── LICENSE ├── README.md ├── ReactBffProxy.sln ├── global.json └── src ├── Client ├── .eslintrc ├── .vscode │ └── settings.json ├── _gitignore ├── index.html ├── package-lock.json ├── package.json ├── public │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── apple-touch-icon.png │ └── favicon.ico ├── service-worker │ └── service-worker.js ├── src │ ├── ErrorPage.tsx │ ├── Home.tsx │ ├── Layout.tsx │ ├── LoginLogoutArea.tsx │ ├── ServiceContext.ts │ ├── ServiceContextProvider.tsx │ ├── auth │ │ ├── AuthContext.tsx │ │ ├── AuthContextProvider.tsx │ │ └── AuthGuard.tsx │ ├── components │ │ ├── AdminComponent.tsx │ │ └── ApiTest.tsx │ ├── getCookie.ts │ ├── main.tsx │ ├── models │ │ ├── AuthData.ts │ │ └── WeatherForecast.ts │ ├── router.tsx │ ├── services │ │ ├── AuthService.ts │ │ ├── HttpClient.ts │ │ ├── TestService.ts │ │ └── useErrorHandledQuery.tsx │ └── vite-env.d.ts ├── tsconfig.json └── vite.config.ts ├── Server ├── AppRoles.cs ├── Controllers │ ├── AuthController.cs │ ├── GraphApiCallsController.cs │ └── WeatherForecastController.cs ├── Extensions │ ├── ApplicationBuilderExtensions.cs │ ├── EndpointRouteBuilderExtensions.cs │ └── ServiceCollectionExtension.cs ├── MsGraphService.cs ├── Pages │ ├── Error.cshtml │ ├── Error.cshtml.cs │ └── _Host.cshtml ├── Program.cs ├── Properties │ └── launchSettings.json ├── ReactBffProxy.Server.csproj ├── SecurityHeadersDefinitions.cs ├── SwaggerCspRelaxingHeaderService.cs ├── SwaggerHeaderFilter.cs ├── appsettings.Development.json └── appsettings.json └── Shared ├── Authorization ├── ClaimValue.cs └── UserInfo.cs ├── DrinkIngredientModel.cs ├── DrinkModel.cs └── ReactBffProxy.Shared.csproj /.editorconfig: -------------------------------------------------------------------------------- 1 | ; EditorConfig to support per-solution formatting. 2 | ; Use the EditorConfig VS add-in to make this work. 3 | ; http://editorconfig.org/ 4 | ; 5 | ; Here are some resources for what's supported for .NET/C# 6 | ; https://kent-boogaart.com/blog/editorconfig-reference-for-c-developers 7 | ; https://docs.microsoft.com/en-us/visualstudio/ide/editorconfig-code-style-settings-reference?view=vs-2017 8 | ; 9 | ; Be **careful** editing this because some of the rules don't support adding a severity level 10 | ; For instance if you change to `dotnet_sort_system_directives_first = true:warning` (adding `:warning`) 11 | ; then the rule will be silently ignored. 12 | 13 | ; This is the default for the codeline. 14 | root = true 15 | 16 | [*] 17 | indent_style = space 18 | charset = utf-8 19 | trim_trailing_whitespace = true 20 | insert_final_newline = true 21 | 22 | [*.cs] 23 | indent_size = 4 24 | dotnet_sort_system_directives_first = true 25 | 26 | # Don't use this. qualifier 27 | dotnet_style_qualification_for_field = false:suggestion 28 | dotnet_style_qualification_for_property = false:suggestion 29 | 30 | # use int x = .. over Int32 31 | dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion 32 | 33 | # use int.MaxValue over Int32.MaxValue 34 | dotnet_style_predefined_type_for_member_access = true:suggestion 35 | 36 | # Require var all the time. 37 | csharp_style_var_for_built_in_types = true:suggestion 38 | csharp_style_var_when_type_is_apparent = true:suggestion 39 | csharp_style_var_elsewhere = true:suggestion 40 | 41 | # Disallow throw expressions. 42 | csharp_style_throw_expression = false:suggestion 43 | 44 | # use file scoped namespace declarations 45 | csharp_style_namespace_declarations = file_scoped 46 | 47 | # internal and private fields should be _camelCase 48 | dotnet_naming_rule.camel_case_for_private_internal_fields.severity = suggestion 49 | dotnet_naming_rule.camel_case_for_private_internal_fields.symbols = private_internal_fields 50 | dotnet_naming_rule.camel_case_for_private_internal_fields.style = camel_case_underscore_style 51 | dotnet_naming_symbols.private_internal_fields.applicable_kinds = field 52 | dotnet_naming_symbols.private_internal_fields.applicable_accessibilities = private, internal 53 | dotnet_naming_style.camel_case_underscore_style.required_prefix = _ 54 | dotnet_naming_style.camel_case_underscore_style.capitalization = camel_case 55 | 56 | # Newline settings 57 | csharp_new_line_before_open_brace = all 58 | csharp_new_line_before_else = true 59 | csharp_new_line_before_catch = true 60 | csharp_new_line_before_finally = true 61 | csharp_new_line_before_members_in_object_initializers = true 62 | csharp_new_line_before_members_in_anonymous_types = true 63 | csharp_new_line_between_query_expression_clauses = true 64 | 65 | # Indentation preferences 66 | csharp_indent_block_contents = true 67 | csharp_indent_braces = false 68 | csharp_indent_case_contents = true 69 | csharp_indent_case_contents_when_block = true 70 | csharp_indent_switch_labels = true 71 | csharp_indent_labels = flush_left 72 | 73 | # Whitespace options 74 | csharp_style_allow_embedded_statements_on_same_line_experimental = false 75 | csharp_style_allow_blank_lines_between_consecutive_braces_experimental = false 76 | csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = false 77 | 78 | # Prefer "var" everywhere 79 | csharp_style_var_for_built_in_types = true:suggestion 80 | csharp_style_var_when_type_is_apparent = true:suggestion 81 | csharp_style_var_elsewhere = true:suggestion 82 | 83 | # Prefer method-like constructs to have a block body 84 | csharp_style_expression_bodied_methods = false:none 85 | csharp_style_expression_bodied_constructors = false:none 86 | csharp_style_expression_bodied_operators = false:none 87 | 88 | # Prefer property-like constructs to have an expression-body 89 | csharp_style_expression_bodied_properties = true:none 90 | csharp_style_expression_bodied_indexers = true:none 91 | csharp_style_expression_bodied_accessors = true:none 92 | 93 | # Suggest more modern language features when available 94 | csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion 95 | csharp_style_pattern_matching_over_as_with_null_check = true:suggestion 96 | csharp_style_inlined_variable_declaration = true:suggestion 97 | csharp_style_throw_expression = true:suggestion 98 | csharp_style_conditional_delegate_call = true:suggestion 99 | 100 | # Space preferences 101 | csharp_space_after_cast = false 102 | csharp_space_after_colon_in_inheritance_clause = true 103 | csharp_space_after_comma = true 104 | csharp_space_after_dot = false 105 | csharp_space_after_keywords_in_control_flow_statements = true 106 | csharp_space_after_semicolon_in_for_statement = true 107 | csharp_space_around_binary_operators = before_and_after 108 | csharp_space_around_declaration_statements = do_not_ignore 109 | csharp_space_before_colon_in_inheritance_clause = true 110 | csharp_space_before_comma = false 111 | csharp_space_before_dot = false 112 | csharp_space_before_open_square_brackets = false 113 | csharp_space_before_semicolon_in_for_statement = false 114 | csharp_space_between_empty_square_brackets = false 115 | csharp_space_between_method_call_empty_parameter_list_parentheses = false 116 | csharp_space_between_method_call_name_and_opening_parenthesis = false 117 | csharp_space_between_method_call_parameter_list_parentheses = false 118 | csharp_space_between_method_declaration_empty_parameter_list_parentheses = false 119 | csharp_space_between_method_declaration_name_and_open_parenthesis = false 120 | csharp_space_between_method_declaration_parameter_list_parentheses = false 121 | csharp_space_between_parentheses = false 122 | csharp_space_between_square_brackets = false 123 | 124 | # Blocks are allowed 125 | csharp_prefer_braces = true:suggestion 126 | csharp_preserve_sin e_line_blocks = true 127 | csharp_preserve_single_line_statements = true 128 | 129 | # Currently only enabled for C# due to crash in VB analyzer. VB can be enabled once 130 | # https://github.com/dotnet/roslyn/pull/54259 has been published. 131 | dotnet_style_allow_statement_immediately_after_block_experimental = false 132 | 133 | # IDE1006: Naming Styles 134 | dotnet_diagnostic.IDE1006.severity = none 135 | 136 | dotnet_diagnostic.IDE0011.severity = none 137 | 138 | 139 | [*.{xml,config,*proj,nuspec,props,resx,targets,yml,tasks}] 140 | indent_size = 2 141 | 142 | # Xml config files 143 | [*.{props,targets,ruleset,config,nuspec,resx,vsixmanifest,vsct}] 144 | indent_size = 2 145 | 146 | [*.json] 147 | indent_size = 2 148 | 149 | [*.{ps1,psm1}] 150 | indent_size = 4 151 | 152 | [*.sh] 153 | indent_size = 4 154 | end_of_line = lf 155 | 156 | [*.{razor,cshtml}] 157 | charset = utf-8-bom 158 | 159 | [*.{cs,vb}] 160 | # CA1018: Mark attributes with AttributeUsageAttribute 161 | dotnet_diagnostic.CA1018.severity = warning 162 | 163 | # CA1047: Do not declare protected member in sealed type 164 | dotnet_diagnostic.CA1047.severity = warning 165 | 166 | # CA1305: Specify IFormatProvider 167 | dotnet_diagnostic.CA1305.severity = warning 168 | 169 | # CA1507: Use nameof to express symbol names 170 | dotnet_diagnostic.CA1507.severity = warning 171 | 172 | # CA1725: Parameter names should match base declaration 173 | dotnet_diagnostic.CA1725.severity = suggestion 174 | 175 | # CA1802: Use literals where appropriate 176 | dotnet_diagnostic.CA1802.severity = warning 177 | 178 | # CA1805: Do not initialize unnecessarily 179 | dotnet_diagnostic.CA1805.severity = warning 180 | 181 | # CA1810: Do not initialize unnecessarily 182 | dotnet_diagnostic.CA1810.severity = suggestion 183 | 184 | # CA1821: Remove empty Finalizers 185 | dotnet_diagnostic.CA1821.severity = warning 186 | 187 | # CA1822: Make member static 188 | dotnet_diagnostic.CA1822.severity = suggestion 189 | 190 | # CA1823: Avoid unused private fields 191 | dotnet_diagnostic.CA1823.severity = warning 192 | 193 | # CA1825: Avoid zero-length array allocations 194 | dotnet_diagnostic.CA1825.severity = warning 195 | 196 | # CA1826: Do not use Enumerable methods on indexable collections. Instead use the collection directly 197 | dotnet_diagnostic.CA1826.severity = warning 198 | 199 | # CA1827: Do not use Count() or LongCount() when Any() can be used 200 | dotnet_diagnostic.CA1827.severity = warning 201 | 202 | # CA1828: Do not use CountAsync() or LongCountAsync() when AnyAsync() can be used 203 | dotnet_diagnostic.CA1828.severity = warning 204 | 205 | # CA1829: Use Length/Count property instead of Count() when available 206 | dotnet_diagnostic.CA1829.severity = warning 207 | 208 | # CA1830: Prefer strongly-typed Append and Insert method overloads on StringBuilder 209 | dotnet_diagnostic.CA1830.severity = warning 210 | 211 | # CA1831: Use AsSpan or AsMemory instead of Range-based indexers when appropriate 212 | # CA1832: Use AsSpan or AsMemory instead of Range-based indexers when appropriate 213 | # CA1833: Use AsSpan or AsMemory instead of Range-based indexers when appropriate 214 | dotnet_diagnostic.CA1831.severity = warning 215 | dotnet_diagnostic.CA1832.severity = warning 216 | dotnet_diagnostic.CA1833.severity = warning 217 | 218 | # CA1834: Consider using 'StringBuilder.Append(char)' when applicable 219 | dotnet_diagnostic.CA1834.severity = warning 220 | 221 | # CA1835: Prefer the 'Memory'-based overloads for 'ReadAsync' and 'WriteAsync' 222 | dotnet_diagnostic.CA1835.severity = warning 223 | 224 | # CA1836: Prefer IsEmpty over Count 225 | dotnet_diagnostic.CA1836.severity = warning 226 | 227 | # CA1837: Use 'Environment.ProcessId' 228 | dotnet_diagnostic.CA1837.severity = warning 229 | 230 | # CA1838: Avoid 'StringBuilder' parameters for P/Invokes 231 | dotnet_diagnostic.CA1838.severity = warning 232 | 233 | # CA1839: Use 'Environment.ProcessPath' 234 | dotnet_diagnostic.CA1839.severity = warning 235 | 236 | # CA1840: Use 'Environment.CurrentManagedThreadId' 237 | dotnet_diagnostic.CA1840.severity = warning 238 | 239 | # CA1841: Prefer Dictionary.Contains methods 240 | dotnet_diagnostic.CA1841.severity = warning 241 | 242 | # CA1842: Do not use 'WhenAll' with a single task 243 | dotnet_diagnostic.CA1842.severity = warning 244 | 245 | # CA1843: Do not use 'WaitAll' with a single task 246 | dotnet_diagnostic.CA1843.severity = warning 247 | 248 | # CA1845: Use span-based 'string.Concat' 249 | dotnet_diagnostic.CA1845.severity = warning 250 | 251 | # CA1846: Prefer AsSpan over Substring 252 | dotnet_diagnostic.CA1846.severity = warning 253 | 254 | # CA2008: Do not create tasks without passing a TaskScheduler 255 | dotnet_diagnostic.CA2008.severity = warning 256 | 257 | # CA2009: Do not call ToImmutableCollection on an ImmutableCollection value 258 | dotnet_diagnostic.CA2009.severity = warning 259 | 260 | # CA2011: Avoid infinite recursion 261 | dotnet_diagnostic.CA2011.severity = warning 262 | 263 | # CA2012: Use ValueTask correctly 264 | dotnet_diagnostic.CA2012.severity = warning 265 | 266 | # CA2013: Do not use ReferenceEquals with value types 267 | dotnet_diagnostic.CA2013.severity = warning 268 | 269 | # CA2014: Do not use stackalloc in loops. 270 | dotnet_diagnostic.CA2014.severity = warning 271 | 272 | # CA2016: Forward the 'CancellationToken' parameter to methods that take one 273 | dotnet_diagnostic.CA2016.severity = warning 274 | 275 | # CA2200: Rethrow to preserve stack details 276 | dotnet_diagnostic.CA2200.severity = warning 277 | 278 | # CA2208: Instantiate argument exceptions correctly 279 | dotnet_diagnostic.CA2208.severity = warning 280 | 281 | # IDE0035: Remove unreachable code 282 | dotnet_diagnostic.IDE0035.severity = warning 283 | 284 | # IDE0036: Order modifiers 285 | dotnet_diagnostic.IDE0036.severity = warning 286 | 287 | # IDE0043: Format string contains invalid placeholder 288 | dotnet_diagnostic.IDE0043.severity = warning 289 | 290 | # IDE0044: Make field readonly 291 | dotnet_diagnostic.IDE0044.severity = warning 292 | 293 | # IDE0073: File header 294 | dotnet_diagnostic.IDE0073.severity = none 295 | 296 | [**/{test,samples,perf}/**.{cs,vb}] 297 | # CA1018: Mark attributes with AttributeUsageAttribute 298 | dotnet_diagnostic.CA1018.severity = suggestion 299 | # CA1507: Use nameof to express symbol names 300 | dotnet_diagnostic.CA1507.severity = suggestion 301 | # CA1802: Use literals where appropriate 302 | dotnet_diagnostic.CA1802.severity = suggestion 303 | # CA1805: Do not initialize unnecessarily 304 | dotnet_diagnostic.CA1805.severity = suggestion 305 | # CA1823: Avoid zero-length array allocations 306 | dotnet_diagnostic.CA1825.severity = suggestion 307 | # CA1826: Do not use Enumerable methods on indexable collections. Instead use the collection directly 308 | dotnet_diagnostic.CA1826.severity = suggestion 309 | # CA1827: Do not use Count() or LongCount() when Any() can be used 310 | dotnet_diagnostic.CA1827.severity = suggestion 311 | # CA1829: Use Length/Count property instead of Count() when available 312 | dotnet_diagnostic.CA1829.severity = suggestion 313 | # CA1834: Consider using 'StringBuilder.Append(char)' when applicable 314 | dotnet_diagnostic.CA1834.severity = suggestion 315 | # CA1835: Prefer the 'Memory'-based overloads for 'ReadAsync' and 'WriteAsync' 316 | dotnet_diagnostic.CA1835.severity = suggestion 317 | # CA1837: Use 'Environment.ProcessId' 318 | dotnet_diagnostic.CA1837.severity = suggestion 319 | # CA1838: Avoid 'StringBuilder' parameters for P/Invokes 320 | dotnet_diagnostic.CA1838.severity = suggestion 321 | # CA1841: Prefer Dictionary.Contains methods 322 | dotnet_diagnostic.CA1841.severity = suggestion 323 | # CA1844: Provide memory-based overrides of async methods when subclassing 'Stream' 324 | dotnet_diagnostic.CA1844.severity = suggestion 325 | # CA1845: Use span-based 'string.Concat' 326 | dotnet_diagnostic.CA1845.severity = suggestion 327 | # CA1846: Prefer AsSpan over Substring 328 | dotnet_diagnostic.CA1846.severity = suggestion 329 | # CA2008: Do not create tasks without passing a TaskScheduler 330 | dotnet_diagnostic.CA2008.severity = suggestion 331 | # CA2012: Use ValueTask correctly 332 | dotnet_diagnostic.CA2012.severity = suggestion 333 | # IDE0044: Make field readonly 334 | dotnet_diagnostic.IDE0044.severity = suggestion 335 | 336 | # CA2016: Forward the 'CancellationToken' parameter to methods that take one 337 | dotnet_diagnostic.CA2016.severity = suggestion 338 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # terraform 7 | .terraform/ 8 | 9 | # smidge cache 10 | [Ss]midge/ 11 | 12 | # User-specific files 13 | *.rsuser 14 | *.suo 15 | *.user 16 | *.userosscache 17 | *.sln.docstates 18 | 19 | # User-specific files (MonoDevelop/Xamarin Studio) 20 | *.userprefs 21 | 22 | # Mono auto generated files 23 | mono_crash.* 24 | 25 | # Build results 26 | [Dd]ebug/ 27 | [Dd]ebugPublic/ 28 | [Rr]elease/ 29 | [Rr]eleases/ 30 | x64/ 31 | x86/ 32 | [Ww][Ii][Nn]32/ 33 | [Aa][Rr][Mm]/ 34 | [Aa][Rr][Mm]64/ 35 | bld/ 36 | [Bb]in/ 37 | [Oo]bj/ 38 | [Ll]og/ 39 | [Ll]ogs/ 40 | 41 | # Visual Studio 2015/2017 cache/options directory 42 | .vs/ 43 | # Uncomment if you have tasks that create the project's static files in wwwroot 44 | #wwwroot/ 45 | 46 | # Visual Studio 2017 auto generated files 47 | Generated\ Files/ 48 | 49 | # MSTest test Results 50 | [Tt]est[Rr]esult*/ 51 | [Bb]uild[Ll]og.* 52 | 53 | # NUnit 54 | *.VisualState.xml 55 | TestResult.xml 56 | nunit-*.xml 57 | 58 | # Build Results of an ATL Project 59 | [Dd]ebugPS/ 60 | [Rr]eleasePS/ 61 | dlldata.c 62 | 63 | # Benchmark Results 64 | BenchmarkDotNet.Artifacts/ 65 | 66 | # .NET Core 67 | project.lock.json 68 | project.fragment.lock.json 69 | artifacts/ 70 | 71 | # ASP.NET Scaffolding 72 | ScaffoldingReadMe.txt 73 | 74 | # StyleCop 75 | StyleCopReport.xml 76 | 77 | # Files built by Visual Studio 78 | *_i.c 79 | *_p.c 80 | *_h.h 81 | *.ilk 82 | *.meta 83 | *.obj 84 | *.iobj 85 | *.pch 86 | *.pdb 87 | *.ipdb 88 | *.pgc 89 | *.pgd 90 | *.rsp 91 | *.sbr 92 | *.tlb 93 | *.tli 94 | *.tlh 95 | *.tmp 96 | *.tmp_proj 97 | *_wpftmp.csproj 98 | *.log 99 | *.vspscc 100 | *.vssscc 101 | .builds 102 | *.pidb 103 | *.svclog 104 | *.scc 105 | 106 | # Chutzpah Test files 107 | _Chutzpah* 108 | 109 | # Visual C++ cache files 110 | ipch/ 111 | *.aps 112 | *.ncb 113 | *.opendb 114 | *.opensdf 115 | *.sdf 116 | *.cachefile 117 | *.VC.db 118 | *.VC.VC.opendb 119 | 120 | # Visual Studio profiler 121 | *.psess 122 | *.vsp 123 | *.vspx 124 | *.sap 125 | 126 | # Visual Studio Trace Files 127 | *.e2e 128 | 129 | # TFS 2012 Local Workspace 130 | $tf/ 131 | 132 | # Guidance Automation Toolkit 133 | *.gpState 134 | 135 | # ReSharper is a .NET coding add-in 136 | _ReSharper*/ 137 | *.[Rr]e[Ss]harper 138 | *.DotSettings.user 139 | 140 | # TeamCity is a build add-in 141 | _TeamCity* 142 | 143 | # DotCover is a Code Coverage Tool 144 | *.dotCover 145 | 146 | # AxoCover is a Code Coverage Tool 147 | .axoCover/* 148 | !.axoCover/settings.json 149 | 150 | # Coverlet is a free, cross platform Code Coverage Tool 151 | coverage*.json 152 | coverage*.xml 153 | coverage*.info 154 | 155 | # Visual Studio code coverage results 156 | *.coverage 157 | *.coveragexml 158 | 159 | # NCrunch 160 | _NCrunch_* 161 | .*crunch*.local.xml 162 | nCrunchTemp_* 163 | 164 | # MightyMoose 165 | *.mm.* 166 | AutoTest.Net/ 167 | 168 | # Web workbench (sass) 169 | .sass-cache/ 170 | 171 | # Installshield output folder 172 | [Ee]xpress/ 173 | 174 | # DocProject is a documentation generator add-in 175 | DocProject/buildhelp/ 176 | DocProject/Help/*.HxT 177 | DocProject/Help/*.HxC 178 | DocProject/Help/*.hhc 179 | DocProject/Help/*.hhk 180 | DocProject/Help/*.hhp 181 | DocProject/Help/Html2 182 | DocProject/Help/html 183 | 184 | # Click-Once directory 185 | publish/ 186 | 187 | # Publish Web Output 188 | *.[Pp]ublish.xml 189 | *.azurePubxml 190 | # Note: Comment the next line if you want to checkin your web deploy settings, 191 | # but database connection strings (with potential passwords) will be unencrypted 192 | *.pubxml 193 | *.publishproj 194 | 195 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 196 | # checkin your Azure Web App publish settings, but sensitive information contained 197 | # in these scripts will be unencrypted 198 | PublishScripts/ 199 | 200 | # NuGet Packages 201 | *.nupkg 202 | # NuGet Symbol Packages 203 | *.snupkg 204 | # The packages folder can be ignored because of Package Restore 205 | **/[Pp]ackages/* 206 | # except build/, which is used as an MSBuild target. 207 | !**/[Pp]ackages/build/ 208 | # Uncomment if necessary however generally it will be regenerated when needed 209 | #!**/[Pp]ackages/repositories.config 210 | # NuGet v3's project.json files produces more ignorable files 211 | *.nuget.props 212 | *.nuget.targets 213 | 214 | # Microsoft Azure Build Output 215 | csx/ 216 | *.build.csdef 217 | 218 | # Microsoft Azure Emulator 219 | ecf/ 220 | rcf/ 221 | 222 | # Windows Store app package directories and files 223 | AppPackages/ 224 | BundleArtifacts/ 225 | Package.StoreAssociation.xml 226 | _pkginfo.txt 227 | *.appx 228 | *.appxbundle 229 | *.appxupload 230 | 231 | # Visual Studio cache files 232 | # files ending in .cache can be ignored 233 | *.[Cc]ache 234 | # but keep track of directories ending in .cache 235 | !?*.[Cc]ache/ 236 | 237 | # Others 238 | ClientBin/ 239 | ~$* 240 | *~ 241 | *.dbmdl 242 | *.dbproj.schemaview 243 | *.jfm 244 | *.pfx 245 | *.publishsettings 246 | orleans.codegen.cs 247 | 248 | # Including strong name files can present a security risk 249 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 250 | #*.snk 251 | 252 | # Since there are multiple workflows, uncomment next line to ignore bower_components 253 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 254 | #bower_components/ 255 | 256 | # RIA/Silverlight projects 257 | Generated_Code/ 258 | 259 | # Backup & report files from converting an old project file 260 | # to a newer Visual Studio version. Backup files are not needed, 261 | # because we have git ;-) 262 | _UpgradeReport_Files/ 263 | Backup*/ 264 | UpgradeLog*.XML 265 | UpgradeLog*.htm 266 | ServiceFabricBackup/ 267 | *.rptproj.bak 268 | 269 | # SQL Server files 270 | *.mdf 271 | *.ldf 272 | *.ndf 273 | 274 | # Business Intelligence projects 275 | *.rdl.data 276 | *.bim.layout 277 | *.bim_*.settings 278 | *.rptproj.rsuser 279 | *- [Bb]ackup.rdl 280 | *- [Bb]ackup ([0-9]).rdl 281 | *- [Bb]ackup ([0-9][0-9]).rdl 282 | 283 | # Microsoft Fakes 284 | FakesAssemblies/ 285 | 286 | # GhostDoc plugin setting file 287 | *.GhostDoc.xml 288 | 289 | # Node.js Tools for Visual Studio 290 | .ntvs_analysis.dat 291 | node_modules/ 292 | 293 | # Visual Studio 6 build log 294 | *.plg 295 | 296 | # Visual Studio 6 workspace options file 297 | *.opt 298 | 299 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 300 | *.vbw 301 | 302 | # Visual Studio LightSwitch build output 303 | **/*.HTMLClient/GeneratedArtifacts 304 | **/*.DesktopClient/GeneratedArtifacts 305 | **/*.DesktopClient/ModelManifest.xml 306 | **/*.Server/GeneratedArtifacts 307 | **/*.Server/ModelManifest.xml 308 | _Pvt_Extensions 309 | 310 | # Paket dependency manager 311 | .paket/paket.exe 312 | paket-files/ 313 | 314 | # FAKE - F# Make 315 | .fake/ 316 | 317 | # CodeRush personal settings 318 | .cr/personal 319 | 320 | # Python Tools for Visual Studio (PTVS) 321 | __pycache__/ 322 | *.pyc 323 | 324 | # Cake - Uncomment if you are using it 325 | # tools/** 326 | # !tools/packages.config 327 | 328 | # Tabs Studio 329 | *.tss 330 | 331 | # Telerik's JustMock configuration file 332 | *.jmconfig 333 | 334 | # BizTalk build output 335 | *.btp.cs 336 | *.btm.cs 337 | *.odx.cs 338 | *.xsd.cs 339 | 340 | # OpenCover UI analysis results 341 | OpenCover/ 342 | 343 | # Azure Stream Analytics local run output 344 | ASALocalRun/ 345 | 346 | # MSBuild Binary and Structured Log 347 | *.binlog 348 | 349 | # NVidia Nsight GPU debugger configuration file 350 | *.nvuser 351 | 352 | # MFractors (Xamarin productivity tool) working folder 353 | .mfractor/ 354 | 355 | # Local History for Visual Studio 356 | .localhistory/ 357 | 358 | # BeatPulse healthcheck temp database 359 | healthchecksdb 360 | 361 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 362 | MigrationBackup/ 363 | 364 | # Ionide (cross platform F# VS Code tools) working folder 365 | .ionide/ 366 | 367 | # Fody - auto-generated XML schema 368 | FodyWeavers.xsd 369 | 370 | /src/_logs-eduFramework-server.txt 371 | /src/_logs-eduFox-server.txt 372 | **/_logs-* 373 | 374 | # jetbrains 375 | .idea/ 376 | 377 | # Git 378 | *.orig 379 | src/Server/wwwroot/* 380 | src/Client/dev-dist/registerSW.js 381 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ASP.NET Core + React BFF Dev Proxy Sample app 2 | 3 | This repo contains a sample app for hosting a React client from an ASP.NET Core WebAPI. During Development, requests for the client are forwarded to the vite development server, making the client application appear as if it was hosted through the server. 4 | 5 | This enables the use of unified security headers in development and production, and allows us to use cookie authentication, as you'd use with a Razor/MVC or Blazor Server app. 6 | 7 | ## Points of Interest: 8 | 9 | - AAD Config: 10 | - `ServiceCollectionExtension.cs` ~L43ff. Configures cookie auth 11 | - Dev Server Proxy: 12 | - `Program.cs` ~L106ff 13 | - `_Host.cshtml` 14 | - Security Headers + CSP with Nonce: 15 | - `SecurityHeadersDefinitions.cs` 16 | - Reading the Nonce in Client app: 17 | - `main.tsx` 18 | - CSP Workaround for Swagger: 19 | - `SwaggerCspRelaxingHeaderService.cs` 20 | - XSRF/CSRF Mitigation with Synchronizer Pattern: 21 | - `_Host.cshtml` - create tokens and pass then through cookies (one HTML-only, one not) 22 | - `HttpClient.ts` - read request token from non-HTML-only cookie, add to header 23 | - Swagger with CSRF: 24 | - `ServiceCollectionExtension.cs` ~L65ff 25 | - `SwaggerHeaderFilter.cs` - automatically add antiforgery request token (check swagger, it's shown as a form field) 26 | - PWA: 27 | - `vite.config.ts` - contains Vite-PWA config 28 | - `service-worker.js` - basic offline support 29 | 30 | ## How to run 31 | 32 | ### development mode 33 | 34 | 1. Open the solution and launch the server app in Kestrel. 35 | 1. Run the client app at /src/Client by running `npm install` then `npm run dev` 36 | 1. Access the application at `localhost:5001` 37 | 38 | ### production mode 39 | 40 | 1. Build the client app at /src/Client by running `npm install` then `npm build`. The build output is placed in the server project's `wwwroot` folder 41 | 1. Build the solution, making sure the `wwwroot` folder is included in the build 42 | 1. Run the webAPI through Kestrel and access it via `https` 43 | -------------------------------------------------------------------------------- /ReactBffProxy.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.0.31903.59 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ReactBffProxy.Server", "src\Server\ReactBffProxy.Server.csproj", "{37DFADAA-3CEC-4DC2-919F-D721E6762108}" 7 | ProjectSection(ProjectDependencies) = postProject 8 | {665B3F0D-C2B7-4BF4-9333-E1794EEB201A} = {665B3F0D-C2B7-4BF4-9333-E1794EEB201A} 9 | EndProjectSection 10 | EndProject 11 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Server", "Server", "{87B9E613-D19F-4F9C-93F4-7037DA5844CB}" 12 | EndProject 13 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ReactBffProxy.Shared", "src\Shared\ReactBffProxy.Shared.csproj", "{665B3F0D-C2B7-4BF4-9333-E1794EEB201A}" 14 | EndProject 15 | Global 16 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 17 | Debug|Any CPU = Debug|Any CPU 18 | Release|Any CPU = Release|Any CPU 19 | EndGlobalSection 20 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 21 | {37DFADAA-3CEC-4DC2-919F-D721E6762108}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 22 | {37DFADAA-3CEC-4DC2-919F-D721E6762108}.Debug|Any CPU.Build.0 = Debug|Any CPU 23 | {37DFADAA-3CEC-4DC2-919F-D721E6762108}.Release|Any CPU.ActiveCfg = Release|Any CPU 24 | {37DFADAA-3CEC-4DC2-919F-D721E6762108}.Release|Any CPU.Build.0 = Release|Any CPU 25 | {665B3F0D-C2B7-4BF4-9333-E1794EEB201A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 26 | {665B3F0D-C2B7-4BF4-9333-E1794EEB201A}.Debug|Any CPU.Build.0 = Debug|Any CPU 27 | {665B3F0D-C2B7-4BF4-9333-E1794EEB201A}.Release|Any CPU.ActiveCfg = Release|Any CPU 28 | {665B3F0D-C2B7-4BF4-9333-E1794EEB201A}.Release|Any CPU.Build.0 = Release|Any CPU 29 | EndGlobalSection 30 | GlobalSection(SolutionProperties) = preSolution 31 | HideSolutionNode = FALSE 32 | EndGlobalSection 33 | GlobalSection(NestedProjects) = preSolution 34 | {37DFADAA-3CEC-4DC2-919F-D721E6762108} = {87B9E613-D19F-4F9C-93F4-7037DA5844CB} 35 | EndGlobalSection 36 | GlobalSection(ExtensibilityGlobals) = postSolution 37 | SolutionGuid = {6DBF8473-06FC-43AF-A14F-41F17AB93C7A} 38 | EndGlobalSection 39 | EndGlobal 40 | -------------------------------------------------------------------------------- /global.json: -------------------------------------------------------------------------------- 1 | { 2 | "sdk": { 3 | "version": "7.0.101", 4 | "rollForward": "latestFeature" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/Client/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "airbnb-base", 4 | "airbnb-typescript/base", 5 | "plugin:@typescript-eslint/recommended", 6 | "plugin:react/recommended", 7 | "plugin:react-hooks/recommended", 8 | "prettier" 9 | ], 10 | "parserOptions": { 11 | "project": ["./tsconfig.json"] 12 | }, 13 | "env": { 14 | "browser": true, 15 | "node": true, 16 | "es6": true 17 | }, 18 | "ignorePatterns": ["vite.config.ts", "service-worker.js"], 19 | "rules": { 20 | "import/no-default-export": "error", 21 | "import/prefer-default-export": "off", 22 | "import/extensions": "off", 23 | "react/react-in-jsx-scope": "off", 24 | "prefer-destructuring": "off" 25 | }, 26 | "settings": { 27 | "react": { 28 | "version": "detect" 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Client/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.tabSize": 2 3 | } -------------------------------------------------------------------------------- /src/Client/_gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /src/Client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | ASP.NET Core + React BFF Proxy Sample 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/Client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "aspnetcore-react-bff-proxy-sample", 3 | "private": true, 4 | "version": "0.1.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "preview": "vite preview" 10 | }, 11 | "dependencies": { 12 | "@emotion/cache": "^11.10.7", 13 | "@emotion/react": "^11.10.6", 14 | "@emotion/styled": "^11.10.6", 15 | "@fontsource/roboto": "^4.5.8", 16 | "@mui/icons-material": "^5.11.16", 17 | "@mui/material": "^5.12.1", 18 | "@tanstack/react-query": "^4.29.5", 19 | "@tanstack/react-table": "^8.9.1", 20 | "@types/react-window": "^1.8.5", 21 | "@typescript-eslint/eslint-plugin": "^5.59.0", 22 | "@typescript-eslint/parser": "^5.59.0", 23 | "axios": "^1.3.6", 24 | "mobx": "^6.9.0", 25 | "mobx-react-lite": "^3.4.3", 26 | "notistack": "^2.0.8", 27 | "react": "^18.2.0", 28 | "react-dom": "^18.2.0", 29 | "react-router-dom": "^6.10.0", 30 | "react-window": "^1.8.9", 31 | "vite-plugin-pwa": "^0.14.7" 32 | }, 33 | "devDependencies": { 34 | "@types/react": "^18.0.28", 35 | "@types/react-dom": "^18.0.11", 36 | "@vitejs/plugin-react": "^2.2.0", 37 | "eslint-config-airbnb-base": "^15.0.0", 38 | "eslint-config-airbnb-typescript": "^17.0.0", 39 | "eslint-config-prettier": "^8.8.0", 40 | "eslint-plugin-import": "^2.27.5", 41 | "eslint-plugin-react": "^7.32.2", 42 | "eslint-plugin-react-hooks": "^4.6.0", 43 | "typescript": "^5.0.2", 44 | "vite": "^3.2.5", 45 | "vite-plugin-mkcert": "^1.14.0" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Client/public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/isolutionsag/aspnet-react-bff-proxy-example/553868bc0b8649490dfcc2cd0ad6ff530209b50a/src/Client/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /src/Client/public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/isolutionsag/aspnet-react-bff-proxy-example/553868bc0b8649490dfcc2cd0ad6ff530209b50a/src/Client/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /src/Client/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/isolutionsag/aspnet-react-bff-proxy-example/553868bc0b8649490dfcc2cd0ad6ff530209b50a/src/Client/public/apple-touch-icon.png -------------------------------------------------------------------------------- /src/Client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/isolutionsag/aspnet-react-bff-proxy-example/553868bc0b8649490dfcc2cd0ad6ff530209b50a/src/Client/public/favicon.ico -------------------------------------------------------------------------------- /src/Client/service-worker/service-worker.js: -------------------------------------------------------------------------------- 1 | import { precacheAndRoute } from "workbox-precaching"; 2 | import { registerRoute } from "workbox-routing"; 3 | import { NetworkFirst } from "workbox-strategies"; 4 | import { BackgroundSyncPlugin } from "workbox-background-sync"; 5 | 6 | const wbManifestPrecacheEntries = self.__WB_MANIFEST; 7 | 8 | // The precache entries by default include index.html (glob patterns seem to be ignored for this). 9 | // since our request to index.html includes cookies with tokens we want to refresh as often as possible, 10 | // we need to remove it from the precache 11 | const wbManifestPrecacheEntriesWithoutIndexHtml = 12 | wbManifestPrecacheEntries.filter((entry) => entry.url !== "index.html"); 13 | 14 | precacheAndRoute(wbManifestPrecacheEntriesWithoutIndexHtml); 15 | 16 | const bgSyncPlugin = new BackgroundSyncPlugin("myQueueName", { 17 | maxRetentionTime: 24 * 60, // Retry for max of 24 Hours (specified in minutes) 18 | }); 19 | 20 | self.addEventListener("install", (event) => { 21 | event.waitUntil(caches.delete("v1")); 22 | }); 23 | 24 | // Try fetching the html first, get from cache if not available 25 | registerRoute( 26 | ({ request }) => { 27 | return request.mode === "navigate"; 28 | }, 29 | new NetworkFirst({ 30 | cacheName: "v1", 31 | plugins: [bgSyncPlugin], // retry this request once we're back online if failed - this will make sure we refresh tokens ASAP 32 | }) 33 | ); 34 | 35 | // Fallback cache for assets 36 | registerRoute( 37 | ({ request }) => { 38 | const relativePath = request.url.replace(self.location.origin, ""); 39 | return relativePath.startsWith("/assets/"); 40 | }, 41 | new NetworkFirst({ 42 | cacheName: "v1", 43 | }) 44 | ); 45 | 46 | // Try fetching Auth info first, get from cache if not available 47 | registerRoute( 48 | ({ request }) => { 49 | const relativePath = request.url.replace(self.location.origin, ""); 50 | return relativePath.startsWith("/api/Auth"); 51 | }, 52 | new NetworkFirst({ 53 | cacheName: "v1", 54 | plugins: [bgSyncPlugin], // retry this request once we're back online if failed - this will make sure we refresh tokens ASAP 55 | }) 56 | ); 57 | -------------------------------------------------------------------------------- /src/Client/src/ErrorPage.tsx: -------------------------------------------------------------------------------- 1 | import { useRouteError } from "react-router-dom"; 2 | 3 | export const ErrorPage = () => { 4 | const error = useRouteError() as { statusText: string; message: string }; 5 | return ( 6 |
7 |

Oops!

8 |

Sorry, an unexpected error has occurred.

9 |

10 | {error.statusText || error.message} 11 |

12 |
13 | ); 14 | }; 15 | -------------------------------------------------------------------------------- /src/Client/src/Home.tsx: -------------------------------------------------------------------------------- 1 | export const Home = () =>

Hi!

; 2 | -------------------------------------------------------------------------------- /src/Client/src/Layout.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | AppBar, 3 | Toolbar, 4 | IconButton, 5 | Typography, 6 | Drawer, 7 | List, 8 | ListItem, 9 | styled, 10 | Container, 11 | ListItemButton, 12 | ListItemText, 13 | ListItemIcon, 14 | Box, 15 | } from "@mui/material"; 16 | import MenuIcon from "@mui/icons-material/Menu"; 17 | import { Outlet, Link } from "react-router-dom"; 18 | import Home from "@mui/icons-material/Home"; 19 | import AdminPanelSettings from "@mui/icons-material/AdminPanelSettings"; 20 | import { Login as LoginLogoutArea } from "./LoginLogoutArea"; 21 | import { AuthGuard } from "./auth/AuthGuard"; 22 | 23 | const Offset = styled("div")(({ theme }) => theme.mixins.toolbar); 24 | 25 | export const Layout = () => ( 26 | <> 27 | 28 | 33 | 41 | 42 | 43 | 50 | ASP.NET Core + React BFF Proxy Sample 51 | 52 | 53 | 54 | 55 | 56 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | Home 68 | 69 | 70 | 71 | 72 | Test 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | Admin 82 | 83 | 84 | 85 | 86 | 87 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | ); 100 | -------------------------------------------------------------------------------- /src/Client/src/LoginLogoutArea.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Tooltip } from "@mui/material"; 2 | import { useContext } from "react"; 3 | import { AuthContext } from "./auth/AuthContext"; 4 | import { getCookie } from "./getCookie"; 5 | 6 | export const Login = () => { 7 | const authContext = useContext(AuthContext); 8 | 9 | async function login() { 10 | authContext.signIn(); 11 | } 12 | 13 | return ( 14 | <> 15 | {!authContext.authData?.isAuthenticated ? ( 16 | 19 | ) : ( 20 |
21 | 22 | 25 | 26 | 32 |
33 | )} 34 | 35 | ); 36 | }; 37 | -------------------------------------------------------------------------------- /src/Client/src/ServiceContext.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { TestService } from "./services/TestService"; 3 | import { AuthService } from "./services/AuthService"; 4 | 5 | export interface ServiceContextContent { 6 | test: TestService; 7 | auth: AuthService; 8 | } 9 | 10 | export const ServiceContext = React.createContext( 11 | {} as ServiceContextContent 12 | ); 13 | -------------------------------------------------------------------------------- /src/Client/src/ServiceContextProvider.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren, useContext, useMemo } from "react"; 2 | import { ServiceContext, ServiceContextContent } from "./ServiceContext"; 3 | import { HttpClient } from "./services/HttpClient"; 4 | import { TestService } from "./services/TestService"; 5 | import { AuthService } from "./services/AuthService"; 6 | 7 | export const ServiceContextProvider = ({ children }: PropsWithChildren) => { 8 | const httpClient = useMemo(() => new HttpClient(), []); 9 | const services: ServiceContextContent = useMemo( 10 | () => ({ 11 | test: new TestService(httpClient), 12 | auth: new AuthService(httpClient), 13 | }), 14 | [httpClient] 15 | ); 16 | return ( 17 | 18 | {children} 19 | 20 | ); 21 | }; 22 | 23 | export const useServices = () => useContext(ServiceContext); 24 | -------------------------------------------------------------------------------- /src/Client/src/auth/AuthContext.tsx: -------------------------------------------------------------------------------- 1 | import { createContext } from "react"; 2 | import { AuthData } from "../models/AuthData"; 3 | 4 | export interface AuthContextContent { 5 | authData?: AuthData; 6 | signIn: () => void; 7 | signOut: () => void; 8 | } 9 | 10 | export const AuthContext = createContext({ 11 | authData: undefined, 12 | signIn: () => undefined, 13 | signOut: () => undefined, 14 | }); 15 | -------------------------------------------------------------------------------- /src/Client/src/auth/AuthContextProvider.tsx: -------------------------------------------------------------------------------- 1 | import { LinearProgress } from "@mui/material"; 2 | import { 3 | PropsWithChildren, 4 | ReactElement, 5 | useContext, 6 | useEffect, 7 | useState, 8 | } from "react"; 9 | import { AuthData } from "../models/AuthData"; 10 | import { AuthContext } from "./AuthContext"; 11 | import { useErrorHandledQuery } from "../services/useErrorHandledQuery"; 12 | import { useServices } from "../ServiceContextProvider"; 13 | 14 | export const AuthContextProvider = ({ 15 | children, 16 | }: PropsWithChildren): ReactElement => { 17 | const authContext = useContext(AuthContext); 18 | const [authData, setAuthData] = useState( 19 | authContext.authData 20 | ); 21 | 22 | const { auth } = useServices(); 23 | 24 | const query = useErrorHandledQuery( 25 | auth.fetchAuthDataQueryConfig(), 26 | "Could not fetch user data" 27 | ); 28 | 29 | useEffect(() => { 30 | if (query.isSuccess) { 31 | const user: AuthData = query.data; 32 | 33 | user.name = 34 | user.claims.find((c) => c.type === user.nameClaimType)?.value || 35 | "unknown"; 36 | 37 | user.roles = 38 | user.claims.filter((c) => c.type === user.roleClaimType).map(c => c.value) || 39 | []; 40 | 41 | if (!user.isAuthenticated) { 42 | setAuthData({ 43 | isAuthenticated: false, 44 | claims: [], 45 | name: "", 46 | nameClaimType: "", 47 | roleClaimType: "", 48 | roles: [] 49 | }); 50 | } else { 51 | setAuthData(user); 52 | } 53 | } 54 | }, [query.data, query.isSuccess]); 55 | 56 | function signIn() { 57 | document.location.href = "/api/Auth/Login"; 58 | } 59 | 60 | function signOut() { 61 | // TODO: create an axios default that adds this header to all requests 62 | // const xsrfToken = (document.querySelector('meta[name="XSRF-requestToken"]') as HTMLMetaElement)?.content; 63 | // const headers = { 64 | // 'X-XSRF-TOKEN': xsrfToken 65 | // } 66 | // axios.post('api/Auth/Logout', {}, { headers}); 67 | } 68 | 69 | return ( 70 | <> 71 | {authData != null ? ( 72 | 73 | {children} 74 | 75 | ) : ( 76 | 77 | )} 78 | 79 | ); 80 | }; 81 | -------------------------------------------------------------------------------- /src/Client/src/auth/AuthGuard.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren, useContext } from "react"; 2 | import { Box } from "@mui/material"; 3 | import { AuthContext } from "./AuthContext"; 4 | 5 | interface AuthGuardProps extends PropsWithChildren { 6 | requiredRole?: string; 7 | } 8 | 9 | export const AuthGuard = ({ requiredRole, children }: AuthGuardProps) => { 10 | const authContext = useContext(AuthContext); 11 | const isSignedIn = authContext.authData?.isAuthenticated ?? false; 12 | const isAuthorized = !requiredRole || authContext.authData?.roles.includes(requiredRole); 13 | 14 | return (isSignedIn && isAuthorized) ? ( 15 | <>{children} 16 | ) : ( 17 | Unauthorized - log in first or request the required roles 18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /src/Client/src/components/AdminComponent.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Typography } from "@mui/material"; 2 | import { useServices } from "../ServiceContextProvider"; 3 | import { useErrorHandledQuery } from "../services/useErrorHandledQuery"; 4 | 5 | export const AdminComponent = () => { 6 | const { auth } = useServices(); 7 | const adminInfo = useErrorHandledQuery(auth.fetchAdminInfoQueryConfig()); 8 | 9 | return ( 10 | 11 | Congrats! You are an admin 12 | Backend result: {adminInfo.data} 13 | 14 | ); 15 | }; 16 | -------------------------------------------------------------------------------- /src/Client/src/components/ApiTest.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Button } from "@mui/material"; 2 | import { useContext, useState } from "react"; 3 | import { useMutation } from "@tanstack/react-query"; 4 | import { AuthContext } from "../auth/AuthContext"; 5 | import { useServices } from "../ServiceContextProvider"; 6 | import { useErrorHandledQuery } from "../services/useErrorHandledQuery"; 7 | 8 | export const ApiTest = () => { 9 | const { test } = useServices(); 10 | 11 | const [runQuery, setRunQuery] = useState(false); 12 | const [runGraphQuery, setRunGraphQuery] = useState(false); 13 | const [mutationResult, setMutationResult] = useState(""); 14 | 15 | const forecastQuery = useErrorHandledQuery({ 16 | ...test.fetchDataQueryConfig(), 17 | enabled: runQuery, 18 | }); 19 | 20 | const graphQuery = useErrorHandledQuery({ 21 | ...test.fetchGraphDataQueryConfig(), 22 | enabled: runGraphQuery, 23 | }); 24 | 25 | const testMutation = useMutation(test.postData, { 26 | onSuccess: () => setMutationResult("success!"), 27 | onError: () => setMutationResult("error!"), 28 | }); 29 | 30 | const authData = useContext(AuthContext); 31 | 32 | return ( 33 | 34 | {!runQuery && ( 35 | <> 36 | 39 | 40 | )} 41 | {JSON.stringify(forecastQuery.data)} 42 |
43 | {!runGraphQuery && ( 44 | <> 45 | 48 | 49 | )} 50 | {JSON.stringify(graphQuery.data)} 51 |
52 | 55 |
56 | {mutationResult} 57 |
58 | 59 | {JSON.stringify(authData)} 60 |
61 | ); 62 | }; 63 | -------------------------------------------------------------------------------- /src/Client/src/getCookie.ts: -------------------------------------------------------------------------------- 1 | export const getCookie = (cookieName: string) => { 2 | const name = `${cookieName}=`; 3 | const decodedCookie = decodeURIComponent(document.cookie); 4 | const ca = decodedCookie.split(";"); 5 | for (let i = 0; i < ca.length; i += 1) { 6 | let c = ca[i]; 7 | while (c.charAt(0) === " ") { 8 | c = c.substring(1); 9 | } 10 | if (c.indexOf(name) === 0) { 11 | return c.substring(name.length, c.length); 12 | } 13 | } 14 | return ""; 15 | }; 16 | -------------------------------------------------------------------------------- /src/Client/src/main.tsx: -------------------------------------------------------------------------------- 1 | // the hashes for all fontsource imports need to be added to the CSP style directive 2 | import "@fontsource/roboto/300.css"; 3 | import "@fontsource/roboto/400.css"; 4 | import "@fontsource/roboto/500.css"; 5 | import "@fontsource/roboto/700.css"; 6 | import React from "react"; 7 | import ReactDOM from "react-dom/client"; 8 | import { CacheProvider } from "@emotion/react"; 9 | import { CssBaseline } from "@mui/material"; 10 | import { RouterProvider } from "react-router-dom"; 11 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 12 | import { SnackbarProvider } from "notistack"; 13 | import createCache from "@emotion/cache"; 14 | import { AuthContextProvider } from "./auth/AuthContextProvider"; 15 | import { router } from "./router"; 16 | import { ServiceContextProvider } from "./ServiceContextProvider"; 17 | 18 | // Get the nonce for emotion/MUI 19 | const nonce = ( 20 | document.querySelector('meta[name="CSP-nonce"]') as unknown as { 21 | content: string; 22 | } 23 | )?.content; 24 | 25 | const cache = createCache({ 26 | key: `mui-emotion-prefix`, 27 | nonce, 28 | }); 29 | 30 | const queryClient = new QueryClient({ 31 | defaultOptions: { 32 | queries: { 33 | refetchOnWindowFocus: false, 34 | networkMode: "always", // needed for service-worker cached queries to fire 35 | refetchOnReconnect: true, 36 | }, 37 | }, 38 | }); 39 | 40 | ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | ); 56 | -------------------------------------------------------------------------------- /src/Client/src/models/AuthData.ts: -------------------------------------------------------------------------------- 1 | export interface AuthData { 2 | isAuthenticated: boolean; 3 | claims: Claim[]; 4 | nameClaimType: string; 5 | roleClaimType: string; 6 | name: string; 7 | roles: string[]; 8 | } 9 | 10 | interface Claim { 11 | type: string; 12 | value: string; 13 | } 14 | -------------------------------------------------------------------------------- /src/Client/src/models/WeatherForecast.ts: -------------------------------------------------------------------------------- 1 | export interface WeatherForecast { 2 | date: Date; 3 | temperatureC: number; 4 | temperatureF: number; 5 | summary?: string; 6 | } 7 | -------------------------------------------------------------------------------- /src/Client/src/router.tsx: -------------------------------------------------------------------------------- 1 | import { createBrowserRouter } from "react-router-dom"; 2 | import { ErrorPage } from "./ErrorPage"; 3 | import { Home } from "./Home"; 4 | import { Layout } from "./Layout"; 5 | import { ApiTest } from "./components/ApiTest"; 6 | import { AuthGuard } from "./auth/AuthGuard"; 7 | import { AdminComponent } from "./components/AdminComponent"; 8 | 9 | export const router = createBrowserRouter([ 10 | { 11 | path: "/", 12 | element: , 13 | errorElement: , 14 | children: [ 15 | { path: "/", element: }, 16 | { 17 | path: "/test", 18 | element: ( 19 | 20 | 21 | 22 | ), 23 | }, 24 | { 25 | path: "/admin", 26 | element: ( 27 | 28 | 29 | 30 | ), 31 | }, 32 | ], 33 | }, 34 | {}, 35 | ]); 36 | -------------------------------------------------------------------------------- /src/Client/src/services/AuthService.ts: -------------------------------------------------------------------------------- 1 | import { AuthData } from "../models/AuthData"; 2 | import { HttpClient } from "./HttpClient"; 3 | 4 | export class AuthService { 5 | public baseQueryKey = ["Auth"]; 6 | 7 | constructor(protected httpClient: HttpClient) {} 8 | 9 | public fetchAuthData = () => this.httpClient.get("Auth/User"); 10 | 11 | public fetchAuthDataQueryConfig = () => ({ 12 | queryFn: this.fetchAuthData, 13 | queryKey: [...this.baseQueryKey, "fetchAuth"], 14 | }); 15 | 16 | public fetchAdminInfo = () => this.httpClient.get("Auth/AdminInfo"); 17 | 18 | public fetchAdminInfoQueryConfig = () => ({ 19 | queryFn: this.fetchAdminInfo, 20 | queryKey: [...this.baseQueryKey, "adminInfo"], 21 | }); 22 | } 23 | -------------------------------------------------------------------------------- /src/Client/src/services/HttpClient.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosInstance } from "axios"; 2 | import { getCookie } from "../getCookie"; 3 | 4 | export class HttpClient { 5 | private readonly axiosInstance: AxiosInstance; 6 | 7 | constructor() { 8 | this.axiosInstance = axios.create(); 9 | this.axiosInstance.interceptors.request.use((config) => { 10 | // eslint-disable-next-line no-param-reassign 11 | config.headers["X-XSRF-TOKEN"] = getCookie("XSRF-RequestToken"); 12 | return config; 13 | }); 14 | } 15 | 16 | async get( 17 | url: string, 18 | params?: TParameters 19 | ): Promise { 20 | return this.doGet(url, params); 21 | } 22 | 23 | private async doGet( 24 | url: string, 25 | params?: TParameters 26 | ): Promise { 27 | const response = await this.axiosInstance.get( 28 | `${HttpClient.getApiUrl()}${url}`, 29 | { 30 | params, 31 | } 32 | ); 33 | return response.data; 34 | } 35 | 36 | async post(url: string, data?: TData): Promise { 37 | const response = await this.axiosInstance.post( 38 | HttpClient.getApiUrl() + url, 39 | data 40 | ); 41 | return response.data; 42 | } 43 | 44 | public static getApiUrl() { 45 | const backendHost = HttpClient.getCurrentHost(); 46 | 47 | return `${backendHost}/api/`; 48 | } 49 | 50 | public static getCurrentHost() { 51 | const host = window.location.host; 52 | const url = `${window.location.protocol}//${host}`; 53 | return url; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Client/src/services/TestService.ts: -------------------------------------------------------------------------------- 1 | import { WeatherForecast } from "../models/WeatherForecast"; 2 | import { HttpClient } from "./HttpClient"; 3 | 4 | export class TestService { 5 | public baseQueryKey = ["testService"]; 6 | 7 | constructor(protected httpClient: HttpClient) {} 8 | 9 | public fetchData = () => 10 | this.httpClient.get("weatherforecast"); 11 | 12 | public fetchDataQueryConfig = () => ({ 13 | queryFn: this.fetchData, 14 | queryKey: this.baseQueryKey, 15 | }); 16 | 17 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 18 | public fetchGraphData = () => this.httpClient.get("graphapicalls"); 19 | 20 | public fetchGraphDataQueryConfig = () => ({ 21 | queryFn: this.fetchGraphData, 22 | queryKey: [...this.baseQueryKey, "graph"], 23 | }); 24 | 25 | public postData = () => this.httpClient.post("weatherforecast", {}); 26 | } 27 | -------------------------------------------------------------------------------- /src/Client/src/services/useErrorHandledQuery.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | QueryKey, 3 | UseQueryOptions, 4 | UseQueryResult, 5 | useQuery, 6 | } from "@tanstack/react-query"; 7 | import { AxiosError } from "axios"; 8 | import { useSnackbar } from "notistack"; 9 | import { useEffect } from "react"; 10 | 11 | export const useErrorHandledQuery = < 12 | TQueryFnData = unknown, 13 | TData = TQueryFnData, 14 | TQueryKey extends QueryKey = QueryKey 15 | >( 16 | options: Omit< 17 | UseQueryOptions, 18 | "initialData" 19 | > & { initialData?: () => undefined }, 20 | customErrorMessage?: string 21 | ): UseQueryResult => { 22 | const { enqueueSnackbar } = useSnackbar(); 23 | const query = useQuery(options); 24 | 25 | const error = query.error; 26 | 27 | useEffect(() => { 28 | if (error) { 29 | enqueueSnackbar(customErrorMessage || error.message, { 30 | variant: "error", 31 | }); 32 | } 33 | }, [customErrorMessage, enqueueSnackbar, error]); 34 | 35 | return query; 36 | }; 37 | -------------------------------------------------------------------------------- /src/Client/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/Client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 5 | "module": "ESNext", 6 | "skipLibCheck": true, 7 | 8 | /* Bundler mode */ 9 | "moduleResolution": "bundler", 10 | "allowImportingTsExtensions": true, 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "noEmit": true, 14 | "jsx": "react-jsx", 15 | 16 | /* Linting */ 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noFallthroughCasesInSwitch": true 21 | }, 22 | "react": ["./node_modules/@types/react"], 23 | "include": ["src"] 24 | } 25 | -------------------------------------------------------------------------------- /src/Client/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react"; 3 | import mkcert from "vite-plugin-mkcert"; 4 | import { VitePWA } from "vite-plugin-pwa"; 5 | 6 | // https://vitejs.dev/config/ 7 | export default defineConfig({ 8 | server: { 9 | https: true, 10 | port: 5173, 11 | strictPort: true, // exit if port is in use 12 | hmr: { 13 | clientPort: 5173, // point vite websocket connection to vite directly, circumventing .net proxy 14 | }, 15 | }, 16 | optimizeDeps: { 17 | force: true, 18 | }, 19 | build: { 20 | outDir: "../Server/wwwroot", 21 | emptyOutDir: true, 22 | rollupOptions: { 23 | output: { 24 | manualChunks: { 25 | "material-ui": ["@mui/material"], 26 | }, 27 | }, 28 | }, 29 | }, 30 | plugins: [ 31 | react(), 32 | mkcert(), 33 | VitePWA({ 34 | registerType: "autoUpdate", 35 | devOptions: { enabled: true }, 36 | srcDir: "service-worker", 37 | filename: "service-worker.js", 38 | strategies: "injectManifest", 39 | manifest: { 40 | icons: [ 41 | { 42 | src: "android-chrome-192x192.png", 43 | sizes: "192x192", 44 | type: "image/png", 45 | }, 46 | { 47 | src: "android-chrome-512x512.png", 48 | sizes: "512x512", 49 | type: "image/png", 50 | }, 51 | { 52 | src: "android-chrome-512x512.png", 53 | sizes: "512x512", 54 | type: "image/png", 55 | purpose: "any maskable", 56 | }, 57 | ], 58 | }, 59 | workbox: { 60 | globPatterns: ["**/*.{js,css,svg,woff,woff2}"], 61 | skipWaiting: true, 62 | clientsClaim: true, 63 | }, 64 | }), 65 | ], 66 | }); 67 | -------------------------------------------------------------------------------- /src/Server/AppRoles.cs: -------------------------------------------------------------------------------- 1 | namespace ReactBffProxy.Server; 2 | 3 | public static class AppRole 4 | { 5 | /// 6 | /// Drink readers can read all drinks available. 7 | /// 8 | public const string DrinkReader = "Drinks.Read"; 9 | 10 | /// 11 | /// Drink writers can add/modify drinks. 12 | /// 13 | public const string DrinkWriters = "Drinks.Write"; 14 | 15 | /// 16 | /// Admin role for demo purposes. 17 | /// 18 | public const string DrinkAdmins = "Admin"; 19 | } 20 | 21 | /// 22 | /// Wrapper class the contain all the authorization policies available in this application. 23 | /// 24 | public static class AuthorizationPolicies 25 | { 26 | public const string AssignmentToDrinkReaderRoleRequired = "AssignmentToDrinkReaderRoleRequired"; 27 | public const string AssignmentToDrinkWriterRoleRequired = "AssignmentToDrinkWriterRoleRequired"; 28 | public const string AssignmentToDrinkAdminRoleRequired = "AssignmentToDrinkAdminRoleRequired"; 29 | } 30 | -------------------------------------------------------------------------------- /src/Server/Controllers/AuthController.cs: -------------------------------------------------------------------------------- 1 | using System.Security.Claims; 2 | using ReactBffProxy.Shared.Authorization; 3 | using Microsoft.AspNetCore.Authentication; 4 | using Microsoft.AspNetCore.Authentication.Cookies; 5 | using Microsoft.AspNetCore.Authentication.OpenIdConnect; 6 | using Microsoft.AspNetCore.Authorization; 7 | using Microsoft.AspNetCore.Mvc; 8 | 9 | namespace ReactBffProxy.Server.Controllers; 10 | 11 | [ValidateAntiForgeryToken] 12 | [Route("api/[controller]")] 13 | public class AuthController : ControllerBase 14 | { 15 | [IgnoreAntiforgeryToken] 16 | [HttpGet("Login")] 17 | public ActionResult Login(string? returnUrl, string? claimsChallenge) 18 | { 19 | var redirectUri = !string.IsNullOrEmpty(returnUrl) ? returnUrl : "/"; 20 | 21 | var properties = new AuthenticationProperties { RedirectUri = redirectUri }; 22 | 23 | if (claimsChallenge != null) 24 | { 25 | var jsonString = claimsChallenge.Replace("\\", "").Trim('"'); 26 | 27 | properties.Items["claims"] = jsonString; 28 | } 29 | 30 | return Challenge(properties); 31 | } 32 | 33 | [Authorize] 34 | [HttpPost("Logout")] 35 | public ActionResult Logout() 36 | { 37 | return SignOut( 38 | new AuthenticationProperties { RedirectUri = "/" }, 39 | CookieAuthenticationDefaults.AuthenticationScheme, 40 | OpenIdConnectDefaults.AuthenticationScheme); 41 | } 42 | 43 | [Authorize(Policy = AuthorizationPolicies.AssignmentToDrinkAdminRoleRequired)] 44 | [HttpGet("AdminInfo")] 45 | public ActionResult GetAdminMock() 46 | { 47 | return Ok("Admin allowed"); 48 | } 49 | 50 | [AllowAnonymous] 51 | [HttpGet("User")] 52 | public ActionResult GetCurrentUser() 53 | { 54 | return Ok(CreateUserInfo(User)); 55 | } 56 | 57 | private static UserInfo CreateUserInfo(ClaimsPrincipal claimsPrincipal) 58 | { 59 | if (!claimsPrincipal?.Identity?.IsAuthenticated ?? true) 60 | { 61 | return UserInfo.Anonymous; 62 | } 63 | 64 | var userInfo = new UserInfo { IsAuthenticated = true }; 65 | 66 | if (claimsPrincipal?.Identity is ClaimsIdentity claimsIdentity) 67 | { 68 | userInfo.NameClaimType = claimsIdentity.NameClaimType; 69 | userInfo.RoleClaimType = claimsIdentity.RoleClaimType; 70 | } 71 | else 72 | { 73 | userInfo.NameClaimType = ClaimTypes.Name; 74 | userInfo.RoleClaimType = ClaimTypes.Role; 75 | } 76 | 77 | if (claimsPrincipal?.Claims?.Any() ?? false) 78 | { 79 | // Add just the name claim 80 | var claims = claimsPrincipal 81 | .FindAll(i => i.Type == userInfo.NameClaimType || i.Type == userInfo.RoleClaimType) 82 | .Select(claim => new ClaimValue(claim.Type, claim.Value)) 83 | .ToList(); 84 | 85 | #pragma warning disable S125 86 | // Uncomment this code if you want to send additional claims to the client. 87 | // var claims = claimsPrincipal.Claims.Select(u => new ClaimValue(u.Type, u.Value)).ToList(); 88 | #pragma warning restore S125 89 | 90 | userInfo.Claims = claims; 91 | } 92 | 93 | return userInfo; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/Server/Controllers/GraphApiCallsController.cs: -------------------------------------------------------------------------------- 1 | using ReactBffProxy.Services; 2 | using Microsoft.AspNetCore.Authentication.Cookies; 3 | using Microsoft.AspNetCore.Authorization; 4 | using Microsoft.AspNetCore.Mvc; 5 | using Microsoft.Identity.Web; 6 | 7 | namespace ReactBffProxy.Server.Controllers; 8 | 9 | [ValidateAntiForgeryToken] 10 | [Authorize(AuthenticationSchemes = CookieAuthenticationDefaults.AuthenticationScheme)] 11 | [AuthorizeForScopes(Scopes = new[] { "User.ReadBasic.All user.read" })] 12 | [ApiController] 13 | [Route("api/[controller]")] 14 | public class GraphApiCallsController : ControllerBase 15 | { 16 | private readonly MsGraphService _graphApiClientService; 17 | 18 | public GraphApiCallsController(MsGraphService graphApiClientService) 19 | { 20 | _graphApiClientService = graphApiClientService; 21 | } 22 | 23 | [HttpGet] 24 | public async Task> Get() 25 | { 26 | var userData = await _graphApiClientService.GetGraphApiUser(); 27 | return new List { $"DisplayName: {userData.DisplayName}", $"GivenName: {userData.GivenName}", $"AboutMe: {userData.AboutMe}" }; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Server/Controllers/WeatherForecastController.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | using Microsoft.AspNetCore.Authorization; 3 | using Microsoft.AspNetCore.Mvc; 4 | 5 | namespace ReactBffProxy.Server.Controllers; 6 | 7 | [ApiController] 8 | [Authorize] 9 | [ValidateAntiForgeryToken] 10 | [Route("api/[controller]")] 11 | public class WeatherForecastController : ControllerBase 12 | { 13 | private static readonly string[] Summaries = new[] 14 | { 15 | "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" 16 | }; 17 | 18 | private readonly ILogger _logger; 19 | 20 | public WeatherForecastController(ILogger logger) 21 | { 22 | _logger = logger; 23 | } 24 | 25 | [HttpGet] 26 | public IEnumerable Get() 27 | { 28 | return Enumerable.Range(1, 5).Select(index => new WeatherForecast 29 | { 30 | Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)), 31 | TemperatureC = Random.Shared.Next(-20, 55), 32 | Summary = Summaries[Random.Shared.Next(Summaries.Length)] 33 | }) 34 | .ToArray(); 35 | } 36 | 37 | [HttpPost] 38 | [Authorize] 39 | public void Post() 40 | { 41 | Debug.WriteLine("Post received!"); 42 | } 43 | } 44 | 45 | public class WeatherForecast 46 | { 47 | public DateOnly Date { get; set; } 48 | 49 | public int TemperatureC { get; set; } 50 | 51 | public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); 52 | 53 | public string? Summary { get; set; } 54 | } 55 | 56 | -------------------------------------------------------------------------------- /src/Server/Extensions/ApplicationBuilderExtensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Net.Http.Headers; 2 | 3 | namespace ReactBffProxy.Server.Extensions; 4 | 5 | public static class ApplicationBuilderExtensions 6 | { 7 | public static IApplicationBuilder UseNoUnauthorizedRedirect(this IApplicationBuilder applicationBuilder, params string[] segments) 8 | { 9 | applicationBuilder.Use(async (httpContext, func) => 10 | { 11 | if (segments.Any(s => httpContext.Request.Path.StartsWithSegments(s))) 12 | { 13 | httpContext.Request.Headers[HeaderNames.XRequestedWith] = "XMLHttpRequest"; 14 | } 15 | 16 | await func(); 17 | }); 18 | 19 | return applicationBuilder; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Server/Extensions/EndpointRouteBuilderExtensions.cs: -------------------------------------------------------------------------------- 1 | namespace ReactBffProxy.Server.Extensions; 2 | 3 | public static class EndpointRouteBuilderExtensions 4 | { 5 | public static IEndpointRouteBuilder MapNotFound(this IEndpointRouteBuilder endpointRouteBuilder, string pattern) 6 | { 7 | endpointRouteBuilder.Map(pattern, context => 8 | { 9 | context.Response.StatusCode = StatusCodes.Status404NotFound; 10 | return Task.CompletedTask; 11 | }); 12 | 13 | return endpointRouteBuilder; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/Server/Extensions/ServiceCollectionExtension.cs: -------------------------------------------------------------------------------- 1 | using ReactBffProxy.Server; 2 | using Microsoft.AspNetCore.Authentication.JwtBearer; 3 | using Microsoft.AspNetCore.Mvc; 4 | using Microsoft.Identity.Web; 5 | using Microsoft.Identity.Web.UI; 6 | using Microsoft.OpenApi.Models; 7 | 8 | // ReSharper disable once CheckNamespace 9 | namespace Microsoft.Extensions.DependencyInjection; 10 | 11 | public static class ServiceCollectionExtension 12 | { 13 | public static IServiceCollection AddInfrastructure(this IServiceCollection services) 14 | { 15 | services.AddOptions(); 16 | services.AddHttpClient(); 17 | services.AddHttpContextAccessor(); 18 | 19 | services.AddControllersWithViews(options => 20 | options.Filters.Add(new AutoValidateAntiforgeryTokenAttribute())); 21 | 22 | services.AddRazorPages() 23 | .AddMicrosoftIdentityUI(); 24 | 25 | services.AddDistributedMemoryCache(); 26 | 27 | return services; 28 | } 29 | 30 | public static IServiceCollection AddSecurity(this IServiceCollection services, IConfiguration configuration) 31 | { 32 | services.AddAntiforgery(options => 33 | { 34 | options.HeaderName = "X-XSRF-TOKEN"; 35 | options.Cookie.Name = "__Host-X-XSRF-TOKEN"; 36 | options.Cookie.SameSite = SameSiteMode.Strict; 37 | options.Cookie.SecurePolicy = CookieSecurePolicy.Always; 38 | }); 39 | 40 | var scopes = configuration.GetValue("DownstreamApi:Scopes"); 41 | var initialScopes = scopes!.Split(' '); 42 | 43 | services.AddMicrosoftIdentityWebAppAuthentication(configuration) 44 | .EnableTokenAcquisitionToCallDownstreamApi(initialScopes) 45 | .AddMicrosoftGraph(defaultScopes: scopes) 46 | .AddInMemoryTokenCaches(); 47 | 48 | services.AddAuthorization(options => 49 | { 50 | options.AddPolicy(AuthorizationPolicies.AssignmentToDrinkReaderRoleRequired, policy => policy.RequireRole(AppRole.DrinkReader)); 51 | options.AddPolicy(AuthorizationPolicies.AssignmentToDrinkWriterRoleRequired, policy => policy.RequireRole(AppRole.DrinkWriters)); 52 | options.AddPolicy(AuthorizationPolicies.AssignmentToDrinkAdminRoleRequired, policy => policy.RequireRole(AppRole.DrinkAdmins)); 53 | }); 54 | 55 | services.AddHsts(options => 56 | { 57 | options.Preload = true; 58 | options.IncludeSubDomains = true; 59 | options.MaxAge = TimeSpan.FromDays(60); 60 | }); 61 | 62 | return services; 63 | } 64 | 65 | public static IServiceCollection AddSwagger(this IServiceCollection services, IConfiguration configuration) 66 | { 67 | services.AddSwaggerGen(options => 68 | { 69 | options.OperationFilter(); 70 | options.SwaggerDoc("v1", 71 | new OpenApiInfo 72 | { 73 | Version = "v1", 74 | Title = "Asp.Net Core + React BFF API", 75 | Description = "Asp.Net Core + React BFF API", 76 | TermsOfService = new Uri(configuration["TermsOfServiceUrl"] ?? string.Empty) 77 | }); 78 | }); 79 | 80 | return services; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/Server/MsGraphService.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Graph; 2 | 3 | namespace ReactBffProxy.Services; 4 | 5 | public class MsGraphService 6 | { 7 | private readonly GraphServiceClient _graphServiceClient; 8 | 9 | public MsGraphService(GraphServiceClient graphServiceClient) 10 | { 11 | _graphServiceClient = graphServiceClient; 12 | } 13 | 14 | public async Task GetGraphApiUser() 15 | { 16 | return await _graphServiceClient 17 | .Me 18 | .Request() 19 | .WithScopes(new[] { "User.ReadBasic.All", "user.read" }) 20 | .GetAsync(); 21 | } 22 | 23 | public async Task GetGraphApiProfilePhoto() 24 | { 25 | try 26 | { 27 | var photo = string.Empty; 28 | // Get user photo 29 | using (var photoStream = await _graphServiceClient 30 | .Me.Photo.Content.Request() 31 | .WithScopes(new[] { "User.ReadBasic.All", "user.read" }).GetAsync()) 32 | { 33 | var photoByte = ((MemoryStream)photoStream).ToArray(); 34 | photo = Convert.ToBase64String(photoByte); 35 | } 36 | 37 | return photo; 38 | } 39 | catch 40 | { 41 | return string.Empty; 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Server/Pages/Error.cshtml: -------------------------------------------------------------------------------- 1 | @page 2 | @model ReactBffProxy.Server.Pages.ErrorModel 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Error 11 | 12 | 13 | 14 | 15 | 16 |
17 |
18 |

Error.

19 |

An error occurred while processing your request.

20 | 21 | @if (Model.ShowRequestId) 22 | { 23 |

24 | Request ID: @Model.RequestId 25 |

26 | } 27 | 28 |

Development Mode

29 |

30 | Swapping to the Development environment displays detailed information about the error that occurred. 31 |

32 |

33 | The Development environment shouldn't be enabled for deployed applications. 34 | It can result in displaying sensitive information from exceptions to end users. 35 | For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development 36 | and restarting the app. 37 |

38 |
39 |
40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /src/Server/Pages/Error.cshtml.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | using Microsoft.AspNetCore.Mvc; 3 | using Microsoft.AspNetCore.Mvc.RazorPages; 4 | 5 | namespace ReactBffProxy.Server.Pages; 6 | 7 | [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)] 8 | [IgnoreAntiforgeryToken] 9 | public class ErrorModel : PageModel 10 | { 11 | public string? RequestId { get; set; } 12 | 13 | public bool ShowRequestId => !string.IsNullOrEmpty(RequestId); 14 | 15 | public void OnGet() 16 | { 17 | RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Server/Pages/_Host.cshtml: -------------------------------------------------------------------------------- 1 | @page "/" 2 | @namespace ReactBffProxy.Server.Pages 3 | @using System.Net; 4 | @using NetEscapades.AspNetCore.SecurityHeaders; 5 | @inject IHostEnvironment hostEnvironment 6 | @inject IConfiguration config 7 | @inject Microsoft.AspNetCore.Antiforgery.IAntiforgery antiForgery 8 | @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers 9 | @{ 10 | Layout = null; 11 | 12 | var source = ""; 13 | if (hostEnvironment.IsDevelopment()) 14 | { 15 | var httpClient = new HttpClient(); 16 | source = await httpClient.GetStringAsync($"{config["SpaDevServerUrl"]}/index.html"); 17 | } 18 | else 19 | { 20 | source = System.IO.File.ReadAllText($"{System.IO.Directory.GetCurrentDirectory()}{@"\wwwroot\index.html"}"); 21 | } 22 | 23 | var nonce = HttpContext.GetNonce(); 24 | 25 | // The nonce is passed to the client through the HTML to avoid sync issues between tabs 26 | source = source.Replace("**REPLACE_THIS_VALUE_WITH_SAFE_NONCE**", nonce); 27 | 28 | var xsrf = antiForgery.GetAndStoreTokens(HttpContext); 29 | var requestToken = xsrf.RequestToken; 30 | 31 | // The XSRF-Tokens are passed to the client through cookies, since we always want the most up-to-date cookies across all tabs 32 | Response.Cookies.Append("XSRF-RequestToken", requestToken ?? "", new CookieOptions() { HttpOnly = false, IsEssential = true, Secure = true, SameSite = SameSiteMode.Strict }); 33 | } 34 | 35 | @Html.Raw(source) 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /src/Server/Program.cs: -------------------------------------------------------------------------------- 1 | using AspNetCore.Proxy; 2 | using Azure.Identity; 3 | using NetEscapades.AspNetCore.SecurityHeaders.Infrastructure; 4 | using ReactBffProxy.Server; 5 | using ReactBffProxy.Server.Extensions; 6 | using ReactBffProxy.Services; 7 | using Serilog; 8 | 9 | #pragma warning disable CA1305 10 | #pragma warning disable CA1852 // Seal internal types 11 | Log.Logger = new LoggerConfiguration() 12 | .WriteTo.Console() 13 | .WriteTo.AzureApp() 14 | .CreateBootstrapLogger(); 15 | #pragma warning restore CA1852 // Seal internal types 16 | #pragma warning restore CA1305 17 | 18 | try 19 | { 20 | Log.Information("Starting web host"); 21 | 22 | var builder = WebApplication.CreateBuilder(args); 23 | 24 | builder.WebHost 25 | .ConfigureKestrel(serverOptions => { serverOptions.AddServerHeader = false; }) 26 | .ConfigureAppConfiguration((_, configurationBuilder) => 27 | { 28 | var config = configurationBuilder.Build(); 29 | var azureKeyVaultEndpoint = config["AzureKeyVaultEndpoint"]; 30 | if (!string.IsNullOrEmpty(azureKeyVaultEndpoint)) 31 | { 32 | // Add Secrets from KeyVault 33 | Log.Information("Use secrets from {AzureKeyVaultEndpoint}", azureKeyVaultEndpoint); 34 | configurationBuilder.AddAzureKeyVault(new Uri(azureKeyVaultEndpoint), new DefaultAzureCredential(new DefaultAzureCredentialOptions 35 | { 36 | VisualStudioTenantId = config["AzureAd:TenantId"], 37 | VisualStudioCodeTenantId = config["AzureAd:TenantId"], 38 | SharedTokenCacheTenantId = config["AzureAd:TenantId"], 39 | InteractiveBrowserTenantId = config["AzureAd:TenantId"], 40 | ExcludeEnvironmentCredential = true, 41 | ExcludeManagedIdentityCredential = true, 42 | })); 43 | } 44 | else 45 | { 46 | // Add Secrets from UserSecrets for local development 47 | configurationBuilder.AddUserSecrets("8D552AC2-3F5E-437D-A29C-BE6101ED94EB"); 48 | } 49 | }); 50 | 51 | builder.Host.UseSerilog((context, loggerConfiguration) => loggerConfiguration.ReadFrom.Configuration(context.Configuration)); 52 | 53 | var services = builder.Services; 54 | var configuration = builder.Configuration; 55 | var env = builder.Environment; 56 | 57 | 58 | builder.Services.AddInfrastructure() 59 | .AddSecurity(configuration) 60 | .AddSwagger(configuration) 61 | .AddScoped() 62 | .AddTransient(); 63 | 64 | if (env.IsDevelopment()) 65 | { 66 | services.AddProxies(); 67 | } 68 | 69 | var app = builder.Build(); 70 | 71 | 72 | app.UseSecurityHeaders( 73 | SecurityHeadersDefinitions.GetHeaderPolicyCollection(env.IsDevelopment(), 74 | configuration["AzureAd:Instance"])); 75 | 76 | if (env.IsDevelopment()) 77 | { 78 | app.UseDeveloperExceptionPage(); 79 | 80 | app.UseSwagger(); 81 | app.UseSwaggerUI(options => 82 | { 83 | options.SwaggerEndpoint("/swagger/v1/swagger.json", "v1"); 84 | options.DisplayRequestDuration(); 85 | }); 86 | } 87 | else 88 | { 89 | app.UseExceptionHandler("/Error"); 90 | app.UseHsts(); 91 | } 92 | 93 | app.UseHttpsRedirection(); 94 | app.UseStaticFiles(); 95 | 96 | app.UseRouting(); 97 | 98 | app.UseNoUnauthorizedRedirect("/api"); 99 | 100 | app.UseAuthentication(); 101 | app.UseAuthorization(); 102 | 103 | app.MapRazorPages(); 104 | app.MapControllers(); 105 | 106 | // SPA-specific routing 107 | 108 | app.MapNotFound("/api/{**segment}"); 109 | 110 | if (env.IsDevelopment()) 111 | { 112 | var spaDevServer = app.Configuration.GetValue("SpaDevServerUrl"); 113 | if (!string.IsNullOrEmpty(spaDevServer)) 114 | { 115 | // proxy any requests that we think should go to the vite dev server 116 | app.MapWhen( 117 | context => 118 | { 119 | var path = context.Request.Path.ToString(); 120 | var isFileRequest = path.StartsWith("/@", StringComparison.InvariantCulture) // some libs 121 | || path.StartsWith("/src", StringComparison.InvariantCulture) // source files 122 | || path.StartsWith("/node_modules", StringComparison.InvariantCulture); // other libs 123 | 124 | return isFileRequest; 125 | }, app => app.Run(context => 126 | { 127 | var targetPath = $"{spaDevServer}{context.Request.Path}{context.Request.QueryString}"; 128 | return context.HttpProxyAsync(targetPath); 129 | })); 130 | 131 | } 132 | } 133 | 134 | // handle urls that we think belong to the SPA routing 135 | app.MapFallbackToPage("/_Host"); 136 | 137 | app.Run(); 138 | } 139 | catch (Exception ex) 140 | { 141 | Log.Fatal(ex, "Host terminated unexpectedly"); 142 | throw; 143 | } 144 | finally 145 | { 146 | Log.CloseAndFlush(); 147 | } 148 | -------------------------------------------------------------------------------- /src/Server/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "iisSettings": { 3 | "windowsAuthentication": false, 4 | "anonymousAuthentication": true, 5 | "iisExpress": { 6 | "applicationUrl": "https://localhost:8080/", 7 | "sslPort": 44300 8 | } 9 | }, 10 | "profiles": { 11 | "IIS Express": { 12 | "commandName": "IISExpress", 13 | "launchBrowser": true, 14 | "environmentVariables": { 15 | "ASPNETCORE_ENVIRONMENT": "Development" 16 | } 17 | }, 18 | "IsolutionsReactTemplate.Server": { 19 | "commandName": "Project", 20 | "launchBrowser": false, 21 | "applicationUrl": "https://localhost:5001", 22 | "environmentVariables": { 23 | "ASPNETCORE_ENVIRONMENT": "Development" 24 | } 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Server/ReactBffProxy.Server.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net7.0 5 | enable 6 | enable 7 | 8D552AC2-3F5E-437D-A29C-BE6101ED94EB 8 | Recommended 9 | true 10 | Latest 11 | false 12 | .\ClientApp\ 13 | $(DefaultItemExcludes);$(SpaRoot)node_modules\** 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | all 22 | runtime; build; native; contentfiles; analyzers; buildtransitive 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /src/Server/SecurityHeadersDefinitions.cs: -------------------------------------------------------------------------------- 1 | namespace ReactBffProxy.Server; 2 | 3 | public static class SecurityHeadersDefinitions 4 | { 5 | public static HeaderPolicyCollection GetHeaderPolicyCollection(bool isDev, string? idpHost, bool relaxCspForSwagger = false) 6 | { 7 | if (idpHost == null) 8 | { 9 | throw new ArgumentNullException(nameof(idpHost)); 10 | } 11 | 12 | var policy = new HeaderPolicyCollection() 13 | .AddFrameOptionsDeny() 14 | .AddXssProtectionBlock() 15 | .AddContentTypeOptionsNoSniff() 16 | .AddReferrerPolicyStrictOriginWhenCrossOrigin() 17 | .AddCrossOriginOpenerPolicy(builder => builder.SameOrigin()) 18 | .AddCrossOriginResourcePolicy(builder => builder.SameOrigin()) 19 | .AddCrossOriginEmbedderPolicy(builder => builder.RequireCorp()) 20 | .AddContentSecurityPolicy(builder => 21 | { 22 | builder.AddObjectSrc().None(); 23 | builder.AddBlockAllMixedContent(); 24 | builder.AddImgSrc().Self().From("data:"); 25 | builder.AddFormAction().Self().From(idpHost); 26 | builder.AddFontSrc().Self().From("data:"); 27 | 28 | if (relaxCspForSwagger) 29 | { 30 | builder.AddStyleSrc().Self().UnsafeInline(); 31 | builder.AddScriptSrc().Self().UnsafeInline(); 32 | } 33 | else 34 | { 35 | builder.AddStyleSrc() 36 | .Self() 37 | .WithNonce() 38 | // fontSource Roboto font hashes 39 | .WithHash256("J+3YsBcGEYMOe4DwBHZqef/THcqobccvabWv1wEouI4=") 40 | .WithHash256("V4IwtdGfOsoBVQHnBa1KtH7U1F/8DOybajAQtg/hH8g=") 41 | .WithHash256("iipbIaWgMvkHAGXdddZx06mBEt4gjdNhScyxvn+CddY=") 42 | .WithHash256("H0tkQ73WFMgIdWr8nm0WpqKPpQ1geGIwKH7hfM+LUXo=") 43 | // browserLink hashes 44 | .WithHash256("47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=") 45 | .WithHash256("tVFibyLEbUGj+pO/ZSi96c01jJCvzWilvI5Th+wLeGE=") 46 | // Swagger styles 47 | .WithHash256("wkAU1AW/h8YFx0XlzvpTllAKnFEO2tw8aKErs5a26LY="); 48 | 49 | 50 | builder.AddScriptSrc() 51 | .Self() 52 | // due to React 53 | .WithHash256("8ZgGo/nOlaDknQkDUYiedLuFRSGJwIz6LAzsOrNxhmU=") // TODO: only in development 54 | .WithHash256("/AO8vAagk08SqUGxY96ci/dGyTDsuoetPOJYMn7sc+E="); // VitePWA 55 | } 56 | 57 | 58 | 59 | builder.AddBaseUri().Self(); 60 | builder.AddFrameAncestors().None(); 61 | 62 | 63 | 64 | }) 65 | .RemoveServerHeader() 66 | .AddPermissionsPolicy(builder => 67 | { 68 | builder.AddAccelerometer().None(); 69 | builder.AddAutoplay().None(); 70 | builder.AddCamera().None(); 71 | builder.AddEncryptedMedia().None(); 72 | builder.AddFullscreen().All(); 73 | builder.AddGeolocation().None(); 74 | builder.AddGyroscope().None(); 75 | builder.AddMagnetometer().None(); 76 | builder.AddMicrophone().None(); 77 | builder.AddMidi().None(); 78 | builder.AddPayment().None(); 79 | builder.AddPictureInPicture().None(); 80 | builder.AddSyncXHR().None(); 81 | builder.AddUsb().None(); 82 | }); 83 | 84 | if (!isDev) 85 | { 86 | // maxage = one year in seconds 87 | policy.AddStrictTransportSecurityMaxAgeIncludeSubDomains(60 * 60 * 24 * 365); 88 | } 89 | 90 | return policy; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/Server/SwaggerCspRelaxingHeaderService.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | using NetEscapades.AspNetCore.SecurityHeaders.Infrastructure; 3 | 4 | namespace ReactBffProxy.Server; 5 | 6 | public class SwaggerCspRelaxingHeaderService : CustomHeaderService 7 | { 8 | private readonly IWebHostEnvironment _hostEnvironment; 9 | private readonly IConfiguration _configuration; 10 | 11 | public SwaggerCspRelaxingHeaderService(IWebHostEnvironment hostEnvironment, IConfiguration configuration) 12 | { 13 | _hostEnvironment = hostEnvironment; 14 | _configuration = configuration; 15 | } 16 | 17 | public override void ApplyResult(HttpResponse response, CustomHeadersResult result) 18 | { 19 | base.ApplyResult(response, result); 20 | } 21 | 22 | public override CustomHeadersResult EvaluatePolicy(HttpContext context, HeaderPolicyCollection policies) 23 | { 24 | var policiesToUse = policies; 25 | if (context.Request.Path.Value?.StartsWith("/swagger",StringComparison.InvariantCulture) ?? false) 26 | { 27 | policiesToUse = SecurityHeadersDefinitions.GetHeaderPolicyCollection(_hostEnvironment.IsDevelopment(), _configuration.GetValue("AzureAd:Instance"), true); 28 | } 29 | 30 | return base.EvaluatePolicy(context,policiesToUse); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Server/SwaggerHeaderFilter.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.OpenApi.Any; 2 | using Microsoft.OpenApi.Models; 3 | using Swashbuckle.AspNetCore.SwaggerGen; 4 | 5 | namespace ReactBffProxy.Server; 6 | 7 | public class SwaggerHeaderFilter : IOperationFilter 8 | { 9 | private readonly IHttpContextAccessor _httpContextAccessor; 10 | 11 | public SwaggerHeaderFilter(IHttpContextAccessor httpContextAccessor) 12 | { 13 | _httpContextAccessor = httpContextAccessor; 14 | } 15 | 16 | public void Apply(OpenApiOperation operation, OperationFilterContext context) 17 | { 18 | var cookieValue = _httpContextAccessor.HttpContext?.Request.Cookies["XSRF-RequestToken"]; 19 | 20 | if (operation.Parameters == null) 21 | operation.Parameters = new List(); 22 | 23 | operation.Parameters.Add(new OpenApiParameter 24 | { 25 | Name = "X-XSRF-TOKEN", 26 | In = ParameterLocation.Header, 27 | Description = "CSRF request token", 28 | Required = false, 29 | Schema = new OpenApiSchema { Type = "string", Default = new OpenApiString(cookieValue) } 30 | }); 31 | } 32 | } 33 | 34 | -------------------------------------------------------------------------------- /src/Server/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "ConnectionStrings": { 3 | "Database": "Server=.;Database=IsolutionsReactTemplate;Trusted_Connection=True;Encrypt=False" 4 | }, 5 | "AzureKeyVaultEndpoint": null, 6 | "Serilog": { 7 | "Using": [ "Serilog.Sinks.Console" ], 8 | "MinimumLevel": { 9 | "Default": "Information", 10 | "Override": { 11 | "Microsoft": "Information", 12 | "Microsoft.EntityFrameworkCore": "Warning", 13 | "System": "Information" 14 | } 15 | }, 16 | "Enrich": [ "FromLogContext", "WithMachineName", "WithThreadId" ], 17 | "WriteTo": [ 18 | { 19 | "Name": "Console", 20 | "Args": { 21 | "theme": "Serilog.Sinks.SystemConsole.Themes.AnsiConsoleTheme::Code, Serilog.Sinks.Console", 22 | "outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} ({SourceContext}){NewLine}{Exception}" 23 | } 24 | } 25 | ] 26 | }, 27 | "SpaDevServerUrl": "https://localhost:5173" 28 | } 29 | -------------------------------------------------------------------------------- /src/Server/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "ApplicationInsights": { 3 | "ConnectionString": "[Enter ApplicationInsights Connection String]" 4 | }, 5 | "AzureKeyVaultEndpoint": "[Enter KeyVault Endpoint e.g. https://[keyvaultname].vault.azure.net/]", 6 | "AzureAd": { 7 | "Instance": "https://login.microsoftonline.com/", 8 | "Domain": "[Enter the domain of your tenant, e.g. contoso.onmicrosoft.com]", 9 | "TenantId": "[Enter 'common', or 'organizations' or the Tenant Id (Obtained from the Azure portal. Select 'Endpoints' from the 'App registrations' blade and use the GUID in any of the URLs), e.g. da41245a5-11b3-996c-00a8-4d99re19f292]", 10 | "ClientId": "[Enter the Client Id (Application ID obtained from the Azure portal), e.g. ba74781c2-53c2-442a-97c2-3d60re42f403]", 11 | "ClientSecret": "[Copy the client secret added to the app from the Azure portal]", 12 | "ClientCertificates": [ 13 | ], 14 | // the following is required to handle Continuous Access Evaluation challenges 15 | "ClientCapabilities": [ "cp1" ], 16 | "CallbackPath": "/signin-oidc" 17 | }, 18 | "DownstreamApi": { 19 | "Scopes": "User.ReadBasic.All user.read" 20 | }, 21 | "TermsOfServiceUrl": "https://www.isolutions.ch/de/impressum/", 22 | "Serilog": { 23 | "Using": [ "Serilog.Sinks.AzureApp", "Serilog.Sinks.ApplicationInsights" ], 24 | "MinimumLevel": { 25 | "Default": "Information", 26 | "Override": { 27 | "Microsoft": "Warning", 28 | "System": "Warning" 29 | } 30 | }, 31 | "Enrich": [ "FromLogContext", "WithMachineName", "WithThreadId" ], 32 | "WriteTo": [ 33 | { 34 | "Name": "AzureApp" 35 | }, 36 | { 37 | "Name": "ApplicationInsights", 38 | "Args": { 39 | "telemetryConverter": "Serilog.Sinks.ApplicationInsights.TelemetryConverters.TraceTelemetryConverter, Serilog.Sinks.ApplicationInsights" 40 | } 41 | } 42 | ] 43 | }, 44 | "AllowedHosts": "*" 45 | } 46 | -------------------------------------------------------------------------------- /src/Shared/Authorization/ClaimValue.cs: -------------------------------------------------------------------------------- 1 | namespace ReactBffProxy.Shared.Authorization; 2 | 3 | public class ClaimValue 4 | { 5 | public ClaimValue() 6 | { 7 | } 8 | 9 | public ClaimValue(string type, string value) 10 | { 11 | Type = type; 12 | Value = value; 13 | } 14 | 15 | public string Type { get; set; } = string.Empty; 16 | 17 | public string Value { get; set; } = string.Empty; 18 | } 19 | -------------------------------------------------------------------------------- /src/Shared/Authorization/UserInfo.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace ReactBffProxy.Shared.Authorization; 4 | 5 | public class UserInfo 6 | { 7 | public static readonly UserInfo Anonymous = new(); 8 | 9 | public bool IsAuthenticated { get; set; } 10 | 11 | public string NameClaimType { get; set; } = string.Empty; 12 | 13 | public string RoleClaimType { get; set; } = string.Empty; 14 | 15 | public ICollection Claims { get; set; } = new List(); 16 | } 17 | -------------------------------------------------------------------------------- /src/Shared/DrinkIngredientModel.cs: -------------------------------------------------------------------------------- 1 | namespace ReactBffProxy.Shared; 2 | 3 | public class DrinkIngredientModel 4 | { 5 | public short Step { get; set; } 6 | public string Ingredient { get; set; } = string.Empty; 7 | public string Measure { get; set; } = string.Empty; 8 | } 9 | -------------------------------------------------------------------------------- /src/Shared/DrinkModel.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace ReactBffProxy.Shared; 4 | 5 | public class DrinkModel 6 | { 7 | public int? DrinkId { get; set; } 8 | public string Name { get; set; } = string.Empty; 9 | 10 | public string? Instructions { get; set; } 11 | public string? ImageUrl { get; set; } 12 | public string? ThumbUrl { get; set; } 13 | public string Category { get; set; } = string.Empty; 14 | public string Type { get; set; } = string.Empty; 15 | public string? Glass { get; set; } 16 | 17 | public IList? Ingredients { get; set; } 18 | } 19 | -------------------------------------------------------------------------------- /src/Shared/ReactBffProxy.Shared.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net7.0 5 | enable 6 | Recommended 7 | 8 | 9 | 10 | 11 | all 12 | runtime; build; native; contentfiles; analyzers; buildtransitive 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | --------------------------------------------------------------------------------