1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
|
// SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
// SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1
using System.Security.Cryptography;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using Npgsql;
using Progmasoft.OAuth.Developers;
using Progmasoft.OAuth.Storage;
using Progmasoft.Progmaweb.Api.Accounts;
using static OpenIddict.Abstractions.OpenIddictConstants;
namespace Progmasoft.OAuth;
/// <summary>Registers Progmasoft OAuth: its tables, the OpenIddict server, its sign-in cookie and its pages.</summary>
internal static class OAuthRegistration
{
/// <summary>The cookie scheme of an account signed in to Progmasoft OAuth.</summary>
/// <remarks>
/// It is separate from the session of the account site: signing in here opens no account-site session, and the
/// cookie is a <c>__Host-</c> cookie of the OAuth host only.
/// </remarks>
public const string CookieScheme = "ProgmasoftOAuth";
/// <summary>How long a sign-in to Progmasoft OAuth lasts.</summary>
public static readonly TimeSpan SignInLifetime = TimeSpan.FromHours(8);
/// <summary>Adds the services of Progmasoft OAuth.</summary>
/// <param name="services">The service collection.</param>
/// <param name="configuration">The configuration of the service.</param>
/// <param name="accountStore">Where the accounts are kept; the OAuth tables are kept in the same place.</param>
/// <param name="isProduction">Whether the service runs in production, where the token keys are required.</param>
/// <returns>The settings, as the pipeline needs them.</returns>
public static OAuthOptions AddProgmasoftOAuth(
this IServiceCollection services,
IConfiguration configuration,
AccountStoreKind accountStore,
bool isProduction)
{
IConfigurationSection section = configuration.GetSection(OAuthOptions.SectionName);
OAuthOptions settings = section.Get<OAuthOptions>() ?? new OAuthOptions();
services.Configure<OAuthOptions>(section);
// With the memory account store the OAuth tables live in an in-memory SQLite database, one per process, which
// a kept-open connection holds alive and every context opens again by name. SQLite, unlike Entity
// Framework's in-memory provider, runs the bulk updates OpenIddict uses to revoke a reused refresh token's
// chain, so development and the tests exercise the same revocation as production.
string memoryDatabase =
$"Data Source=progmasoft-oauth-{Guid.NewGuid():N};Mode=Memory;Cache=Shared";
services.AddDbContext<OAuthDbContext>(options =>
{
if (accountStore is AccountStoreKind.PostgreSql)
{
options.UseNpgsql(configuration.GetConnectionString(AccountStoreRegistration.ConnectionStringName));
}
else
{
options.UseSqlite(memoryDatabase);
}
options.UseOpenIddict();
});
if (accountStore is AccountStoreKind.PostgreSql)
{
// Registered after the account schema, which it refers to.
services.AddHostedService<OAuthSchemaInitializer>();
}
else
{
services.AddSingleton(new MemoryOAuthDatabase(memoryDatabase));
services.AddHostedService<MemoryOAuthDatabaseInitializer>();
}
services.AddOpenIddict()
.AddCore(core => core.UseEntityFrameworkCore().UseDbContext<OAuthDbContext>())
.AddServer(server =>
{
server.SetIssuer(settings.Issuer);
// The endpoints a browser visits live on the authorize host, with the sign-in and consent pages;
// the discovery document names them with that host. The others stay under the issuer.
server.SetAuthorizationEndpointUris($"https://{settings.AuthorizeHost}/connect/authorize")
.SetTokenEndpointUris("connect/token")
.SetUserInfoEndpointUris("connect/userinfo")
.SetEndSessionEndpointUris($"https://{settings.AuthorizeHost}/connect/logout")
.SetRevocationEndpointUris("connect/revoke")
.SetIntrospectionEndpointUris("connect/introspect");
// OAuth 2.1: the authorization code with PKCE for every client, refresh tokens, and client
// credentials for services. The implicit and password grants are not offered at all.
server.AllowAuthorizationCodeFlow()
.AllowRefreshTokenFlow()
.AllowClientCredentialsFlow();
server.RequireProofKeyForCodeExchange();
// OAuth 2.1 allows only the S256 challenge; "plain" would put the verifier itself in the browser.
server.Configure(options => options.CodeChallengeMethods.Remove(CodeChallengeMethods.Plain));
server.RegisterScopes(
[Scopes.OpenId, .. OAuthScopes.All.Select(scope => scope.Name)]);
server.RegisterClaims(
Claims.Subject, Claims.Name, Claims.PreferredUsername, Claims.Email, Claims.EmailVerified);
server.SetAuthorizationCodeLifetime(TimeSpan.FromMinutes(2))
.SetAccessTokenLifetime(TimeSpan.FromMinutes(15))
.SetIdentityTokenLifetime(TimeSpan.FromMinutes(15))
.SetRefreshTokenLifetime(TimeSpan.FromDays(30))
// OAuth 2.1 refresh token rotation: a refresh token that was used once is refused, and its reuse
// revokes the whole chain. OpenIddict tolerates reuse for 30 seconds unless told otherwise.
.SetRefreshTokenReuseLeeway(TimeSpan.Zero);
AddKeys(server, settings, isProduction);
server.UseAspNetCore()
.EnableAuthorizationEndpointPassthrough()
.EnableTokenEndpointPassthrough()
.EnableUserInfoEndpointPassthrough()
.EnableEndSessionEndpointPassthrough()
.EnableStatusCodePagesIntegration();
})
.AddValidation(validation =>
{
validation.UseLocalServer();
validation.UseAspNetCore();
});
services.AddAuthentication()
.AddCookie(CookieScheme, options =>
{
options.Cookie.Name = "__Host-ProgmasoftOAuth";
options.Cookie.HttpOnly = true;
options.Cookie.IsEssential = true;
// Lax, so that the redirect back from the sign-in page to the authorization endpoint carries it.
options.Cookie.SameSite = SameSiteMode.Lax;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
options.ExpireTimeSpan = SignInLifetime;
options.SlidingExpiration = false;
// The sign-in page is a page of the Progmasoft site (Next.js) on the same host.
options.LoginPath = "/signin";
options.AccessDeniedPath = "/signin";
});
services.AddScoped<ApplicationRegistry>();
return settings;
}
/// <summary>Adds the keys that sign and encrypt tokens.</summary>
/// <remarks>
/// Production reads both keys from files outside the release, so that tokens survive a restart and a new
/// release. Every other environment makes keys that live as long as the process.
/// </remarks>
/// <param name="server">The OpenIddict server builder.</param>
/// <param name="settings">The settings that name the key files.</param>
/// <param name="isProduction">Whether the service runs in production.</param>
/// <exception cref="InvalidOperationException">A key file is missing in production.</exception>
private static void AddKeys(OpenIddictServerBuilder server, OAuthOptions settings, bool isProduction)
{
if (string.IsNullOrWhiteSpace(settings.SigningKeyPath) || string.IsNullOrWhiteSpace(settings.EncryptionKeyPath))
{
if (isProduction)
{
throw new InvalidOperationException(
$"{OAuthOptions.SectionName}:{nameof(OAuthOptions.SigningKeyPath)} and " +
$"{OAuthOptions.SectionName}:{nameof(OAuthOptions.EncryptionKeyPath)} must name the token key " +
"files in production.");
}
server.AddEphemeralSigningKey().AddEphemeralEncryptionKey();
return;
}
RSA signing = RSA.Create();
signing.ImportFromPem(File.ReadAllText(settings.SigningKeyPath));
// A key identifier derived from the public key lets clients pick the key of a token from the key set.
byte[] publicKey = signing.ExportSubjectPublicKeyInfo();
server.AddSigningKey(new RsaSecurityKey(signing)
{
KeyId = Base64UrlEncoder.Encode(SHA256.HashData(publicKey)[..16])
});
byte[] encryption = Convert.FromBase64String(File.ReadAllText(settings.EncryptionKeyPath).Trim());
if (encryption.Length != 32)
{
throw new InvalidOperationException("The OAuth encryption key must be 256 bits, written as base64.");
}
server.AddEncryptionKey(new SymmetricSecurityKey(encryption));
}
}
/// <summary>Brings the OAuth tables to the current schema before the service accepts requests.</summary>
/// <param name="dataSource">Connections to the database of the accounts.</param>
/// <param name="logger">Receives what was done.</param>
internal sealed class OAuthSchemaInitializer(
NpgsqlDataSource dataSource,
ILogger<OAuthSchemaInitializer> logger) : IHostedService
{
/// <summary>Writes the schema version and the number of steps taken at this start.</summary>
private static readonly Action<ILogger, int, int, Exception?> SchemaReport = LoggerMessage.Define<int, int>(
LogLevel.Information,
new EventId(20, "OAuthSchema"),
"The OAuth tables are at schema version {Version}; {Taken} step(s) were taken at this start.");
/// <inheritdoc/>
public async Task StartAsync(CancellationToken cancellationToken)
{
int taken = await OAuthSchema.ApplyAsync(dataSource, cancellationToken);
SchemaReport(logger, OAuthSchema.CurrentVersion, taken, null);
}
/// <inheritdoc/>
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
/// <summary>Keeps the in-memory SQLite database of the OAuth tables alive for the lifetime of the process.</summary>
/// <remarks>A shared in-memory SQLite database exists as long as one connection to it is open.</remarks>
internal sealed class MemoryOAuthDatabase : IDisposable
{
/// <summary>The connection whose lifetime is the lifetime of the database.</summary>
private readonly SqliteConnection keeper;
/// <summary>Opens the connection that holds the database.</summary>
/// <param name="connectionString">The connection string of the shared in-memory database.</param>
public MemoryOAuthDatabase(string connectionString)
{
keeper = new SqliteConnection(connectionString);
keeper.Open();
}
/// <inheritdoc/>
public void Dispose() => keeper.Dispose();
}
/// <summary>Creates the OAuth tables in the in-memory database before the service accepts requests.</summary>
/// <param name="database">The connection that keeps the database alive.</param>
/// <param name="services">Creates the scope of the context.</param>
internal sealed class MemoryOAuthDatabaseInitializer(MemoryOAuthDatabase database, IServiceProvider services)
: IHostedService
{
/// <inheritdoc/>
public async Task StartAsync(CancellationToken cancellationToken)
{
GC.KeepAlive(database);
await using AsyncServiceScope scope = services.CreateAsyncScope();
await scope.ServiceProvider.GetRequiredService<OAuthDbContext>().Database.EnsureCreatedAsync(cancellationToken);
}
/// <inheritdoc/>
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
|