Skip to content

Commit 351ff65

Browse files
authored
CLOUDP-110641: Add device grant flow to atlas client (#274)
1 parent 6afea60 commit 351ff65

File tree

8 files changed

+733
-15
lines changed

8 files changed

+733
-15
lines changed

auth/device_flow.go

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
// Copyright 2022 MongoDB Inc
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package auth
16+
17+
import (
18+
"context"
19+
"errors"
20+
"net/http"
21+
"net/url"
22+
"strings"
23+
"time"
24+
25+
atlas "go.mongodb.org/atlas/mongodbatlas"
26+
)
27+
28+
// DeviceCode holds information about the authorization-in-progress.
29+
type DeviceCode struct {
30+
UserCode string `json:"user_code"` // UserCode is the code presented to users
31+
VerificationURI string `json:"verification_uri"` // VerificationURI is the URI where users will need to confirm the code
32+
DeviceCode string `json:"device_code"` // DeviceCode is the internal code to confirm the status of the flow
33+
ExpiresIn int `json:"expires_in"` // ExpiresIn when the code will expire
34+
Interval int `json:"interval"` // Interval how often to verify the status of the code
35+
36+
timeNow func() time.Time
37+
timeSleep func(time.Duration)
38+
}
39+
40+
const deviceBasePath = "api/private/unauth/account/device"
41+
42+
// RequestCode initiates the authorization flow by requesting a code.
43+
func (c Config) RequestCode(ctx context.Context) (*DeviceCode, *atlas.Response, error) {
44+
req, err := c.NewRequest(ctx, http.MethodPost, deviceBasePath+"/authorize",
45+
url.Values{
46+
"client_id": {c.ClientID},
47+
"scope": {strings.Join(c.Scopes, " ")},
48+
},
49+
)
50+
if err != nil {
51+
return nil, nil, err
52+
}
53+
var r *DeviceCode
54+
resp, err2 := c.Do(ctx, req, &r)
55+
return r, resp, err2
56+
}
57+
58+
// GetToken gets a device token.
59+
func (c Config) GetToken(ctx context.Context, deviceCode string) (*Token, *atlas.Response, error) {
60+
req, err := c.NewRequest(ctx, http.MethodPost, deviceBasePath+"/token",
61+
url.Values{
62+
"client_id": {c.ClientID},
63+
"device_code": {deviceCode},
64+
"grant_type": {"urn:ietf:params:oauth:grant-type:device_code"},
65+
},
66+
)
67+
if err != nil {
68+
return nil, nil, err
69+
}
70+
var t *Token
71+
resp, err2 := c.Do(ctx, req, &t)
72+
if err2 != nil {
73+
return nil, resp, err2
74+
}
75+
return t, resp, err2
76+
}
77+
78+
// ErrTimeout is returned when polling the server for the granted token has timed out.
79+
var ErrTimeout = errors.New("authentication timed out")
80+
81+
// PollToken polls the server until an access token is granted or denied.
82+
func (c Config) PollToken(ctx context.Context, code *DeviceCode) (*Token, *atlas.Response, error) {
83+
timeNow := code.timeNow
84+
if timeNow == nil {
85+
timeNow = time.Now
86+
}
87+
timeSleep := code.timeSleep
88+
if timeSleep == nil {
89+
timeSleep = time.Sleep
90+
}
91+
92+
checkInterval := time.Duration(code.Interval) * time.Second
93+
expiresAt := timeNow().Add(time.Duration(code.ExpiresIn) * time.Second)
94+
95+
for {
96+
timeSleep(checkInterval)
97+
token, resp, err := c.GetToken(ctx, code.DeviceCode)
98+
var target *atlas.ErrorResponse
99+
if errors.As(err, &target) && target.ErrorCode == "DEVICE_AUTHORIZATION_PENDING" {
100+
continue
101+
}
102+
if err != nil {
103+
return nil, resp, err
104+
}
105+
106+
if timeNow().After(expiresAt) {
107+
return nil, nil, ErrTimeout
108+
}
109+
return token, resp, nil
110+
}
111+
}

auth/device_flow_test.go

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
// Copyright 2022 MongoDB Inc
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package auth
16+
17+
import (
18+
"fmt"
19+
"net/http"
20+
"testing"
21+
22+
"github.com/go-test/deep"
23+
)
24+
25+
func TestConfig_RequestCode(t *testing.T) {
26+
config, mux, teardown := setup()
27+
defer teardown()
28+
29+
mux.HandleFunc("/api/private/unauth/account/device/authorize", func(w http.ResponseWriter, r *http.Request) {
30+
testMethod(t, r, http.MethodPost)
31+
fmt.Fprintf(w, `{
32+
"user_code": "QW3PYV7R",
33+
"verification_uri": "%s/account/connect",
34+
"device_code": "61eef18e310968047ff5e02a",
35+
"expires_in": 600,
36+
"interval": 10
37+
}`, baseURLPath)
38+
})
39+
40+
results, _, err := config.RequestCode(ctx)
41+
if err != nil {
42+
t.Fatalf("RequestCode returned error: %v", err)
43+
}
44+
45+
expected := &DeviceCode{
46+
UserCode: "QW3PYV7R",
47+
VerificationURI: baseURLPath + "/account/connect",
48+
DeviceCode: "61eef18e310968047ff5e02a",
49+
ExpiresIn: 600,
50+
Interval: 10,
51+
}
52+
53+
if diff := deep.Equal(results, expected); diff != nil {
54+
t.Error(diff)
55+
}
56+
}
57+
58+
func TestConfig_GetToken(t *testing.T) {
59+
config, mux, teardown := setup()
60+
defer teardown()
61+
62+
mux.HandleFunc("/api/private/unauth/account/device/token", func(w http.ResponseWriter, r *http.Request) {
63+
testMethod(t, r, http.MethodPost)
64+
fmt.Fprint(w, `{
65+
"access_token": "secret1",
66+
"refresh_token": "secret2",
67+
"scope": "openid",
68+
"id_token": "idtoken",
69+
"token_type": "Bearer",
70+
"expires_in": 3600
71+
}`)
72+
})
73+
code := &DeviceCode{
74+
DeviceCode: "61eef18e310968047ff5e02a",
75+
ExpiresIn: 600,
76+
Interval: 10,
77+
}
78+
results, _, err := config.GetToken(ctx, code.DeviceCode)
79+
if err != nil {
80+
t.Fatalf("PollToken returned error: %v", err)
81+
}
82+
83+
expected := &Token{
84+
AccessToken: "secret1",
85+
RefreshToken: "secret2",
86+
Scope: "openid",
87+
IDToken: "idtoken",
88+
TokenType: "Bearer",
89+
ExpiresIn: 3600,
90+
}
91+
92+
if diff := deep.Equal(results, expected); diff != nil {
93+
t.Error(diff)
94+
}
95+
}
96+
97+
func TestConfig_PollToken(t *testing.T) {
98+
config, mux, teardown := setup()
99+
defer teardown()
100+
101+
mux.HandleFunc("/api/private/unauth/account/device/token", func(w http.ResponseWriter, r *http.Request) {
102+
testMethod(t, r, http.MethodPost)
103+
fmt.Fprint(w, `{
104+
"access_token": "secret1",
105+
"refresh_token": "secret2",
106+
"scope": "openid",
107+
"id_token": "idtoken",
108+
"token_type": "Bearer",
109+
"expires_in": 3600
110+
}`)
111+
})
112+
code := &DeviceCode{
113+
DeviceCode: "61eef18e310968047ff5e02a",
114+
ExpiresIn: 600,
115+
Interval: 10,
116+
}
117+
results, _, err := config.PollToken(ctx, code)
118+
if err != nil {
119+
t.Fatalf("PollToken returned error: %v", err)
120+
}
121+
122+
expected := &Token{
123+
AccessToken: "secret1",
124+
RefreshToken: "secret2",
125+
Scope: "openid",
126+
IDToken: "idtoken",
127+
TokenType: "Bearer",
128+
ExpiresIn: 3600,
129+
}
130+
131+
if diff := deep.Equal(results, expected); diff != nil {
132+
t.Error(diff)
133+
}
134+
}

auth/doc.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// Copyright 2022 MongoDB Inc
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
/*
16+
Package auth provides a way to follow a Device Authorization Grant https://datatracker.ietf.org/doc/html/rfc8628.
17+
18+
Usage
19+
20+
import "go.mongodb.org/atlas/auth"
21+
22+
Construct a new client Config, then use the various methods to complete a flow.
23+
For example:
24+
25+
config := auth.NewConfigWithOptions(nil, auth.SetClientID("my-client-id"), auth.SetScopes([]string{"openid"}))
26+
27+
code, _, err := config.RequestCode(ctx)
28+
if err!= nil {
29+
panic(err)
30+
}
31+
token, _, err := config.PollToken(ctx, code)
32+
if err!= nil {
33+
panic(err)
34+
}
35+
fmt.PrintLn(accessToken.AccessToken)
36+
37+
NOTE: Using the https://godoc.org/context package, one can easily
38+
pass cancellation signals and deadlines to various services of the client for
39+
handling a request. In case there is no context available, then context.Background()
40+
can be used as a starting point.
41+
42+
43+
*/
44+
package auth

0 commit comments

Comments
 (0)