OIDC clients

An OIDC client is your application's registration inside Huudis — the client_id, the redirect URIs Huudis will trust, the scopes it can request. Every product that wants to delegate sign-in to Huudis must register a client. The Go SDK exposes them on client.OidcClients. For the protocol see OIDC overview; for the HTTP surface see API → OIDC clients.

Field on the Client

client.OidcClients — type *huudis.OidcClientsResource. There's no Get — List returns the full record for every client (typically fewer than a dozen rows).

Methods

List

Signature. func (r *OidcClientsResource) List(ctx context.Context, auth RequestAuth) ([]OidcClient, error)

Returns every OIDC client registered in the active workspace. ClientSecret is never present on the list response — only at create-time and on RotateSecret.

all, err := client.OidcClients.List(ctx, huudis.RequestAuth{})
if err != nil {
    return err
}
for _, c := range all {
    kind := "(public)"
    if c.Confidential {
        kind = "(confidential)"
    }
    fmt.Println(c.ID, c.Name, kind)
}

Create

Signature. func (r *OidcClientsResource) Create(ctx context.Context, in CreateOidcClientInput, auth RequestAuth) (*OidcClient, error)

Registers a new client. RedirectUris must be exact-match HTTPS URLs (with localhost HTTP allowed in development). Confidential is a *bool — pass huudis.Ptr(true) to mint a client_secret, huudis.Ptr(false) for SPAs/native apps using PKCE alone.

confidential := true
oc, err := client.OidcClients.Create(ctx, huudis.CreateOidcClientInput{
    Name: "Acme Web",
    RedirectUris: []string{
        "https://acme.example.com/callback",
        "https://staging.acme.example.com/callback",
    },
    Scopes:       []string{"openid", "profile", "email"},
    Confidential: &confidential,
}, huudis.RequestAuth{})
if err != nil {
    return err
}
log.Println("client_id:", oc.ID)
log.Println("client_secret:", oc.ClientSecret) // SAVE NOW — never shown again

ClientSecret is shown exactly once. It's set only on this create response and on RotateSecret. Lose it and the only recovery is rotation. Public clients (Confidential = false) never get a secret — they authenticate via PKCE alone.

Update

Signature. func (r *OidcClientsResource) Update(ctx context.Context, id string, patch map[string]any, auth RequestAuth) (*OidcClient, error)

PATCH — only keys in patch change. Renaming is fine. Flipping confidential from false to true after creation is rejected (state_conflict).

all, _ := client.OidcClients.List(ctx, huudis.RequestAuth{})
var existing huudis.OidcClient
for _, c := range all {
    if c.ID == clientID {
        existing = c
        break
    }
}
_, err := client.OidcClients.Update(ctx, clientID, map[string]any{
    "redirectUris": append(existing.RedirectUris, "https://acme.example.com/auth/callback"),
}, huudis.RequestAuth{})

RotateSecret

Signature. func (r *OidcClientsResource) RotateSecret(ctx context.Context, id string, auth RequestAuth) (*RotateSecretResult, error)

Mints a new secret and invalidates the old one immediately. Confidential-only — public clients return state_conflict.

res, err := client.OidcClients.RotateSecret(ctx, "client_01HX...", huudis.RequestAuth{})
if err != nil {
    return err
}
storeSecret(res.ClientSecret)

There's no overlap window. Coordinate with a deploy: write the new secret first, restart your service so it picks it up, then issue the rotate call.

Delete

Signature. func (r *OidcClientsResource) Delete(ctx context.Context, id string, auth RequestAuth) error

Permanently removes the client. Existing access tokens issued by this client continue to work until they expire (typically 1h); refresh tokens are revoked immediately.

if err := client.OidcClients.Delete(ctx, "client_01HX...", huudis.RequestAuth{}); err != nil {
    return err
}

Types

type OidcClient struct {
    ID           string   `json:"id"`              // the client_id ("client_...")
    AccountID    string   `json:"accountId"`
    Name         string   `json:"name"`
    RedirectUris []string `json:"redirectUris"`
    Scopes       []string `json:"scopes"`
    ClientSecret string   `json:"clientSecret,omitempty"` // only on create + RotateSecret
    Confidential bool     `json:"confidential"`
    CreatedAt    string   `json:"createdAt"`       // ISO-8601
    UpdatedAt    string   `json:"updatedAt"`
}

type RotateSecretResult struct {
    Secret       string `json:"secret,omitempty"`
    ClientSecret string `json:"clientSecret,omitempty"`
}

See API → OIDC clients for the field-level reference.

Common patterns

Per-environment clients

The cleanest model: one client per environment, with environment-specific redirect URIs.

func provisionEnvironments(ctx context.Context, c *huudis.Client) error {
    confidential := true
    for _, env := range []string{"production", "staging", "development"} {
        oc, err := c.OidcClients.Create(ctx, huudis.CreateOidcClientInput{
            Name:         fmt.Sprintf("Acme Web (%s)", env),
            RedirectUris: []string{fmt.Sprintf("https://%s.acme.example.com/callback", env)},
            Confidential: &confidential,
        }, huudis.RequestAuth{})
        if err != nil {
            return err
        }
        writeEnvFile(env, map[string]string{
            "HUUDIS_CLIENT_ID":     oc.ID,
            "HUUDIS_CLIENT_SECRET": oc.ClientSecret,
        })
    }
    return nil
}

Add a new redirect URI without downtime

Update replaces the array, so always include the existing entries when adding:

var existing huudis.OidcClient
all, _ := client.OidcClients.List(ctx, huudis.RequestAuth{})
for _, c := range all {
    if c.ID == clientID {
        existing = c
        break
    }
}
_, _ = client.OidcClients.Update(ctx, clientID, map[string]any{
    "redirectUris": append(existing.RedirectUris, "https://new.example.com/callback"),
}, huudis.RequestAuth{})

Detect un-rotated secrets

A monthly audit:

func staleClients(ctx context.Context, c *huudis.Client, maxDays int) ([]huudis.OidcClient, error) {
    all, err := c.OidcClients.List(ctx, huudis.RequestAuth{})
    if err != nil {
        return nil, err
    }
    cutoff := time.Now().Add(-time.Duration(maxDays) * 24 * time.Hour)
    var stale []huudis.OidcClient
    for _, oc := range all {
        if !oc.Confidential {
            continue
        }
        t, _ := time.Parse(time.RFC3339, oc.UpdatedAt)
        if t.Before(cutoff) {
            stale = append(stale, oc)
        }
    }
    return stale, nil
}

Pair with a CI job that pages on the result — confidential client secrets should rotate at least every six months.

Errors

Code Status Cause
validation_error 400 Bad redirect URI (non-HTTPS, fragment), unknown scope, name too long.
not_found 404 Client doesn't exist or lives in another workspace.
conflict 409 Name already taken in this workspace.
state_conflict 409 Rotating a public client; flipping confidential after creation.
forbidden 403 Caller isn't an admin/owner.

Next