auth.ldap checks a username and password against the domain controllers over LDAPS or StartTLS: found with a service account, checked by binding as the person. Groups, nested ones included, give staff roles and places in the tree. Accounts are tied to the objectGUID; the leader reads the directory every 15 minutes, suspends whom it disabled or removed and reinstates them after, and refuses a sync that would suspend most. Entra ID sends no groups for someone in more than 200, only where to fetch them; that was read as none and took an administrator's role away. It is now left as it was, and logged. An account made by SCIM or OIDC can be reinstated again. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
111 lines
4.0 KiB
Go
111 lines
4.0 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 {
|
|
if !validStaffRole(role) {
|
|
return fmt.Errorf("auth.oidc.roles: %q is not viewer, operator, billing or admin", role)
|
|
}
|
|
}
|
|
for g, where := range o.Memberships {
|
|
if !validPlace(where) {
|
|
return fmt.Errorf("auth.oidc.memberships.%s is %q: org:NAME, team:ORG/TEAM or project:ORG/TEAM/PROJECT", g, where)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validStaffRole is a role groups may give: viewer, operator, billing, admin.
|
|
func validStaffRole(role string) bool {
|
|
switch role {
|
|
case "viewer", "operator", "billing", "admin":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// validPlace is a place in the tree a group may give: org:NAME,
|
|
// team:ORG/TEAM or project:ORG/TEAM/PROJECT.
|
|
func validPlace(where string) bool {
|
|
kind, path, ok := strings.Cut(where, ":")
|
|
return ok && path != "" && (kind == "org" || kind == "team" || kind == "project")
|
|
}
|
|
|
|
// 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"`
|
|
}
|