Backend guides
Adding an OAuth provider
Wire up an external OAuth provider (Google, GitHub, ...) alongside the cookie sessions, end to end.
On this page
How OAuth fits in
Slicekit authenticates with cookie sessions (see Authentication). OAuth sits beside
that: a provider proves who the user is, and Slicekit either signs in an existing identity or links the external
account to the current one. Every provider is an IOAuthProvider implementation registered in DI. The generic
endpoints under Slicekit.Api/Endpoints/v1/Auth/ (OAuthStartEndpoint, OAuthCallbackEndpoint,
LinkOAuthStartEndpoint, LinkOAuthCallbackEndpoint) drive every provider, so adding one never means writing a
new endpoint.
Adding a provider is four touch-points: a settings block, a provider class, one DI line, and a frontend entry. This guide adds Discord as the worked example.
1. Add provider settings
In api/src/Slicekit.Core/Settings/OAuthSettings.cs, add a property for the new provider next to Google and
GitHub:
public OAuthProviderSettings Discord { get; set; } = new();
Each provider reuses the shared OAuthProviderSettings record (Enabled, ClientId, ClientSecret,
RedirectUri), whose IsConfigured check guards the three credentials. Add a matching rule to Validate() so a
misconfigured provider fails at boot rather than at first login:
if (Discord.Enabled && !Discord.IsConfigured)
yield return new ValidationResult(
"OAuth:Discord requires ClientId, ClientSecret, and RedirectUri when Enabled",
[nameof(Discord)]);
Add the section to api/src/Slicekit.Api/appsettings.json with placeholder values. The dev ClientSecret stays
empty: real secrets come from env vars, never from the committed file.
"Discord": {
"Enabled": false,
"ClientId": "",
"ClientSecret": "",
"RedirectUri": "https://localhost:5077/api/v1/auth/discord/callback"
}
List the production env vars in .env.prod.example. The double-underscore maps onto the OAuth:Discord:* config
keys:
OAuth__Discord__Enabled=true
OAuth__Discord__ClientId=
OAuth__Discord__ClientSecret=
OAuth__Discord__RedirectUri=
2. Implement IOAuthProvider
Create api/src/Slicekit.Api/Auth/OAuth/DiscordOAuthProvider.cs. The interface (in IOAuthProvider.cs) is small:
build the authorization URL, exchange the code for tokens, and read back user info. GitHubOAuthProvider is the
closest template for a non-OIDC provider.
public sealed class DiscordOAuthProvider(
IHttpClientFactory httpClientFactory,
IOptions<OAuthSettings> oauth) : IOAuthProvider
{
public string Provider => "Discord";
public (string url, string codeVerifier) BuildAuthorizationUrl(string state)
{
var cfg = oauth.Value.Discord;
var (verifier, challenge) = PkceHelper.Generate();
var url = $"https://discord.com/oauth2/authorize?response_type=code" +
$"&client_id={Uri.EscapeDataString(cfg.ClientId)}" +
$"&redirect_uri={Uri.EscapeDataString(cfg.RedirectUri)}" +
$"&scope={Uri.EscapeDataString("identify email")}" +
$"&state={Uri.EscapeDataString(state)}" +
$"&code_challenge={challenge}" +
$"&code_challenge_method=S256";
return (url, verifier);
}
public async Task<OAuthTokens?> ExchangeCodeAsync(string code, string codeVerifier, CancellationToken ct)
{
// POST to the provider token endpoint; return OAuthTokens, or null on failure.
}
public async Task<OAuthUserInfo?> GetUserInfoAsync(string accessToken, CancellationToken ct)
{
// GET userinfo; return OAuthUserInfo(externalId, email), or null.
// externalId must be the provider's stable user ID, not the email.
}
}
The records the methods hand back live alongside the interface:
public sealed record OAuthTokens(string AccessToken, string? RefreshToken, string? IdToken = null);
public sealed record OAuthUserInfo(string ExternalId, string Email);
Three invariants matter:
Provideris the storage key. The string is persisted in theOAuthProviderLinkstable and is the lookup key inOAuthProviderFactory(it lower-cases on read, so casing is cosmetic but the value is not). Once used in production, never rename it, or existing links break. Capitalise it as a proper noun ("GitHub", not"github").ExternalIdmust be stable. Use the provider’s opaque or numeric user ID, never the email address, which a user can change.- OIDC providers validate the ID token. Override the default
ValidateIdTokenAsyncand check the ID token against the provider’s JWKS usingOidcIdTokenValidator. SeeGoogleOAuthProviderfor that pattern. Non-OIDC providers like GitHub and Discord leave the default no-op in place.
PkceHelper.Generate() returns a (verifier, challenge) pair. Use the challenge in the URL when the provider
supports PKCE; if it does not (GitHub, for example), still generate the verifier for interface consistency and
ignore the challenge.
3. Register in DI
In api/src/Slicekit.Api/Configuration/Auth.cs, inside the if (oauthSettings.Enabled) block, add one line beside
the existing Google and GitHub registrations:
if (oauthSettings.Discord.Enabled)
builder.Services.AddScoped<IOAuthProvider, DiscordOAuthProvider>();
OAuthProviderFactory is registered right after and discovers every IOAuthProvider from the container, so no
further wiring is needed. A provider that is configured but not Enabled is simply never registered.
4. Update the frontend
Add the provider to the union type and the static list in frontend/src/features/oauth/types.ts:
export type OAuthProviderName = 'google' | 'github' | 'discord';
export const SUPPORTED_OAUTH_PROVIDERS = [
{ id: 'google' as OAuthProviderName, label: 'Google' },
{ id: 'github' as OAuthProviderName, label: 'GitHub' },
{ id: 'discord' as OAuthProviderName, label: 'Discord' },
];
Give it an icon in frontend/src/features/oauth/components/OAuthButtons.tsx:
import { FaDiscord } from 'react-icons/fa';
const ICONS: Record<OAuthProviderName, IconType> = {
google: FcGoogle,
github: FaGithub,
discord: FaDiscord,
};
The button components filter to the providers returned by GET /api/v1/auth/features, so the Discord button
appears only when OAuth:Discord:Enabled is true. No conditional rendering beyond what is already there.
5. Whitelist the redirect URI
In the provider’s own OAuth app console, register the exact redirect URI from your settings. For local dev that is:
https://localhost:5077/api/v1/auth/discord/callback
The generic OAuthCallbackEndpoint handles the return leg for every provider, so there is no new endpoint file.
Verify
dotnet build api/slicekit.slnxpasses.- With
OAuth:EnabledandOAuth:Discord:Enabledtrue and the three credentials set,dotnet run --project api/src/Slicekit.Apistarts cleanly. ValidateOnStart rejects a half-configured provider at boot. - Log in via the new provider and confirm the account appears under Settings, Security, Linked accounts.
- Repeat from an existing session to confirm account-linking attaches the provider to the current identity.
See Getting started for bringing the API and frontend up together, and Authentication for how the resulting session cookie behaves.
Checklist
-
OAuthProviderSettingsproperty added toOAuthSettingswith a matchingValidate()rule. -
appsettings.jsonplaceholder block and.env.prod.exampleenv vars added. -
IOAuthProviderimplementation created, with a stableProvidername andExternalId. - OIDC providers override
ValidateIdTokenAsync; non-OIDC leave the default. - DI line added inside the
oauthSettings.Enabledblock inAuth.cs. - Frontend
types.tsunion, list, andOAuthButtons.tsxicon updated. - Redirect URI whitelisted in the provider’s OAuth app console.
- Build passes; login and account-linking both verified.