diff --git a/go.mod b/go.mod index 58db081..bc6d264 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ toolchain go1.24.4 require ( github.com/cheggaaa/pb v1.0.29 + github.com/coreos/go-oidc/v3 v3.14.1 github.com/fatih/color v1.18.0 github.com/fatih/structs v1.1.0 github.com/go-ldap/ldap/v3 v3.4.11 @@ -19,6 +20,7 @@ require ( github.com/zeebo/xxh3 v1.0.2 go.etcd.io/etcd/client/v3 v3.6.1 golang.org/x/crypto v0.39.0 + golang.org/x/oauth2 v0.30.0 golang.org/x/sys v0.33.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -30,6 +32,7 @@ require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect + github.com/go-jose/go-jose/v4 v4.0.5 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/protobuf v1.5.4 // indirect diff --git a/go.sum b/go.sum index 8f1dc0f..d54be90 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7V github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= github.com/cheggaaa/pb v1.0.29 h1:FckUN5ngEk2LpvuG0fw1GEFx6LtyY2pWI/Z2QgCnEYo= github.com/cheggaaa/pb v1.0.29/go.mod h1:W40334L7FMC5JKWldsTWbdGjLo0RxUKK73K+TuPxX30= +github.com/coreos/go-oidc/v3 v3.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk= +github.com/coreos/go-oidc/v3 v3.14.1/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU= github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4= github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec= github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= @@ -20,6 +22,8 @@ github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo= github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= +github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE= +github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA= github.com/go-ldap/ldap/v3 v3.4.11 h1:4k0Yxweg+a3OyBLjdYn5OKglv18JNvfDykSoI8bW0gU= github.com/go-ldap/ldap/v3 v3.4.11/go.mod h1:bY7t0FLK8OAVpp/vV6sSlpz3EQDGcQwc8pF0ujLgKvM= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= @@ -151,6 +155,8 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/oidc/utils.go b/oidc/utils.go new file mode 100644 index 0000000..3e787bc --- /dev/null +++ b/oidc/utils.go @@ -0,0 +1,184 @@ +// Copyright (c) 2015-2025 MinIO, Inc. +// +// # This file is part of MinIO Object Storage stack +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package oidc + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" +) + +/////////// Types and functions for OpenID IAM testing + +// OpenIDClientAppParams - contains openID client application params, used in +// testing. +type OpenIDClientAppParams struct { + ClientID, ClientSecret, ProviderURL, RedirectURL string + Transport http.RoundTripper + Debug bool +} + +// MockOpenIDTestUserInteraction - tries to login to dex using provided credentials. +// It performs the user's browser interaction to login and retrieves the auth +// code from dex and exchanges it for a JWT. +func MockOpenIDTestUserInteraction(ctx context.Context, pro OpenIDClientAppParams, username, password string) (string, string, string, error) { + var debug bool + + debug = false + if pro.Debug { + debug = true + } + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + if pro.Transport != nil { + ctx = oidc.ClientContext(ctx, &http.Client{Transport: pro.Transport}) + } + + provider, err := oidc.NewProvider(ctx, pro.ProviderURL) + if err != nil { + return "", "", "", fmt.Errorf("unable to create provider: %v", err) + } + + // Configure an OpenID Connect aware OAuth2 client. + oauth2Config := oauth2.Config{ + ClientID: pro.ClientID, + ClientSecret: pro.ClientSecret, + RedirectURL: pro.RedirectURL, + + // Discovery returns the OAuth2 endpoints. + Endpoint: provider.Endpoint(), + + // "openid" is a required scope for OpenID Connect flows. + Scopes: []string{oidc.ScopeOpenID, "groups", "offline_access"}, + } + + state := fmt.Sprintf("x%dx", time.Now().Unix()) + authCodeURL := oauth2Config.AuthCodeURL(state) + + var lastReq *http.Request + checkRedirect := func(req *http.Request, _ []*http.Request) error { + // Save the last request in a redirect chain. + lastReq = req + // We do not follow redirect back to client application. + if req.URL.Path == "/oauth_callback" { + return http.ErrUseLastResponse + } + return nil + } + + dexClient := http.Client{ + CheckRedirect: checkRedirect, + Transport: pro.Transport, + } + + u, err := url.Parse(authCodeURL) + if err != nil { + return "", "", "", fmt.Errorf("url parse err: %v", err) + } + + // Start the user auth flow. This page would present the login with + // email or LDAP option. + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return "", "", "", fmt.Errorf("new request err: %v", err) + } + resp, err := dexClient.Do(req) + + if err != nil { + return "", "", "", fmt.Errorf("auth url request err: %v", err) + } + if resp.StatusCode != http.StatusOK { + return "", "", "", fmt.Errorf("auth url request returned HTTP status: %d", resp.StatusCode) + } + + // Modify u to choose the ldap option + u.Path += "/ldap" + + // Pick the LDAP login option. This would return a form page after + // following some redirects. `lastReq` would be the URL of the form + // page, where we need to POST (submit) the form. + req, err = http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return "", "", "", fmt.Errorf("new request err (/ldap): %v", err) + } + resp, err = dexClient.Do(req) + if err != nil { + return "", "", "", fmt.Errorf("request err: %v", err) + } + if resp.StatusCode != http.StatusOK { + return "", "", "", fmt.Errorf("ew request (/ldap) returned HTTP status: %d", resp.StatusCode) + } + + // Fill the login form with our test creds: + formData := url.Values{} + formData.Set("login", username) + formData.Set("password", password) + req, err = http.NewRequestWithContext(ctx, http.MethodPost, lastReq.URL.String(), strings.NewReader(formData.Encode())) + if err != nil { + return "", "", "", fmt.Errorf("new request err (/login): %v", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + _, err = dexClient.Do(req) + if err != nil { + return "", "", "", fmt.Errorf("post form err: %v", err) + } + + if debug { + fmt.Printf("resp: %#v %#v\n", resp.StatusCode, resp.Header) + bodyBuf, err := io.ReadAll(resp.Body) + if err != nil { + return "", "", "", fmt.Errorf("Error reading body: %v", err) + } + fmt.Printf("resp body: %s\n", string(bodyBuf)) + fmt.Printf("lastReq: %#v\n", lastReq.URL.String()) + } + + // On form submission, the last redirect response contains the auth + // code, which we now have in `lastReq`. Exchange it for a JWT id_token. + q := lastReq.URL.Query() + code := q.Get("code") + oauth2Token, err := oauth2Config.Exchange(ctx, code) + if err != nil { + return "", "", "", fmt.Errorf("unable to exchange code for id token: %v", err) + } + + rawIDToken, ok := oauth2Token.Extra("id_token").(string) + if !ok { + return "", "", "", fmt.Errorf("id_token not found") + } + + accessIDToken, ok := oauth2Token.Extra("access_token").(string) + if !ok { + return "", "", "", fmt.Errorf("access_token not found") + } + + refreshToken, ok := oauth2Token.Extra("refresh_token").(string) + if !ok { + return "", "", "", fmt.Errorf("refresh_token not found") + } + + return rawIDToken, accessIDToken, refreshToken, nil +}