diff --git a/README.md b/README.md index 41788609..7ea75b2f 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,12 @@ heavily relying on C# and non-portable .NET Framework. ## Changes -- **2019-11-02: Library API breaking changes:** Since #1 gets merged every request method should take a ctx for the first arg. -You can pass a nil for it to use the default ctx kept in http.Client. +- **2019-11-02 Library API breaking changes:** + - [#1][PR1] every request method should take a ctx for the first arg + - [#2][PR2] package auth renamed to msauth + +[PR1]: https://github.com/yaegashi/msgraph.go/pull/1 +[PR2]: https://github.com/yaegashi/msgraph.go/pull/2 ## Usage diff --git a/auth/client_credentials_grant.go b/auth/client_credentials_grant.go deleted file mode 100644 index 616eb267..00000000 --- a/auth/client_credentials_grant.go +++ /dev/null @@ -1,22 +0,0 @@ -package auth - -import "net/url" - -const ( - clientCredentialsGrantType = "client_credentials" -) - -// ClientCredentialsGrant authenticates via OAuth 2.0 client credentials grant -func (m *TokenManager) ClientCredentialsGrant(tenantID, clientID, clientSecret, scope string) (*Token, error) { - t, err := m.refreshToken(tenantID, clientID, clientSecret, scope) - if err == nil && t != nil { - return t, nil - } - values := url.Values{ - "client_id": {clientID}, - "client_secret": {clientSecret}, - "scope": {scope}, - "grant_type": {clientCredentialsGrantType}, - } - return m.requestToken(tenantID, clientID, values) -} diff --git a/auth/token.go b/auth/token.go deleted file mode 100644 index 0c947e9e..00000000 --- a/auth/token.go +++ /dev/null @@ -1,50 +0,0 @@ -package auth - -import ( - "context" - "fmt" - "net/http" - - "golang.org/x/oauth2" -) - -const ( - // DefaultMSGraphScope is the default scope for MS Graph API - DefaultMSGraphScope = "https://graph.microsoft.com/.default" -) - -// Token contains OAuth2 tokens returned by the token endpoint -type Token struct { - TokenType string `json:"token_type"` - Scope string `json:"scope"` - ExpiresIn int `json:"expires_in"` - ExpiresOn int `json:"expires_on"` - AccessToken string `json:"access_token"` - IDToken string `json:"id_token"` - RefreshToken string `json:"refresh_token"` -} - -// Token implements oauth2.TokenSource interface -func (t *Token) Token() (*oauth2.Token, error) { - return &oauth2.Token{ - TokenType: t.TokenType, - AccessToken: t.AccessToken, - RefreshToken: t.RefreshToken, - }, nil -} - -// Client returns *http.Client -func (t *Token) Client(ctx context.Context) *http.Client { - return oauth2.NewClient(ctx, t) -} - -// TokenError is returned on failed authentication -type TokenError struct { - ErrorX string `json:"error"` - ErrorDescription string `json:"error_description"` -} - -// Error implements error interface -func (t *TokenError) Error() string { - return fmt.Sprintf("%s: %s", t.ErrorX, t.ErrorDescription) -} diff --git a/auth/token_manager.go b/auth/token_manager.go deleted file mode 100644 index 7942a20c..00000000 --- a/auth/token_manager.go +++ /dev/null @@ -1,129 +0,0 @@ -package auth - -import ( - "encoding/json" - "fmt" - "net/http" - "net/url" - "os" - "time" -) - -const ( - endpointURLFormat = "https://login.microsoftonline.com/%s/oauth2/v2.0/%s" - refreshTokenGrantType = "refresh_token" -) - -func generateKey(tenantID, clientID string) string { - return fmt.Sprintf("%s:%s", tenantID, clientID) -} - -func deviceCodeURL(tenantID string) string { - return fmt.Sprintf(endpointURLFormat, tenantID, "devicecode") -} - -func tokenURL(tenantID string) string { - return fmt.Sprintf(endpointURLFormat, tenantID, "token") -} - -// TokenManager is a persistent store for Token -type TokenManager struct { - Store map[string]*Token - Dirty bool -} - -// NewTokenManager returns a new TokenManager instance -func NewTokenManager() *TokenManager { - return &TokenManager{Store: map[string]*Token{}} -} - -// Load loads Token store -func (m *TokenManager) Load(path string) error { - file, err := os.Open(path) - if err != nil { - return err - } - defer file.Close() - dec := json.NewDecoder(file) - err = dec.Decode(&m.Store) - if err != nil { - return err - } - m.Dirty = false - return nil -} - -// Save saves Token store -func (m *TokenManager) Save(path string) error { - if !m.Dirty { - return nil - } - file, err := os.Create(path) - if err != nil { - return err - } - enc := json.NewEncoder(file) - err = enc.Encode(m.Store) - if err != nil { - file.Close() - return err - } - err = file.Close() - if err != nil { - return err - } - m.Dirty = false - return nil -} - -func (m *TokenManager) requestToken(tenantID, clientID string, values url.Values) (*Token, error) { - res, err := http.PostForm(tokenURL(tenantID), values) - if err != nil { - return nil, err - } - defer res.Body.Close() - if res.StatusCode == http.StatusOK { - t := &Token{} - dec := json.NewDecoder(res.Body) - err = dec.Decode(t) - if err != nil { - return nil, err - } - if t.ExpiresOn == 0 { - t.ExpiresOn = int(time.Now().Unix()) + t.ExpiresIn - } - m.Store[generateKey(tenantID, clientID)] = t - m.Dirty = true - return t, nil - } - t := &TokenError{} - dec := json.NewDecoder(res.Body) - err = dec.Decode(t) - if err != nil { - return nil, err - } - return nil, t -} - -func (m *TokenManager) refreshToken(tenantID, clientID, clientSecret, scope string) (*Token, error) { - t, ok := m.Store[generateKey(tenantID, clientID)] - if !ok { - t = &Token{} - } - if int(time.Now().Unix()) < t.ExpiresOn { - return t, nil - } - if t.RefreshToken == "" { - return nil, fmt.Errorf("No refresh token") - } - values := url.Values{ - "client_id": {clientID}, - "grant_type": {refreshTokenGrantType}, - "scope": {scope}, - "refresh_token": {t.RefreshToken}, - } - if clientSecret != "" { - values.Add("client_secret", clientSecret) - } - return m.requestToken(tenantID, clientID, values) -} diff --git a/cmd/msgraph-me/main.go b/cmd/msgraph-me/main.go index 27af059b..9e8602f2 100644 --- a/cmd/msgraph-me/main.go +++ b/cmd/msgraph-me/main.go @@ -10,18 +10,20 @@ import ( "net/http" "os" - "github.com/yaegashi/msgraph.go/auth" "github.com/yaegashi/msgraph.go/jsonx" + "github.com/yaegashi/msgraph.go/msauth" msgraph "github.com/yaegashi/msgraph.go/v1.0" + "golang.org/x/oauth2" ) const ( defaultTenantID = "common" defaultClientID = "45c7f99c-0a94-42ff-a6d8-a8d657229e8c" - defaultScope = "openid profile offline_access User.Read Files.Read" - tokenStorePath = "token_store.json" + tokenCachePath = "token_cache.json" ) +var defaultScopes = []string{"openid", "profile", "offline_access", "User.Read", "Files.Read"} + func dump(o interface{}) { enc := jsonx.NewEncoder(os.Stdout) enc.SetIndent("", " ") @@ -30,20 +32,20 @@ func dump(o interface{}) { func main() { var tenantID, clientID string - flag.StringVar(&tenantID, "tenant_id", defaultTenantID, "Tenant ID") - flag.StringVar(&clientID, "client_id", defaultClientID, "Client ID") + flag.StringVar(&tenantID, "tenant-id", defaultTenantID, "Tenant ID") + flag.StringVar(&clientID, "client-id", defaultClientID, "Client ID") flag.Parse() - m := auth.NewTokenManager() - m.Load(tokenStorePath) - t, err := m.DeviceAuthorizationGrant(tenantID, clientID, defaultScope, nil) + ctx := context.Background() + m := msauth.NewManager() + m.LoadFile(tokenCachePath) + ts, err := m.DeviceAuthorizationGrant(ctx, tenantID, clientID, defaultScopes, nil) if err != nil { log.Fatal(err) } - m.Save(tokenStorePath) + m.SaveFile(tokenCachePath) - ctx := context.Background() - httpClient := t.Client(ctx) + httpClient := oauth2.NewClient(ctx, ts) graphClient := msgraph.NewClient(httpClient) { diff --git a/cmd/msgraph-spoget/main.go b/cmd/msgraph-spoget/main.go index 05ea6014..5d39082e 100644 --- a/cmd/msgraph-spoget/main.go +++ b/cmd/msgraph-spoget/main.go @@ -10,59 +10,62 @@ import ( "net/http" "os" - "github.com/yaegashi/msgraph.go/auth" "github.com/yaegashi/msgraph.go/jsonx" + "github.com/yaegashi/msgraph.go/msauth" msgraph "github.com/yaegashi/msgraph.go/v1.0" + "golang.org/x/oauth2" ) const ( defaultTenantID = "common" defaultClientID = "45c7f99c-0a94-42ff-a6d8-a8d657229e8c" - defaultScope = "Sites.Read.All Files.Read.All" ) +var defaultScopes = []string{"Sites.Read.All", "Files.Read.All"} + // Config is serializable configuration type Config struct { TenantID string `json:"tenant_id,omitempty"` ClientID string `json:"client_id,omitempty"` ClientSecret string `json:"client_secret,omitempty"` - TokenStore string `json:"token_store,omitempty"` + TokenCache string `json:"token_cache,omitempty"` } // App is application type App struct { Config URL string - Token *auth.Token + TokenSource oauth2.TokenSource GraphClient *msgraph.GraphServiceRequestBuilder } // Authenticate performs OAuth2 authentication func (app *App) Authenticate(ctx context.Context) error { var err error - m := auth.NewTokenManager() + m := msauth.NewManager() if app.ClientSecret == "" { - if app.TokenStore != "" { + if app.TokenCache != "" { // ignore errors - m.Load(app.TokenStore) + m.LoadFile(app.TokenCache) } - app.Token, err = m.DeviceAuthorizationGrant(app.TenantID, app.ClientID, defaultScope, nil) + app.TokenSource, err = m.DeviceAuthorizationGrant(ctx, app.TenantID, app.ClientID, defaultScopes, nil) if err != nil { return err } - if app.TokenStore != "" { - err = m.Save(app.TokenStore) + if app.TokenCache != "" { + err = m.SaveFile(app.TokenCache) if err != nil { return err } } } else { - app.Token, err = m.ClientCredentialsGrant(app.TenantID, app.ClientID, app.ClientSecret, auth.DefaultMSGraphScope) + scopes := []string{msauth.DefaultMSGraphScope} + app.TokenSource, err = m.ClientCredentialsGrant(ctx, app.TenantID, app.ClientID, app.ClientSecret, scopes) if err != nil { return err } } - app.GraphClient = msgraph.NewClient(app.Token.Client(ctx)) + app.GraphClient = msgraph.NewClient(oauth2.NewClient(ctx, app.TokenSource)) return nil } @@ -117,7 +120,7 @@ func main() { flag.StringVar(&app.TenantID, "tenant-id", "", "Tenant ID (default:"+defaultTenantID+")") flag.StringVar(&app.ClientID, "client-id", "", "Client ID (default: "+defaultClientID+")") flag.StringVar(&app.ClientSecret, "client-secret", "", "Client secret (for client credentials grant)") - flag.StringVar(&app.TokenStore, "token-store", "", "OAuth2 token store path") + flag.StringVar(&app.TokenCache, "token-cache", "", "OAuth2 token cache path") flag.StringVar(&app.URL, "url", "", "URL") flag.Parse() @@ -144,8 +147,8 @@ func main() { if app.ClientSecret == "" { app.ClientSecret = cfg.ClientSecret } - if app.TokenStore == "" { - app.TokenStore = cfg.TokenStore + if app.TokenCache == "" { + app.TokenCache = cfg.TokenCache } ctx := context.Background() diff --git a/cmd/msgraph-sshpubkey/main.go b/cmd/msgraph-sshpubkey/main.go index bfa86333..bc773931 100644 --- a/cmd/msgraph-sshpubkey/main.go +++ b/cmd/msgraph-sshpubkey/main.go @@ -10,25 +10,27 @@ import ( "os" "strings" - "github.com/yaegashi/msgraph.go/auth" "github.com/yaegashi/msgraph.go/jsonx" + "github.com/yaegashi/msgraph.go/msauth" msgraph "github.com/yaegashi/msgraph.go/v1.0" + "golang.org/x/oauth2" ) const ( defaultExtensionName = "dev.l0w.ssh_public_keys" defaultTenantID = "common" defaultClientID = "45c7f99c-0a94-42ff-a6d8-a8d657229e8c" - defaultScope = "openid User.ReadWrite" ) +var defaultScopes = []string{"openid", "User.ReadWrite"} + // Config is serializable configuration type Config struct { TenantID string `json:"tenant_id,omitempty"` ClientID string `json:"client_id,omitempty"` ClientSecret string `json:"client_secret,omitempty"` ExtensionName string `json:"extension_name,omitempty"` - TokenStore string `json:"token_store,omitempty"` + TokenCache string `json:"token_cache,omitempty"` LoginMap map[string]string `json:"login_map"` } @@ -39,7 +41,7 @@ type App struct { In string Out string Login string - Token *auth.Token + TokenSource oauth2.TokenSource GraphClient *msgraph.GraphServiceRequestBuilder } @@ -60,29 +62,30 @@ func (app *App) User() *msgraph.UserRequestBuilder { // Authenticate performs OAuth2 authentication func (app *App) Authenticate(ctx context.Context) error { var err error - m := auth.NewTokenManager() + m := msauth.NewManager() if app.ClientSecret == "" { - if app.TokenStore != "" { + if app.TokenCache != "" { // ignore errors - m.Load(app.TokenStore) + m.LoadFile(app.TokenCache) } - app.Token, err = m.DeviceAuthorizationGrant(app.TenantID, app.ClientID, defaultScope, nil) + app.TokenSource, err = m.DeviceAuthorizationGrant(ctx, app.TenantID, app.ClientID, defaultScopes, nil) if err != nil { return err } - if app.TokenStore != "" { - err = m.Save(app.TokenStore) + if app.TokenCache != "" { + err = m.SaveFile(app.TokenCache) if err != nil { return err } } } else { - app.Token, err = m.ClientCredentialsGrant(app.TenantID, app.ClientID, app.ClientSecret, auth.DefaultMSGraphScope) + scopes := []string{msauth.DefaultMSGraphScope} + app.TokenSource, err = m.ClientCredentialsGrant(ctx, app.TenantID, app.ClientID, app.ClientSecret, scopes) if err != nil { return err } } - app.GraphClient = msgraph.NewClient(app.Token.Client(ctx)) + app.GraphClient = msgraph.NewClient(oauth2.NewClient(ctx, app.TokenSource)) return nil } @@ -162,7 +165,7 @@ func main() { flag.StringVar(&app.TenantID, "tenant-id", "", "Tenant ID (default:"+defaultTenantID+")") flag.StringVar(&app.ClientID, "client-id", "", "Client ID (default: "+defaultClientID+")") flag.StringVar(&app.ClientSecret, "client-secret", "", "Client secret (for client credentials grant)") - flag.StringVar(&app.TokenStore, "token-store", "", "OAuth2 token store path") + flag.StringVar(&app.TokenCache, "token-cache", "", "OAuth2 token cache path") flag.StringVar(&app.ExtensionName, "extension-name", "", "Extension name (default: "+defaultExtensionName+")") flag.StringVar(&app.Login, "login", "", "Login name (default: authenticated user)") flag.StringVar(&app.In, "in", "", "Input file (\"-\" for stdin)") @@ -193,8 +196,8 @@ func main() { if app.ClientSecret == "" { app.ClientSecret = cfg.ClientSecret } - if app.TokenStore == "" { - app.TokenStore = cfg.TokenStore + if app.TokenCache == "" { + app.TokenCache = cfg.TokenCache } if app.ExtensionName == "" { app.ExtensionName = cfg.ExtensionName diff --git a/cmd/msgraph-usergroup/main.go b/cmd/msgraph-usergroup/main.go index a19e29d0..f0fcc05c 100644 --- a/cmd/msgraph-usergroup/main.go +++ b/cmd/msgraph-usergroup/main.go @@ -8,10 +8,11 @@ import ( "os" "github.com/google/uuid" - "github.com/yaegashi/msgraph.go/auth" msgraph "github.com/yaegashi/msgraph.go/beta" "github.com/yaegashi/msgraph.go/jsonx" + "github.com/yaegashi/msgraph.go/msauth" P "github.com/yaegashi/msgraph.go/ptr" + "golang.org/x/oauth2" ) // Default ID and secret: you should replace them with your own @@ -51,19 +52,20 @@ func dump(o interface{}) { func main() { var tenantID, clientID, clientSecret string - flag.StringVar(&tenantID, "tenant_id", defaultTenantID, "Tenant ID") - flag.StringVar(&clientID, "client_id", defaultClientID, "Client ID") - flag.StringVar(&clientSecret, "client_secret", defaultClientSecret, "Client Secret") + flag.StringVar(&tenantID, "tenant-id", defaultTenantID, "Tenant ID") + flag.StringVar(&clientID, "client-id", defaultClientID, "Client ID") + flag.StringVar(&clientSecret, "client-secret", defaultClientSecret, "Client Secret") flag.Parse() - m := auth.NewTokenManager() - t, err := m.ClientCredentialsGrant(tenantID, clientID, clientSecret, auth.DefaultMSGraphScope) + ctx := context.Background() + m := msauth.NewManager() + scopes := []string{msauth.DefaultMSGraphScope} + ts, err := m.ClientCredentialsGrant(ctx, tenantID, clientID, clientSecret, scopes) if err != nil { log.Fatal(err) } - ctx := context.Background() - httpClient := t.Client(ctx) + httpClient := oauth2.NewClient(ctx, ts) graphClient := msgraph.NewClient(httpClient) { diff --git a/msauth/README.md b/msauth/README.md new file mode 100644 index 00000000..43aead20 --- /dev/null +++ b/msauth/README.md @@ -0,0 +1,70 @@ +# msauth + +## Introduction + +Very simple package to authorize applications against [Microsoft identity platform]. + +It utilizes [v2.0 endpoint] so that it can authorize users using both personal (Microsoft) and organizational (Azure AD) account. + +## Usage + +### Device authorization grant + +- [OAuth 2.0 device authorization grant flow] + +```go +const ( + tenantID = "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX" + clientID = "YYYYYYYY-YYYY-YYYY-YYYY-YYYYYYYYYYYY" + tokenCachePath = "token_cache.json" +) + +var scopes = []string{"openid", "profile", "offline_access", "User.Read", "Files.Read"} + + ctx := context.Background() + m := msauth.NewManager() + m.LoadFile(tokenCachePath) + ts, err := m.DeviceAuthorizationGrant(ctx, tenantID, clientID, scopes, nil) + if err != nil { + log.Fatal(err) + } + m.SaveFile(tokenCachePath) + + httpClient := oauth2.NewClient(ctx, ts) + ... +``` + +### Client credentials grant + +- [OAuth 2.0 client credentials grant flow] + +```go +const ( + tenantID = "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX" + clientID = "YYYYYYYY-YYYY-YYYY-YYYY-YYYYYYYYYYYY" + clientSecret = "ZZZZZZZZZZZZZZZZZZZZZZZZ" +) + +var scopes = []string{msauth.DefaultMSGraphScope} + + ctx := context.Background() + m := msauth.NewManager() + ts, err := m.ClientCredentialsGrant(ctx, tenantID, clientID, clientSecret, scopes) + if err != nil { + log.Fatal(err) + } + + httpClient := oauth2.NewClient(ctx, ts) + ... +``` + +### Authorization code grant + +- [OAuth 2.0 authorization code grant flow] +- Not yet implemented. + +[Microsoft identity platform]: https://docs.microsoft.com/en-us/azure/active-directory/develop/ +[v2.0 endpoint]: https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-overview +[OAuth 2.0 device authorization grant flow]: https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-device-code +[OAuth 2.0 client credentials grant flow]: https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-client-creds-grant-flow +[OAuth 2.0 authorization code grant flow]: https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-auth-code-flow \ No newline at end of file diff --git a/msauth/client_credentials_grant.go b/msauth/client_credentials_grant.go new file mode 100644 index 00000000..bc50883d --- /dev/null +++ b/msauth/client_credentials_grant.go @@ -0,0 +1,27 @@ +package msauth + +import ( + "context" + + "golang.org/x/oauth2" + "golang.org/x/oauth2/clientcredentials" + "golang.org/x/oauth2/microsoft" +) + +// ClientCredentialsGrant performs OAuth 2.0 client credentials grant and returns auto-refreshing TokenSource +func (m *Manager) ClientCredentialsGrant(ctx context.Context, tenantID, clientID, clientSecret string, scopes []string) (oauth2.TokenSource, error) { + config := &clientcredentials.Config{ + ClientID: clientID, + ClientSecret: clientSecret, + TokenURL: microsoft.AzureADEndpoint(tenantID).TokenURL, + Scopes: scopes, + AuthStyle: oauth2.AuthStyleInParams, + } + var err error + ts := config.TokenSource(ctx) + _, err = ts.Token() + if err != nil { + return nil, err + } + return ts, nil +} diff --git a/auth/device_authorization_grant.go b/msauth/device_authorization_grant.go similarity index 57% rename from auth/device_authorization_grant.go rename to msauth/device_authorization_grant.go index 2b05656c..4baafd8d 100644 --- a/auth/device_authorization_grant.go +++ b/msauth/device_authorization_grant.go @@ -1,13 +1,18 @@ -package auth +package msauth import ( + "context" "encoding/json" "fmt" "io/ioutil" "net/http" "net/url" "os" + "strings" "time" + + "golang.org/x/oauth2" + "golang.org/x/oauth2/microsoft" ) const ( @@ -25,12 +30,25 @@ type DeviceCode struct { Message string `json:"message"` } -// DeviceAuthorizationGrant authenticates via OAuth 2.0 device authorization grant -func (m *TokenManager) DeviceAuthorizationGrant(tenantID, clientID, scope string, callback func(*DeviceCode) error) (*Token, error) { - t, err := m.refreshToken(tenantID, clientID, "", scope) - if err == nil && t != nil { - return t, nil +// DeviceAuthorizationGrant performs OAuth 2.0 device authorization grant and returns auto-refreshing TokenSource +func (m *Manager) DeviceAuthorizationGrant(ctx context.Context, tenantID, clientID string, scopes []string, callback func(*DeviceCode) error) (oauth2.TokenSource, error) { + endpoint := microsoft.AzureADEndpoint(tenantID) + endpoint.AuthStyle = oauth2.AuthStyleInParams + config := &oauth2.Config{ + ClientID: clientID, + Endpoint: endpoint, + Scopes: scopes, + } + if t, ok := m.TokenCache[generateKey(tenantID, clientID)]; ok { + tt, err := config.TokenSource(ctx, t).Token() + if err == nil { + return config.TokenSource(ctx, tt), nil + } + if _, ok := err.(*oauth2.RetrieveError); !ok { + return nil, err + } } + scope := strings.Join(scopes, " ") res, err := http.PostForm(deviceCodeURL(tenantID), url.Values{"client_id": {clientID}, "scope": {scope}}) if err != nil { return nil, err @@ -65,12 +83,13 @@ func (m *TokenManager) DeviceAuthorizationGrant(tenantID, clientID, scope string } for { time.Sleep(time.Second * time.Duration(interval)) - t, err := m.requestToken(tenantID, clientID, values) + token, err := m.requestToken(ctx, tenantID, clientID, values) if err == nil { - return t, nil + m.Cache(tenantID, clientID, token) + return config.TokenSource(ctx, token), nil } tokenError, ok := err.(*TokenError) - if !ok || tokenError.ErrorX != authorizationPendingError { + if !ok || tokenError.ErrorObject != authorizationPendingError { return nil, err } } diff --git a/msauth/msauth.go b/msauth/msauth.go new file mode 100644 index 00000000..c2a86985 --- /dev/null +++ b/msauth/msauth.go @@ -0,0 +1,148 @@ +// Package msauth implements a library to authorize against Microsoft identity platform: +// https://docs.microsoft.com/en-us/azure/active-directory/develop/ +// +// It utilizes v2.0 endpoint +// so it can authorize users with both personal (Microsoft) and organizational (Azure AD) account. +package msauth + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "sync" + "time" + + "golang.org/x/oauth2" +) + +const ( + // DefaultMSGraphScope is the default scope for MS Graph API + DefaultMSGraphScope = "https://graph.microsoft.com/.default" + endpointURLFormat = "https://login.microsoftonline.com/%s/oauth2/v2.0/%s" +) + +// TokenError is returned on failed authentication +type TokenError struct { + ErrorObject string `json:"error"` + ErrorDescription string `json:"error_description"` +} + +// Error implements error interface +func (t *TokenError) Error() string { + return fmt.Sprintf("%s: %s", t.ErrorObject, t.ErrorDescription) +} + +func generateKey(tenantID, clientID string) string { + return fmt.Sprintf("%s:%s", tenantID, clientID) +} + +func deviceCodeURL(tenantID string) string { + return fmt.Sprintf(endpointURLFormat, tenantID, "devicecode") +} + +func tokenURL(tenantID string) string { + return fmt.Sprintf(endpointURLFormat, tenantID, "token") +} + +type tokenJSON struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` +} + +func (e *tokenJSON) expiry() (t time.Time) { + if v := e.ExpiresIn; v != 0 { + return time.Now().Add(time.Duration(v) * time.Second) + } + return +} + +// Manager is oauth2 token cache manager +type Manager struct { + mu sync.Mutex + TokenCache map[string]*oauth2.Token +} + +// NewManager returns a new Manager instance +func NewManager() *Manager { + return &Manager{TokenCache: map[string]*oauth2.Token{}} +} + +// LoadBytes loads token cache from opaque bytes (it's actually JSON) +func (m *Manager) LoadBytes(b []byte) error { + m.mu.Lock() + defer m.mu.Unlock() + return json.Unmarshal(b, &m.TokenCache) +} + +// SaveBytes saves token cache to opaque bytes (it's actually JSON) +func (m *Manager) SaveBytes() ([]byte, error) { + m.mu.Lock() + defer m.mu.Unlock() + return json.Marshal(m.TokenCache) +} + +// LoadFile loads token cache from file +func (m *Manager) LoadFile(path string) error { + b, err := ioutil.ReadFile(path) + if err != nil { + return err + } + return m.LoadBytes(b) +} + +// SaveFile saves token cache to file +func (m *Manager) SaveFile(path string) error { + b, err := m.SaveBytes() + if err != nil { + return err + } + return ioutil.WriteFile(path, b, 0644) +} + +// Cache stores a token into token cache +func (m *Manager) Cache(tenantID, clientID string, token *oauth2.Token) { + m.TokenCache[generateKey(tenantID, clientID)] = token +} + +// requestToken requests a token from the token endpoint +// TODO(ctx): use http client from ctx +func (m *Manager) requestToken(ctx context.Context, tenantID, clientID string, values url.Values) (*oauth2.Token, error) { + res, err := http.PostForm(tokenURL(tenantID), values) + if err != nil { + return nil, err + } + defer res.Body.Close() + b, err := ioutil.ReadAll(res.Body) + if err != nil { + return nil, err + } + if res.StatusCode != http.StatusOK { + var terr *TokenError + err = json.Unmarshal(b, &terr) + if err != nil { + return nil, err + } + return nil, terr + } + var tj *tokenJSON + err = json.Unmarshal(b, &tj) + if err != nil { + return nil, err + } + token := &oauth2.Token{ + AccessToken: tj.AccessToken, + TokenType: tj.TokenType, + RefreshToken: tj.RefreshToken, + Expiry: tj.expiry(), + } + if token.AccessToken == "" { + return nil, errors.New("msauth: server response missing access_token") + } + return token, nil +} diff --git a/msauth/msauth_test.go b/msauth/msauth_test.go new file mode 100644 index 00000000..8dd1683a --- /dev/null +++ b/msauth/msauth_test.go @@ -0,0 +1,45 @@ +package msauth_test + +import ( + "context" + "fmt" + "io/ioutil" + "log" + + "github.com/yaegashi/msgraph.go/msauth" + "golang.org/x/oauth2" +) + +const ( + tenantID = "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX" + clientID = "YYYYYYYY-YYYY-YYYY-YYYY-YYYYYYYYYYYY" + clientSecret = "ZZZZZZZZZZZZZZZZZZZZZZZZ" + tokenStorePath = "token_store.json" +) + +var scopes = []string{"openid", "profile", "offline_access", "User.Read", "Files.Read"} + +func ExampleManager_DeviceAuthorizationGrant() { + ctx := context.Background() + m := msauth.NewManager() + m.LoadFile(tokenStorePath) + ts, err := m.DeviceAuthorizationGrant(ctx, tenantID, clientID, scopes, nil) + if err != nil { + log.Fatal(err) + } + err = m.SaveFile(tokenStorePath) + if err != nil { + log.Fatal(err) + } + httpClient := oauth2.NewClient(ctx, ts) + res, err := httpClient.Get("https://graph.microsoft.com/v1.0/me") + if err != nil { + log.Fatal(err) + } + defer res.Body.Close() + b, err := ioutil.ReadAll(res.Body) + if err != nil { + log.Fatal(err) + } + fmt.Printf("%s\n", string(string(b))) +}