Auth: Azure AD OAuth (#20030)

* Implement Azure AD oauth

* Use go-jose and cleanup

* Update go-jose in go.mod

* cleanup

* Add unit tests

* Fix scopes

* Add documentation page

* Improve documentation

* Convert extract_role into function.

* Do not use upn and replace unique_name with preferred_username

* Configure login button

* Use official microsoft icon and color from branding guideline.

* Add Azure AD config section in sample.ini.
This commit is contained in:
twendt
2020-02-13 12:12:25 +03:00
committed by GitHub
parent ceca067d4f
commit ff6a082e23
33 changed files with 1602 additions and 59 deletions
+2
View File
@@ -341,6 +341,8 @@ func GetAuthProviderLabel(authModule string) string {
return "GitHub"
case "oauth_google":
return "Google"
case "oauth_azuread":
return "AzureAD"
case "oauth_gitlab":
return "GitLab"
case "oauth_grafana_com", "oauth_grafananet":
+2
View File
@@ -160,6 +160,7 @@ func TestMetrics(t *testing.T) {
oauthProviders := map[string]bool{
"github": true,
"gitlab": true,
"azuread": true,
"google": true,
"generic_oauth": true,
"grafana_com": true,
@@ -254,6 +255,7 @@ func TestMetrics(t *testing.T) {
So(metrics.Get("stats.auth_enabled.oauth_github.count").MustInt(), ShouldEqual, 1)
So(metrics.Get("stats.auth_enabled.oauth_gitlab.count").MustInt(), ShouldEqual, 1)
So(metrics.Get("stats.auth_enabled.oauth_google.count").MustInt(), ShouldEqual, 1)
So(metrics.Get("stats.auth_enabled.oauth_azuread.count").MustInt(), ShouldEqual, 1)
So(metrics.Get("stats.auth_enabled.oauth_generic_oauth.count").MustInt(), ShouldEqual, 1)
So(metrics.Get("stats.auth_enabled.oauth_grafana_com.count").MustInt(), ShouldEqual, 1)
+111
View File
@@ -0,0 +1,111 @@
package social
import (
"errors"
"fmt"
"net/http"
"strings"
"github.com/grafana/grafana/pkg/models"
"golang.org/x/oauth2"
"gopkg.in/square/go-jose.v2/jwt"
)
type SocialAzureAD struct {
*SocialBase
allowedDomains []string
allowSignup bool
}
type azureClaims struct {
Email string `json:"email"`
PreferredUsername string `json:"preferred_username"`
Roles []string `json:"roles"`
Name string `json:"name"`
ID string `json:"oid"`
}
func (s *SocialAzureAD) Type() int {
return int(models.AZUREAD)
}
func (s *SocialAzureAD) IsEmailAllowed(email string) bool {
return isEmailAllowed(email, s.allowedDomains)
}
func (s *SocialAzureAD) IsSignupAllowed() bool {
return s.allowSignup
}
func (s *SocialAzureAD) UserInfo(_ *http.Client, token *oauth2.Token) (*BasicUserInfo, error) {
idToken := token.Extra("id_token")
if idToken == nil {
return nil, fmt.Errorf("No id_token found")
}
parsedToken, err := jwt.ParseSigned(idToken.(string))
if err != nil {
return nil, fmt.Errorf("Error parsing id token")
}
var claims azureClaims
if err := parsedToken.UnsafeClaimsWithoutVerification(&claims); err != nil {
return nil, fmt.Errorf("Error getting claims from id token")
}
email := extractEmail(claims)
if email == "" {
return nil, errors.New("Error getting user info: No email found in access token")
}
role := extractRole(claims)
return &BasicUserInfo{
Id: claims.ID,
Name: claims.Name,
Email: email,
Login: email,
Role: string(role),
}, nil
}
func extractEmail(claims azureClaims) string {
if claims.Email == "" {
if claims.PreferredUsername != "" {
return claims.PreferredUsername
}
}
return claims.Email
}
func extractRole(claims azureClaims) models.RoleType {
if len(claims.Roles) == 0 {
return models.ROLE_VIEWER
}
roleOrder := []models.RoleType{
models.ROLE_ADMIN,
models.ROLE_EDITOR,
models.ROLE_VIEWER,
}
for _, role := range roleOrder {
if found := hasRole(claims.Roles, role); found {
return role
}
}
return models.ROLE_VIEWER
}
func hasRole(roles []string, role models.RoleType) bool {
for _, item := range roles {
if strings.EqualFold(item, string(role)) {
return true
}
}
return false
}
+234
View File
@@ -0,0 +1,234 @@
package social
import (
"golang.org/x/oauth2"
"gopkg.in/square/go-jose.v2"
"gopkg.in/square/go-jose.v2/jwt"
"net/http"
"reflect"
"testing"
"time"
)
func TestSocialAzureAD_UserInfo(t *testing.T) {
type fields struct {
SocialBase *SocialBase
allowedDomains []string
allowSignup bool
}
type args struct {
client *http.Client
}
tests := []struct {
name string
fields fields
claims *azureClaims
args args
want *BasicUserInfo
wantErr bool
}{
{
name: "Email in email claim",
claims: &azureClaims{
Email: "me@example.com",
PreferredUsername: "",
Roles: []string{},
Name: "My Name",
ID: "1234",
},
want: &BasicUserInfo{
Id: "1234",
Name: "My Name",
Email: "me@example.com",
Login: "me@example.com",
Company: "",
Role: "Viewer",
Groups: nil,
},
},
{
name: "No email",
claims: &azureClaims{
Email: "",
PreferredUsername: "",
Roles: []string{},
Name: "My Name",
ID: "1234",
},
want: nil,
wantErr: true,
},
{
name: "No id token",
claims: nil,
want: nil,
wantErr: true,
},
{
name: "Email in preferred_username claim",
claims: &azureClaims{
Email: "",
PreferredUsername: "me@example.com",
Roles: []string{},
Name: "My Name",
ID: "1234",
},
want: &BasicUserInfo{
Id: "1234",
Name: "My Name",
Email: "me@example.com",
Login: "me@example.com",
Company: "",
Role: "Viewer",
Groups: nil,
},
},
{
name: "Admin role",
claims: &azureClaims{
Email: "me@example.com",
PreferredUsername: "",
Roles: []string{"Admin"},
Name: "My Name",
ID: "1234",
},
want: &BasicUserInfo{
Id: "1234",
Name: "My Name",
Email: "me@example.com",
Login: "me@example.com",
Company: "",
Role: "Admin",
Groups: nil,
},
},
{
name: "Lowercase Admin role",
claims: &azureClaims{
Email: "me@example.com",
PreferredUsername: "",
Roles: []string{"admin"},
Name: "My Name",
ID: "1234",
},
want: &BasicUserInfo{
Id: "1234",
Name: "My Name",
Email: "me@example.com",
Login: "me@example.com",
Company: "",
Role: "Admin",
Groups: nil,
},
},
{
name: "Only other roles",
claims: &azureClaims{
Email: "me@example.com",
PreferredUsername: "",
Roles: []string{"AppAdmin"},
Name: "My Name",
ID: "1234",
},
want: &BasicUserInfo{
Id: "1234",
Name: "My Name",
Email: "me@example.com",
Login: "me@example.com",
Company: "",
Role: "Viewer",
Groups: nil,
},
},
{
name: "Editor role",
claims: &azureClaims{
Email: "me@example.com",
PreferredUsername: "",
Roles: []string{"Editor"},
Name: "My Name",
ID: "1234",
},
want: &BasicUserInfo{
Id: "1234",
Name: "My Name",
Email: "me@example.com",
Login: "me@example.com",
Company: "",
Role: "Editor",
Groups: nil,
},
},
{
name: "Admin and Editor roles in claim",
claims: &azureClaims{
Email: "me@example.com",
PreferredUsername: "",
Roles: []string{"Admin", "Editor"},
Name: "My Name",
ID: "1234",
},
want: &BasicUserInfo{
Id: "1234",
Name: "My Name",
Email: "me@example.com",
Login: "me@example.com",
Company: "",
Role: "Admin",
Groups: nil,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := &SocialAzureAD{
SocialBase: tt.fields.SocialBase,
allowedDomains: tt.fields.allowedDomains,
allowSignup: tt.fields.allowSignup,
}
key := []byte("secret")
sig, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.HS256, Key: key}, (&jose.SignerOptions{}).WithType("JWT"))
if err != nil {
panic(err)
}
cl := jwt.Claims{
Subject: "subject",
Issuer: "issuer",
NotBefore: jwt.NewNumericDate(time.Date(2016, 1, 1, 0, 0, 0, 0, time.UTC)),
Audience: jwt.Audience{"leela", "fry"},
}
var raw string
if tt.claims != nil {
raw, err = jwt.Signed(sig).Claims(cl).Claims(tt.claims).CompactSerialize()
if err != nil {
t.Error(err)
}
} else {
raw, err = jwt.Signed(sig).Claims(cl).CompactSerialize()
if err != nil {
t.Error(err)
}
}
token := &oauth2.Token{}
if tt.claims != nil {
token = token.WithExtra(map[string]interface{}{"id_token": raw})
}
got, err := s.UserInfo(tt.args.client, token)
if (err != nil) != tt.wantErr {
t.Errorf("UserInfo() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("UserInfo() got = %v, want %v", got, tt.want)
}
})
}
}
+13 -1
View File
@@ -55,7 +55,7 @@ const (
var (
SocialBaseUrl = "/login/"
SocialMap = make(map[string]SocialConnector)
allOauthes = []string{"github", "gitlab", "google", "generic_oauth", "grafananet", grafanaCom}
allOauthes = []string{"github", "gitlab", "google", "generic_oauth", "grafananet", grafanaCom, "azuread"}
)
func NewOAuthService() {
@@ -152,6 +152,18 @@ func NewOAuthService() {
}
}
// AzureAD.
if name == "azuread" {
SocialMap["azuread"] = &SocialAzureAD{
SocialBase: &SocialBase{
Config: &config,
log: logger,
},
allowedDomains: info.AllowedDomains,
allowSignup: info.AllowSignup,
}
}
// Generic - Uses the same scheme as Github.
if name == "generic_oauth" {
SocialMap["generic_oauth"] = &SocialGenericOAuth{
+1
View File
@@ -9,4 +9,5 @@ const (
GENERIC
GRAFANA_COM
GITLAB
AZUREAD
)