Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
98 lines
3.6 KiB
Go
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"`
|
|
}
|