Skip to content
Slicekit

Backend guides

Adding an OAuth provider

Wire up an external OAuth provider (Google, GitHub, ...) alongside the cookie sessions, end to end.

View .md
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:

  • Provider is the storage key. The string is persisted in the OAuthProviderLinks table and is the lookup key in OAuthProviderFactory (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").
  • ExternalId must 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 ValidateIdTokenAsync and check the ID token against the provider’s JWKS using OidcIdTokenValidator. See GoogleOAuthProvider for 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.slnx passes.
  • With OAuth:Enabled and OAuth:Discord:Enabled true and the three credentials set, dotnet run --project api/src/Slicekit.Api starts 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

  • OAuthProviderSettings property added to OAuthSettings with a matching Validate() rule.
  • appsettings.json placeholder block and .env.prod.example env vars added.
  • IOAuthProvider implementation created, with a stable Provider name and ExternalId.
  • OIDC providers override ValidateIdTokenAsync; non-OIDC leave the default.
  • DI line added inside the oauthSettings.Enabled block in Auth.cs.
  • Frontend types.ts union, list, and OAuthButtons.tsx icon updated.
  • Redirect URI whitelisted in the provider’s OAuth app console.
  • Build passes; login and account-linking both verified.