Skip to content

feat(auth): register a custom OAuth/OIDC provider as an admin - #590

Merged
Tr00d merged 1 commit into
masterfrom
feat/auth.admin.create_provider
Oct 29, 2026
Merged

Tr00d merged 1 commit into
masterfrom
feat/auth.admin.create_provider

Conversation

@Tr00d

@Tr00d Tr00d commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Admins can register a custom OAuth 2.0 or OpenID Connect provider. This PR also adds the shared custom-provider types that the get, list and update PRs build on.

Capability: auth.admin.create_provider: Admin Create Provider.

Public API

// IGotrueAdminClient<TUser>
Task<CustomProvider> CreateCustomProviderAsync(NewCustomProvider provider, CustomProviderOptions? options = null, CancellationToken cancellationToken = default);

New types, laid out by use case (CONVENTIONS §2):

Type Namespace Members
CustomProviderType Supabase.Gotrue.Common OAuth2, Oidc
CustomProvider (response record) Supabase.Gotrue.Common Id, Type, Identifier, Name, ClientId, AcceptableClientIds, Scopes, PkceEnabled, AttributeMapping, CustomClaimsAllowlist, AuthorizationParameters, Enabled, EmailOptional, Issuer, DiscoveryUrl, SkipNonceCheck, DiscoveryDocument, AuthorizationUrl, TokenUrl, UserInfoUrl, JwksUri, CreatedAt, UpdatedAt. Collections are read-only and never null.
OidcDiscoveryDocument Supabase.Gotrue.Common Issuer, AuthorizationEndpoint, TokenEndpoint, UserInfoEndpoint, JwksUri, ScopesSupported, ResponseTypesSupported, GrantTypesSupported, SubjectTypesSupported
NewCustomProvider Supabase.Gotrue.CreateCustomProvider Built with NewCustomProvider.OAuth2(identifier, name, clientId, clientSecret, authorizationUrl, tokenUrl, userInfoUrl) or NewCustomProvider.Oidc(identifier, name, clientId, clientSecret, issuer). The constructor is private.
CustomProviderOptions Supabase.Gotrue.Common Optional settings, all nullable. A null member is not sent, so the server applies its default.
using Supabase.Gotrue.Common;
using Supabase.Gotrue.CreateCustomProvider;

var admin = new AdminClient(serviceRoleKey, new ClientOptions { Url = authUrl });
var provider = await admin.CreateCustomProviderAsync(
    NewCustomProvider.Oidc("custom:mycompany", "My Company SSO", clientId: "id", clientSecret: "secret",
        issuer: new Uri("https://accounts.mycompany.com")),
    new CustomProviderOptions { Scopes = new[] { "email", "profile" } });

IGotrueApi<TUser, TSession> gains the matching CreateCustomProviderAsync(string jwt, NewCustomProvider, CustomProviderOptions?, CancellationToken).

Behavior

Operation POST /admin/custom-providers with the service-role key
Input The factory supplies the fields each protocol needs: provider_type, identifier, name, client_id and client_secret, plus issuer (OIDC) or authorization_url, token_url and userinfo_url (OAuth2). Options are sent only when set. Otherwise the server's defaults apply: PKCE on, enabled, email required, nonce checked. For OIDC the server adds openid to the scopes.
Output 201 → CustomProvider. Null collections in the response become empty.
Checks before the request ArgumentNullException for a null provider or a null factory argument. ArgumentException (the same check as delete) when the identifier lacks the case-sensitive custom: prefix or contains /, \, ?, # or %. The server accepts such identifiers on create, but the SDK could then never get, update or delete the provider.
Failures GotrueException. ErrorCode is conflict (identifier taken), over_custom_provider_quota or validation_failed (bad settings, a non-HTTPS or loopback URL, an OIDC discovery fetch or issuer mismatch), all with 400. Reason = AdminTokenRequired on 401/403. ErrorCode = feature_disabled on 404. An unrecognized provider_type or a null body in the response also throws GotrueException.
State None

NewCustomProvider.ToString() leaves out the client secret. By default a record prints every property.

Deviations from supabase-js

  • supabase-js sends an untyped parameter bag. Here the per-protocol required fields are mandatory through named factories, and an identifier that would change the route is rejected before anything is sent.
  • supabase-js names the discovery-document fields supported_scopes, supported_response_types and so on. The auth server sends scopes_supported, response_types_supported, grant_types_supported and subject_types_supported, and OidcDiscoveryDocument follows the server.

Tests

Tier Class Pins
E2E CreateCustomProviderTests Registering an OAuth2 provider with options stores them and applies defaults. A taken identifier → ErrorCode = conflict. A non-admin key → AdminTokenRequired.
Contract CreateCustomProviderContractTests Route, method and service-role credentials. Mapping of OAuth2, OIDC (including the discovery document) and sparse responses. Unknown type and null body → GotrueException. Null provider. A cancelled token sends nothing. The call completes when the caller blocks on a context that never runs continuations.
Contract (approval) CreateCustomProviderApprovalTests Exact body for an OAuth2 provider without options, and for an OIDC provider with every option
Unit NewCustomProviderTests Null factory arguments, identifiers custom provider administration cannot address, secret kept out of ToString()

Changed-line coverage: 100% (208 lines). Mutation: 20/20 killed on changed lines, 0 survivors (3 compile errors, 3 ignored).

Layout

packages/Gotrue/Gotrue/
  Common/                              shared by create, get, list and update
    CustomProvider.cs                  result type every custom-provider use case returns
    CustomProviderResponse.cs          internal: wire DTO and mapping to CustomProvider
    CustomProviderType.cs
    OidcDiscoveryDocument.cs
    OidcDiscoveryDocumentResponse.cs   internal
    CustomProviderOptions.cs           settings create and update (#593) both accept
    CustomProviderOptionsBody.cs       internal: wire mapping of those settings, the base of each use case's body
  CreateCustomProvider/
    NewCustomProvider.cs
    CreateCustomProviderBody.cs        internal: create's wire body
packages/Gotrue/Gotrue.Tests/
  Common/                              CustomProviderAdminFixture (E2E seeding and cleanup), CustomProviderResponses, Data/*.json
  CreateCustomProvider/                E2E, contract, approval and unit tests, Data/*.verified.txt
  Support/BlockingCaller.cs            the deadlock check every async contract test uses

The Common/ types are in Common/ from this PR, even though only create uses them here. They are the result type that get (#591), list (#592) and update (#593) return, and CustomProviderOptions is also accepted by update (#593). Putting them in Common/ now avoids moving them in the next PR. The same reasoning applies to the response fixtures and the E2E base in Gotrue.Tests/Common/.

CustomProviderOptionsBody maps the optional settings once for create's body and update's body. Each body is its own type, with no mode switch. [JsonPropertyOrder] keeps the approved field order.

The shipped delete code (CustomProviders/CustomProviderIdentifier.cs, Admin/AdminCustomProvider*Tests.cs) is not moved.

Notes

@Tr00d
Tr00d force-pushed the feat/auth.admin.create_provider branch from df67450 to 627dbc6 Compare October 29, 2026 14:28
@Tr00d
Tr00d marked this pull request as ready for review October 29, 2026 14:41
@Tr00d
Tr00d merged commit 4c11610 into master Oct 29, 2026
3 checks passed
@Tr00d
Tr00d deleted the feat/auth.admin.create_provider branch October 29, 2026 14:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant