629 lines
19 KiB
Go
629 lines
19 KiB
Go
package authconfig
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/aritmolab/thothii/tools/tht/internal/config"
|
|
"github.com/aritmolab/thothii/tools/tht/internal/safeio"
|
|
"golang.org/x/term"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
const maxPasswordFileBytes int64 = 1025
|
|
|
|
var errCommandRefused = errors.New("authentication command refused")
|
|
|
|
// Run implements the host-only authentication operator surface. It accepts password bytes only
|
|
// from an echo-free terminal or a bounded private file, and never writes them to either stream.
|
|
func Run(ctx context.Context, installation config.Installation, args []string, stdin io.Reader, stdout, stderr io.Writer) int {
|
|
_ = ctx
|
|
if len(args) == 0 {
|
|
return authFailure(stderr, "auth requires a subcommand")
|
|
}
|
|
directory := installation.AuthenticationDirectory()
|
|
switch args[0] {
|
|
case "configure":
|
|
if err := configure(directory, args[1:], stdin, stderr); err != nil {
|
|
return authFailure(stderr, authMessage(err))
|
|
}
|
|
return 0
|
|
case "status":
|
|
if err := status(directory, args[1:], stdout); err != nil {
|
|
return authFailure(stderr, authMessage(err))
|
|
}
|
|
return 0
|
|
case "user":
|
|
if err := user(directory, args[1:], stdin, stdout, stderr); err != nil {
|
|
return authFailure(stderr, authMessage(err))
|
|
}
|
|
return 0
|
|
case "check":
|
|
return authFailure(stderr, "auth check is not available in this release")
|
|
default:
|
|
return authFailure(stderr, "unknown auth subcommand")
|
|
}
|
|
}
|
|
|
|
func authFailure(stderr io.Writer, message string) int {
|
|
fmt.Fprintf(stderr, "tht: %s\n", message)
|
|
return 2
|
|
}
|
|
|
|
func authMessage(err error) string {
|
|
switch {
|
|
case errors.Is(err, errPasswordFileRequired):
|
|
return "--password-file is required when standard input is not an interactive terminal"
|
|
case errors.Is(err, errExistingConfiguration):
|
|
return "authentication is already configured; refusing to overwrite it"
|
|
case errors.Is(err, errOIDCUsers):
|
|
return "local user commands are unavailable while OIDC authentication is configured"
|
|
case errors.Is(err, errLastAdministrator):
|
|
return "refusing to remove or disable the last enabled administrator"
|
|
case errors.Is(err, errAmbiguousGroups):
|
|
return "OIDC user and administrator groups must be different"
|
|
case errors.Is(err, errCommandRefused):
|
|
return "authentication command refused"
|
|
default:
|
|
return "authentication configuration is unavailable or invalid"
|
|
}
|
|
}
|
|
|
|
var (
|
|
errPasswordFileRequired = errors.New("password file required")
|
|
errExistingConfiguration = errors.New("existing configuration")
|
|
errOIDCUsers = errors.New("OIDC user commands")
|
|
errLastAdministrator = errors.New("last administrator")
|
|
errAmbiguousGroups = errors.New("ambiguous groups")
|
|
)
|
|
|
|
type configureRequest struct {
|
|
mode, publicURL, adminUser, adminDisplayName, passwordFile string
|
|
issuer, clientID, authentikBaseURL, userGroup, adminGroup string
|
|
}
|
|
|
|
func configure(directory string, args []string, stdin io.Reader, stderr io.Writer) error {
|
|
request, err := parseConfigure(args)
|
|
if err != nil {
|
|
return errCommandRefused
|
|
}
|
|
if err := safeio.EnsurePrivateDirectory(directory); err != nil {
|
|
return errCommandRefused
|
|
}
|
|
if _, _, err := Load(directory); err == nil {
|
|
return errExistingConfiguration
|
|
}
|
|
for _, name := range []string{authFileName, usersFileName} {
|
|
if _, err := os.Lstat(filepath.Join(directory, name)); !errors.Is(err, os.ErrNotExist) {
|
|
return errCommandRefused
|
|
}
|
|
}
|
|
if request.mode == "oidc" {
|
|
if request.userGroup == request.adminGroup {
|
|
return errAmbiguousGroups
|
|
}
|
|
return writeInitialOIDC(directory, request)
|
|
}
|
|
if request.adminUser == "" {
|
|
if err := promptLocalConfigure(&request, stdin, stderr); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
password, err := readPassword(request.passwordFile, stdin, stderr)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer zero(password)
|
|
hash, err := HashPassword(password, nil)
|
|
if err != nil {
|
|
return errCommandRefused
|
|
}
|
|
initialUser, err := newUser(request.adminUser, request.adminDisplayName, hash, []Role{RoleAdmin})
|
|
if err != nil {
|
|
return errCommandRefused
|
|
}
|
|
return writeInitialLocal(directory, request.publicURL, initialUser)
|
|
}
|
|
|
|
func parseConfigure(args []string) (configureRequest, error) {
|
|
request := configureRequest{}
|
|
values := map[string]*string{
|
|
"--mode": &request.mode, "--public-url": &request.publicURL, "--admin-user": &request.adminUser,
|
|
"--admin-display-name": &request.adminDisplayName, "--password-file": &request.passwordFile,
|
|
"--issuer": &request.issuer, "--client-id": &request.clientID, "--authentik-base-url": &request.authentikBaseURL,
|
|
"--user-group": &request.userGroup, "--admin-group": &request.adminGroup,
|
|
}
|
|
for len(args) > 0 {
|
|
option := args[0]
|
|
args = args[1:]
|
|
target, exists := values[option]
|
|
if !exists || len(args) == 0 || *target != "" {
|
|
return configureRequest{}, errCommandRefused
|
|
}
|
|
*target, args = args[0], args[1:]
|
|
}
|
|
if request.mode != "local" && request.mode != "oidc" || !validPublicURL(request.publicURL) {
|
|
return configureRequest{}, errCommandRefused
|
|
}
|
|
if request.mode == "local" {
|
|
if request.issuer != "" || request.clientID != "" || request.authentikBaseURL != "" || request.userGroup != "" || request.adminGroup != "" {
|
|
return configureRequest{}, errCommandRefused
|
|
}
|
|
if request.adminUser != "" && request.passwordFile == "" { // TTY supplies the password interactively.
|
|
return request, nil
|
|
}
|
|
return request, nil
|
|
}
|
|
if request.adminUser != "" || request.adminDisplayName != "" || request.passwordFile != "" || request.issuer == "" || request.clientID == "" || request.authentikBaseURL == "" || request.userGroup == "" || request.adminGroup == "" || !validOIDCURL(request.issuer) || !validHTTPSURL(request.authentikBaseURL) || (!isHTTPS(request.publicURL) && !isLoopbackURL(request.publicURL)) {
|
|
return configureRequest{}, errCommandRefused
|
|
}
|
|
return request, nil
|
|
}
|
|
|
|
func promptLocalConfigure(request *configureRequest, stdin io.Reader, stderr io.Writer) error {
|
|
file, ok := stdin.(*os.File)
|
|
if !ok || !term.IsTerminal(int(file.Fd())) {
|
|
return errPasswordFileRequired
|
|
}
|
|
reader := bufio.NewReader(file)
|
|
username, err := promptLine(reader, stderr, "Initial administrator username: ")
|
|
if err != nil || username == "" {
|
|
return errCommandRefused
|
|
}
|
|
displayName, err := promptLine(reader, stderr, "Initial administrator display name (optional): ")
|
|
if err != nil {
|
|
return errCommandRefused
|
|
}
|
|
request.adminUser, request.adminDisplayName = username, displayName
|
|
return nil
|
|
}
|
|
|
|
func promptLine(reader *bufio.Reader, stderr io.Writer, prompt string) (string, error) {
|
|
fmt.Fprint(stderr, prompt)
|
|
line, err := reader.ReadString('\n')
|
|
if err != nil && !errors.Is(err, io.EOF) {
|
|
return "", err
|
|
}
|
|
return strings.TrimSpace(line), nil
|
|
}
|
|
|
|
func writeInitialLocal(directory, publicURL string, user User) error {
|
|
registryBytes, err := yaml.Marshal(Registry{Version: 1, Users: []User{user}})
|
|
if err != nil {
|
|
return errCommandRefused
|
|
}
|
|
configBytes, err := yaml.Marshal(Config{Version: 1, Mode: "local", PublicURL: publicURL, Session: defaultSession(), Local: LocalConfig{UsersFile: usersFileName}})
|
|
if err != nil {
|
|
return errCommandRefused
|
|
}
|
|
if err := safeio.WriteCanonicalNewFile(filepath.Join(directory, usersFileName), append(registryBytes, '\n'), 0o600); err != nil {
|
|
return errCommandRefused
|
|
}
|
|
if err := safeio.WriteCanonicalNewFile(filepath.Join(directory, authFileName), append(configBytes, '\n'), 0o600); err != nil {
|
|
return errCommandRefused
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func writeInitialOIDC(directory string, request configureRequest) error {
|
|
configuration := Config{
|
|
Version: 1, Mode: "oidc", PublicURL: request.publicURL, Session: defaultSession(),
|
|
OIDC: OIDCConfig{Issuer: request.issuer, ClientID: request.clientID, ClientSecretRef: "THT_OIDC_CLIENT_SECRET", Scopes: []string{"openid", "profile", "email"}, GroupsClaim: "groups"},
|
|
GroupCatalog: GroupCatalogConfig{Driver: "authentik", BaseURL: request.authentikBaseURL, APITokenRef: "THT_AUTHENTIK_API_TOKEN"},
|
|
Authorization: AuthorizationConfig{GroupRoles: map[string][]Role{request.userGroup: {RoleUser}, request.adminGroup: {RoleAdmin}}},
|
|
}
|
|
contents, err := yaml.Marshal(configuration)
|
|
if err != nil {
|
|
return errCommandRefused
|
|
}
|
|
if err := safeio.WriteCanonicalNewFile(filepath.Join(directory, authFileName), append(contents, '\n'), 0o600); err != nil {
|
|
return errCommandRefused
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func defaultSession() SessionConfig {
|
|
return SessionConfig{RegularTTLSeconds: 43200, RegularIdleSeconds: 7200, RememberTTLSeconds: 2592000, RememberIdleSeconds: 604800, OIDCTTLSeconds: 28800}
|
|
}
|
|
|
|
func readPassword(path string, stdin io.Reader, stderr io.Writer) ([]byte, error) {
|
|
if path != "" {
|
|
if err := safeio.ValidatePrivateRegular(path); err != nil {
|
|
return nil, errCommandRefused
|
|
}
|
|
password, err := safeio.ReadCanonicalRegular(path, maxPasswordFileBytes)
|
|
if err != nil || len(password) > passwordMaxBytes+1 {
|
|
return nil, errCommandRefused
|
|
}
|
|
password = trimOneNewline(password)
|
|
return password, validatePasswordInput(password)
|
|
}
|
|
file, ok := stdin.(*os.File)
|
|
if !ok || !term.IsTerminal(int(file.Fd())) {
|
|
return nil, errPasswordFileRequired
|
|
}
|
|
fmt.Fprint(stderr, "Password: ")
|
|
password, err := term.ReadPassword(int(file.Fd()))
|
|
fmt.Fprintln(stderr)
|
|
if err != nil {
|
|
return nil, errCommandRefused
|
|
}
|
|
fmt.Fprint(stderr, "Confirm password: ")
|
|
confirmation, err := term.ReadPassword(int(file.Fd()))
|
|
fmt.Fprintln(stderr)
|
|
if err != nil {
|
|
zero(password)
|
|
return nil, errCommandRefused
|
|
}
|
|
matched := string(password) == string(confirmation)
|
|
zero(confirmation)
|
|
if !matched {
|
|
zero(password)
|
|
return nil, errCommandRefused
|
|
}
|
|
return password, validatePasswordInput(password)
|
|
}
|
|
|
|
func trimOneNewline(value []byte) []byte {
|
|
if bytes.HasSuffix(value, []byte("\r\n")) {
|
|
return value[:len(value)-2]
|
|
}
|
|
if bytes.HasSuffix(value, []byte("\n")) {
|
|
return value[:len(value)-1]
|
|
}
|
|
return value
|
|
}
|
|
|
|
func validatePasswordInput(password []byte) error {
|
|
if !validPassword(password) {
|
|
return errCommandRefused
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func zero(value []byte) {
|
|
for index := range value {
|
|
value[index] = 0
|
|
}
|
|
}
|
|
|
|
type statusResult struct {
|
|
Mode string `json:"mode"`
|
|
PublicURL string `json:"publicUrl"`
|
|
UserCounts map[string]int `json:"userCounts"`
|
|
Revision string `json:"configRevision"`
|
|
}
|
|
|
|
func status(directory string, args []string, stdout io.Writer) error {
|
|
jsonMode := len(args) == 1 && args[0] == "--json"
|
|
if len(args) != 0 && !jsonMode {
|
|
return errCommandRefused
|
|
}
|
|
configuration, registry, err := Load(directory)
|
|
if err != nil {
|
|
return errCommandRefused
|
|
}
|
|
counts := map[string]int{"user": 0, "admin": 0}
|
|
for _, user := range registry.Users {
|
|
for _, role := range user.Roles {
|
|
counts[string(role)]++
|
|
}
|
|
}
|
|
revision, err := configRevision(directory, configuration.Mode)
|
|
if err != nil {
|
|
return errCommandRefused
|
|
}
|
|
result := statusResult{Mode: configuration.Mode, PublicURL: configuration.PublicURL, UserCounts: counts, Revision: revision}
|
|
if jsonMode {
|
|
encoder := json.NewEncoder(stdout)
|
|
encoder.SetEscapeHTML(false)
|
|
return encoder.Encode(result)
|
|
}
|
|
_, err = fmt.Fprintf(stdout, "Authentication mode: %s\nPublic URL: %s\nUsers: user=%d admin=%d\nConfig revision: %s\n", result.Mode, result.PublicURL, counts["user"], counts["admin"], result.Revision)
|
|
return err
|
|
}
|
|
|
|
func configRevision(directory, mode string) (string, error) {
|
|
auth, err := safeio.ReadCanonicalRegular(filepath.Join(directory, authFileName), maxYAMLBytes)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
sum := sha256.New()
|
|
_, _ = sum.Write(auth)
|
|
if mode == "local" {
|
|
users, err := safeio.ReadCanonicalRegular(filepath.Join(directory, usersFileName), maxYAMLBytes)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
_, _ = sum.Write(users)
|
|
}
|
|
return "sha256:" + hex.EncodeToString(sum.Sum(nil)), nil
|
|
}
|
|
|
|
func user(directory string, args []string, stdin io.Reader, stdout, stderr io.Writer) error {
|
|
if len(args) == 0 {
|
|
return errCommandRefused
|
|
}
|
|
configuration, _, err := Load(directory)
|
|
if err != nil {
|
|
return errCommandRefused
|
|
}
|
|
if configuration.Mode != "local" {
|
|
return errOIDCUsers
|
|
}
|
|
switch args[0] {
|
|
case "list":
|
|
return listUsers(directory, args[1:], stdout)
|
|
case "add":
|
|
return addUser(directory, args[1:], stdin, stderr)
|
|
case "set-password":
|
|
return setPassword(directory, args[1:], stdin, stderr)
|
|
case "enable", "disable":
|
|
return setEnabled(directory, args[0] == "enable", args[1:])
|
|
case "grant", "revoke":
|
|
return changeRole(directory, args[0] == "grant", args[1:])
|
|
case "logout-all":
|
|
return logoutAll(directory, args[1:])
|
|
default:
|
|
return errCommandRefused
|
|
}
|
|
}
|
|
|
|
func listUsers(directory string, args []string, stdout io.Writer) error {
|
|
jsonMode := len(args) == 1 && args[0] == "--json"
|
|
if len(args) != 0 && !jsonMode {
|
|
return errCommandRefused
|
|
}
|
|
_, registry, err := Load(directory)
|
|
if err != nil {
|
|
return errCommandRefused
|
|
}
|
|
if jsonMode {
|
|
return json.NewEncoder(stdout).Encode(struct {
|
|
Users []User `json:"users"`
|
|
}{Users: registry.Users})
|
|
}
|
|
for _, item := range registry.Users {
|
|
if _, err := fmt.Fprintf(stdout, "%s\t%s\tenabled=%t\troles=%s\n", item.Username, item.DisplayName, item.Enabled, strings.Join(rolesToStrings(item.Roles), ",")); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func addUser(directory string, args []string, stdin io.Reader, stderr io.Writer) error {
|
|
if len(args) == 0 {
|
|
return errCommandRefused
|
|
}
|
|
username, request, err := parseUserRolePassword(args)
|
|
if err != nil {
|
|
return errCommandRefused
|
|
}
|
|
password, err := readPassword(request.passwordFile, stdin, stderr)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer zero(password)
|
|
hash, err := HashPassword(password, nil)
|
|
if err != nil {
|
|
return errCommandRefused
|
|
}
|
|
return MutateUsers(directory, func(registry *Registry) error {
|
|
if registry.FindByUsername(username) != nil {
|
|
return errCommandRefused
|
|
}
|
|
item, err := newUser(username, request.displayName, hash, []Role{request.role})
|
|
if err != nil {
|
|
return errCommandRefused
|
|
}
|
|
registry.Users = append(registry.Users, item)
|
|
return nil
|
|
})
|
|
}
|
|
|
|
type userRequest struct {
|
|
role Role
|
|
displayName, passwordFile string
|
|
}
|
|
|
|
func parseUserRolePassword(args []string) (string, userRequest, error) {
|
|
username := args[0]
|
|
args = args[1:]
|
|
request := userRequest{}
|
|
for len(args) > 0 {
|
|
option := args[0]
|
|
args = args[1:]
|
|
if len(args) == 0 {
|
|
return "", userRequest{}, errCommandRefused
|
|
}
|
|
value := args[0]
|
|
args = args[1:]
|
|
switch option {
|
|
case "--role":
|
|
if request.role != "" {
|
|
return "", userRequest{}, errCommandRefused
|
|
}
|
|
request.role = Role(value)
|
|
case "--display-name":
|
|
if request.displayName != "" {
|
|
return "", userRequest{}, errCommandRefused
|
|
}
|
|
request.displayName = value
|
|
case "--password-file":
|
|
if request.passwordFile != "" {
|
|
return "", userRequest{}, errCommandRefused
|
|
}
|
|
request.passwordFile = value
|
|
default:
|
|
return "", userRequest{}, errCommandRefused
|
|
}
|
|
}
|
|
if !usernamePattern.MatchString(username) || (request.role != RoleUser && request.role != RoleAdmin) {
|
|
return "", userRequest{}, errCommandRefused
|
|
}
|
|
return username, request, nil
|
|
}
|
|
|
|
func setPassword(directory string, args []string, stdin io.Reader, stderr io.Writer) error {
|
|
if len(args) < 1 || !usernamePattern.MatchString(args[0]) {
|
|
return errCommandRefused
|
|
}
|
|
username := args[0]
|
|
passwordFile, err := parsePasswordFile(args[1:])
|
|
if err != nil {
|
|
return errCommandRefused
|
|
}
|
|
password, err := readPassword(passwordFile, stdin, stderr)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer zero(password)
|
|
hash, err := HashPassword(password, nil)
|
|
if err != nil {
|
|
return errCommandRefused
|
|
}
|
|
return MutateUsers(directory, func(registry *Registry) error {
|
|
item := registry.FindByUsername(username)
|
|
if item == nil {
|
|
return errCommandRefused
|
|
}
|
|
item.PasswordHash = hash
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func parsePasswordFile(args []string) (string, error) {
|
|
if len(args) == 0 {
|
|
return "", nil
|
|
}
|
|
if len(args) != 2 || args[0] != "--password-file" || args[1] == "" {
|
|
return "", errCommandRefused
|
|
}
|
|
return args[1], nil
|
|
}
|
|
|
|
func setEnabled(directory string, enabled bool, args []string) error {
|
|
if len(args) != 1 || !usernamePattern.MatchString(args[0]) {
|
|
return errCommandRefused
|
|
}
|
|
return MutateUsers(directory, func(registry *Registry) error {
|
|
item := registry.FindByUsername(args[0])
|
|
if item == nil {
|
|
return errCommandRefused
|
|
}
|
|
if !enabled && item.Enabled && hasRole(item.Roles, RoleAdmin) && enabledAdminCount(*registry) == 1 {
|
|
return errLastAdministrator
|
|
}
|
|
item.Enabled = enabled
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func changeRole(directory string, grant bool, args []string) error {
|
|
if len(args) != 3 || !usernamePattern.MatchString(args[0]) || args[1] != "--role" || (args[2] != string(RoleUser) && args[2] != string(RoleAdmin)) {
|
|
return errCommandRefused
|
|
}
|
|
role := Role(args[2])
|
|
return MutateUsers(directory, func(registry *Registry) error {
|
|
item := registry.FindByUsername(args[0])
|
|
if item == nil {
|
|
return errCommandRefused
|
|
}
|
|
if grant {
|
|
if hasRole(item.Roles, role) {
|
|
return errCommandRefused
|
|
}
|
|
item.Roles = append(item.Roles, role)
|
|
return nil
|
|
}
|
|
if !hasRole(item.Roles, role) || len(item.Roles) == 1 {
|
|
return errCommandRefused
|
|
}
|
|
if role == RoleAdmin && item.Enabled && enabledAdminCount(*registry) == 1 {
|
|
return errLastAdministrator
|
|
}
|
|
item.Roles = withoutRole(item.Roles, role)
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func logoutAll(directory string, args []string) error {
|
|
if len(args) != 2 || !usernamePattern.MatchString(args[0]) || args[1] != "--yes" {
|
|
return errCommandRefused
|
|
}
|
|
return MutateUsers(directory, func(registry *Registry) error {
|
|
item := registry.FindByUsername(args[0])
|
|
if item == nil || item.AuthRevision == ^uint64(0) {
|
|
return errCommandRefused
|
|
}
|
|
item.AuthRevision++
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func enabledAdminCount(registry Registry) int {
|
|
count := 0
|
|
for _, item := range registry.Users {
|
|
if item.Enabled && hasRole(item.Roles, RoleAdmin) {
|
|
count++
|
|
}
|
|
}
|
|
return count
|
|
}
|
|
|
|
func withoutRole(roles []Role, unwanted Role) []Role {
|
|
result := make([]Role, 0, len(roles)-1)
|
|
for _, role := range roles {
|
|
if role != unwanted {
|
|
result = append(result, role)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func rolesToStrings(roles []Role) []string {
|
|
values := make([]string, len(roles))
|
|
for index, role := range roles {
|
|
values[index] = string(role)
|
|
}
|
|
return values
|
|
}
|
|
|
|
func validPublicURL(value string) bool {
|
|
parsed, err := url.Parse(value)
|
|
return err == nil && (parsed.Scheme == "http" || parsed.Scheme == "https") && parsed.Host != "" && parsed.User == nil && parsed.Path == "" && parsed.RawQuery == "" && parsed.Fragment == ""
|
|
}
|
|
|
|
func validOIDCURL(value string) bool {
|
|
parsed, err := url.Parse(value)
|
|
return err == nil && parsed.Scheme == "https" && parsed.Host != "" && parsed.User == nil && parsed.RawQuery == "" && parsed.Fragment == ""
|
|
}
|
|
|
|
func validHTTPSURL(value string) bool {
|
|
return validOIDCURL(value) && strings.TrimSuffix(value, "/") == value
|
|
}
|
|
func isHTTPS(value string) bool {
|
|
parsed, err := url.Parse(value)
|
|
return err == nil && parsed.Scheme == "https"
|
|
}
|
|
func isLoopbackURL(value string) bool {
|
|
parsed, err := url.Parse(value)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
host := parsed.Hostname()
|
|
return host == "localhost" || net.ParseIP(host) != nil && net.ParseIP(host).IsLoopback()
|
|
}
|