Skip to content

Commit 64cab42

Browse files
authored
fix(oauth): real app identifiers in known-client directory; fix Tray auth (#432)
The known-client directory used invented/incorrect software_ids that no app actually sends, so registering apps were never matched as known. Replace them with each app's real, stable identifier: - Trio: org.trio.diabetes -> org.nightscout.trio - xDrip+: org.nightscoutfoundation.xdrip -> com.eveningoutpost.dexdrip - AAPS: org.androidaps.aaps -> info.nightscout.androidaps - Sugarmate: io.sugarmate -> com.tandemdiabetes.sugarmate - Nightscout: github.nightscout.nightscout -> org.nightscout.cgm-remote-monitor (server follower) - Nightwatch: com.nickenilsson.nightwatch (404 repo) -> se.cornixit.nightwatch Each custom-scheme redirect is aligned to its software_id (RFC 8252). Fix the Tray, which hardcoded a client_id ("nocturne-tray") that no server recognises and never registered: it now performs Dynamic Client Registration with software_id com.nocturne.tray (auth-code + PKCE, loopback redirect) and carries the issued client_id through token exchange and refresh. Its directory entry gains the loopback redirect so the seeded client can use the authorization-code flow. Note: Trio/xDrip+/Loop/AAPS/Sugarmate/Nightwatch authenticate to Nightscout via the legacy API-secret today and do not perform OAuth DCR; these entries take effect only once each app adopts OAuth with the listed software_id.
1 parent 83afb76 commit 64cab42

5 files changed

Lines changed: 130 additions & 36 deletions

File tree

src/Core/Nocturne.Core.Models/Authorization/KnownOAuthClients.cs

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,13 @@ public static class KnownOAuthClients
1616
{
1717
new()
1818
{
19-
SoftwareId = "org.trio.diabetes",
19+
// iOS bundle id is org.nightscout.$(DEVELOPMENT_TEAM).trio; the team segment
20+
// varies per build, so the stable software_id is the team-independent base.
21+
SoftwareId = "org.nightscout.trio",
2022
DisplayName = "Trio",
21-
Homepage = "https://github.com/nightscout/Trio",
23+
Homepage = "https://triodocs.org",
2224
LogoUri = "/logos/trio.svg",
23-
RedirectUris = ["trio://oauth/callback"],
25+
RedirectUris = ["org.nightscout.trio://oauth/callback"],
2426
TypicalScopes =
2527
[
2628
OAuthScopes.GlucoseReadWrite,
@@ -31,11 +33,12 @@ public static class KnownOAuthClients
3133
},
3234
new()
3335
{
34-
SoftwareId = "org.nightscoutfoundation.xdrip",
36+
// Real Android applicationId (Play Store / APK identity).
37+
SoftwareId = "com.eveningoutpost.dexdrip",
3538
DisplayName = "xDrip+",
3639
Homepage = "https://github.com/NightscoutFoundation/xDrip",
3740
LogoUri = "/logos/xdrip.svg",
38-
RedirectUris = ["org.nightscoutfoundation.xdrip://oauth/callback"],
41+
RedirectUris = ["com.eveningoutpost.dexdrip://oauth/callback"],
3942
TypicalScopes =
4043
[
4144
OAuthScopes.GlucoseReadWrite,
@@ -61,11 +64,13 @@ public static class KnownOAuthClients
6164
},
6265
new()
6366
{
64-
SoftwareId = "org.androidaps.aaps",
67+
// Real Android applicationId. (app.aaps is only the new internal source
68+
// namespace; the installed package id remains info.nightscout.androidaps.)
69+
SoftwareId = "info.nightscout.androidaps",
6570
DisplayName = "AAPS",
66-
Homepage = "https://androidaps.readthedocs.io",
71+
Homepage = "https://wiki.aaps.app",
6772
LogoUri = "/logos/aaps.svg",
68-
RedirectUris = ["org.androidaps.aaps://oauth/callback"],
73+
RedirectUris = ["info.nightscout.androidaps://oauth/callback"],
6974
TypicalScopes =
7075
[
7176
OAuthScopes.GlucoseReadWrite,
@@ -76,8 +81,11 @@ public static class KnownOAuthClients
7681
},
7782
new()
7883
{
79-
SoftwareId = "github.nightscout.nightscout",
80-
DisplayName = "Nightscout",
84+
// The classic self-hosted Nightscout server (cgm-remote-monitor) acting as a
85+
// read-only follower of a Nocturne tenant. "Nightscout" is the server, not a
86+
// distinct client app, so the id is rooted on the cgm-remote-monitor repo.
87+
SoftwareId = "org.nightscout.cgm-remote-monitor",
88+
DisplayName = "Nightscout (server)",
8189
Homepage = "https://nightscout.github.io/",
8290
LogoUri = "/logos/nightscout.svg",
8391
RedirectUris = [],
@@ -91,7 +99,8 @@ public static class KnownOAuthClients
9199
},
92100
new()
93101
{
94-
SoftwareId = "io.sugarmate",
102+
// Real Android applicationId (Sugarmate is now a Tandem Diabetes Care app).
103+
SoftwareId = "com.tandemdiabetes.sugarmate",
95104
DisplayName = "Sugarmate",
96105
Homepage = "https://sugarmate.io/",
97106
LogoUri = "/logos/sugarmate.svg",
@@ -100,9 +109,10 @@ public static class KnownOAuthClients
100109
},
101110
new()
102111
{
103-
SoftwareId = "com.nickenilsson.nightwatch",
112+
// The maintained "Nightwatch" Android app (Markus Kallander); real package id.
113+
SoftwareId = "se.cornixit.nightwatch",
104114
DisplayName = "Nightwatch",
105-
Homepage = "https://github.com/nickenilsson/nightwatch",
115+
Homepage = "https://play.google.com/store/apps/details?id=se.cornixit.nightwatch",
106116
LogoUri = "/logos/nightwatch.svg",
107117
RedirectUris = [],
108118
TypicalScopes = [OAuthScopes.GlucoseRead, OAuthScopes.TreatmentsRead],
@@ -156,7 +166,9 @@ public static class KnownOAuthClients
156166
DisplayName = "Nocturne Tray",
157167
Homepage = "https://github.com/nightscout/nocturne",
158168
LogoUri = "/logos/nocturne.svg",
159-
RedirectUris = [],
169+
// Auth-code + PKCE via a loopback listener (RFC 8252). The port varies per
170+
// login; loopback redirect matching is port-agnostic at authorize time.
171+
RedirectUris = ["http://127.0.0.1/callback"],
160172
TypicalScopes =
161173
[
162174
OAuthScopes.GlucoseRead,

src/Desktop/Nocturne.Desktop.Tray/Services/OidcAuthService.cs

Lines changed: 87 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,15 @@ namespace Nocturne.Desktop.Tray.Services;
1414
/// </summary>
1515
public sealed class OidcAuthService : IDisposable
1616
{
17-
private const string ClientId = "nocturne-tray";
17+
// Reverse-DNS software_id matching the bundled known-client directory entry. The
18+
// server issues an opaque, per-tenant client_id for it via Dynamic Client
19+
// Registration — client_ids are not stable across instances, so they can't be
20+
// hardcoded.
21+
private const string SoftwareId = "com.nocturne.tray";
22+
private const string ClientName = "Nocturne Tray";
23+
// Loopback redirect (RFC 8252) registered without a port. The actual login uses a
24+
// random port; loopback redirect matching is port-agnostic at authorize time.
25+
private const string RegistrationRedirectUri = "http://127.0.0.1/callback";
1826
private const string Scopes = "glucose.read treatments.read devices.read therapy.read";
1927

2028
private readonly SettingsService _settingsService;
@@ -25,6 +33,7 @@ public sealed class OidcAuthService : IDisposable
2533
// PKCE + loopback state (live between StartLogin and callback)
2634
private string? _codeVerifier;
2735
private string? _redirectUri;
36+
private string? _clientId;
2837
private HttpListener? _loopbackListener;
2938

3039
/// <summary>
@@ -52,14 +61,22 @@ public OidcAuthService(SettingsService settingsService, ICredentialStore credent
5261

5362
/// <summary>
5463
/// Starts the OAuth 2.0 Authorization Code + PKCE flow.
55-
/// Opens the system browser to the OAuth authorize endpoint and starts
56-
/// a loopback HTTP listener to receive the authorization code callback.
64+
/// Registers the client (Dynamic Client Registration) to obtain a client_id, opens
65+
/// the system browser to the OAuth authorize endpoint, and starts a loopback HTTP
66+
/// listener to receive the authorization code callback.
5767
/// </summary>
58-
public void StartLogin()
68+
/// <returns><see langword="true"/> if the browser flow was launched.</returns>
69+
public async Task<bool> StartLoginAsync()
5970
{
6071
var serverUrl = _settingsService.Settings.ServerUrl?.TrimEnd('/');
6172
if (string.IsNullOrEmpty(serverUrl))
62-
return;
73+
return false;
74+
75+
// Obtain an opaque client_id for this server. client_ids are per-tenant random
76+
// values, so they can't be hardcoded — register by stable software_id instead.
77+
_clientId = await RegisterClientAsync(serverUrl);
78+
if (_clientId is null)
79+
return false;
6380

6481
// Generate PKCE pair
6582
_codeVerifier = GenerateCodeVerifier();
@@ -68,13 +85,13 @@ public void StartLogin()
6885
// Start loopback listener on a random port
6986
_redirectUri = StartLoopbackListener();
7087
if (_redirectUri is null)
71-
return;
88+
return false;
7289

7390
// Build OAuth authorize URL
7491
var authorizeUrl =
7592
$"{serverUrl}/api/oauth/authorize"
7693
+ $"?response_type=code"
77-
+ $"&client_id={Uri.EscapeDataString(ClientId)}"
94+
+ $"&client_id={Uri.EscapeDataString(_clientId)}"
7895
+ $"&redirect_uri={Uri.EscapeDataString(_redirectUri)}"
7996
+ $"&scope={Uri.EscapeDataString(Scopes)}"
8097
+ $"&code_challenge={Uri.EscapeDataString(codeChallenge)}"
@@ -87,6 +104,41 @@ public void StartLogin()
87104
UseShellExecute = true,
88105
}
89106
);
107+
return true;
108+
}
109+
110+
/// <summary>
111+
/// Obtains an opaque <c>client_id</c> via RFC 7591 Dynamic Client Registration.
112+
/// Registration is idempotent per tenant on <see cref="SoftwareId"/>, so reconnects
113+
/// reuse the same client. Returns <see langword="null"/> on failure.
114+
/// </summary>
115+
private async Task<string?> RegisterClientAsync(string serverUrl)
116+
{
117+
try
118+
{
119+
var request = new ClientRegistrationRequest
120+
{
121+
SoftwareId = SoftwareId,
122+
ClientName = ClientName,
123+
RedirectUris = [RegistrationRedirectUri],
124+
Scope = Scopes,
125+
};
126+
127+
var response = await _httpClient.PostAsJsonAsync(
128+
$"{serverUrl}/api/oauth/register",
129+
request
130+
);
131+
if (!response.IsSuccessStatusCode)
132+
return null;
133+
134+
var registration =
135+
await response.Content.ReadFromJsonAsync<ClientRegistrationResponse>();
136+
return string.IsNullOrEmpty(registration?.ClientId) ? null : registration.ClientId;
137+
}
138+
catch
139+
{
140+
return null;
141+
}
90142
}
91143

92144
/// <summary>
@@ -121,7 +173,7 @@ public async Task InitializeAsync()
121173
public async Task<bool> RefreshTokensAsync()
122174
{
123175
var creds = await _credentialStore.GetCredentialsAsync();
124-
if (creds is null || string.IsNullOrEmpty(creds.RefreshToken))
176+
if (creds is null || string.IsNullOrEmpty(creds.RefreshToken) || string.IsNullOrEmpty(creds.ClientId))
125177
return false;
126178

127179
try
@@ -132,7 +184,7 @@ public async Task<bool> RefreshTokensAsync()
132184
{
133185
["grant_type"] = "refresh_token",
134186
["refresh_token"] = creds.RefreshToken,
135-
["client_id"] = ClientId,
187+
["client_id"] = creds.ClientId,
136188
}
137189
);
138190

@@ -308,7 +360,7 @@ private async Task WaitForCallbackAsync(HttpListener listener)
308360
private async Task ExchangeCodeForTokensAsync(string code)
309361
{
310362
var serverUrl = _settingsService.Settings.ServerUrl?.TrimEnd('/');
311-
if (string.IsNullOrEmpty(serverUrl) || _codeVerifier is null || _redirectUri is null)
363+
if (string.IsNullOrEmpty(serverUrl) || _codeVerifier is null || _redirectUri is null || _clientId is null)
312364
{
313365
IsAuthenticated = false;
314366
AuthStateChanged?.Invoke();
@@ -323,7 +375,7 @@ private async Task ExchangeCodeForTokensAsync(string code)
323375
["grant_type"] = "authorization_code",
324376
["code"] = code,
325377
["redirect_uri"] = _redirectUri,
326-
["client_id"] = ClientId,
378+
["client_id"] = _clientId,
327379
["code_verifier"] = _codeVerifier,
328380
}
329381
);
@@ -350,6 +402,7 @@ private async Task ExchangeCodeForTokensAsync(string code)
350402
var credentials = new NocturneCredentials
351403
{
352404
ApiUrl = serverUrl,
405+
ClientId = _clientId,
353406
AccessToken = tokenResponse.AccessToken,
354407
RefreshToken = tokenResponse.RefreshToken ?? "",
355408
ExpiresAt = DateTimeOffset.UtcNow.AddSeconds(tokenResponse.ExpiresIn),
@@ -370,6 +423,7 @@ private async Task ExchangeCodeForTokensAsync(string code)
370423
{
371424
_codeVerifier = null;
372425
_redirectUri = null;
426+
_clientId = null;
373427
}
374428
}
375429

@@ -429,7 +483,28 @@ public void Dispose()
429483
_httpClient.Dispose();
430484
}
431485

432-
// ── Response model ──────────────────────────────────────────────────
486+
// ── Request/response models ─────────────────────────────────────────
487+
488+
private sealed record ClientRegistrationRequest
489+
{
490+
[JsonPropertyName("software_id")]
491+
public string SoftwareId { get; init; } = "";
492+
493+
[JsonPropertyName("client_name")]
494+
public string? ClientName { get; init; }
495+
496+
[JsonPropertyName("redirect_uris")]
497+
public List<string> RedirectUris { get; init; } = [];
498+
499+
[JsonPropertyName("scope")]
500+
public string? Scope { get; init; }
501+
}
502+
503+
private sealed record ClientRegistrationResponse
504+
{
505+
[JsonPropertyName("client_id")]
506+
public string ClientId { get; init; } = "";
507+
}
433508

434509
private sealed record OAuthTokenResponse
435510
{

src/Desktop/Nocturne.Desktop.Tray/Views/SettingsWindow.xaml.cs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -183,8 +183,14 @@ private async void OnSignInOutClick(object sender, RoutedEventArgs e)
183183
return;
184184
}
185185

186-
_authService.StartLogin();
187-
AuthStatusText.Text = "Waiting for browser sign-in...";
186+
if (await _authService.StartLoginAsync())
187+
{
188+
AuthStatusText.Text = "Waiting for browser sign-in...";
189+
}
190+
else
191+
{
192+
AuthStatusText.Text = "Could not start sign-in. Check the server URL and try again.";
193+
}
188194
}
189195
}
190196

src/Infrastructure/Nocturne.Infrastructure.Data/Entities/OAuthClientEntity.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ public class OAuthClientEntity : ITenantScoped, IEntityTimestamped
3535

3636
/// <summary>
3737
/// RFC 7591 software_id — reverse-DNS identifier that is stable across installs of the
38-
/// same product (e.g., "org.trio.diabetes"). Used to match self-registering clients
38+
/// same product (e.g., "org.nightscout.trio"). Used to match self-registering clients
3939
/// against the bundled known app directory for idempotent DCR.
4040
/// </summary>
4141
[MaxLength(255)]

tests/Unit/Nocturne.API.Tests/Authorization/KnownOAuthClientsTests.cs

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,14 @@ namespace Nocturne.API.Tests.Authorization;
77
public class KnownOAuthClientsTests
88
{
99
[Theory]
10-
[InlineData("org.trio.diabetes", "Trio")]
11-
[InlineData("org.nightscoutfoundation.xdrip", "xDrip+")]
12-
[InlineData("org.androidaps.aaps", "AAPS")]
10+
[InlineData("org.nightscout.trio", "Trio")]
11+
[InlineData("com.eveningoutpost.dexdrip", "xDrip+")]
12+
[InlineData("info.nightscout.androidaps", "AAPS")]
1313
[InlineData("org.loopkit.loop", "Loop")]
14-
[InlineData("github.nightscout.nightscout", "Nightscout")]
15-
[InlineData("io.sugarmate", "Sugarmate")]
16-
[InlineData("com.nickenilsson.nightwatch", "Nightwatch")]
14+
[InlineData("org.nightscout.cgm-remote-monitor", "Nightscout (server)")]
15+
[InlineData("com.tandemdiabetes.sugarmate", "Sugarmate")]
16+
[InlineData("se.cornixit.nightwatch", "Nightwatch")]
17+
[InlineData("com.nocturne.tray", "Nocturne Tray")]
1718
[InlineData("dev.nocturne.prelude", "Prelude")]
1819
public void MatchBySoftwareId_ReturnsEntry_ForKnownSoftwareId(string softwareId, string expectedDisplayName)
1920
{
@@ -36,7 +37,7 @@ public void MatchBySoftwareId_ReturnsNull_ForUnknown(string softwareId)
3637
public void MatchBySoftwareId_IsCaseSensitive()
3738
{
3839
// software_id is case-sensitive per RFC 7591
39-
KnownOAuthClients.MatchBySoftwareId("ORG.TRIO.DIABETES").Should().BeNull();
40+
KnownOAuthClients.MatchBySoftwareId("ORG.NIGHTSCOUT.TRIO").Should().BeNull();
4041
}
4142

4243
[Fact]

0 commit comments

Comments
 (0)