This commit is contained in:
esozbek 2021-10-21 08:41:00 +03:00
commit 93bfbb290e
37 changed files with 2702 additions and 216 deletions

275
.editorconfig Normal file
View file

@ -0,0 +1,275 @@
# EditorConfig is awesome: https://EditorConfig.org
# top-most EditorConfig file
root = true
# Don't use tabs for indentation.
[*]
indent_style = space
# (Please don't specify an indent_size here; that has too many unintended consequences.)
# Code files
[*.{cs,csx,vb,vbx}]
indent_size = 4
insert_final_newline = true
charset = utf-8-bom
# XML project files
[*.{csproj,vbproj,vcxproj,vcxproj.filters,proj,projitems,shproj}]
indent_size = 2
# XML config files
[*.{props,targets,ruleset,config,nuspec,resx,vsixmanifest,vsct}]
indent_size = 2
# JSON files
[*.json]
indent_size = 2
# Powershell files
[*.ps1]
indent_size = 2
# Shell script files
[*.sh]
end_of_line = lf
indent_size = 2
# Dotnet code style settings:
[*.{cs,vb}]
# IDE0055: Fix formatting
dotnet_diagnostic.IDE0055.severity = warning
# Sort using and Import directives with System.* appearing first
dotnet_sort_system_directives_first = true
dotnet_separate_import_directive_groups = false
# Avoid "this." and "Me." if not necessary
dotnet_style_qualification_for_field = false:refactoring
dotnet_style_qualification_for_property = false:refactoring
dotnet_style_qualification_for_method = false:refactoring
dotnet_style_qualification_for_event = false:refactoring
# Use language keywords instead of framework type names for type references
dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion
dotnet_style_predefined_type_for_member_access = true:suggestion
# Suggest more modern language features when available
dotnet_style_object_initializer = true:suggestion
dotnet_style_collection_initializer = true:suggestion
dotnet_style_coalesce_expression = true:suggestion
dotnet_style_null_propagation = true:suggestion
dotnet_style_explicit_tuple_names = true:suggestion
# Non-private static fields are PascalCase
dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.severity = suggestion
dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.symbols = non_private_static_fields
dotnet_naming_rule.non_private_static_fields_should_be_pascal_case.style = non_private_static_field_style
dotnet_naming_symbols.non_private_static_fields.applicable_kinds = field
dotnet_naming_symbols.non_private_static_fields.applicable_accessibilities = public, protected, internal, protected_internal, private_protected
dotnet_naming_symbols.non_private_static_fields.required_modifiers = static
dotnet_naming_style.non_private_static_field_style.capitalization = pascal_case
# Non-private readonly fields are PascalCase
dotnet_naming_rule.non_private_readonly_fields_should_be_pascal_case.severity = suggestion
dotnet_naming_rule.non_private_readonly_fields_should_be_pascal_case.symbols = non_private_readonly_fields
dotnet_naming_rule.non_private_readonly_fields_should_be_pascal_case.style = non_private_readonly_field_style
dotnet_naming_symbols.non_private_readonly_fields.applicable_kinds = field
dotnet_naming_symbols.non_private_readonly_fields.applicable_accessibilities = public, protected, internal, protected_internal, private_protected
dotnet_naming_symbols.non_private_readonly_fields.required_modifiers = readonly
dotnet_naming_style.non_private_readonly_field_style.capitalization = pascal_case
# Constants are PascalCase
dotnet_naming_rule.constants_should_be_pascal_case.severity = suggestion
dotnet_naming_rule.constants_should_be_pascal_case.symbols = constants
dotnet_naming_rule.constants_should_be_pascal_case.style = constant_style
dotnet_naming_symbols.constants.applicable_kinds = field, local
dotnet_naming_symbols.constants.required_modifiers = const
dotnet_naming_style.constant_style.capitalization = pascal_case
# Static fields are camelCase and start with s_
dotnet_naming_rule.static_fields_should_be_camel_case.severity = suggestion
dotnet_naming_rule.static_fields_should_be_camel_case.symbols = static_fields
dotnet_naming_rule.static_fields_should_be_camel_case.style = static_field_style
dotnet_naming_symbols.static_fields.applicable_kinds = field
dotnet_naming_symbols.static_fields.required_modifiers = static
dotnet_naming_style.static_field_style.capitalization = camel_case
dotnet_naming_style.static_field_style.required_prefix = s_
# Instance fields are camelCase and start with _
dotnet_naming_rule.instance_fields_should_be_camel_case.severity = suggestion
dotnet_naming_rule.instance_fields_should_be_camel_case.symbols = instance_fields
dotnet_naming_rule.instance_fields_should_be_camel_case.style = instance_field_style
dotnet_naming_symbols.instance_fields.applicable_kinds = field
dotnet_naming_style.instance_field_style.capitalization = camel_case
dotnet_naming_style.instance_field_style.required_prefix = _
# Locals and parameters are camelCase
dotnet_naming_rule.locals_should_be_camel_case.severity = suggestion
dotnet_naming_rule.locals_should_be_camel_case.symbols = locals_and_parameters
dotnet_naming_rule.locals_should_be_camel_case.style = camel_case_style
dotnet_naming_symbols.locals_and_parameters.applicable_kinds = parameter, local
dotnet_naming_style.camel_case_style.capitalization = camel_case
# Local functions are PascalCase
dotnet_naming_rule.local_functions_should_be_pascal_case.severity = suggestion
dotnet_naming_rule.local_functions_should_be_pascal_case.symbols = local_functions
dotnet_naming_rule.local_functions_should_be_pascal_case.style = local_function_style
dotnet_naming_symbols.local_functions.applicable_kinds = local_function
dotnet_naming_style.local_function_style.capitalization = pascal_case
# By default, name items with PascalCase
dotnet_naming_rule.members_should_be_pascal_case.severity = suggestion
dotnet_naming_rule.members_should_be_pascal_case.symbols = all_members
dotnet_naming_rule.members_should_be_pascal_case.style = pascal_case_style
dotnet_naming_symbols.all_members.applicable_kinds = *
dotnet_naming_style.pascal_case_style.capitalization = pascal_case
# error RS2008: Enable analyzer release tracking for the analyzer project containing rule '{0}'
dotnet_diagnostic.RS2008.severity = none
# IDE0035: Remove unreachable code
dotnet_diagnostic.IDE0035.severity = warning
# IDE0036: Order modifiers
dotnet_diagnostic.IDE0036.severity = warning
# IDE0043: Format string contains invalid placeholder
dotnet_diagnostic.IDE0043.severity = warning
# IDE0044: Make field readonly
dotnet_diagnostic.IDE0044.severity = warning
# RS0016: Only enable if API files are present
dotnet_public_api_analyzer.require_api_files = true
# CSharp code style settings:
[*.cs]
# Newline settings
csharp_new_line_before_open_brace = all
csharp_new_line_before_else = true
csharp_new_line_before_catch = true
csharp_new_line_before_finally = true
csharp_new_line_before_members_in_object_initializers = true
csharp_new_line_before_members_in_anonymous_types = true
csharp_new_line_between_query_expression_clauses = true
# Indentation preferences
csharp_indent_block_contents = true
csharp_indent_braces = false
csharp_indent_case_contents = true
csharp_indent_case_contents_when_block = true
csharp_indent_switch_labels = true
csharp_indent_labels = flush_left
# Prefer "var" everywhere
csharp_style_var_for_built_in_types = true:suggestion
csharp_style_var_when_type_is_apparent = true:suggestion
csharp_style_var_elsewhere = true:suggestion
# Prefer method-like constructs to have a block body
csharp_style_expression_bodied_methods = false:none
csharp_style_expression_bodied_constructors = false:none
csharp_style_expression_bodied_operators = false:none
# Prefer property-like constructs to have an expression-body
csharp_style_expression_bodied_properties = true:none
csharp_style_expression_bodied_indexers = true:none
csharp_style_expression_bodied_accessors = true:none
# Suggest more modern language features when available
csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion
csharp_style_pattern_matching_over_as_with_null_check = true:suggestion
csharp_style_inlined_variable_declaration = true:suggestion
csharp_style_throw_expression = true:suggestion
csharp_style_conditional_delegate_call = true:suggestion
# Space preferences
csharp_space_after_cast = false
csharp_space_after_colon_in_inheritance_clause = true
csharp_space_after_comma = true
csharp_space_after_dot = false
csharp_space_after_keywords_in_control_flow_statements = true
csharp_space_after_semicolon_in_for_statement = true
csharp_space_around_binary_operators = before_and_after
csharp_space_around_declaration_statements = do_not_ignore
csharp_space_before_colon_in_inheritance_clause = true
csharp_space_before_comma = false
csharp_space_before_dot = false
csharp_space_before_open_square_brackets = false
csharp_space_before_semicolon_in_for_statement = false
csharp_space_between_empty_square_brackets = false
csharp_space_between_method_call_empty_parameter_list_parentheses = false
csharp_space_between_method_call_name_and_opening_parenthesis = false
csharp_space_between_method_call_parameter_list_parentheses = false
csharp_space_between_method_declaration_empty_parameter_list_parentheses = false
csharp_space_between_method_declaration_name_and_open_parenthesis = false
csharp_space_between_method_declaration_parameter_list_parentheses = false
csharp_space_between_parentheses = false
csharp_space_between_square_brackets = false
# Blocks are allowed
csharp_prefer_braces = true:silent
csharp_preserve_single_line_blocks = true
csharp_preserve_single_line_statements = true
[src/CodeStyle/**.{cs,vb}]
# warning RS0005: Do not use generic CodeAction.Create to create CodeAction
dotnet_diagnostic.RS0005.severity = none
[src/{Analyzers,CodeStyle,Features,Workspaces,EditorFeatures,VisualStudio}/**/*.{cs,vb}]
# IDE0011: Add braces
csharp_prefer_braces = when_multiline:warning
# NOTE: We need the below severity entry for Add Braces due to https://github.com/dotnet/roslyn/issues/44201
dotnet_diagnostic.IDE0011.severity = warning
# IDE0040: Add accessibility modifiers
dotnet_diagnostic.IDE0040.severity = warning
# CONSIDER: Are IDE0051 and IDE0052 too noisy to be warnings for IDE editing scenarios? Should they be made build-only warnings?
# IDE0051: Remove unused private member
dotnet_diagnostic.IDE0051.severity = warning
# IDE0052: Remove unread private member
dotnet_diagnostic.IDE0052.severity = warning
# IDE0059: Unnecessary assignment to a value
dotnet_diagnostic.IDE0059.severity = warning
# IDE0060: Remove unused parameter
dotnet_diagnostic.IDE0060.severity = warning
# CA1012: Abstract types should not have public constructors
dotnet_diagnostic.CA1012.severity = warning
# CA1822: Make member static
dotnet_diagnostic.CA1822.severity = warning
# Prefer "var" everywhere
dotnet_diagnostic.IDE0007.severity = warning
csharp_style_var_for_built_in_types = true:warning
csharp_style_var_when_type_is_apparent = true:warning
csharp_style_var_elsewhere = true:warning
[src/{VisualStudio}/**/*.{cs,vb}]
# CA1822: Make member static
# Not enforced as a build 'warning' for 'VisualStudio' layer due to large number of false positives from https://github.com/dotnet/roslyn-analyzers/issues/3857 and https://github.com/dotnet/roslyn-analyzers/issues/3858
# Additionally, there is a risk of accidentally breaking an internal API that partners rely on though IVT.
dotnet_diagnostic.CA1822.severity = suggestion

1
.gitattributes vendored
View file

@ -2,6 +2,7 @@
# Set default behavior to automatically normalize line endings.
###############################################################################
* text=auto
*.sh text eol=lf
###############################################################################
# Set default behavior for command prompt diff.

53
.github/workflows/docker-image.yml vendored Normal file
View file

@ -0,0 +1,53 @@
name: Docker Image CI
on:
push:
branches:
- master
tags:
- '*'
pull_request:
branches:
- master
jobs:
build:
runs-on: ubuntu-latest
steps:
-
name: Checkout
uses: actions/checkout@v2
-
name: Login to GitHub Container Registry
uses: docker/login-action@v1
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
-
name: Docker meta
id: meta
uses: docker/metadata-action@v3
with:
images: |
ghcr.io/neothor/steam-openid-connect-provider
tags: |
type=schedule
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=sha
-
name: Build and push
uses: docker/build-push-action@v2
with:
context: src/
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

6
.gitignore vendored
View file

@ -1,3 +1,7 @@
## For secrets in through docker env variables
docker-compose.secrets.yml
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
@ -330,3 +334,5 @@ ASALocalRun/
# MFractors (Xamarin productivity tool) working folder
.mfractor/
dist/

View file

@ -1,17 +0,0 @@
FROM mcr.microsoft.com/dotnet/core/sdk:3.1 AS build
WORKDIR /src
COPY ["src/SteamOpenIdConnectProvider.csproj", "SteamOpenIdConnectProvider/"]
RUN dotnet restore "SteamOpenIdConnectProvider/SteamOpenIdConnectProvider.csproj"
COPY ["src/", "SteamOpenIdConnectProvider/"]
WORKDIR "/src/SteamOpenIdConnectProvider"
RUN dotnet build "SteamOpenIdConnectProvider.csproj" -c Release -o /app
FROM build AS publish
RUN dotnet publish "SteamOpenIdConnectProvider.csproj" -c Release -o /app
FROM mcr.microsoft.com/dotnet/core/aspnet:3.1 AS base
WORKDIR /app
COPY --from=publish /app .
EXPOSE 80
HEALTHCHECK CMD curl --fail http://localhost/health || exit 1
ENTRYPOINT ["dotnet", "SteamOpenIdConnectProvider.dll"]

29
dev.local.plantuml Normal file
View file

@ -0,0 +1,29 @@
@startuml
node Host {
component Browser
node Docker {
component Proxy
component Keycloak
component SteamIdp
component Postgresql
Proxy --> Keycloak: keycloak (http)
Proxy --> SteamIdp: steamidp (http)
Keycloak --> Postgresql: postgres
Proxy <-- Keycloak: dev.local (https)
}
component SteamIdpDev as "SteamIdp"
}
cloud Internet {
component Steam
}
Browser --> Proxy: dev.local (https)
Proxy ..> SteamIdpDev: host.docker.internal (http)
@enduml

View file

@ -0,0 +1,19 @@
FROM jboss/keycloak
USER root
# Install sudo and utils to configure jboss user
RUN microdnf update -y && \
microdnf install -y sudo shadow-utils passwd && \
microdnf clean all
# 'Fix' jboss user, add to sudoers
RUN usermod --password jboss jboss && \
usermod -aG wheel jboss && \
sed -i 's/# includedir/includedir/' /etc/sudoers && \
echo "jboss ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers.d/jboss
ADD ./add-to-truststore.sh /opt/jboss/startup-scripts/add-to-truststore.sh
RUN chmod +x /opt/jboss/startup-scripts/add-to-truststore.sh
USER jboss

View file

@ -0,0 +1,10 @@
#!/usr/bin/env bash
CACERTS=$(readlink -e $(dirname $(readlink -e $(which keytool)))"/../lib/security/cacerts")
sudo keytool \
-import -trustcacerts \
-alias "dev.local.crt" -file /tmp/dev.local.crt \
-keystore ${CACERTS} \
-storepass changeit \
-noprompt

File diff suppressed because it is too large Load diff

9
develop/proxy/Dockerfile Normal file
View file

@ -0,0 +1,9 @@
FROM nginx
ENV SSL_CERT=/tmp/dev.local.crt
ENV SSL_KEY=/tmp/dev.local.key
ENV KEYCLOAK_URI=http://keycloak:8080
ENV STEAMIDP_URI=http://steamidp:80
ADD ./proxy_ssl.conf.template /etc/nginx/templates/

View file

@ -0,0 +1,21 @@
server {
listen 443 ssl;
ssl_certificate ${SSL_CERT};
ssl_certificate_key ${SSL_KEY};
proxy_set_header X-Forwarded-For $proxy_protocol_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
location / {
return 301 https://$host/auth/realms/dev/account;
}
location /auth {
proxy_pass ${KEYCLOAK_URI};
}
location /steam {
proxy_pass ${STEAMIDP_URI};
}
}

7
docker-compose.debug.yml Normal file
View file

@ -0,0 +1,7 @@
version: '2'
services:
proxy:
environment:
STEAMIDP_URI: https://steamidp-debugger:5001
extra_hosts:
- "steamidp-debugger:host-gateway"

View file

@ -0,0 +1,14 @@
version: '2'
services:
proxy:
volumes:
- "<local-path>/dev.local.crt:/tmp/dev.local.crt"
- "<local-path>/dev.local.key:/tmp/dev.local.key"
keycloak:
volumes:
- "<local-path>/dev.local.crt:/tmp/dev.local.crt"
steamidp:
environment:
Steam__ApplicationKey: <steam-app-key>

57
docker-compose.yml Normal file
View file

@ -0,0 +1,57 @@
version: '2'
volumes:
postgres_data:
driver: local
services:
proxy:
image: neothor/proxy:develop
build: ./develop/proxy
container_name: proxy
ports:
- 443:443
links:
- keycloak
- steamidp
postgres:
image: postgres
container_name: postgres
volumes:
- postgres_data:/var/lib/postgresql/data
environment:
POSTGRES_DB: keycloak
POSTGRES_USER: keycloak
POSTGRES_PASSWORD: password
keycloak:
image: neothor/keycloak:develop
build: ./develop/keycloak
container_name: keycloak
environment:
DB_VENDOR: POSTGRES
DB_ADDR: postgres
DB_DATABASE: keycloak
DB_USER: keycloak
DB_SCHEMA: public
DB_PASSWORD: password
KEYCLOAK_USER: admin
KEYCLOAK_PASSWORD: changeit
PROXY_ADDRESS_FORWARDING: "true"
links:
- postgres
extra_hosts:
- "dev.local:host-gateway"
steamidp:
image: neothor/steam-openid-connect-provider:develop
build: ./src
container_name: steamidp
links:
- keycloak
environment:
OpenID__ClientID: keycloak
OpenID__ClientName: keycloak
OpenId__ClientSecret: keycloak
OpenID__RedirectUri: https://dev.local/auth/realms/dev/broker/steam/endpoint
Hosting__BasePath: /steam

View file

@ -1,33 +1,48 @@
using System;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using IdentityServer4.Events;
using IdentityServer4.Extensions;
using IdentityServer4.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using SteamOpenIdConnectProvider.Domains.IdentityServer;
namespace SteamOpenIdConnectProvider.Controllers
{
[AllowAnonymous]
[Route("[action]")]
public class ExternalLoginController : Controller
{
private readonly SignInManager<IdentityUser> _signInManager;
private readonly UserManager<IdentityUser> _userManager;
private readonly IIdentityServerInteractionService _interaction;
private readonly IEventService _events;
private readonly OpenIdConfig _config;
private readonly ILogger<ExternalLoginController> _logger;
public ExternalLoginController(
SignInManager<IdentityUser> signInManager,
UserManager<IdentityUser> userManager,
IIdentityServerInteractionService interaction,
IEventService events,
IOptions<OpenIdConfig> config,
ILogger<ExternalLoginController> logger)
{
_signInManager = signInManager;
_userManager = userManager;
_config = config.Value;
_logger = logger;
_interaction = interaction;
_events = events;
}
[HttpGet]
[HttpGet("external-login")]
public Task<IActionResult> ExternalLogin(string returnUrl = null)
{
const string provider = "Steam";
@ -37,11 +52,11 @@ namespace SteamOpenIdConnectProvider.Controllers
return Task.FromResult<IActionResult>(new ChallengeResult(provider, properties));
}
[HttpGet]
[HttpGet("external-login-callback")]
public async Task<IActionResult> ExternalLoginCallback(string returnUrl = null, string remoteError = null)
{
returnUrl ??= Url.Content("~/");
if (remoteError != null)
{
throw new Exception($"Error from external provider: {remoteError}");
@ -62,7 +77,17 @@ namespace SteamOpenIdConnectProvider.Controllers
var userName = info.Principal.FindFirstValue(ClaimTypes.Name);
var userId = info.Principal.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrEmpty(userId))
{
throw new ArgumentNullException(nameof(userId), $"No claim found for {ClaimTypes.NameIdentifier}");
}
if (string.IsNullOrEmpty(userName))
{
userName = userId.Split('/').Last();
}
var user = new IdentityUser { UserName = userName, Id = userId };
_userManager.UserValidators.Clear();
@ -86,5 +111,21 @@ namespace SteamOpenIdConnectProvider.Controllers
return BadRequest();
}
[HttpGet("external-logout")]
public async Task<ActionResult> ExternalLogout(string logoutId)
{
var logout = await _interaction.GetLogoutContextAsync(logoutId);
if (User?.Identity.IsAuthenticated == true)
{
await _signInManager.SignOutAsync();
await _events.RaiseAsync(new UserLogoutSuccessEvent(User.GetSubjectId(), User.GetDisplayName()));
}
return Redirect(logout?.PostLogoutRedirectUri ??
_config.PostLogoutRedirectUris.FirstOrDefault() ??
Url.Content("~/"));
}
}
}

26
src/Dockerfile Normal file
View file

@ -0,0 +1,26 @@
FROM mcr.microsoft.com/dotnet/sdk:5.0 AS build
WORKDIR /src
# Copy the project file to create layer with packages
COPY SteamOpenIdConnectProvider.csproj .
RUN dotnet restore ./SteamOpenIdConnectProvider.csproj
# Copy the rest of the source
COPY . .
RUN dotnet build ./SteamOpenIdConnectProvider.csproj -c Release -o /app
FROM build AS publish
RUN dotnet publish ./SteamOpenIdConnectProvider.csproj -c Release -o /app
FROM mcr.microsoft.com/dotnet/aspnet:5.0
RUN apt-get update && \
apt-get install -y curl
WORKDIR /app
COPY --from=publish /app .
EXPOSE 80
HEALTHCHECK CMD curl --fail http://localhost/health || exit 1
ENTRYPOINT ["dotnet", "SteamOpenIdConnectProvider.dll"]

View file

@ -0,0 +1,16 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace SteamOpenIdConnectProvider.Domains.Common
{
public class HostingConfig
{
public static readonly string Key = "Hosting";
public string BasePath { get; set; }
public string PublicOrigin { get; set; }
}
}

View file

@ -2,7 +2,7 @@
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
namespace SteamOpenIdConnectProvider.Database
namespace SteamOpenIdConnectProvider.Domains.IdentityServer
{
// This is completely in-memory, we do not need a persistent store.
public class AppInMemoryDbContext : IdentityDbContext<IdentityUser>

View file

@ -1,33 +1,35 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq;
using IdentityServer4;
using IdentityServer4.Models;
using SteamOpenIdConnectProvider.Domains.IdentityServer;
namespace SteamOpenIdConnectProvider
namespace SteamOpenIdConnectProvider.Models.IdentityServer
{
public class IdentityServerConfig
public static class IdentityServerConfigFactory
{
public static IEnumerable<Client> GetClients(string clientId, string secret, string redirectUri, string logoutRedirectUri)
public static IEnumerable<Client> GetClients(OpenIdConfig config)
{
yield return new Client
var client = new Client
{
ClientId = clientId,
ClientName = "Proxy Client",
ClientId = config.ClientID,
ClientName = config.ClientName,
AllowedGrantTypes = GrantTypes.Code,
RequireConsent = false,
ClientSecrets =
{
new Secret(secret.Sha256())
new Secret(config.ClientSecret.Sha256())
},
AlwaysSendClientClaims = true,
AlwaysIncludeUserClaimsInIdToken = true,
// where to redirect to after login
RedirectUris = redirectUri.Split(",").Select(x => x.Trim()).ToArray(),
RedirectUris = config.RedirectUris.ToArray(),
// where to redirect to after logout
PostLogoutRedirectUris = { logoutRedirectUri },
PostLogoutRedirectUris = config.PostLogoutRedirectUris.ToArray(),
RequirePkce = false,
AllowedScopes = new List<string>
{
@ -35,6 +37,7 @@ namespace SteamOpenIdConnectProvider
IdentityServerConstants.StandardScopes.Profile,
}
};
yield return client;
}
public static IEnumerable<IdentityResource> GetIdentityResources()
@ -46,4 +49,4 @@ namespace SteamOpenIdConnectProvider
};
}
}
}
}

View file

@ -0,0 +1,26 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace SteamOpenIdConnectProvider.Domains.IdentityServer
{
public class OpenIdConfig
{
public static readonly string Key = "OpenID";
public string ClientID { get; set; }
public string ClientSecret { get; set; }
public string RedirectUri { get; set; }
public string PostLogoutRedirectUri { get; set; }
public string ClientName { get; set; } = "Proxy Client";
public IEnumerable<string> RedirectUris => (RedirectUri ?? string.Empty).Split(
new[] { ',', ';' },
StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
public IEnumerable<string> PostLogoutRedirectUris => (PostLogoutRedirectUri ?? string.Empty).Split(
new[] { ',', ';' },
StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
}
}

View file

@ -0,0 +1,30 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace SteamOpenIdConnectProvider.Domains.IdentityServer
{
public static class OpenIdStandardClaims
{
public static readonly string Name = "name";
public static readonly string GivenName = "given_name";
public static readonly string FamilyName = "family_name";
public static readonly string MiddleName = "middle_name";
public static readonly string Nickname = "nickname";
public static readonly string PreferredUsername = "preferred_username";
public static readonly string Profile = "profile";
public static readonly string Picture = "picture";
public static readonly string Website = "website";
public static readonly string Email = "email";
public static readonly string EmailVerified = "email_verified";
public static readonly string Gender = "gender";
public static readonly string BirthDate = "BirthDate";
public static readonly string Zoneinfo = "zoneinfo";
public static readonly string Locale = "locale";
public static readonly string PhoneNumber = "phone_number";
public static readonly string PhoneNumberVerified = "phone_number_verified";
public static readonly string Address = "address";
public static readonly string UpdatedAt = "updated_at";
}
}

View file

@ -0,0 +1,7 @@
namespace SteamOpenIdConnectProvider.Domains.IdentityServer
{
public static class SteamClaims
{
public static readonly string SteamId = "steam_id";
}
}

View file

@ -0,0 +1,14 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace SteamOpenIdConnectProvider.Domains.Steam
{
public static class Constants
{
public static readonly string OpenIdUrl = "https://steamcommunity.com/openid/id/";
public static readonly string ApiUrl = "https://api.steampowered.com/";
public static readonly string GetPlayerSummariesUrl = ApiUrl + "ISteamUser/GetPlayerSummaries/v0002";
}
}

View file

@ -0,0 +1,12 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
using SteamOpenIdConnectProvider.Profile.Models;
namespace SteamOpenIdConnectProvider.Domains.Steam
{
public sealed class GetPlayerSummariesResponse
{
[JsonPropertyName("players")]
public ICollection<Player> Players { get; set; }
}
}

View file

@ -0,0 +1,59 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace SteamOpenIdConnectProvider.Profile.Models
{
public sealed class Player
{
[JsonPropertyName("steamid")]
public string SteamId { get; set; }
[JsonPropertyName("communityvisibilitystate")]
public int CommunityVisibilityState { get; set; }
[JsonPropertyName("profilestate")]
public int ProfileState { get; set; }
[JsonPropertyName("personaname")]
public string PersonaName { get; set; }
[JsonPropertyName("commentpermission")]
public int CommentPermission { get; set; }
[JsonPropertyName("profileurl")]
public string ProfileUrl { get; set; }
[JsonPropertyName("avatar")]
public string Avatar { get; set; }
[JsonPropertyName("avatarmedium")]
public string AvatarMedium { get; set; }
[JsonPropertyName("avatarfull")]
public string AvatarFull { get; set; }
[JsonPropertyName("avatarhash")]
public string AvatarHash { get; set; }
[JsonPropertyName("lastlogoff")]
public int LastLogoff { get; set; }
[JsonPropertyName("personastate")]
public int PersonaState { get; set; }
[JsonPropertyName("realname")]
public string RealName { get; set; }
[JsonPropertyName("primaryclanid")]
public string PrimaryClanId { get; set; }
[JsonPropertyName("timecreated")]
public int TimeCreated { get; set; }
[JsonPropertyName("personastateflags")]
public int PersonaStateFlags { get; set; }
[JsonPropertyName("loccountrycode")]
public string LocCountryCode { get; set; }
}
}

View file

@ -0,0 +1,14 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace SteamOpenIdConnectProvider.Domains.Steam
{
public class SteamConfig
{
public static readonly string Key = "Steam";
public string ApplicationKey { get; set; }
}
}

View file

@ -1,46 +1,40 @@
using System.Collections.Generic;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Security.Claims;
using System.Text.Json;
using System.Threading.Tasks;
using IdentityServer4.Extensions;
using IdentityServer4.Models;
using IdentityServer4.Services;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Configuration;
using Newtonsoft.Json;
using SteamOpenIdConnectProvider.Profile.Models;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using SteamOpenIdConnectProvider.Domains.IdentityServer;
using SteamOpenIdConnectProvider.Domains.Steam;
using SteamOpenIdConnectProvider.Models.Steam;
namespace SteamOpenIdConnectProvider.Profile
namespace SteamOpenIdConnectProvider.Services
{
public class SteamProfileService : IProfileService
{
private readonly HttpClient _httpClient;
private readonly IConfiguration _configuration;
private readonly SteamConfig _config;
private readonly IUserClaimsPrincipalFactory<IdentityUser> _claimsFactory;
private readonly ILogger<SteamProfileService> _logger;
private readonly UserManager<IdentityUser> _userManager;
private async Task<GetPlayerSummariesResponse> GetPlayerSummariesAsync(IEnumerable<string> steamIds)
{
const string baseurl = "https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002";
var applicationKey = _configuration["Authentication:Steam:ApplicationKey"];
var url = $"{baseurl}/?key={applicationKey}&steamids={string.Join(',', steamIds)}";
var res = await _httpClient.GetStringAsync(url);
var response = JsonConvert.DeserializeObject<SteamResponse<GetPlayerSummariesResponse>>(res);
return response.Response;
}
public SteamProfileService(
UserManager<IdentityUser> userManager,
IUserClaimsPrincipalFactory<IdentityUser> claimsFactory,
IConfiguration configuration,
IOptions<SteamConfig> config,
ILogger<SteamProfileService> logger,
HttpClient httpClient)
{
_userManager = userManager;
_claimsFactory = claimsFactory;
_configuration = configuration;
_logger = logger;
_config = config.Value;
_httpClient = httpClient;
}
@ -53,23 +47,42 @@ namespace SteamOpenIdConnectProvider.Profile
var claims = principal.Claims.ToList();
claims = claims.Where(claim => context.RequestedClaimTypes.Contains(claim.Type)).ToList();
const string steamUrl = "https://steamcommunity.com/openid/id/";
var steamId = sub.Substring(steamUrl.Length);
var steamId = sub.Substring(Constants.OpenIdUrl.Length);
AddClaim(claims, SteamClaims.SteamId, steamId);
var userSummary = await GetPlayerSummariesAsync(new[] { steamId });
var player = userSummary.Players.FirstOrDefault();
if (player != null)
{
AddClaim(claims, "picture", player.AvatarFull);
AddClaim(claims, "nickname", player.PersonaName);
AddClaim(claims, "given_name", player.RealName);
AddClaim(claims, "website", player.ProfileUrl);
AddClaim(claims, OpenIdStandardClaims.Picture, player.AvatarFull);
AddClaim(claims, OpenIdStandardClaims.Nickname, player.PersonaName);
AddClaim(claims, OpenIdStandardClaims.PreferredUsername, player.PersonaName);
AddClaim(claims, OpenIdStandardClaims.GivenName, player.RealName);
AddClaim(claims, OpenIdStandardClaims.Website, player.ProfileUrl);
}
if (_logger.IsEnabled(LogLevel.Debug))
{
foreach (var claim in claims)
{
_logger.LogDebug("Issued claim {claim}:{value} for {principle}",
claim.Type,
claim.Value,
principal.Identity.Name);
}
}
context.IssuedClaims = claims;
}
public async Task IsActiveAsync(IsActiveContext context)
{
var sub = context.Subject.GetSubjectId();
var user = await _userManager.FindByIdAsync(sub);
context.IsActive = user != null;
}
private void AddClaim(List<Claim> claims, string type, string value)
{
if (!string.IsNullOrEmpty(value))
@ -78,11 +91,12 @@ namespace SteamOpenIdConnectProvider.Profile
}
}
public async Task IsActiveAsync(IsActiveContext context)
private async Task<GetPlayerSummariesResponse> GetPlayerSummariesAsync(IEnumerable<string> steamIds)
{
var sub = context.Subject.GetSubjectId();
var user = await _userManager.FindByIdAsync(sub);
context.IsActive = user != null;
var url = $"{Constants.GetPlayerSummariesUrl}/?key={_config.ApplicationKey}&steamids={string.Join(',', steamIds)}";
var res = await _httpClient.GetStringAsync(url);
var response = JsonSerializer.Deserialize<SteamResponse<GetPlayerSummariesResponse>>(res);
return response.Response;
}
}
}
}

View file

@ -0,0 +1,10 @@
using System.Text.Json.Serialization;
namespace SteamOpenIdConnectProvider.Models.Steam
{
public sealed class SteamResponse<T>
{
[JsonPropertyName("response")]
public T Response { get; set; }
}
}

View file

@ -1,11 +0,0 @@
using System.Collections.Generic;
using Newtonsoft.Json;
namespace SteamOpenIdConnectProvider.Profile.Models
{
public sealed class GetPlayerSummariesResponse
{
[JsonProperty("players")]
public ICollection<Player> Players { get; set; }
}
}

View file

@ -1,58 +0,0 @@
using Newtonsoft.Json;
namespace SteamOpenIdConnectProvider.Profile.Models
{
public sealed class Player
{
[JsonProperty("steamid")]
public ulong SteamId { get; set; }
[JsonProperty("communityvisibilitystate")]
public int CommunityVisibilityState { get; set; }
[JsonProperty("profilestate")]
public int ProfileState { get; set; }
[JsonProperty("personaname")]
public string PersonaName { get; set; }
[JsonProperty("commentpermission")]
public int CommentPermission { get; set; }
[JsonProperty("profileurl")]
public string ProfileUrl { get; set; }
[JsonProperty("avatar")]
public string Avatar { get; set; }
[JsonProperty("avatarmedium")]
public string AvatarMedium { get; set; }
[JsonProperty("avatarfull")]
public string AvatarFull { get; set; }
[JsonProperty("avatarhash")]
public string AvatarHash { get; set; }
[JsonProperty("lastlogoff")]
public int LastLogoff { get; set; }
[JsonProperty("personastate")]
public int PersonaState { get; set; }
[JsonProperty("realname")]
public string RealName { get; set; }
[JsonProperty("primaryclanid")]
public ulong PrimaryClanId { get; set; }
[JsonProperty("timecreated")]
public int TimeCreated { get; set; }
[JsonProperty("personastateflags")]
public int PersonaStateFlags { get; set; }
[JsonProperty("loccountrycode")]
public string LocCountryCode { get; set; }
}
}

View file

@ -1,10 +0,0 @@
using Newtonsoft.Json;
namespace SteamOpenIdConnectProvider.Profile.Models
{
public sealed class SteamResponse<T>
{
[JsonProperty("response")]
public T Response { get; set; }
}
}

View file

@ -1,5 +1,7 @@
using Microsoft.AspNetCore;
using System;
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Hosting;
using Serilog;
using Serilog.Events;
using Serilog.Sinks.SystemConsole.Themes;
@ -8,24 +10,42 @@ namespace SteamOpenIdConnectProvider
{
public class Program
{
public static void Main(string[] args)
public static int Main(string[] args)
{
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Debug()
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
.MinimumLevel.Override("System", LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.AspNetCore.Authentication", LogEventLevel.Information)
.MinimumLevel.Override("Microsoft", LogEventLevel.Information)
.Enrich.FromLogContext()
.WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level}] {SourceContext}{NewLine}{Message:lj}{NewLine}{Exception}{NewLine}", theme: AnsiConsoleTheme.Literate)
.CreateLogger();
.WriteTo.Console()
.CreateBootstrapLogger();
CreateWebHostBuilder(args).Build().Run();
try
{
Log.Information("Starting web host");
CreateHostBuilder(args).Build().Run();
return 0;
}
catch (Exception ex)
{
Log.Fatal(ex, "Host terminated unexpectedly");
return 1;
}
finally
{
Log.CloseAndFlush();
}
}
public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
WebHost.CreateDefaultBuilder(args)
.UseKestrel()
.UseSerilog()
.UseStartup<Startup>();
public static IHostBuilder CreateHostBuilder(string[] args) =>
Host.CreateDefaultBuilder(args)
.UseSerilog((context, services, configuration) => configuration
.ReadFrom.Configuration(context.Configuration)
.ReadFrom.Services(services)
.MinimumLevel.Is(context.HostingEnvironment.IsDevelopment() ? LogEventLevel.Debug : LogEventLevel.Information)
.Enrich.FromLogContext()
.WriteTo.Console(theme: AnsiConsoleTheme.Code))
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseStartup<Startup>();
});
}
}

View file

@ -1,5 +1,4 @@
using System;
using System.Net.Http;
using IdentityServer4.Extensions;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
@ -12,8 +11,15 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using IdentityServer4.Services;
using Microsoft.AspNetCore.HttpOverrides;
using SteamOpenIdConnectProvider.Database;
using SteamOpenIdConnectProvider.Profile;
using SteamOpenIdConnectProvider.Services;
using SteamOpenIdConnectProvider.Models.IdentityServer;
using SteamOpenIdConnectProvider.Domains.Common;
using SteamOpenIdConnectProvider.Domains.IdentityServer;
using SteamOpenIdConnectProvider.Domains.Steam;
using System.IO;
using System.Text;
using Serilog;
using Microsoft.Extensions.Logging;
namespace SteamOpenIdConnectProvider
{
@ -29,10 +35,8 @@ namespace SteamOpenIdConnectProvider
public void ConfigureServices(IServiceCollection services)
{
services.AddControllers()
.AddNewtonsoftJson()
.SetCompatibilityVersion(CompatibilityVersion.Version_3_0);
services.AddSingleton(Configuration);
services.AddDbContext<AppInMemoryDbContext>(options =>
options.UseInMemoryDatabase("default"));
@ -43,21 +47,24 @@ namespace SteamOpenIdConnectProvider
.AddEntityFrameworkStores<AppInMemoryDbContext>()
.AddDefaultTokenProviders();
services.AddIdentityServer(options =>
var openIdConfig = Configuration.GetSection(OpenIdConfig.Key);
services
.Configure<OpenIdConfig>(openIdConfig)
.AddIdentityServer(options =>
{
options.UserInteraction.LoginUrl = "/ExternalLogin";
options.UserInteraction.LoginUrl = "/external-login";
options.UserInteraction.LogoutUrl = "/external-logout";
})
.AddAspNetIdentity<IdentityUser>()
.AddInMemoryClients(IdentityServerConfig.GetClients(
Configuration["OpenID:ClientID"],
Configuration["OpenID:ClientSecret"],
Configuration["OpenID:RedirectUri"],
Configuration["OpenID:PostLogoutRedirectUri"]))
.AddInMemoryClients(IdentityServerConfigFactory.GetClients(openIdConfig.Get<OpenIdConfig>()))
.AddInMemoryPersistedGrants()
.AddDeveloperSigningCredential(true)
.AddInMemoryIdentityResources(IdentityServerConfig.GetIdentityResources());
.AddInMemoryIdentityResources(IdentityServerConfigFactory.GetIdentityResources());
services.AddHttpClient<IProfileService, SteamProfileService>();
var steamConfig = Configuration.GetSection(SteamConfig.Key);
services
.Configure<SteamConfig>(steamConfig)
.AddHttpClient<IProfileService, SteamProfileService>();
services.AddAuthentication()
.AddCookie(options =>
@ -67,32 +74,37 @@ namespace SteamOpenIdConnectProvider
})
.AddSteam(options =>
{
options.ApplicationKey = Configuration["Authentication:Steam:ApplicationKey"];
options.ApplicationKey = steamConfig.Get<SteamConfig>().ApplicationKey;
});
services.AddHealthChecks()
.AddUrlGroup(new Uri("https://steamcommunity.com/openid"), "Steam");
.AddUrlGroup(new Uri(Constants.OpenIdUrl), "Steam");
}
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
var logger = app.ApplicationServices.GetRequiredService<ILogger<Startup>>();
if (env.IsDevelopment())
{
logger.LogWarning("Starting up in development mode");
app.UseDeveloperExceptionPage();
}
if (!string.IsNullOrEmpty(Configuration["Hosting:PathBase"]))
var hostingConfig = Configuration.GetSection(HostingConfig.Key).Get<HostingConfig>();
if (!string.IsNullOrEmpty(hostingConfig.BasePath))
{
app.UsePathBase(Configuration["Hosting:PathBase"]);
app.UsePathBase(hostingConfig.BasePath);
}
app.UseSerilogRequestLogging();
app.UseCookiePolicy();
app.Use(async (ctx, next) =>
{
var origin = Configuration["Hosting:PublicOrigin"];
if (!string.IsNullOrEmpty(origin))
if (!string.IsNullOrEmpty(hostingConfig.PublicOrigin))
{
ctx.SetIdentityServerOrigin(origin);
ctx.SetIdentityServerOrigin(hostingConfig.PublicOrigin);
}
await next();

View file

@ -1,29 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>netcoreapp3.1</TargetFramework>
<TargetFramework>net5.0</TargetFramework>
<AspNetCoreHostingModel>InProcess</AspNetCoreHostingModel>
<UserSecretsId>9f8cb9ce-f696-422f-901a-563af9413684</UserSecretsId>
<LangVersion>8</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AspNet.Security.OpenId.Steam" Version="3.1.0" />
<PackageReference Include="AspNet.Security.OpenIdConnect.Server" Version="2.0.0" />
<PackageReference Include="AspNetCore.HealthChecks.Uris" Version="3.1.2" />
<PackageReference Include="IdentityServer4" Version="4.1.1" />
<PackageReference Include="IdentityServer4.AspNetIdentity" Version="4.1.1" />
<PackageReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="AspNet.Security.OpenId.Steam" Version="5.0.0" />
<PackageReference Include="AspNetCore.HealthChecks.Uris" Version="5.0.1" />
<PackageReference Include="IdentityServer4" Version="4.1.2" />
<PackageReference Include="IdentityServer4.AspNetIdentity" Version="4.1.2" />
<PackageReference Include="Microsoft.AspNetCore.Identity" Version="2.2.0" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="3.1.8" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="3.1.8" />
<PackageReference Include="Microsoft.AspNetCore.Razor.Design" Version="2.2.0" PrivateAssets="All" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="3.1.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="3.1.8" />
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="3.1.4" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="5.0.5" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="5.0.5" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="5.0.5" />
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="5.0.2" />
<PackageReference Include="Serilog" Version="2.10.0" />
<PackageReference Include="Serilog.AspNetCore" Version="3.4.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="3.1.1" />
<PackageReference Include="Serilog.AspNetCore" Version="4.1.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="4.0.0" />
</ItemGroup>
</Project>

View file

@ -3,7 +3,7 @@ Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 16
VisualStudioVersion = 16.0.28803.202
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SteamOpenIdConnectProvider", "src\SteamOpenIdConnectProvider.csproj", "{39261E1A-B9AB-45C1-991E-B88ED3E076A2}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SteamOpenIdConnectProvider", "SteamOpenIdConnectProvider.csproj", "{39261E1A-B9AB-45C1-991E-B88ED3E076A2}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution

View file

@ -1,12 +1,5 @@
{
"Logging": {
"LogLevel": {
"Default": "Debug",
"System": "Information",
"Microsoft": "Information"
}
},
"Hosting": {
"PathBase": "/test"
"BasePath": "/steam"
}
}

View file

@ -1,17 +1,18 @@
{
"Logging": {
"LogLevel": {
"Default": "Warning"
}
},
"OpenID": {
"ClientID": "proxy",
"ClientSecret": "secret",
"RedirectUri": "http://localhost:8080/auth/realms/master/broker/steam/endpoint",
"PostLogoutRedirectUri": ""
},
"AllowedHosts": "*",
"OpenID": {
"ClientID": "keycloak",
"ClientSecret": "keycloak",
"ClientName": "keycloak",
"RedirectUri": "https://dev.local/auth/realms/dev/broker/steam/endpoint",
// TODO: Don't think this is how it suppose to work.
"PostLogoutRedirectUri": "https://dev.local/auth/realms/dev/protocol/openid-connect/logout?initiating_idp=steam"
},
"Hosting": {
"PathBase": ""
"BasePath": ""
},
"Steam": {
"ApplicationKey": "secret"
}
}