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:
@@ -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":
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
@@ -9,4 +9,5 @@ const (
|
||||
GENERIC
|
||||
GRAFANA_COM
|
||||
GITLAB
|
||||
AZUREAD
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user