Skip to content

Commit a0d2e52

Browse files
authored
Add device flow (#32)
1 parent 450d2ca commit a0d2e52

File tree

12 files changed

+407
-193
lines changed

12 files changed

+407
-193
lines changed

README.md

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,13 +220,34 @@ sending the user's credentials to the OAuth2 server.
220220
oauth2c https://oauth2c.us.authz.cloudentity.io/oauth2c/demo \
221221
--client-id cauktionbud6q8ftlqq0 \
222222
--client-secret HCwQ5uuUWBRHd04ivjX5Kl0Rz8zxMOekeLtqzki0GPc \
223-
--grant-type password --username demo --password demo \
223+
--grant-type password \
224+
--username demo \
225+
--password demo \
224226
--auth-method client_secret_basic \
225227
--scopes openid
226228
```
227229
228230
[Learn more about the password flow](https://cloudentity.com/developers/basics/oauth-grant-types/resource-owner-password-credentials/)
229231

232+
#### Device
233+
234+
This grant type is a two-step process that allows a user to grant access to their data without
235+
having to enter a username and password. In the first step, the user grants permission for the client to access
236+
their data. In the second step, the client exchanges the authorization code received in the first step for an
237+
access token. This grant type is commonly used in server-side applications, such as when accessing a device
238+
from a TV or other non-interactive device.
239+
240+
``` sh
241+
oauth2c https://oauth2c.us.authz.cloudentity.io/oauth2c/demo \
242+
--client-id cauktionbud6q8ftlqq0 \
243+
--client-secret HCwQ5uuUWBRHd04ivjX5Kl0Rz8zxMOekeLtqzki0GPc \
244+
--grant-type urn:ietf:params:oauth:grant-type:device_code \
245+
--auth-method client_secret_basic \
246+
--scopes openid,email,offline_access
247+
```
248+
249+
[Learn more about the device flow](https://cloudentity.com/developers/basics/oauth-grant-types/device/)
250+
230251
#### JWT Bearer
231252

232253
This grant type involves the client providing a JSON Web Token (JWT) to the OAuth2 server, which then returns

cmd/oauth2.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,8 @@ func (c *OAuth2Cmd) Authorize(clientConfig oauth2.ClientConfig, hc *http.Client)
179179
return c.JWTBearerGrantFlow(clientConfig, serverConfig, hc)
180180
case oauth2.TokenExchangeGrantType:
181181
return c.TokenExchangeGrantFlow(clientConfig, serverConfig, hc)
182+
case oauth2.DeviceGrantType:
183+
return c.DeviceGrantFlow(clientConfig, serverConfig, hc)
182184
}
183185

184186
return fmt.Errorf("Unknown grant type: %s", clientConfig.GrantType)

cmd/oauth2_device.go

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"errors"
6+
"net/http"
7+
"time"
8+
9+
"github.com/cloudentity/oauth2c/internal/oauth2"
10+
"github.com/pkg/browser"
11+
)
12+
13+
func (c *OAuth2Cmd) DeviceGrantFlow(clientConfig oauth2.ClientConfig, serverConfig oauth2.ServerConfig, hc *http.Client) error {
14+
var (
15+
authorizationRequest oauth2.Request
16+
authorizationResponse oauth2.DeviceAuthorizationResponse
17+
tokenRequest oauth2.Request
18+
tokenResponse oauth2.TokenResponse
19+
err error
20+
)
21+
22+
LogHeader("Device Flow")
23+
24+
// device authorization endpoint
25+
LogSection("Request device authorization")
26+
27+
if authorizationRequest, authorizationResponse, err = oauth2.RequestDeviceAuthorization(context.Background(), clientConfig, serverConfig, hc); err != nil {
28+
return err
29+
}
30+
31+
LogRequestAndResponse(authorizationRequest, authorizationResponse)
32+
33+
Logfln("\nOpen the following URL:\n\n%s\n", authorizationResponse.VerificationURIComplete)
34+
35+
if err = browser.OpenURL(authorizationResponse.VerificationURIComplete); err != nil {
36+
LogError(err)
37+
}
38+
39+
Logln()
40+
41+
// polling
42+
tokenStatus := LogAction("Waiting for token. Go to the browser to authenticate...")
43+
44+
ticker := time.NewTicker(time.Duration(authorizationResponse.Interval) * time.Second)
45+
done := make(chan error)
46+
47+
go func() {
48+
var oauth2Error *oauth2.Error
49+
50+
defer close(done)
51+
52+
for {
53+
select {
54+
case <-done:
55+
return
56+
case <-ticker.C:
57+
if tokenRequest, tokenResponse, err = oauth2.RequestToken(
58+
context.Background(),
59+
clientConfig,
60+
serverConfig,
61+
hc,
62+
oauth2.WithDeviceCode(authorizationResponse.DeviceCode),
63+
); err != nil {
64+
if errors.As(err, &oauth2Error) {
65+
switch oauth2Error.ErrorCode {
66+
case oauth2.ErrAuthorizationPending, oauth2.ErrSlowDown:
67+
continue
68+
}
69+
}
70+
71+
done <- err
72+
73+
return
74+
} else {
75+
return
76+
}
77+
}
78+
}
79+
}()
80+
81+
err = <-done
82+
83+
LogSection("Exchange device code for token")
84+
85+
if err != nil {
86+
LogRequestAndResponseln(tokenRequest, err)
87+
return err
88+
}
89+
90+
LogAuthMethod(clientConfig)
91+
LogRequestAndResponse(tokenRequest, tokenResponse)
92+
LogTokenPayloadln(tokenResponse)
93+
94+
c.PrintResult(tokenResponse)
95+
96+
tokenStatus("Obtained token")
97+
98+
return nil
99+
}

internal/oauth2/error.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ import (
77
"strings"
88
)
99

10+
const (
11+
ErrAuthorizationPending = "authorization_pending"
12+
ErrSlowDown = "slow_down"
13+
)
14+
1015
type Error struct {
1116
StatusCode int `json:"-"`
1217
TraceID string `json:"-"`

internal/oauth2/jwk.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package oauth2
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"io"
7+
"net/http"
8+
"os"
9+
"strings"
10+
11+
"github.com/go-jose/go-jose/v3"
12+
"github.com/pkg/errors"
13+
)
14+
15+
type KeyUse string
16+
17+
const (
18+
SigningKey KeyUse = "sig"
19+
EncryptionKey KeyUse = "enc"
20+
)
21+
22+
func ReadKey(use KeyUse, location string, hc *http.Client) (jose.JSONWebKey, error) {
23+
var (
24+
keys jose.JSONWebKeySet
25+
bs []byte
26+
resp *http.Response
27+
err error
28+
)
29+
30+
if strings.HasPrefix(location, "http") {
31+
if resp, err = hc.Get(location); err != nil {
32+
return jose.JSONWebKey{}, errors.Wrapf(err, "failed to call: %s", location)
33+
}
34+
defer resp.Body.Close()
35+
36+
if bs, err = io.ReadAll(resp.Body); err != nil {
37+
return jose.JSONWebKey{}, errors.Wrapf(err, "failed to read response body from: %s", location)
38+
}
39+
40+
if resp.StatusCode != 200 {
41+
return jose.JSONWebKey{}, fmt.Errorf("received unexpected status code: %d, body: %s", resp.StatusCode, string(bs))
42+
}
43+
} else {
44+
if bs, err = os.ReadFile(location); err != nil {
45+
return jose.JSONWebKey{}, errors.Wrapf(err, "failed to read file: %s", location)
46+
}
47+
}
48+
49+
if err = json.Unmarshal(bs, &keys); err != nil {
50+
return jose.JSONWebKey{}, errors.Wrapf(err, "failed to parse jwks keys: %s", location)
51+
}
52+
53+
if len(keys.Keys) == 0 {
54+
return jose.JSONWebKey{}, errors.New("keys are empty")
55+
}
56+
57+
for _, key := range keys.Keys {
58+
if key.Use == string(use) {
59+
return key, nil
60+
}
61+
}
62+
63+
return jose.JSONWebKey{}, fmt.Errorf("could not find %s key", use)
64+
}

internal/oauth2/jwk_test.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package oauth2
2+
3+
import (
4+
"net/http"
5+
"testing"
6+
7+
"github.com/stretchr/testify/require"
8+
)
9+
10+
func TestReadKey(t *testing.T) {
11+
key, err := ReadKey(SigningKey, "../../data/key.json", http.DefaultClient)
12+
require.NoError(t, err)
13+
14+
require.NotNil(t, key)
15+
}

internal/oauth2/jwt.go

Lines changed: 117 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
package oauth2
22

3-
import "github.com/go-jose/go-jose/v3/jwt"
3+
import (
4+
"encoding/json"
5+
"net/http"
6+
"time"
7+
8+
"github.com/go-jose/go-jose/v3"
9+
"github.com/go-jose/go-jose/v3/jwt"
10+
"github.com/pkg/errors"
11+
)
412

513
func UnsafeParseJWT(token string) (*jwt.JSONWebToken, map[string]interface{}, error) {
614
var (
@@ -19,3 +27,111 @@ func UnsafeParseJWT(token string) (*jwt.JSONWebToken, map[string]interface{}, er
1927

2028
return t, claims, nil
2129
}
30+
31+
type SignerProvider func() (jose.Signer, interface{}, error)
32+
33+
func JWKSigner(clientConfig ClientConfig, hc *http.Client) SignerProvider {
34+
return func() (signer jose.Signer, _ interface{}, err error) {
35+
var key jose.JSONWebKey
36+
37+
if clientConfig.SigningKey == "" {
38+
return nil, nil, errors.New("no signing key path")
39+
}
40+
41+
if key, err = ReadKey(SigningKey, clientConfig.SigningKey, hc); err != nil {
42+
return nil, nil, errors.Wrapf(err, "failed to read signing key from %s", clientConfig.SigningKey)
43+
}
44+
45+
if signer, err = jose.NewSigner(jose.SigningKey{
46+
Algorithm: jose.SignatureAlgorithm(key.Algorithm),
47+
Key: key.Key,
48+
}, &jose.SignerOptions{
49+
ExtraHeaders: map[jose.HeaderKey]interface{}{"kid": key.KeyID},
50+
}); err != nil {
51+
return nil, nil, errors.Wrapf(err, "failed to create a signer")
52+
}
53+
54+
return signer, key.Key, nil
55+
}
56+
}
57+
58+
func SecretSigner(secret []byte) SignerProvider {
59+
return func() (jose.Signer, interface{}, error) {
60+
signer, err := jose.NewSigner(jose.SigningKey{
61+
Algorithm: jose.HS256,
62+
Key: secret,
63+
}, nil)
64+
65+
return signer, secret, err
66+
}
67+
}
68+
69+
type ClaimsProvider func() (map[string]interface{}, error)
70+
71+
func AssertionClaims(serverConfig ServerConfig, clientConfig ClientConfig) ClaimsProvider {
72+
return func() (map[string]interface{}, error) {
73+
var err error
74+
75+
claims := map[string]interface{}{
76+
"iss": serverConfig.TokenEndpoint,
77+
"aud": serverConfig.TokenEndpoint,
78+
"iat": time.Now().Unix(),
79+
"exp": time.Now().Add(time.Minute * 10).Unix(),
80+
"jti": RandomString(20),
81+
}
82+
83+
if clientConfig.Assertion == "" {
84+
clientConfig.Assertion = "{}"
85+
}
86+
87+
if err = json.Unmarshal([]byte(clientConfig.Assertion), &claims); err != nil {
88+
return nil, err
89+
}
90+
91+
return claims, nil
92+
}
93+
}
94+
95+
func ClientAssertionClaims(serverConfig ServerConfig, clientConfig ClientConfig) ClaimsProvider {
96+
return func() (map[string]interface{}, error) {
97+
return map[string]interface{}{
98+
"iss": clientConfig.ClientID,
99+
"sub": clientConfig.ClientID,
100+
"aud": serverConfig.TokenEndpoint,
101+
"iat": time.Now().Unix(),
102+
"exp": time.Now().Add(time.Minute * 10).Unix(),
103+
"jti": RandomString(20),
104+
}, nil
105+
}
106+
}
107+
108+
func SignJWT(claimsProvider ClaimsProvider, signerProvider SignerProvider) (jwt string, key interface{}, err error) {
109+
var (
110+
signer jose.Signer
111+
claims map[string]interface{}
112+
jws *jose.JSONWebSignature
113+
bs []byte
114+
)
115+
116+
if signer, key, err = signerProvider(); err != nil {
117+
return "", nil, errors.Wrapf(err, "failed to create signer")
118+
}
119+
120+
if claims, err = claimsProvider(); err != nil {
121+
return "", nil, errors.Wrapf(err, "failed to build claims")
122+
}
123+
124+
if bs, err = json.Marshal(claims); err != nil {
125+
return "", nil, errors.Wrapf(err, "failed to serialize claims")
126+
}
127+
128+
if jws, err = signer.Sign(bs); err != nil {
129+
return "", nil, errors.Wrapf(err, "failed to sign jwt")
130+
}
131+
132+
if jwt, err = jws.CompactSerialize(); err != nil {
133+
return "", nil, err
134+
}
135+
136+
return jwt, key, nil
137+
}

0 commit comments

Comments
 (0)