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
328 changes: 310 additions & 18 deletions auth/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,24 @@ package auth

import (
"bytes"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"

"github.com/apache/openserverless-cli/config"
"github.com/pkg/browser"
"github.com/zalando/go-keyring"
)

Expand All @@ -40,6 +46,28 @@ type LoginResult struct {
ApiHost string
}

type oidcDiscovery struct {
TokenEndpoint string `json:"token_endpoint"`
DeviceAuthorizationEndpoint string `json:"device_authorization_endpoint"`
}

type deviceAuthorizationResponse struct {
DeviceCode string `json:"device_code"`
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"`
Error string `json:"error"`
ErrorDescription string `json:"error_description"`
}

type oidcTokenResponse struct {
AccessToken string `json:"access_token"`
Error string `json:"error"`
ErrorDescription string `json:"error_description"`
}

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

Expand All @@ -51,6 +79,7 @@ Options:
-h, --help Show usage`

const whiskLoginPath = "/api/v1/web/whisk-system/nuv/login"
const oidcLoginPath = "/system/api/v1/auth/oidc"
const defaultUser = "nuvolaris"
const opsSecretServiceName = "nuvolaris"

Expand Down Expand Up @@ -90,9 +119,9 @@ func LoginCmd() (*LoginResult, error) {
if apihost == "" {
apihost = args[0]
}
url := apihost + whiskLoginPath

apihost = ensureSchema(apihost)
passwordLoginURL := apihost + whiskLoginPath
oidcLoginURL := apihost + oidcLoginPath

// try to get the user from the environment
user := os.Getenv("OPS_USER")
Expand All @@ -111,28 +140,49 @@ func LoginCmd() (*LoginResult, error) {
}
}

// if still not set, use the default user
if user == "" {
fmt.Println("Using the default user:", defaultUser)
user = defaultUser
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 oidcToken != "" {
fmt.Println("Logging in", apihost, "with OIDC")
creds, err = doOIDCLogin(oidcLoginURL, oidcToken)
if err != nil {
return nil, err
}
user = loginFromCredentials(creds, user)
} else {
if ssoEnabled {
return nil, errors.New("SSO is enabled but OIDC login did not return an access token")
}
// if still not set, use the default user
if user == "" {
fmt.Println("Using the default user:", defaultUser)
user = defaultUser
}

fmt.Println("Logging in", apihost, "as", user)
fmt.Println("Logging in", apihost, "as", user)

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

creds, err = doLogin(passwordLoginURL, user, password)
if err != nil {
return nil, err
}
password = pwd
fmt.Println()
}

creds, err := doLogin(url, user, password)
if err != nil {
return nil, err
}

if _, ok := creds["AUTH"]; !ok {
Expand Down Expand Up @@ -183,6 +233,200 @@ func LoginCmd() (*LoginResult, error) {
}, nil
}

func oidcDeviceAccessToken() (string, error) {
issuer := strings.TrimRight(firstNonEmpty(os.Getenv("SSO_OIDC_ISSUER_URL"), os.Getenv("OIDC_ISSUER_URL")), "/")
clientID := firstNonEmpty(os.Getenv("SSO_OIDC_AUDIENCE"), os.Getenv("OIDC_AUDIENCE"))
if issuer == "" {
return "", errors.New("SSO is enabled but SSO_OIDC_ISSUER_URL is not configured")
}
if clientID == "" {
return "", errors.New("SSO is enabled but SSO_OIDC_AUDIENCE is not configured")
}

discovery, err := fetchOIDCDiscovery(issuer)
if err != nil {
return "", err
}
if discovery.DeviceAuthorizationEndpoint == "" {
return "", errors.New("OIDC provider does not expose device_authorization_endpoint")
}
if discovery.TokenEndpoint == "" {
return "", errors.New("OIDC provider does not expose token_endpoint")
}

codeVerifier, codeChallenge, err := pkceChallenge()
if err != nil {
return "", err
}

device, err := startOIDCDeviceAuthorization(discovery.DeviceAuthorizationEndpoint, clientID, codeChallenge)
if err != nil {
return "", err
}

verificationURL := device.VerificationURIComplete
if verificationURL == "" {
verificationURL = device.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 device.UserCode != "" {
fmt.Println("Code:", device.UserCode)
}
fmt.Println("Waiting for authentication...")

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

return pollOIDCDeviceToken(discovery.TokenEndpoint, clientID, device, codeVerifier)
}

func fetchOIDCDiscovery(issuer string) (*oidcDiscovery, error) {
resp, err := http.Get(issuer + "/.well-known/openid-configuration")
if err != nil {
return nil, err
}
defer resp.Body.Close()

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

var discovery oidcDiscovery
if err := json.NewDecoder(resp.Body).Decode(&discovery); err != nil {
return nil, errors.New("failed to decode OIDC discovery response")
}
return &discovery, nil
}

func startOIDCDeviceAuthorization(endpoint, clientID, codeChallenge string) (*deviceAuthorizationResponse, error) {
form := url.Values{}
form.Set("client_id", clientID)
form.Set("scope", "openid email profile")
form.Set("code_challenge", codeChallenge)
form.Set("code_challenge_method", "S256")

resp, err := http.PostForm(endpoint, form)
if err != nil {
return nil, err
}
defer resp.Body.Close()

var device deviceAuthorizationResponse
if err := json.NewDecoder(resp.Body).Decode(&device); err != nil {
return nil, errors.New("failed to decode OIDC device authorization response")
}
if resp.StatusCode != http.StatusOK {
if device.Error != "" {
return nil, fmt.Errorf("OIDC device authorization failed (%d): %s: %s", resp.StatusCode, device.Error, device.ErrorDescription)
}
return nil, fmt.Errorf("OIDC device authorization failed with status code %d", resp.StatusCode)
}
if device.DeviceCode == "" {
return nil, errors.New("OIDC device authorization response missing device_code")
}
if device.Interval <= 0 {
device.Interval = 5
}
if device.ExpiresIn <= 0 {
device.ExpiresIn = 600
}
return &device, nil
}

func pollOIDCDeviceToken(tokenEndpoint, clientID string, device *deviceAuthorizationResponse, codeVerifier string) (string, error) {
deadline := time.Now().Add(time.Duration(device.ExpiresIn) * time.Second)
interval := time.Duration(device.Interval) * time.Second

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

form := url.Values{}
form.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code")
form.Set("client_id", clientID)
form.Set("device_code", device.DeviceCode)
form.Set("code_verifier", codeVerifier)

resp, err := http.PostForm(tokenEndpoint, form)
if err != nil {
return "", err
}

var token oidcTokenResponse
decodeErr := json.NewDecoder(resp.Body).Decode(&token)
resp.Body.Close()
if decodeErr != nil {
return "", errors.New("failed to decode OIDC token response")
}

if resp.StatusCode == http.StatusOK && token.AccessToken != "" {
return token.AccessToken, nil
}

switch token.Error {
case "authorization_pending":
continue
case "slow_down":
interval += 5 * time.Second
continue
case "access_denied":
return "", errors.New("OIDC device login denied")
case "expired_token":
return "", errors.New("OIDC device login expired")
default:
if token.Error != "" {
return "", fmt.Errorf("OIDC token polling failed: %s: %s", token.Error, token.ErrorDescription)
}
return "", fmt.Errorf("OIDC token polling failed with status code %d", resp.StatusCode)
}
}
}

func pkceChallenge() (string, string, error) {
random := make([]byte, 32)
if _, err := rand.Read(random); err != nil {
return "", "", err
}
verifier := base64.RawURLEncoding.EncodeToString(random)
sum := sha256.Sum256([]byte(verifier))
challenge := base64.RawURLEncoding.EncodeToString(sum[:])
return verifier, challenge, nil
}

func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}

func isTruthy(value string) bool {
switch strings.ToLower(strings.TrimSpace(value)) {
case "1", "true", "yes", "y", "on":
return true
default:
return false
}
}

func loginFromCredentials(creds map[string]string, fallback string) string {
for _, key := range []string{"NAMESPACE", "LOGIN", "USER", "USERNAME"} {
if value := strings.TrimSpace(creds[key]); value != "" {
return value
}
}
return fallback
}

func ensureSchema(apihost string) string {
if !strings.HasPrefix(apihost, "http://") && !strings.HasPrefix(apihost, "https://") {
if apihost == "localhost" {
Expand Down Expand Up @@ -227,6 +471,54 @@ func doLogin(url, user, password string) (map[string]string, error) {
return creds, nil
}

func doOIDCLogin(url, accessToken string) (map[string]string, error) {
token := strings.TrimSpace(accessToken)
if token == "" {
return nil, errors.New("missing OIDC access token")
}

data := map[string]string{
"access_token": strings.TrimPrefix(token, "Bearer "),
}
loginJson, err := json.Marshal(data)
if err != nil {
return nil, err
}

req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(loginJson))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
if strings.HasPrefix(token, "Bearer ") {
req.Header.Set("Authorization", token)
} else {
req.Header.Set("Authorization", "Bearer "+token)
}

resp, err := http.DefaultClient.Do(req)
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 login failed with status code %d", resp.StatusCode)
}
return nil, fmt.Errorf("OIDC login failed (%d): %s", resp.StatusCode, string(body))
}

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

return creds, nil
}

func storeCredentials(creds map[string]string) error {
for k, v := range creds {
err := keyring.Set(opsSecretServiceName, k, v)
Expand Down
Loading
Loading