-
-
Notifications
You must be signed in to change notification settings - Fork 41
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
[#35] Implement OpenID Connect auth provider
Signed-off-by: Knut Ahlers <knut@ahlers.me>
- Loading branch information
Showing
3 changed files
with
266 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,248 @@ | ||
package oidc | ||
|
||
import ( | ||
"context" | ||
"encoding/gob" | ||
"fmt" | ||
"net/http" | ||
"strings" | ||
|
||
"golang.org/x/oauth2" | ||
yaml "gopkg.in/yaml.v2" | ||
|
||
"github.com/coreos/go-oidc" | ||
"github.com/gorilla/sessions" | ||
"github.com/pkg/errors" | ||
|
||
"github.com/Luzifer/nginx-sso/plugins" | ||
) | ||
|
||
const ( | ||
userIDMethodFullEmail = "full-email" | ||
userIDMethodLocalPart = "local-part" | ||
userIDMethodSubject = "subject" | ||
) | ||
|
||
type AuthOIDC struct { | ||
ClientID string `yaml:"client_id"` | ||
ClientSecret string `yaml:"client_secret"` | ||
IssuerName string `yaml:"issuer_name"` | ||
IssuerURL string `yaml:"issuer_url"` | ||
RedirectURL string `yaml:"redirect_url"` | ||
|
||
RequireDomain string `yaml:"require_domain"` | ||
UserIDMethod string `yaml:"user_id_method"` | ||
|
||
cookie plugins.CookieConfig | ||
cookieStore *sessions.CookieStore | ||
|
||
provider *oidc.Provider | ||
} | ||
|
||
func init() { | ||
gob.Register(&oauth2.Token{}) | ||
} | ||
|
||
func New(cs *sessions.CookieStore) *AuthOIDC { | ||
return &AuthOIDC{ | ||
IssuerName: "OpenID Connect", | ||
UserIDMethod: userIDMethodSubject, | ||
cookieStore: cs, | ||
} | ||
} | ||
|
||
// AuthenticatorID needs to return an unique string to identify | ||
// this special authenticator | ||
func (a *AuthOIDC) AuthenticatorID() (id string) { return "oidc" } | ||
|
||
// Configure loads the configuration for the Authenticator from the | ||
// global config.yaml file which is passed as a byte-slice. | ||
// If no configuration for the Authenticator is supplied the function | ||
// needs to return the ErrProviderUnconfigured | ||
func (a *AuthOIDC) Configure(yamlSource []byte) (err error) { | ||
envelope := struct { | ||
Cookie plugins.CookieConfig `yaml:"cookie"` | ||
Providers struct { | ||
OIDC *AuthOIDC `yaml:"oidc"` | ||
} `yaml:"providers"` | ||
}{} | ||
|
||
if err := yaml.Unmarshal(yamlSource, &envelope); err != nil { | ||
return err | ||
} | ||
|
||
if envelope.Providers.OIDC == nil { | ||
return plugins.ErrProviderUnconfigured | ||
} | ||
|
||
a.ClientID = envelope.Providers.OIDC.ClientID | ||
a.ClientSecret = envelope.Providers.OIDC.ClientSecret | ||
a.IssuerURL = envelope.Providers.OIDC.IssuerURL | ||
a.RedirectURL = envelope.Providers.OIDC.RedirectURL | ||
a.RequireDomain = envelope.Providers.OIDC.RequireDomain | ||
|
||
if envelope.Providers.OIDC.IssuerName != "" { | ||
a.IssuerName = envelope.Providers.OIDC.IssuerName | ||
} | ||
|
||
if envelope.Providers.OIDC.UserIDMethod != "" { | ||
a.UserIDMethod = envelope.Providers.OIDC.UserIDMethod | ||
} | ||
|
||
a.cookie = envelope.Cookie | ||
|
||
provider, err := oidc.NewProvider(context.Background(), a.IssuerURL) | ||
if err != nil { | ||
return errors.Wrap(err, "Unable to fetch provider configuration") | ||
} | ||
a.provider = provider | ||
|
||
return nil | ||
} | ||
|
||
// DetectUser is used to detect a user without a login form from | ||
// a cookie, header or other methods | ||
// If no user was detected the ErrNoValidUserFound needs to be | ||
// returned | ||
func (a *AuthOIDC) DetectUser(res http.ResponseWriter, r *http.Request) (user string, groups []string, err error) { | ||
sess, err := a.cookieStore.Get(r, strings.Join([]string{a.cookie.Prefix, a.AuthenticatorID()}, "-")) | ||
if err != nil { | ||
return "", nil, plugins.ErrNoValidUserFound | ||
} | ||
|
||
token, ok := sess.Values["oauth_token"].(*oauth2.Token) | ||
if !ok { | ||
return "", nil, plugins.ErrNoValidUserFound | ||
} | ||
|
||
u, err := a.getUserFromToken(r.Context(), token) | ||
if err != nil { | ||
if err == plugins.ErrNoValidUserFound { | ||
return "", nil, err | ||
} | ||
return "", nil, errors.Wrap(err, "Unable to fetch user info") | ||
} | ||
|
||
// We had a cookie, lets renew it | ||
sess.Options = a.cookie.GetSessionOpts() | ||
if err := sess.Save(r, res); err != nil { | ||
return "", nil, err | ||
} | ||
|
||
return u, nil, nil // TODO: Maybe get group info? | ||
} | ||
|
||
// Login is called when the user submits the login form and needs | ||
// to authenticate the user or throw an error. If the user has | ||
// successfully logged in the persistent cookie should be written | ||
// in order to use DetectUser for the next login. | ||
// With the login result an array of mfaConfig must be returned. In | ||
// case there is no MFA config or the provider does not support MFA | ||
// return nil. | ||
// If the user did not login correctly the ErrNoValidUserFound | ||
// needs to be returned | ||
func (a *AuthOIDC) Login(res http.ResponseWriter, r *http.Request) (user string, mfaConfigs []plugins.MFAConfig, err error) { | ||
var ( | ||
code = r.URL.Query().Get("code") | ||
state = r.URL.Query().Get("state") | ||
u string | ||
) | ||
|
||
if code == "" || state != a.AuthenticatorID() { | ||
return "", nil, plugins.ErrNoValidUserFound | ||
} | ||
|
||
token, err := a.getOAuthConfig().Exchange(r.Context(), code) | ||
if err != nil { | ||
return "", nil, errors.Wrap(err, "Unable to exchange token") | ||
} | ||
|
||
u, err = a.getUserFromToken(r.Context(), token) | ||
if err != nil { | ||
if err == plugins.ErrNoValidUserFound { | ||
return "", nil, err | ||
} | ||
return "", nil, errors.Wrap(err, "Unable to fetch user info") | ||
} | ||
|
||
sess, _ := a.cookieStore.Get(r, strings.Join([]string{a.cookie.Prefix, a.AuthenticatorID()}, "-")) // #nosec G104 - On error empty session is returned | ||
sess.Options = a.cookie.GetSessionOpts() | ||
sess.Values["oauth_token"] = token | ||
|
||
return u, nil, sess.Save(r, res) | ||
} | ||
|
||
// LoginFields needs to return the fields required for this login | ||
// method. If no login using this method is possible the function | ||
// needs to return nil. | ||
func (a *AuthOIDC) LoginFields() (fields []plugins.LoginField) { | ||
loginURL := a.getOAuthConfig().AuthCodeURL(a.AuthenticatorID()) | ||
|
||
return []plugins.LoginField{ | ||
{ | ||
Action: fmt.Sprintf("window.location.href='%s'", loginURL), | ||
Label: "Trigger Login", | ||
Name: "button", | ||
Placeholder: fmt.Sprintf("Sign in with %s", a.IssuerName), | ||
Type: "button", | ||
}, | ||
} | ||
} | ||
|
||
// Logout is called when the user visits the logout endpoint and | ||
// needs to destroy any persistent stored cookies | ||
func (a *AuthOIDC) Logout(res http.ResponseWriter, r *http.Request) (err error) { | ||
sess, _ := a.cookieStore.Get(r, strings.Join([]string{a.cookie.Prefix, a.AuthenticatorID()}, "-")) // #nosec G104 - On error empty session is returned | ||
sess.Options = a.cookie.GetSessionOpts() | ||
sess.Options.MaxAge = -1 // Instant delete | ||
return sess.Save(r, res) | ||
} | ||
|
||
// SupportsMFA returns the MFA detection capabilities of the login | ||
// provider. If the provider can provide mfaConfig objects from its | ||
// configuration return true. If this is true the login interface | ||
// will display an additional field for this provider for the user | ||
// to fill in their MFA token. | ||
func (a *AuthOIDC) SupportsMFA() bool { return false } | ||
|
||
func (a *AuthOIDC) getOAuthConfig() *oauth2.Config { | ||
return &oauth2.Config{ | ||
ClientID: a.ClientID, | ||
ClientSecret: a.ClientSecret, | ||
Endpoint: a.provider.Endpoint(), | ||
RedirectURL: a.RedirectURL, | ||
Scopes: []string{ | ||
oidc.ScopeOpenID, | ||
"profile", | ||
"email", | ||
}, | ||
} | ||
} | ||
|
||
func (a *AuthOIDC) getUserFromToken(ctx context.Context, token *oauth2.Token) (string, error) { | ||
ui, err := a.provider.UserInfo(ctx, oauth2.StaticTokenSource(token)) | ||
if err != nil { | ||
return "", errors.Wrap(err, "Unable to fetch user info") | ||
} | ||
|
||
if a.RequireDomain != "" && !strings.HasSuffix(ui.Email, "@"+a.RequireDomain) { | ||
// E-Mail domain is enforced, ignore all other users | ||
return "", plugins.ErrNoValidUserFound | ||
} | ||
|
||
switch a.UserIDMethod { | ||
case userIDMethodFullEmail: | ||
return ui.Email, nil | ||
|
||
case userIDMethodLocalPart: | ||
return strings.Split(ui.Email, "@")[0], nil | ||
|
||
case "": | ||
fallthrough | ||
case userIDMethodSubject: | ||
return ui.Subject, nil | ||
|
||
default: | ||
return "", errors.Errorf("Invalid user_id_method %q", a.UserIDMethod) | ||
} | ||
} |