Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
244 changes: 238 additions & 6 deletions auth/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,18 +68,41 @@ type oidcTokenResponse struct {
ErrorDescription string `json:"error_description"`
}

type backendDeviceStartResponse struct {
FlowID string `json:"flow_id"`
UserCode string `json:"user_code"`
VerificationURI string `json:"verification_uri"`
VerificationURIComplete string `json:"verification_uri_complete"`
ExpiresIn int `json:"expires_in"`
Interval int `json:"interval"`
}

type backendDevicePollResponse struct {
Status string `json:"status"`
Message string `json:"message"`
Interval int `json:"interval"`
}

const usage = `Usage:
ops -login <apihost> [<user>]
ops -login [options] <apihost> [<user>]

Login to an OpenServerless instance. If no user is specified, the default user "nuvolaris" is used.
You can set the environment variables OPS_APIHOST and OPS_USER to avoid specifying them on the command line.
You can set OPS_PASSWORD to avoid entering the password interactively.
When SSO is enabled, set OPS_SSO_LOGIN_FLOW=password or pass --sso-flow password to use
OIDC password grant instead of the browser/device flow. OPS_SSO_USERNAME can override the
identity-provider username when it differs from the OpenServerless namespace.

Options:
--sso-flow FLOW SSO login flow: device or password. Default: device
--sso-username USER Identity-provider username for SSO password flow
-h, --help Show usage`

const whiskLoginPath = "/api/v1/web/whisk-system/nuv/login"
const oidcLoginPath = "/system/api/v1/auth/oidc"
const oidcDeviceStartPath = "/system/api/v1/auth/oidc/device/start"
const oidcDevicePollPath = "/system/api/v1/auth/oidc/device/poll"
const oidcPasswordPath = "/system/api/v1/auth/oidc/password"
const defaultUser = "nuvolaris"
const opsSecretServiceName = "nuvolaris"

Expand All @@ -96,8 +119,12 @@ func LoginCmd() (*LoginResult, error) {
}

var helpFlag bool
var ssoFlowFlag string
var ssoUsernameFlag string
flag.BoolVar(&helpFlag, "h", false, "Show usage")
flag.BoolVar(&helpFlag, "help", false, "Show usage")
flag.StringVar(&ssoFlowFlag, "sso-flow", "", "SSO login flow: device or password")
flag.StringVar(&ssoUsernameFlag, "sso-username", "", "Identity-provider username for SSO password flow")
err := flag.Parse(os.Args[1:])
if err != nil {
return nil, err
Expand Down Expand Up @@ -139,24 +166,43 @@ func LoginCmd() (*LoginResult, error) {
}
}
}
if user == "" {
user = os.Getenv("OPSDEV_USERNAME")
}

var creds map[string]string
ssoEnabled := isTruthy(os.Getenv("SSO_ENABLED"))
var oidcToken string
if ssoEnabled {
oidcToken, err = oidcDeviceAccessToken()
if err != nil {
return nil, err
if useBackendManagedOIDCPasswordFlow(ssoFlowFlag) {
fmt.Println("Logging in", apihost, "with backend-managed OIDC password grant")
creds, err = backendManagedOIDCPasswordLogin(apihost, user, firstNonEmpty(ssoUsernameFlag, os.Getenv("OPS_SSO_USERNAME")))
if err != nil {
return nil, err
}
user = loginFromCredentials(creds, user)
} else if useBackendManagedOIDCDeviceFlow() {
fmt.Println("Logging in", apihost, "with backend-managed OIDC")
creds, err = backendManagedOIDCDeviceLogin(apihost, user)
if err != nil {
return nil, err
}
user = loginFromCredentials(creds, user)
} else {
oidcToken, err = oidcDeviceAccessToken()
if err != nil {
return nil, err
}
}
}
if oidcToken != "" {
if creds == nil && oidcToken != "" {
fmt.Println("Logging in", apihost, "with OIDC")
creds, err = doOIDCLogin(oidcLoginURL, oidcToken)
if err != nil {
return nil, err
}
user = loginFromCredentials(creds, user)
} else {
} else if creds == nil {
if ssoEnabled {
return nil, errors.New("SSO is enabled but OIDC login did not return an access token")
}
Expand Down Expand Up @@ -284,6 +330,192 @@ func oidcDeviceAccessToken() (string, error) {
return pollOIDCDeviceToken(discovery.TokenEndpoint, clientID, device, codeVerifier)
}

func useBackendManagedOIDCDeviceFlow() bool {
return strings.EqualFold(strings.TrimSpace(os.Getenv("SSO_CLIENT_MODE")), "confidential") ||
isTruthy(firstNonEmpty(os.Getenv("SSO_OIDC_CLIENT_SECRET_CONFIGURED"), os.Getenv("OIDC_CLIENT_SECRET_CONFIGURED")))
}

func useBackendManagedOIDCPasswordFlow(flagValue string) bool {
return strings.EqualFold(firstNonEmpty(flagValue, os.Getenv("OPS_SSO_LOGIN_FLOW")), "password")
}

func backendManagedOIDCDeviceLogin(apihost, requestedNamespace string) (map[string]string, error) {
startURL := strings.TrimRight(apihost, "/") + oidcDeviceStartPath
pollURL := strings.TrimRight(apihost, "/") + oidcDevicePollPath

start, err := startBackendManagedOIDCDeviceFlow(startURL, requestedNamespace)
if err != nil {
return nil, err
}
if start.FlowID == "" {
return nil, errors.New("SSO device login response missing flow_id")
}
if start.Interval <= 0 {
start.Interval = 5
}
if start.ExpiresIn <= 0 {
start.ExpiresIn = 600
}

verificationURL := start.VerificationURIComplete
if verificationURL == "" {
verificationURL = start.VerificationURI
}
fmt.Println()
fmt.Println("SSO is enabled for this cluster.")
fmt.Println("Open this URL in your browser to login:")
fmt.Println(verificationURL)
if start.UserCode != "" {
fmt.Println("Code:", start.UserCode)
}
fmt.Println("Waiting for authentication...")

if verificationURL != "" && !isTruthy(os.Getenv("OPS_SSO_DISABLE_BROWSER")) {
_ = browser.OpenURL(verificationURL)
}

return pollBackendManagedOIDCDeviceFlow(pollURL, start, requestedNamespace)
}

func backendManagedOIDCPasswordLogin(apihost, requestedNamespace, idpUsername string) (map[string]string, error) {
username := firstNonEmpty(idpUsername, requestedNamespace)
if username == "" {
return nil, errors.New("SSO password login requires a username")
}

password := os.Getenv("OPS_PASSWORD")
if password == "" {
fmt.Print("Enter SSO Password: ")
pwd, err := AskPassword()
if err != nil {
return nil, err
}
password = pwd
fmt.Println()
}

return doOIDCPasswordLogin(
strings.TrimRight(apihost, "/")+oidcPasswordPath,
username,
password,
requestedNamespace,
)
}

func doOIDCPasswordLogin(url, username, password, requestedNamespace string) (map[string]string, error) {
payload := map[string]string{
"username": username,
"password": password,
}
if strings.TrimSpace(requestedNamespace) != "" {
payload["namespace"] = strings.TrimSpace(requestedNamespace)
}
loginJSON, err := json.Marshal(payload)
if err != nil {
return nil, err
}

resp, err := http.Post(url, "application/json", bytes.NewBuffer(loginJSON))
if err != nil {
return nil, err
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("OIDC password login failed with status code %d", resp.StatusCode)
}
return nil, fmt.Errorf("OIDC password login failed (%d): %s", resp.StatusCode, string(body))
}

var creds map[string]string
if err := json.NewDecoder(resp.Body).Decode(&creds); err != nil {
return nil, errors.New("failed to decode response from OIDC password login request")
}
return creds, nil
}

func startBackendManagedOIDCDeviceFlow(url, requestedNamespace string) (*backendDeviceStartResponse, error) {
payload := map[string]string{}
if strings.TrimSpace(requestedNamespace) != "" {
payload["namespace"] = strings.TrimSpace(requestedNamespace)
}
startJSON, err := json.Marshal(payload)
if err != nil {
return nil, err
}
resp, err := http.Post(url, "application/json", bytes.NewBuffer(startJSON))
if err != nil {
return nil, err
}
defer resp.Body.Close()

body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("OIDC device authorization failed (%d): %s", resp.StatusCode, string(body))
}

var start backendDeviceStartResponse
if err := json.Unmarshal(body, &start); err != nil {
return nil, errors.New("failed to decode OIDC device authorization response")
}
return &start, nil
}

func pollBackendManagedOIDCDeviceFlow(url string, start *backendDeviceStartResponse, requestedNamespace string) (map[string]string, error) {
deadline := time.Now().Add(time.Duration(start.ExpiresIn) * time.Second)
interval := time.Duration(start.Interval) * time.Second

for {
if time.Now().After(deadline) {
return nil, errors.New("OIDC device login expired")
}
time.Sleep(interval)

payload := map[string]string{"flow_id": start.FlowID}
if strings.TrimSpace(requestedNamespace) != "" {
payload["namespace"] = strings.TrimSpace(requestedNamespace)
}
loginJSON, err := json.Marshal(payload)
if err != nil {
return nil, err
}

resp, err := http.Post(url, "application/json", bytes.NewBuffer(loginJSON))
if err != nil {
return nil, err
}

body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}

if resp.StatusCode == http.StatusAccepted {
var pending backendDevicePollResponse
if err := json.Unmarshal(body, &pending); err == nil && pending.Interval > 0 {
interval = time.Duration(pending.Interval) * time.Second
}
continue
}

if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("OIDC token polling failed (%d): %s", resp.StatusCode, string(body))
}

var creds map[string]string
if err := json.Unmarshal(body, &creds); err != nil {
return nil, errors.New("failed to decode response from OIDC device login request")
}
return creds, nil
}
}

func fetchOIDCDiscovery(issuer string) (*oidcDiscovery, error) {
resp, err := http.Get(issuer + "/.well-known/openid-configuration")
if err != nil {
Expand Down
Loading
Loading