Skip to content

Commit

Permalink
[#35] Implement OpenID Connect auth provider
Browse files Browse the repository at this point in the history
Signed-off-by: Knut Ahlers <knut@ahlers.me>
  • Loading branch information
Luzifer committed Apr 22, 2019
1 parent abc203a commit 6575bc5
Show file tree
Hide file tree
Showing 3 changed files with 266 additions and 0 deletions.
16 changes: 16 additions & 0 deletions config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,22 @@ providers:
# Optional, defaults to false
allow_insecure: false

# Authentication through OAuth2 workflow with OpenID Connect provider
# Supports: Users
oidc:
client_id: ""
client_secret: ""
# Optional, defaults to "OpenID Connect"
issuer_name: ""
issuer_url: ""
redirect_url: "https://login.luifer.io/login"

# Optional, defaults to no limitations
require_domain: "example.com"
# Optional, defaults to "subject"
user_id_method: "full-email"


# Authentication against embedded user database
# Supports: Users, Groups, MFA
simple:
Expand Down
2 changes: 2 additions & 0 deletions core.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"github.com/Luzifer/nginx-sso/plugins/auth/crowd"
"github.com/Luzifer/nginx-sso/plugins/auth/google"
"github.com/Luzifer/nginx-sso/plugins/auth/ldap"
"github.com/Luzifer/nginx-sso/plugins/auth/oidc"
"github.com/Luzifer/nginx-sso/plugins/auth/simple"
"github.com/Luzifer/nginx-sso/plugins/auth/token"
auth_yubikey "github.com/Luzifer/nginx-sso/plugins/auth/yubikey"
Expand All @@ -16,6 +17,7 @@ func registerModules() {
registerAuthenticator(crowd.New())
registerAuthenticator(ldap.New(cookieStore))
registerAuthenticator(google.New(cookieStore))
registerAuthenticator(oidc.New(cookieStore))
registerAuthenticator(simple.New(cookieStore))
registerAuthenticator(token.New())
registerAuthenticator(auth_yubikey.New(cookieStore))
Expand Down
248 changes: 248 additions & 0 deletions plugins/auth/oidc/auth.go
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)
}
}

0 comments on commit 6575bc5

Please sign in to comment.