PostIt: switch OIDC client from confidential (client_secret) to public (PKCE/JWT)
PostIt is a desktop/mobile app talking to Yavsc.Org (https://yavsc.pschneider.fr) as an OIDC identity provider. The previous grant used the client_credentials flow with a client_secret embedded in postit-settings.json: this was both insecure (secret travels with the binary) and inappropriate for an interactive app (token had no user identity, so the API could not scope or audit). The new flow is Authorization Code + PKCE: * PostIt client (Settings/AuthenticationSettings.cs): the ClientSecret property is removed; GetOidcClientOptions now drops the secret and accepts an optional IBrowser supplied per-platform. * Settings.cs: new AndroidRedirectUri constant ('android://postit-signin') that the Android app uses; RedirectUri is no longer hard-coded in MainViewModel. * MainViewModel.cs: the manual discovery + client_credentials POST is replaced with OidcClient.LoginAsync (Authorization Code + PKCE). * Settings sample: Authority points at the real Yavsc.Org OP, not at a non-existent Keycloak-style realm path. * Yavsc.Org/Extensions/HostingExtensions.cs: the 'postit' client seed is now idempotent (MigratePostItClientToPublic) and detects legacy state on existing ConfigurationDb rows - flips RequireClientSecret=false, RequirePkce=true, drops any ClientSecret row, and replaces the legacy RedirectUris (https://yavsc.pschneider.fr/, yavsc://callback) with the current set (http://127.0.0.1:7890/, android://postit-signin). PostIt.Android: * MainActivity: explicit Name attribute so the activity alias can target a stable component; LaunchMode.SingleTask so the existing instance receives the deep-link Intent; OnNewIntent forwards the callback URI through AndroidOidcCallbackSink. * AndroidManifest.xml: activity-alias PostIt.Android.OidcCallbackActivity exposing scheme=android host=postit-signin to Android, so the OP redirect lands back in the running PostIt instance. The IdentityModel.OidcClient.Browser.SystemBrowser package and a thin AndroidSystemBrowser implementation are added in a follow-up so OidcClient.LoginAsync can actually drive Chrome Custom Tabs and consume AndroidOidcCallbackSink.
This commit is contained in:
parent
2fd799c09f
commit
512a0ef06f
9 changed files with 324 additions and 116 deletions
|
|
@ -354,68 +354,208 @@ public static class HostingExtensions
|
|||
};
|
||||
}
|
||||
|
||||
private const string PostItClientId = "postit";
|
||||
|
||||
private static readonly string[] PostItRedirectUris = new[]
|
||||
{
|
||||
// Loopback URI for desktop / browser-based PKCE flows.
|
||||
"http://127.0.0.1:7890/",
|
||||
// Custom-scheme URI for Android. The matching IntentFilter must be
|
||||
// declared in PostIt.Android/Properties/AndroidManifest.xml.
|
||||
"android://postit-signin",
|
||||
};
|
||||
|
||||
private static readonly string[] PostItGrantTypes = new[]
|
||||
{
|
||||
"authorization_code",
|
||||
"client_credentials",
|
||||
};
|
||||
|
||||
private static readonly string[] PostItScopes = new[]
|
||||
{
|
||||
"blog",
|
||||
IdentityServer8.IdentityServerConstants.StandardScopes.OpenId,
|
||||
IdentityServer8.IdentityServerConstants.StandardScopes.Profile,
|
||||
};
|
||||
|
||||
private static Action<DbContext, bool> EnsureDefaultConfiguration()
|
||||
{
|
||||
return (context, _) =>
|
||||
{
|
||||
EnsureDefaultApplicationScopes()(context, _);
|
||||
|
||||
var existingClient = context.Set<IdentityServer8.EntityFramework.Entities.Client>().FirstOrDefault(c => c.ClientId == "postit");
|
||||
if (existingClient == null)
|
||||
var clients = context.Set<IdentityServer8.EntityFramework.Entities.Client>();
|
||||
var existingClient = clients.FirstOrDefault(c => c.ClientId == PostItClientId);
|
||||
|
||||
if (existingClient is null)
|
||||
{
|
||||
var client = new IdentityServer8.EntityFramework.Entities.Client
|
||||
{
|
||||
ClientId = "postit",
|
||||
Enabled = true,
|
||||
RequireClientSecret = true,
|
||||
ProtocolType = "oidc",
|
||||
RequireConsent = false,
|
||||
};
|
||||
SeedNewPostItClient(context);
|
||||
return;
|
||||
}
|
||||
|
||||
context.Set<Client>().Add(client);
|
||||
// allow authorization code (interactive) and client credentials (m2m)
|
||||
MigratePostItClientToPublic(context, existingClient);
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Insert a brand new <c>postit</c> client configured as a public OIDC
|
||||
/// client using Authorization Code + PKCE. Used the first time the
|
||||
/// ConfigurationDb is seeded.
|
||||
/// </summary>
|
||||
private static void SeedNewPostItClient(DbContext context)
|
||||
{
|
||||
// PostIt is a public client (Authorization Code + PKCE).
|
||||
// No client secret is stored or transmitted; PKCE binds the
|
||||
// authorization code to the requesting device.
|
||||
var client = new IdentityServer8.EntityFramework.Entities.Client
|
||||
{
|
||||
ClientId = PostItClientId,
|
||||
Enabled = true,
|
||||
RequireClientSecret = false,
|
||||
RequirePkce = true,
|
||||
ProtocolType = "oidc",
|
||||
RequireConsent = false,
|
||||
};
|
||||
|
||||
context.Set<Client>().Add(client);
|
||||
|
||||
foreach (var grantType in PostItGrantTypes)
|
||||
{
|
||||
context.Set<ClientGrantType>().Add(new IdentityServer8.EntityFramework.Entities.ClientGrantType
|
||||
{
|
||||
Client = client,
|
||||
GrantType = grantType
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var scope in PostItScopes)
|
||||
{
|
||||
context.Set<ClientScope>().Add(new IdentityServer8.EntityFramework.Entities.ClientScope
|
||||
{
|
||||
Client = client,
|
||||
Scope = scope
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var redirectUri in PostItRedirectUris)
|
||||
{
|
||||
context.Set<ClientRedirectUri>().Add(new IdentityServer8.EntityFramework.Entities.ClientRedirectUri
|
||||
{
|
||||
Client = client,
|
||||
RedirectUri = redirectUri
|
||||
});
|
||||
}
|
||||
|
||||
// No ClientSecret row: PKCE-only clients don't need one.
|
||||
context.SaveChanges();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bring an existing <c>postit</c> client up to the current public-client
|
||||
/// configuration. Idempotent: each change is applied only when the row is
|
||||
/// currently in the legacy state.
|
||||
/// </summary>
|
||||
private static void MigratePostItClientToPublic(
|
||||
DbContext context,
|
||||
IdentityServer8.EntityFramework.Entities.Client client)
|
||||
{
|
||||
var changed = false;
|
||||
|
||||
// 1. Drop the client secret. PKCE-only clients must not have one.
|
||||
var secrets = context.Set<ClientSecret>().Where(s => s.Client.Id == client.Id);
|
||||
if (secrets.Any())
|
||||
{
|
||||
context.Set<ClientSecret>().RemoveRange(secrets);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
// 2. Flip the security flags.
|
||||
if (client.RequireClientSecret)
|
||||
{
|
||||
client.RequireClientSecret = false;
|
||||
changed = true;
|
||||
}
|
||||
if (!client.RequirePkce)
|
||||
{
|
||||
client.RequirePkce = true;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
// 3. Ensure all expected grant types are present (don't remove extras
|
||||
// that may have been added by hand).
|
||||
var existingGrantTypes = context.Set<ClientGrantType>()
|
||||
.Where(g => g.Client.Id == client.Id)
|
||||
.Select(g => g.GrantType)
|
||||
.ToHashSet();
|
||||
foreach (var grantType in PostItGrantTypes)
|
||||
{
|
||||
if (!existingGrantTypes.Contains(grantType))
|
||||
{
|
||||
context.Set<ClientGrantType>().Add(new IdentityServer8.EntityFramework.Entities.ClientGrantType
|
||||
{
|
||||
Client = client,
|
||||
GrantType = "authorization_code"
|
||||
});
|
||||
context.Set<ClientGrantType>().Add(new IdentityServer8.EntityFramework.Entities.ClientGrantType
|
||||
{
|
||||
Client = client,
|
||||
GrantType = "client_credentials"
|
||||
GrantType = grantType
|
||||
});
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Ensure all expected scopes are present.
|
||||
var existingScopes = context.Set<ClientScope>()
|
||||
.Where(s => s.Client.Id == client.Id)
|
||||
.Select(s => s.Scope)
|
||||
.ToHashSet();
|
||||
foreach (var scope in PostItScopes)
|
||||
{
|
||||
if (!existingScopes.Contains(scope))
|
||||
{
|
||||
context.Set<ClientScope>().Add(new IdentityServer8.EntityFramework.Entities.ClientScope
|
||||
{
|
||||
Client = client,
|
||||
Scope = "blog"
|
||||
});
|
||||
context.Set<ClientScope>().Add(new IdentityServer8.EntityFramework.Entities.ClientScope
|
||||
{
|
||||
Client = client,
|
||||
Scope = IdentityServer8.IdentityServerConstants.StandardScopes.OpenId
|
||||
});
|
||||
context.Set<ClientScope>().Add(new IdentityServer8.EntityFramework.Entities.ClientScope
|
||||
{
|
||||
Client = client,
|
||||
Scope = IdentityServer8.IdentityServerConstants.StandardScopes.Profile
|
||||
Scope = scope
|
||||
});
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Ensure all expected redirect URIs are present. Legacy entries
|
||||
// pointing at the OP itself (e.g. https://yavsc.pschneider.fr/)
|
||||
// are removed — they redirect back into IdentityServer's own home
|
||||
// page and create a login loop.
|
||||
var existingRedirects = context.Set<ClientRedirectUri>()
|
||||
.Where(r => r.Client.Id == client.Id)
|
||||
.ToList();
|
||||
var existingRedirectUris = existingRedirects
|
||||
.Select(r => r.RedirectUri)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
foreach (var redirectUri in PostItRedirectUris)
|
||||
{
|
||||
if (!existingRedirectUris.Contains(redirectUri))
|
||||
{
|
||||
context.Set<ClientRedirectUri>().Add(new IdentityServer8.EntityFramework.Entities.ClientRedirectUri
|
||||
{
|
||||
Client = client,
|
||||
RedirectUri = "http://127.0.0.1:7890/"
|
||||
RedirectUri = redirectUri
|
||||
});
|
||||
|
||||
context.Set<ClientSecret>().Add(new IdentityServer8.EntityFramework.Entities.ClientSecret
|
||||
{
|
||||
Client = client,
|
||||
Value = "postit-secret".ToSha256(),
|
||||
});
|
||||
|
||||
context.SaveChanges();
|
||||
changed = true;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
var legacyRedirects = existingRedirects
|
||||
.Where(r => r.RedirectUri == "https://yavsc.pschneider.fr/"
|
||||
|| r.RedirectUri == "yavsc://callback")
|
||||
.ToList();
|
||||
if (legacyRedirects.Count > 0)
|
||||
{
|
||||
context.Set<ClientRedirectUri>().RemoveRange(legacyRedirects);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed)
|
||||
{
|
||||
context.SaveChanges();
|
||||
}
|
||||
}
|
||||
|
||||
private static void ConfigureRequestLocalization(IServiceCollection services)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue