diff --git a/.gitignore b/.gitignore index 9c9df7b..506ad82 100644 --- a/.gitignore +++ b/.gitignore @@ -21,4 +21,7 @@ # "ClientSecret": "as09djfa9sjdf", # "TenantID": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxx", # } -test.json \ No newline at end of file +test.json + +main.go +msgoraph \ No newline at end of file diff --git a/Makefile b/Makefile index e10e457..2224b9b 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,10 @@ build: - go build github.com/mhoc/msgoraph - go build github.com/mhoc/msgoraph/auth - go build github.com/mhoc/msgoraph/common - go build github.com/mhoc/msgoraph/internal - go build github.com/mhoc/msgoraph/users + vgo build github.com/mhoc/msgoraph + vgo build github.com/mhoc/msgoraph/client + vgo build github.com/mhoc/msgoraph/common + vgo build github.com/mhoc/msgoraph/internal + vgo build github.com/mhoc/msgoraph/scopes + vgo build github.com/mhoc/msgoraph/users docs: @echo "http://localhost:6060/pkg/github.com/mhoc/msgoraph/" diff --git a/README.md b/README.md index 2ebf6ef..e759960 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,13 @@ A zero dependency Go Client for [Microsoft's Graph API](https://developer.microsoft.com/en-us/graph/docs/concepts/overview). This is built and distributed under all of the philosophies of [vgo](https://research.swtch.com/vgo) for future compatibility, but should work with a simple `go get`, `dep`, or your package management tool of choice until `vgo` is stable. -## Stability Warning +## Disclaimers -This library is pre-release, under active development, and has no tests. We will do our best to ensure that tagged releases are stable enough to use the functionality they export, but bugs could happen. Becuase this is pre-release, the Go Import Compatibility Rule does not apply and backward-incompatible changes should be expected between minor pre-release versions. Make sure to pin your version. +This library is completely unaffiliated with Microsoft. + +This library is pre-release, under active development, and has no tests. We will do our best to ensure that tagged releases are stable enough to use the functionality they export, but bugs could happen. + +This library is in pre-release, and as such the Go Import Compatibility Rule does not apply. Backward-incompatible changes should be expected between all tagged versions and commits. ## Supported Operations diff --git a/auth/credentials.go b/auth/credentials.go deleted file mode 100644 index dc338de..0000000 --- a/auth/credentials.go +++ /dev/null @@ -1,90 +0,0 @@ -package auth - -import ( - "encoding/json" - "fmt" - "io/ioutil" - "net/http" - "net/url" - "sync" - "time" -) - -// Credentials stores enough information to authenticate a connection with some given -// TenantID. This includes the root clientID/clientSecret that authenticates a connection, as well -// as storage and logic to refresh access tokens when they expire. -type Credentials struct { - AccessToken string - AccessTokenExpiresAt time.Time - AccessTokenUpdating sync.Mutex - ClientID string - ClientSecret string - TenantID string -} - -// NewCredentials creates a new set of credentials. These credentials cannot immediately be used -// for fetching information from the Graph API; an access token first needs to be fetched. This -// will be done automatically on the first request to the Graph API, or manually by calling -// Credentials.RefreshAccessToken(). -func NewCredentials(tenantID string, clientID string, clientSecret string) *Credentials { - return &Credentials{ - ClientID: clientID, - ClientSecret: clientSecret, - TenantID: tenantID, - } -} - -// AuthEndpoint returns the oauth2 endpoint that is used to refresh the access token used to -// authenticate requests to the Graph API -func (c *Credentials) AuthEndpoint() string { - return fmt.Sprintf("https://login.microsoftonline.com/%v/oauth2/v2.0/token", c.TenantID) -} - -// RefreshAccessToken retrieves a 5 minute access token for the given set of credentials and upates -// it within the Credentials struct. This will refuse to update the token if the token hasn't -// yet expired, or if another goroutine is already doing the update. -func (c *Credentials) RefreshAccessToken() error { - c.AccessTokenUpdating.Lock() - defer c.AccessTokenUpdating.Unlock() - if c.AccessToken != "" && c.AccessTokenExpiresAt.After(time.Now()) { - return nil - } - resp, err := http.PostForm(c.AuthEndpoint(), url.Values{ - "client_id": {c.ClientID}, - "client_secret": {c.ClientSecret}, - "grant_type": {"client_credentials"}, - "scope": {"https://graph.microsoft.com/.default"}, - }) - if err != nil { - return err - } - b, err := ioutil.ReadAll(resp.Body) - if err != nil { - return err - } - var data map[string]interface{} - err = json.Unmarshal(b, &data) - if err != nil { - return err - } - serverErrCode, ok := data["error"].(string) - if ok { - serverErr, ok := data["error_description"].(string) - if ok { - return fmt.Errorf("%v: %v", serverErrCode, serverErr) - } - return fmt.Errorf(serverErrCode) - } - accessToken, ok := data["access_token"].(string) - if !ok || accessToken == "" { - return fmt.Errorf("no access token found in response") - } - durationSecs, ok := data["expires_in"].(float64) - if !ok || durationSecs == 0 { - return fmt.Errorf("no token duration found in response") - } - expiresAt := time.Now().Add(time.Duration(durationSecs) * time.Second) - c.AccessToken = accessToken - c.AccessTokenExpiresAt = expiresAt - return nil -} diff --git a/auth/doc.go b/auth/doc.go deleted file mode 100644 index fc2e90e..0000000 --- a/auth/doc.go +++ /dev/null @@ -1,3 +0,0 @@ -// Package auth implements client state, authentication, pagination, type helpers, and method -// helpers concerning accessing resources in the Microsoft Graph API. -package auth diff --git a/client/client.go b/client/client.go new file mode 100644 index 0000000..77034f4 --- /dev/null +++ b/client/client.go @@ -0,0 +1,30 @@ +package client + +import ( + "sync" + "time" +) + +// Client is an interface which all client types abide by. It guarantees operations around +// credentials; primarily getting, initializing, and refreshing. +type Client interface { + Credentials() *RequestCredentials + + // InitializeCredentials should make the initial requests necessary to establish the first set of + // authentication credentials within the Client. + InitializeCredentials() error + + // RefreshCredentials should initiate an internal refresh of the request credentials inside this + // client. This refresh should, whenever possible, check the + // RequestCredentials.AccessTokenExpiresAt field to determine whether it should actually refresh + // the credentials or if the credentials are still valid. + RefreshCredentials() error +} + +// RequestCredentials stores all the information necessary to authenticate a request with the +// Microsoft GraphAPI +type RequestCredentials struct { + AccessToken string + AccessTokenExpiresAt time.Time + AccessTokenUpdating sync.Mutex +} diff --git a/client/doc.go b/client/doc.go new file mode 100644 index 0000000..9b67af8 --- /dev/null +++ b/client/doc.go @@ -0,0 +1,3 @@ +// Package client implements client state, authentication, pagination, type helpers, and method +// helpers concerning accessing resources in the Microsoft Graph API. +package client diff --git a/client/web_client.go b/client/web_client.go new file mode 100644 index 0000000..c28533f --- /dev/null +++ b/client/web_client.go @@ -0,0 +1,281 @@ +package client + +import ( + "context" + "encoding/json" + "fmt" + "io/ioutil" + "log" + "net/http" + "net/url" + "os/exec" + "runtime" + "strings" + "time" + + "github.com/mhoc/msgoraph/scopes" +) + +// Web is used to authenticate requests in the context of an online/user-facing app, such +// as a website. This type of client is mostly useful for debugging or for command line apps where +// the user configures their own app on the Microsoft Graph portal. In a normal web app, the +// InitializeCredentials()->setAuthorizationCode() part of this would be called on the client, +// then the code would be sent to the backend for the setAccessToken() part, given that that part +// does require an ApplicationSecret. Be sure to specify DelegatedOfflineAccess as a scope if you +// want refreshing to work. +type Web struct { + ApplicationID string + ApplicationSecret string + AuthorizationCode string + Error error + LocalhostPort int + RefreshToken string + RequestCredentials *RequestCredentials + Scopes scopes.Scopes +} + +// NewWeb creates a new client.Web connection. To initialize the authentication on this, call +// web.InitializeCredentials() +func NewWeb(applicationID string, applicationSecret string, redirectURIPort int, scopes scopes.Scopes) *Web { + return &Web{ + ApplicationID: applicationID, + ApplicationSecret: applicationSecret, + LocalhostPort: redirectURIPort, + RequestCredentials: &RequestCredentials{}, + Scopes: scopes, + } +} + +// Credentials returns back the set of request credentials in this client. Conforms to the +// client.Client interface. +func (w *Web) Credentials() *RequestCredentials { + return w.RequestCredentials +} + +// InitializeCredentials starts an oauth login flow to retrieve an authorization code, then exchange +// that authorization code for an access token and (if offline access is enabled) a refresh token. +func (w *Web) InitializeCredentials() error { + err := w.setAuthorizationCode() + if err != nil { + return err + } + err = w.setAccessToken() + return err +} + +func (w *Web) localServer() *http.Server { + srv := &http.Server{Addr: fmt.Sprintf(":%v", w.LocalhostPort)} + http.HandleFunc("/login", func(wr http.ResponseWriter, r *http.Request) { + err := r.ParseForm() + if err != nil { + w.Error = fmt.Errorf("Error while parsing form from response %s", err) + return + } + if v, ok := r.Form["error"]; ok && len(v) > 0 { + errorDescription, ok := r.Form["error_description"] + if ok && len(errorDescription) > 0 { + err = fmt.Errorf("%v: %v", strings.Join(v, ""), errorDescription) + fmt.Fprintf(wr, "%v", err) + w.Error = err + } else { + err = fmt.Errorf("%v", strings.Join(v, "")) + fmt.Fprintf(wr, "%v", err) + w.Error = err + } + return + } + code, codeOk := r.Form["code"] + if len(code) > 0 && codeOk { + fmt.Fprintf(wr, "authorization done. you may close this window now") + w.AuthorizationCode = strings.Join(code, "") + return + } + err = fmt.Errorf("error getting authorization code from login response") + fmt.Fprintf(wr, "%v", err) + w.Error = err + }) + go func() { + if err := srv.ListenAndServe(); err != nil { + // This will throw an error when we shutdown the server during the normal authorization flow + // So we try to catch that error, and only return the real error if it isn't the expected + // error. + if !strings.Contains(err.Error(), "Server closed") { + w.Error = fmt.Errorf("error on ListenAndServe: %v", err) + } + } + }() + return srv +} + +func (w *Web) redirectURI() string { + return fmt.Sprintf("http://localhost:%v/login", w.LocalhostPort) +} + +// RefreshCredentials will attempt to refresh the access token if it is expired. This call will fail +// if the original authorization was not made with a Offline scope provided. +func (w *Web) RefreshCredentials() error { + if !w.Scopes.HasScope(scopes.DelegatedOfflineAccess) { + return fmt.Errorf("this web client was not configured for offline access and token refresh. to configure this, provide an offline scope during the initial client authorization") + } + if w.RefreshToken == "" { + return fmt.Errorf("client.Web: no refresh token found in web client. call client.InitialAuth to fill this") + } + w.RequestCredentials.AccessTokenUpdating.Lock() + defer w.RequestCredentials.AccessTokenUpdating.Unlock() + if w.RequestCredentials.AccessToken != "" && w.RequestCredentials.AccessTokenExpiresAt.After(time.Now()) { + return nil + } + tokenURI, err := url.Parse("https://login.microsoftonline.com/common/oauth2/v2.0/token") + if err != nil { + return err + } + resp, err := http.PostForm(tokenURI.String(), url.Values{ + "client_id": {w.ApplicationID}, + "grant_type": {"refresh_token"}, + "redirect_uri": {w.redirectURI()}, + "refresh_token": {w.RefreshToken}, + "scope": {w.Scopes.QueryString()}, + }) + if err != nil { + return err + } + b, err := ioutil.ReadAll(resp.Body) + if err != nil { + return err + } + var data map[string]interface{} + err = json.Unmarshal(b, &data) + if err != nil { + return err + } + serverErrCode, ok := data["error"].(string) + if ok { + serverErr, ok := data["error_description"].(string) + if ok { + return fmt.Errorf("%v: %v", serverErrCode, serverErr) + } + return fmt.Errorf(serverErrCode) + } + accessToken, ok := data["access_token"].(string) + if !ok || accessToken == "" { + return fmt.Errorf("no access token found in response") + } + durationSecs, ok := data["expires_in"].(float64) + if !ok || durationSecs == 0 { + return fmt.Errorf("no token duration found in response") + } + refreshToken, ok := data["refresh_token"].(string) + if !ok || refreshToken == "" { + return fmt.Errorf("no refresh token found in response") + } + expiresAt := time.Now().Add(time.Duration(durationSecs) * time.Second) + w.RequestCredentials.AccessToken = accessToken + w.RequestCredentials.AccessTokenExpiresAt = expiresAt + w.RefreshToken = refreshToken + return nil +} + +func (w *Web) setAccessToken() error { + if w.AuthorizationCode == "" { + return fmt.Errorf("client.Web: no access code found in web client") + } + w.RequestCredentials.AccessTokenUpdating.Lock() + defer w.RequestCredentials.AccessTokenUpdating.Unlock() + if w.RequestCredentials.AccessToken != "" && w.RequestCredentials.AccessTokenExpiresAt.After(time.Now()) { + return nil + } + tokenURI, err := url.Parse("https://login.microsoftonline.com/common/oauth2/v2.0/token") + if err != nil { + return err + } + resp, err := http.PostForm(tokenURI.String(), url.Values{ + "client_id": {w.ApplicationID}, + "client_secret": {w.ApplicationSecret}, + "code": {w.AuthorizationCode}, + "grant_type": {"authorization_code"}, + "redirect_uri": {w.redirectURI()}, + "scope": {w.Scopes.QueryString()}, + }) + if err != nil { + return err + } + b, err := ioutil.ReadAll(resp.Body) + if err != nil { + return err + } + var data map[string]interface{} + err = json.Unmarshal(b, &data) + if err != nil { + return err + } + serverErrCode, ok := data["error"].(string) + if ok { + serverErr, ok := data["error_description"].(string) + if ok { + return fmt.Errorf("%v: %v", serverErrCode, serverErr) + } + return fmt.Errorf(serverErrCode) + } + accessToken, ok := data["access_token"].(string) + if !ok || accessToken == "" { + return fmt.Errorf("no access token found in response") + } + durationSecs, ok := data["expires_in"].(float64) + if !ok || durationSecs == 0 { + return fmt.Errorf("no token duration found in response") + } + if w.Scopes.HasScope(scopes.DelegatedOfflineAccess) { + refreshToken, ok := data["refresh_token"].(string) + if !ok || refreshToken == "" { + return fmt.Errorf("no refresh token found in response") + } + w.RefreshToken = refreshToken + } + expiresAt := time.Now().Add(time.Duration(durationSecs) * time.Second) + w.RequestCredentials.AccessToken = accessToken + w.RequestCredentials.AccessTokenExpiresAt = expiresAt + return nil +} + +func (w *Web) setAuthorizationCode() error { + formVals := url.Values{} + formVals.Set("client_id", w.ApplicationID) + formVals.Set("grant_type", "authorization_code") + formVals.Set("redirect_uri", w.redirectURI()) + formVals.Set("response_mode", "query") + formVals.Set("response_type", "code") + formVals.Set("scope", w.Scopes.QueryString()) + uri, err := url.Parse("https://login.microsoftonline.com/common/oauth2/v2.0/authorize") + if err != nil { + return err + } + uri.RawQuery = formVals.Encode() + switch runtime.GOOS { + case "darwin": + err = exec.Command("open", uri.String()).Start() + case "linux": + err = exec.Command("xdg-open", uri.String()).Start() + case "windows": + err = exec.Command("rundll32", "url.dll,FileProtocolHandler", uri.String()).Start() + default: + err = fmt.Errorf("unsupported platform") + } + if err != nil { + log.Fatal(err) + } + server := w.localServer() + running := true + for running { + fmt.Printf("%v", w.AuthorizationCode) + if w.Error != nil || w.AuthorizationCode != "" { + if err := server.Shutdown(context.TODO()); err != nil { + return fmt.Errorf("error on server shutdown: %v", err) + } + if w.Error != nil { + return w.Error + } + return nil + } + } + return nil +} diff --git a/internal/http.go b/internal/http.go index d9e04b8..83c4c93 100644 --- a/internal/http.go +++ b/internal/http.go @@ -9,7 +9,7 @@ import ( "net/http" "net/url" - "github.com/mhoc/msgoraph/auth" + "github.com/mhoc/msgoraph/client" ) const ( @@ -20,16 +20,16 @@ const ( // BasicGraphRequest is similar to GraphRequest, but it assumes an already fully formed url and no // body. This is primarily useful for methods that need to pagniate; it just makes that a little bit // easier. -func BasicGraphRequest(credentials *auth.Credentials, method string, url string) ([]byte, error) { +func BasicGraphRequest(client client.Client, method string, url string) ([]byte, error) { req, err := http.NewRequest(method, url, nil) if err != nil { return nil, err } - err = credentials.RefreshAccessToken() + err = client.RefreshCredentials() if err != nil { return nil, err } - req.Header.Add("Authorization", fmt.Sprintf("Bearer %v", credentials.AccessToken)) + req.Header.Add("Authorization", fmt.Sprintf("Bearer %v", client.Credentials().AccessToken)) req.Header.Add("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { @@ -41,7 +41,7 @@ func BasicGraphRequest(credentials *auth.Credentials, method string, url string) // GraphRequest creates and executes a new http request against the Graph API. The path // provided should be the entire path of the url, including the version specifier. It returns the // response body, along with any errors that might occur during the request process. -func GraphRequest(credentials *auth.Credentials, method string, path string, params url.Values, body interface{}) ([]byte, error) { +func GraphRequest(client client.Client, method string, path string, params url.Values, body interface{}) ([]byte, error) { var graphURL string if len(params) > 0 { graphURL = fmt.Sprintf("%v%v?%v", GraphAPIRootURL, path, params.Encode()) @@ -60,11 +60,11 @@ func GraphRequest(credentials *auth.Credentials, method string, path string, par if err != nil { return nil, err } - err = credentials.RefreshAccessToken() + err = client.RefreshCredentials() if err != nil { return nil, err } - req.Header.Add("Authorization", fmt.Sprintf("Bearer %v", credentials.AccessToken)) + req.Header.Add("Authorization", fmt.Sprintf("Bearer %v", client.Credentials().AccessToken)) req.Header.Add("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { diff --git a/scopes/scope.go b/scopes/scope.go index ffd0b8f..d55e7ef 100644 --- a/scopes/scope.go +++ b/scopes/scope.go @@ -13,11 +13,25 @@ type Scope struct { Permission string } -// CreateQueryString will turn a list of scopes into a query string suitable for consumption by +// Scopes is an alias to an array of scopes, with some additional logic on the type to make interfacing +// with the collection easier. +type Scopes []Scope + +// HasScope returns true if the list of scopes contains the requested scope. +func (s Scopes) HasScope(scope Scope) bool { + for _, iS := range s { + if iS.Permission == scope.Permission && iS.Application == scope.Application && iS.Delegated == scope.Delegated { + return true + } + } + return false +} + +// QueryString will turn a list of scopes into a query string suitable for consumption by // the Graph API. Something like "offline_access user.read mail.read". -func CreateQueryString(scopes []Scope) string { +func (s Scopes) QueryString() string { qs := "" - for _, scope := range scopes { + for _, scope := range s { qs += scope.Permission + " " } return qs diff --git a/users/service.go b/users/service.go index 7284177..a1458f2 100644 --- a/users/service.go +++ b/users/service.go @@ -5,7 +5,7 @@ import ( "fmt" "net/url" - "github.com/mhoc/msgoraph/auth" + "github.com/mhoc/msgoraph/client" "github.com/mhoc/msgoraph/internal" ) @@ -35,12 +35,12 @@ type ListUsersResponse struct { // ServiceContext represents a namespace under which all of the operations against user-namespaced // resources are accessed. type ServiceContext struct { - credentials *auth.Credentials + client client.Client } // Service creates a new users.ServiceContext with the given authentication credentials. -func Service(credentials *auth.Credentials) *ServiceContext { - return &ServiceContext{credentials: credentials} +func Service(client client.Client) *ServiceContext { + return &ServiceContext{client: client} } // UpdateUserRequest contains the request body to update a user. @@ -81,7 +81,7 @@ type UpdateUserRequest struct { // CreateUser creates a new user in the tenant. func (s *ServiceContext) CreateUser(createUser CreateUserRequest) (User, error) { - body, err := internal.GraphRequest(s.credentials, "POST", "v1.0/users", nil, createUser) + body, err := internal.GraphRequest(s.client, "POST", "v1.0/users", nil, createUser) var data GetUserResponse err = json.Unmarshal(body, &data) if err != nil { @@ -93,7 +93,7 @@ func (s *ServiceContext) CreateUser(createUser CreateUserRequest) (User, error) // DeleteUser deletes an existing user by id or principal name. func (s *ServiceContext) DeleteUser(userIDOrPrincipal string) error { reqURL := fmt.Sprintf("v1.0/users/%v", userIDOrPrincipal) - _, err := internal.GraphRequest(s.credentials, "DELETE", reqURL, nil, nil) + _, err := internal.GraphRequest(s.client, "DELETE", reqURL, nil, nil) return err } @@ -120,7 +120,7 @@ func (s *ServiceContext) GetUserWithFields(userIDOrPrincipal string, projection v := url.Values{} v.Set("$select", selectFields) reqURL := fmt.Sprintf("v1.0/users/%v", userIDOrPrincipal) - b, err := internal.GraphRequest(s.credentials, "GET", reqURL, v, nil) + b, err := internal.GraphRequest(s.client, "GET", reqURL, v, nil) var data GetUserResponse err = json.Unmarshal(b, &data) if err != nil { @@ -140,7 +140,7 @@ func (s *ServiceContext) ListUsers() ([]User, error) { // UserAllFields, or customize it depending on what you want. func (s *ServiceContext) ListUsersWithFields(projection []Field) ([]User, error) { getUserPage := func(url string) ([]User, string, error) { - b, err := internal.BasicGraphRequest(s.credentials, "GET", url) + b, err := internal.BasicGraphRequest(s.client, "GET", url) var data ListUsersResponse err = json.Unmarshal(b, &data) if err != nil { @@ -175,6 +175,6 @@ func (s *ServiceContext) ListUsersWithFields(projection []Field) ([]User, error) // to update. func (s *ServiceContext) UpdateUser(userIDOrPrincipal string, u UpdateUserRequest) error { reqURL := fmt.Sprintf("v1.0/users/%v", userIDOrPrincipal) - _, err := internal.GraphRequest(s.credentials, "PATCH", reqURL, nil, u) + _, err := internal.GraphRequest(s.client, "PATCH", reqURL, nil, u) return err }