Files
llmgw/internal/config/oidc.go
T

98 lines
3.6 KiB
Go

package config
import (
"errors"
"fmt"
"net/url"
"strings"
)
// OIDC is sign-in through an identity provider: Keycloak, Entra ID, Google,
// Okta, Authentik — anything that speaks OpenID Connect.
//
// auth:
// oidc:
// issuer: https://login.example.com/realms/acme
// client_id: llmgw
// client_secret_file: /etc/llmgw/secrets/oidc-client-secret
// label: Acme SSO
// create_accounts: true
// roles: {admin: [llmgw-admins], operator: [llmgw-ops]}
// memberships: {ml-team: "team:Acme/ml"}
type OIDC struct {
// Issuer is the provider's address; its /.well-known/openid-configuration
// says the rest.
Issuer string `koanf:"issuer"`
// ClientID and the file holding its secret, as registered with the
// provider.
ClientID string `koanf:"client_id"`
ClientSecretFile string `koanf:"client_secret_file"`
// RedirectURL is where the provider sends people back:
// https://<gateway>/login/oidc/callback. Empty works it out from the
// request, which is right unless a proxy changes the address.
RedirectURL string `koanf:"redirect_url"`
// Label is what the sign-in button says: "Sign in with <label>".
Label string `koanf:"label"`
// Scopes asked for besides openid; empty is email and profile, and
// groups when Roles or Memberships use them.
Scopes []string `koanf:"scopes"`
// GroupsClaim names the ID token claim holding groups; empty is
// "groups".
GroupsClaim string `koanf:"groups_claim"`
// CreateAccounts makes an account, active, for a person the provider
// vouches for and the gateway does not know. Off, only existing accounts
// sign in this way.
CreateAccounts bool `koanf:"create_accounts"`
// Roles maps a staff role (viewer, operator, billing, admin) to the
// groups that give it. Set at every sign-in: someone taken out of the
// group loses the role the next time.
Roles map[string][]string `koanf:"roles"`
// Memberships maps a group to a place in the tree — org:NAME,
// team:ORG/TEAM, project:ORG/TEAM/PROJECT — joined at sign-in.
Memberships map[string]string `koanf:"memberships"`
// PasswordSignIn off refuses passwords on the sign-in form: people come
// in through the provider only. Administrators still can, so a broken
// provider does not lock everybody out.
NoPasswords bool `koanf:"no_passwords"`
}
// Enabled reports whether OIDC sign-in is configured.
func (o OIDC) Enabled() bool { return o.Issuer != "" }
// Validate checks the settings before a provider is asked anything.
func (o OIDC) Validate() error {
if !o.Enabled() {
return nil
}
u, err := url.Parse(o.Issuer)
if err != nil || u.Scheme != "https" && !(u.Scheme == "http" && (u.Hostname() == "localhost" || u.Hostname() == "127.0.0.1")) {
return fmt.Errorf("auth.oidc.issuer %q: an https address", o.Issuer)
}
if o.ClientID == "" {
return errors.New("auth.oidc.client_id is required")
}
for role := range o.Roles {
switch role {
case "viewer", "operator", "billing", "admin":
default:
return fmt.Errorf("auth.oidc.roles: %q is not viewer, operator, billing or admin", role)
}
}
for g, where := range o.Memberships {
kind, path, ok := strings.Cut(where, ":")
if !ok || path == "" || (kind != "org" && kind != "team" && kind != "project") {
return fmt.Errorf("auth.oidc.memberships.%s is %q: org:NAME, team:ORG/TEAM or project:ORG/TEAM/PROJECT", g, where)
}
}
return nil
}
// SCIM lets an identity provider provision accounts (/scim/v2): made,
// suspended and reinstated as they are there, with groups mapped by
// auth.oidc's roles and memberships.
type SCIM struct {
// TokenFile holds the bearer token the provider sends. Empty turns
// provisioning off.
TokenFile string `koanf:"token_file"`
}