Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

sso-auth: Azure AD provider #118

Open
wants to merge 39 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 33 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
c3ae45d
First pass port of Azure v2 provider
sporkmonger Oct 5, 2018
5acfd70
Porting of Azure AD provider largely complete
sporkmonger Nov 15, 2018
83e43a1
Fix group tests, add sign-in tests, and generate nonces
sporkmonger Nov 16, 2018
4e51eae
Add golang-lru to Godeps
sporkmonger Nov 16, 2018
413d831
Expand abbreviation in comment
Jan 8, 2019
3fcdff4
Update documentation and compile time checking of provider interface
Jan 9, 2019
315d1bd
Update Azure provider constant for consistency
Jan 9, 2019
38d0523
Fix up comments related to common default tenant ID
Jan 9, 2019
4f8b330
Document odata pagination hyperlinking
Jan 9, 2019
23e5067
Remove unused helper function
Jan 9, 2019
97957bc
Remove unused contains helper function
Jan 9, 2019
70b7c20
Removed commented out code
Jan 9, 2019
0d4d57e
Remove unnecessary new line from comment
Jan 9, 2019
837ad42
Switch things to package private that don't need to be exported
Jan 9, 2019
82c429b
Fix return value on error
Jan 9, 2019
bd344fe
This API should only return 200 on success
Jan 9, 2019
e0e9804
Consistent mutex variable names
Jan 9, 2019
5234346
This API should only return 200 on success
Jan 9, 2019
515bb6a
Make it clearer that this is a template value
Jan 9, 2019
359275b
Drop usage of named return values
Jan 9, 2019
0ff780c
Update comments to accurately reflect what's happening in Marshal/Unm…
Jan 9, 2019
91b3768
Drop another usage of named return values
Jan 9, 2019
dfc5040
Combine cache lookup and success check into a single if statement
Jan 9, 2019
e786df6
Add explanatory text to interface comments to highlight purpose in mocks
Jan 9, 2019
ce8b2d0
Drop debug logging lines
Jan 12, 2019
759506e
Switch from HMAC to AEAD to simplify nonce validation
Jan 12, 2019
1f06ed2
Rename to ms_graph_api.go and add top-level comment
Jan 12, 2019
fcf4c96
Move generic OIDC discovery logic into generic OIDC provider
Jan 15, 2019
d19cddf
Add clarification around error handling
Jan 15, 2019
55d4776
Update mock file to match rename of graph service struct
Jan 18, 2019
fd7b43f
Remove methods again
Jan 19, 2019
4c8c289
Remove debug lines
Jan 19, 2019
b95cb54
Get sign out working
Jan 30, 2019
b5d595d
Extend lifetime deadline for OIDC provider
kevinoconnor7 Feb 13, 2019
c30c7c8
Add OIDC discovery URL environment var
kevinoconnor7 Feb 14, 2019
0dba59c
Allow OIDC as a provider in options
kevinoconnor7 Feb 14, 2019
a736235
OIDC doesn't require a token validation endpoint (though extensions d…
kevinoconnor7 Feb 14, 2019
eecf0a8
Merge branch 'master' into azure-ad-provider
Jul 2, 2019
552ce5b
go fmt
Jul 2, 2019
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
6 changes: 6 additions & 0 deletions Godeps
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
github.com/18F/hmacauth 1.0.1
gopkg.in/yaml.v2 v2
github.com/imdario/mergo v0.3.4
github.com/bitly/go-simplejson da1a8928f709389522c8023062a3739f3b4af419
github.com/mreiferson/go-options 77551d20752b54535462404ad9d877ebdb26e53d
github.com/datadog/datadog-go/statsd c74bd0589c83817c93e4eff39ccae69d6c46df9b
golang.org/x/oauth2 7fdf09982454086d5570c7db3e11f360194830ca
golang.org/x/net/context 242b6b35177ec3909636b6cf6a47e8c2c6324b5d
Expand All @@ -11,3 +13,7 @@ github.com/kelseyhightower/envconfig v1.3.0
github.com/miscreant/miscreant-go 6b98fbe3dd42dfd24a8ecbabdb3586ada20dc5f8
github.com/sirupsen/logrus e54a77765aca7bbdd8e56c1c54f60579968b2dc9
github.com/rakyll/statik v0.1.4
github.com/coreos/go-oidc v2.0.0
gopkg.in/square/go-jose.v2 v2.1.9
github.com/pquerna/cachecontrol 1555304b9b35fdd2b425bccf1a5613677705e7d0
github.com/hashicorp/golang-lru v0.5.0
10 changes: 5 additions & 5 deletions internal/auth/authenticator.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ func (p *Authenticator) newMux() http.Handler {
serviceMux.HandleFunc("/start", p.withMethods(p.OAuthStart, "GET"))
serviceMux.HandleFunc("/sign_in", p.withMethods(p.validateClientID(p.validateRedirectURI(p.validateSignature(p.SignIn))), "GET"))
serviceMux.HandleFunc("/sign_out", p.withMethods(p.validateRedirectURI(p.validateSignature(p.SignOut)), "GET", "POST"))
serviceMux.HandleFunc("/oauth2/callback", p.withMethods(p.OAuthCallback, "GET"))
serviceMux.HandleFunc("/oauth2/callback", p.withMethods(p.OAuthCallback, "GET", "POST"))
serviceMux.HandleFunc("/profile", p.withMethods(p.validateClientID(p.validateClientSecret(p.GetProfile)), "GET"))
serviceMux.HandleFunc("/validate", p.withMethods(p.validateClientID(p.validateClientSecret(p.ValidateToken)), "GET"))
serviceMux.HandleFunc("/redeem", p.withMethods(p.validateClientID(p.validateClientSecret(p.Redeem)), "POST"))
Expand Down Expand Up @@ -533,8 +533,8 @@ func (p *Authenticator) OAuthStart(rw http.ResponseWriter, req *http.Request) {
// Here we validate the redirect that is nested within the redirect_uri.
// `authRedirectURL` points to step D, `proxyRedirectURL` points to step E.
//
// A* B C D E
// /start -> Google -> auth /callback -> /sign_in -> proxy /callback
// A* B C D E
// /start -> IdProvider -> auth /callback -> /sign_in -> proxy /callback
//
// * you are here
proxyRedirectURL, err := url.Parse(authRedirectURL.Query().Get("redirect_uri"))
Expand All @@ -558,7 +558,7 @@ func (p *Authenticator) OAuthStart(rw http.ResponseWriter, req *http.Request) {

func (p *Authenticator) redeemCode(host, code string) (*sessions.SessionState, error) {
// The authenticator redeems `code` for an access token, and uses the token to request user
// info from the provider (Google).
// info from the provider.

redirectURI := p.GetRedirectURI(host)
// see providers/google.go#Redeem for more info
Expand All @@ -574,7 +574,7 @@ func (p *Authenticator) redeemCode(host, code string) (*sessions.SessionState, e
}

func (p *Authenticator) getOAuthCallback(rw http.ResponseWriter, req *http.Request) (string, error) {
// After the provider (Google) redirects back to the sso proxy, the proxy uses this
// After the provider redirects back to the sso proxy, the proxy uses this
// endpoint to set up auth cookies.
logger := log.NewLogEntry()

Expand Down
12 changes: 11 additions & 1 deletion internal/auth/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ type Options struct {
EmailDomains []string `envconfig:"SSO_EMAIL_DOMAIN"`
ProxyRootDomains []string `envconfig:"PROXY_ROOT_DOMAIN"`

AzureTenant string `envconfig:"AZURE_TENANT"`
GoogleAdminEmail string `envconfig:"GOOGLE_ADMIN_EMAIL"`
GoogleServiceAccountJSON string `envconfig:"GOOGLE_SERVICE_ACCOUNT_JSON"`
sporkmonger marked this conversation as resolved.
Show resolved Hide resolved

Expand Down Expand Up @@ -260,7 +261,14 @@ func newProvider(o *Options) (providers.Provider, error) {

var singleFlightProvider providers.Provider
switch o.Provider {
case providers.GoogleProviderName: // Google
case providers.AzureProviderName:
azureProvider, err := providers.NewAzureV2Provider(p)
if err != nil {
return nil, err
}
azureProvider.Configure(o.AzureTenant)
singleFlightProvider = providers.NewSingleFlightProvider(azureProvider)
case providers.GoogleProviderName:
sporkmonger marked this conversation as resolved.
Show resolved Hide resolved
if o.GoogleServiceAccountJSON != "" {
_, err := os.Open(o.GoogleServiceAccountJSON)
if err != nil {
Expand Down Expand Up @@ -308,6 +316,8 @@ func AssignStatsdClient(opts *Options) func(*Authenticator) error {

proxy.StatsdClient = StatsdClient
switch v := proxy.provider.(type) {
case *providers.AzureV2Provider:
v.SetStatsdClient(StatsdClient)
case *providers.GoogleProvider:
v.SetStatsdClient(StatsdClient)
case *providers.SingleFlightProvider:
Expand Down
309 changes: 309 additions & 0 deletions internal/auth/providers/azure.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,309 @@
package providers

import (
"context"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"net/url"
"strings"
"time"

"github.com/buzzfeed/sso/internal/pkg/aead"
"github.com/buzzfeed/sso/internal/pkg/sessions"
"github.com/datadog/datadog-go/statsd"
"golang.org/x/oauth2"

log "github.com/buzzfeed/sso/internal/pkg/logging"
)

var (
azureOIDCConfigURLTemplate = "https://login.microsoftonline.com/{tenant}/v2.0"
azureOIDCProfileURL = "https://graph.microsoft.com/oidc/userinfo"

// This is a compile-time check to make sure our types correctly implement the interface:
// https://medium.com/@matryer/c167afed3aae
_ Provider = &AzureV2Provider{}
)

// AzureV2Provider is an Azure AD v2 specific implementation of the Provider interface.
type AzureV2Provider struct {
*ProviderData
*OIDCProvider

Tenant string

StatsdClient *statsd.Client
NonceCipher aead.Cipher
GraphService GraphService
}

// NewAzureV2Provider creates a new AzureV2Provider struct
func NewAzureV2Provider(p *ProviderData) (*AzureV2Provider, error) {
if p.ProviderName == "" {
p.ProviderName = "Azure AD"
}

if p.ClientSecret == "" {
return nil, errors.New("client secret cannot be empty")
}
// Can't guarantee the client secret will be 32 or 64 bytes in length,
// hash to derive a key, error on empty string to avoid silent failure.
key := sha256.Sum256([]byte(p.ClientSecret))
nonceCipher, err := aead.NewMiscreantCipher(key[:])
if err != nil {
return nil, err
}

return &AzureV2Provider{
ProviderData: p,
NonceCipher: nonceCipher,
OIDCProvider: nil,
}, nil
}

// SetStatsdClient sets the azure provider statsd client
func (p *AzureV2Provider) SetStatsdClient(statsdClient *statsd.Client) {
p.StatsdClient = statsdClient
}

// Redeem fulfills the Provider interface.
// The authenticator uses this method to redeem the code provided to /callback after the user logs into their Azure AD account.
func (p *AzureV2Provider) Redeem(redirectURL, code string) (*sessions.SessionState, error) {
ctx := context.Background()
c := oauth2.Config{
ClientID: p.ClientID,
ClientSecret: p.ClientSecret,
Endpoint: oauth2.Endpoint{
TokenURL: p.RedeemURL.String(),
},
RedirectURL: redirectURL,
}
token, err := c.Exchange(ctx, code)
if err != nil {
return nil, fmt.Errorf("token exchange: %v", err)
}

rawIDToken, ok := token.Extra("id_token").(string)
// BUG? rawIDToken is empty string here in current test cases. Test cases
// are shipping invalid ID tokens, but the Extra function doesn't seem to have
// code that would be affected by that.
if !ok || rawIDToken == "" {
fmt.Printf("token: %+v\n", token)
return nil, fmt.Errorf("token response did not contain an id_token")
}

// should only happen if oidc autodiscovery is broken or unconfigured
if p.OIDCProvider == nil || p.OIDCProvider.Verifier == nil {
return nil, fmt.Errorf("oidc verifier missing")
}

// Parse and verify ID Token payload.
idToken, err := p.OIDCProvider.Verifier.Verify(ctx, rawIDToken)
if err != nil {
return nil, fmt.Errorf("could not verify id_token: %v", err)
}

// Extract custom claims.
var claims struct {
Email string `json:"email"`
UPN string `json:"upn"`
Nonce string `json:"nonce"`
}
if err := idToken.Claims(&claims); err != nil {
return nil, fmt.Errorf("failed to parse id_token claims: %v", err)
}
if claims.Email == "" {
return nil, fmt.Errorf("id_token did not contain an email")
}
if claims.Nonce == "" {
return nil, fmt.Errorf("id_token did not contain a nonce")
}
if !p.validateNonce(claims.Nonce) {
return nil, fmt.Errorf("unable to validate id_token nonce")
}
// TODO: test this w/ an account that uses an alias and compare email claim
// with UPN claim; UPN has usually been what you want, but I think it's not
// rendered as a full email address here.

s := &sessions.SessionState{
AccessToken: token.AccessToken,
RefreshToken: token.RefreshToken,

RefreshDeadline: token.Expiry,
LifetimeDeadline: sessions.ExtendDeadline(p.SessionLifetimeTTL),

Email: claims.Email,
User: claims.UPN,
}

if p.GraphService != nil {
groupNames, err := p.GraphService.GetGroups(claims.Email)
if err != nil {
return nil, fmt.Errorf("could not get groups: %v", err)
}
s.Groups = groupNames
}
return s, nil
}

// Configure sets the Azure tenant ID value for the provider
func (p *AzureV2Provider) Configure(tenant string) error {
p.Tenant = tenant
if p.Tenant == "" {
// TODO: See below, "common" is the right default value, and while
// Azure AD docs suggest this should work, it results in an error.
p.Tenant = "common"
}
discoveryURL := strings.Replace(azureOIDCConfigURLTemplate, "{tenant}", p.Tenant, -1)

// Configure discoverable provider data.
sporkmonger marked this conversation as resolved.
Show resolved Hide resolved
var err error
p.OIDCProvider, err = NewOIDCProvider(p.ProviderData, discoveryURL)
if err != nil {
return err
}

p.GraphService = NewMSGraphService(p.ClientID, p.ClientSecret, p.RedeemURL.String())
return nil
}

// RefreshSessionIfNeeded takes in a SessionState and
// returns false if the session is not refreshed and true if it is.
func (p *AzureV2Provider) RefreshSessionIfNeeded(s *sessions.SessionState) (bool, error) {
if s == nil || !s.RefreshPeriodExpired() || s.RefreshToken == "" {
return false, nil
}

newToken, duration, err := p.RefreshAccessToken(s.RefreshToken)
if err != nil {
return false, err
}
logger := log.NewLogEntry()

s.AccessToken = newToken

s.RefreshDeadline = time.Now().Add(duration).Truncate(time.Second)
logger.WithUser(s.Email).WithRefreshDeadline(s.RefreshDeadline).Info("refreshed access token")

return true, nil
}

// RefreshAccessToken uses default OAuth2 TokenSource method to get a new access token.
func (p *AzureV2Provider) RefreshAccessToken(refreshToken string) (string, time.Duration, error) {
if refreshToken == "" {
return "", 0, errors.New("missing refresh token")
}
logger := log.NewLogEntry()
logger.Info("refreshing access token")

ctx := context.Background()
c := oauth2.Config{
ClientID: p.ClientID,
ClientSecret: p.ClientSecret,
Endpoint: oauth2.Endpoint{
TokenURL: p.RedeemURL.String(),
},
}
t := oauth2.Token{
RefreshToken: refreshToken,
}
ts := c.TokenSource(ctx, &t)
newToken, err := ts.Token()
if err != nil {
return "", 0, fmt.Errorf("token exchange: %v", err)
}

return newToken.AccessToken, newToken.Expiry.Sub(time.Now()), nil
}

// Revoke does nothing for Azure AD, but needs to be declared to avoid a
// not implemented error which would prevent clearing sessions on sign out.
func (p *AzureV2Provider) Revoke(s *sessions.SessionState) error {
return nil
}

// ValidateSessionState attempts to validate the session state's access token.
func (p *AzureV2Provider) ValidateSessionState(s *sessions.SessionState) bool {
// return validateToken(p, s.AccessToken, nil)
// TODO Validate ID token
return true
}

// GetSignInURL returns the sign in url with typical oauth parameters
func (p *AzureV2Provider) GetSignInURL(redirectURI, state string) string {
var a url.URL
a = *p.SignInURL
params, _ := url.ParseQuery(a.RawQuery)
params.Set("client_id", p.ClientID)
params.Set("response_type", "id_token code")
params.Set("redirect_uri", redirectURI)
params.Set("response_mode", "form_post")
params.Add("scope", p.Scope)
params.Add("state", state)
params.Set("prompt", p.ApprovalPrompt)
params.Set("nonce", p.calculateNonce(state)) // required parameter
a.RawQuery = params.Encode()

return a.String()
}

// calculateNonce generates a verifiable nonce from the state value.
// A nonce can be subsequently validated by attempting to decrypt it.
func (p *AzureV2Provider) calculateNonce(state string) string {
rawNonce, err := p.NonceCipher.Encrypt([]byte(state))
if err != nil {
// GetSignInURL can't return an error and this shouldn't fail silently
panic(err)
}
return base64.URLEncoding.EncodeToString(rawNonce)
}

// validateNonce attempts to decrypt the nonce value. If it decrypts
// successfully, the nonce is considered valid.
func (p *AzureV2Provider) validateNonce(nonce string) bool {
rawNonce, err := base64.URLEncoding.DecodeString(nonce)
if err != nil {
return false
}
state, err := p.NonceCipher.Decrypt(rawNonce)
if err != nil {
return false
}
// Sanity check to ensure state contains roughly what we expect
_, err = base64.URLEncoding.DecodeString(string(state))
if err != nil {
return false
}
return true
}

// ValidateGroupMembership takes in an email and the allowed groups and returns the groups that the email is part of in that list.
// If `allGroups` is an empty list it returns all the groups that the user belongs to.
func (p *AzureV2Provider) ValidateGroupMembership(email string, allGroups []string) ([]string, error) {
if p.GraphService == nil {
panic("provider has not been configured")
}

userGroups, err := p.GraphService.GetGroups(email)
if err != nil {
return nil, err
}

// if `allGroups` is empty use the groups resource
if len(allGroups) == 0 {
return userGroups, nil
}

filtered := []string{}
for _, userGroup := range userGroups {
for _, allowedGroup := range allGroups {
if userGroup == allowedGroup {
filtered = append(filtered, userGroup)
}
}
}

return filtered, nil
}