Merge pull request #1706 from justin-slowik/device_flow

Implementing the OAuth2 Device Authorization Grant
This commit is contained in:
Joel Speed 2020-08-28 11:35:46 +01:00 committed by GitHub
commit 336c73c0a2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 2529 additions and 322 deletions

View file

@ -279,6 +279,9 @@ type Expiry struct {
// AuthRequests defines the duration of time for which the AuthRequests will be valid. // AuthRequests defines the duration of time for which the AuthRequests will be valid.
AuthRequests string `json:"authRequests"` AuthRequests string `json:"authRequests"`
// DeviceRequests defines the duration of time for which the DeviceRequests will be valid.
DeviceRequests string `json:"deviceRequests"`
} }
// Logger holds configuration required to customize logging for dex. // Logger holds configuration required to customize logging for dex.

View file

@ -119,6 +119,7 @@ expiry:
signingKeys: "7h" signingKeys: "7h"
idTokens: "25h" idTokens: "25h"
authRequests: "25h" authRequests: "25h"
deviceRequests: "10m"
logger: logger:
level: "debug" level: "debug"
@ -197,9 +198,10 @@ logger:
}, },
}, },
Expiry: Expiry{ Expiry: Expiry{
SigningKeys: "7h", SigningKeys: "7h",
IDTokens: "25h", IDTokens: "25h",
AuthRequests: "25h", AuthRequests: "25h",
DeviceRequests: "10m",
}, },
Logger: Logger{ Logger: Logger{
Level: "debug", Level: "debug",

View file

@ -269,7 +269,14 @@ func serve(cmd *cobra.Command, args []string) error {
logger.Infof("config auth requests valid for: %v", authRequests) logger.Infof("config auth requests valid for: %v", authRequests)
serverConfig.AuthRequestsValidFor = authRequests serverConfig.AuthRequestsValidFor = authRequests
} }
if c.Expiry.DeviceRequests != "" {
deviceRequests, err := time.ParseDuration(c.Expiry.DeviceRequests)
if err != nil {
return fmt.Errorf("invalid config value %q for device request expiry: %v", c.Expiry.AuthRequests, err)
}
logger.Infof("config device requests valid for: %v", deviceRequests)
serverConfig.DeviceRequestsValidFor = deviceRequests
}
serv, err := server.NewServer(context.Background(), serverConfig) serv, err := server.NewServer(context.Background(), serverConfig)
if err != nil { if err != nil {
return fmt.Errorf("failed to initialize server: %v", err) return fmt.Errorf("failed to initialize server: %v", err)

View file

@ -64,6 +64,7 @@ telemetry:
# Uncomment this block to enable configuration for the expiration time durations. # Uncomment this block to enable configuration for the expiration time durations.
# expiry: # expiry:
# deviceRequests: "5m"
# signingKeys: "6h" # signingKeys: "6h"
# idTokens: "24h" # idTokens: "24h"
@ -95,7 +96,11 @@ staticClients:
- 'http://127.0.0.1:5555/callback' - 'http://127.0.0.1:5555/callback'
name: 'Example App' name: 'Example App'
secret: ZXhhbXBsZS1hcHAtc2VjcmV0 secret: ZXhhbXBsZS1hcHAtc2VjcmV0
# - id: example-device-client
# redirectURIs:
# - /device/callback
# name: 'Static Client for Device Flow'
# public: true
connectors: connectors:
- type: mockCallback - type: mockCallback
id: mock id: mock

View file

@ -0,0 +1,12 @@
apiVersion: apiextensions.k8s.io/v1beta1
kind: CustomResourceDefinition
metadata:
name: devicerequests.dex.coreos.com
spec:
group: dex.coreos.com
names:
kind: DeviceRequest
listKind: DeviceRequestList
plural: devicerequests
singular: devicerequest
version: v1

View file

@ -0,0 +1,12 @@
apiVersion: apiextensions.k8s.io/v1beta1
kind: CustomResourceDefinition
metadata:
name: devicetokens.dex.coreos.com
spec:
group: dex.coreos.com
names:
kind: DeviceToken
listKind: DeviceTokenList
plural: devicetokens
singular: devicetoken
version: v1

View file

@ -0,0 +1,390 @@
package server
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"path"
"strconv"
"strings"
"time"
"github.com/dexidp/dex/storage"
)
type deviceCodeResponse struct {
//The unique device code for device authentication
DeviceCode string `json:"device_code"`
//The code the user will exchange via a browser and log in
UserCode string `json:"user_code"`
//The url to verify the user code.
VerificationURI string `json:"verification_uri"`
//The verification uri with the user code appended for pre-filling form
VerificationURIComplete string `json:"verification_uri_complete"`
//The lifetime of the device code
ExpireTime int `json:"expires_in"`
//How often the device is allowed to poll to verify that the user login occurred
PollInterval int `json:"interval"`
}
func (s *Server) getDeviceVerificationURI() string {
return path.Join(s.issuerURL.Path, "/device/auth/verify_code")
}
func (s *Server) handleDeviceExchange(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
// Grab the parameter(s) from the query.
// If "user_code" is set, pre-populate the user code text field.
// If "invalid" is set, set the invalidAttempt boolean, which will display a message to the user that they
// attempted to redeem an invalid or expired user code.
userCode := r.URL.Query().Get("user_code")
invalidAttempt, err := strconv.ParseBool(r.URL.Query().Get("invalid"))
if err != nil {
invalidAttempt = false
}
if err := s.templates.device(r, w, s.getDeviceVerificationURI(), userCode, invalidAttempt); err != nil {
s.logger.Errorf("Server template error: %v", err)
s.renderError(r, w, http.StatusNotFound, "Page not found")
}
default:
s.renderError(r, w, http.StatusBadRequest, "Requested resource does not exist.")
}
}
func (s *Server) handleDeviceCode(w http.ResponseWriter, r *http.Request) {
pollIntervalSeconds := 5
switch r.Method {
case http.MethodPost:
err := r.ParseForm()
if err != nil {
s.logger.Errorf("Could not parse Device Request body: %v", err)
s.tokenErrHelper(w, errInvalidRequest, "", http.StatusNotFound)
return
}
//Get the client id and scopes from the post
clientID := r.Form.Get("client_id")
clientSecret := r.Form.Get("client_secret")
scopes := strings.Fields(r.Form.Get("scope"))
s.logger.Infof("Received device request for client %v with scopes %v", clientID, scopes)
//Make device code
deviceCode := storage.NewDeviceCode()
//make user code
userCode, err := storage.NewUserCode()
if err != nil {
s.logger.Errorf("Error generating user code: %v", err)
s.tokenErrHelper(w, errInvalidRequest, "", http.StatusInternalServerError)
}
//Generate the expire time
expireTime := time.Now().Add(s.deviceRequestsValidFor)
//Store the Device Request
deviceReq := storage.DeviceRequest{
UserCode: userCode,
DeviceCode: deviceCode,
ClientID: clientID,
ClientSecret: clientSecret,
Scopes: scopes,
Expiry: expireTime,
}
if err := s.storage.CreateDeviceRequest(deviceReq); err != nil {
s.logger.Errorf("Failed to store device request; %v", err)
s.tokenErrHelper(w, errInvalidRequest, "", http.StatusInternalServerError)
return
}
//Store the device token
deviceToken := storage.DeviceToken{
DeviceCode: deviceCode,
Status: deviceTokenPending,
Expiry: expireTime,
LastRequestTime: s.now(),
PollIntervalSeconds: 0,
}
if err := s.storage.CreateDeviceToken(deviceToken); err != nil {
s.logger.Errorf("Failed to store device token %v", err)
s.tokenErrHelper(w, errInvalidRequest, "", http.StatusInternalServerError)
return
}
u, err := url.Parse(s.issuerURL.String())
if err != nil {
s.logger.Errorf("Could not parse issuer URL %v", err)
s.tokenErrHelper(w, errInvalidRequest, "", http.StatusInternalServerError)
return
}
u.Path = path.Join(u.Path, "device")
vURI := u.String()
q := u.Query()
q.Set("user_code", userCode)
u.RawQuery = q.Encode()
vURIComplete := u.String()
code := deviceCodeResponse{
DeviceCode: deviceCode,
UserCode: userCode,
VerificationURI: vURI,
VerificationURIComplete: vURIComplete,
ExpireTime: int(s.deviceRequestsValidFor.Seconds()),
PollInterval: pollIntervalSeconds,
}
enc := json.NewEncoder(w)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
enc.Encode(code)
default:
s.renderError(r, w, http.StatusBadRequest, "Invalid device code request type")
s.tokenErrHelper(w, errInvalidRequest, "", http.StatusBadRequest)
}
}
func (s *Server) handleDeviceToken(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.Method {
case http.MethodPost:
err := r.ParseForm()
if err != nil {
s.logger.Warnf("Could not parse Device Token Request body: %v", err)
s.tokenErrHelper(w, errInvalidRequest, "", http.StatusBadRequest)
return
}
deviceCode := r.Form.Get("device_code")
if deviceCode == "" {
s.tokenErrHelper(w, errInvalidRequest, "No device code received", http.StatusBadRequest)
return
}
grantType := r.PostFormValue("grant_type")
if grantType != grantTypeDeviceCode {
s.tokenErrHelper(w, errInvalidGrant, "", http.StatusBadRequest)
return
}
now := s.now()
//Grab the device token, check validity
deviceToken, err := s.storage.GetDeviceToken(deviceCode)
if err != nil {
if err != storage.ErrNotFound {
s.logger.Errorf("failed to get device code: %v", err)
}
s.tokenErrHelper(w, errInvalidRequest, "Invalid Device code.", http.StatusBadRequest)
return
} else if now.After(deviceToken.Expiry) {
s.tokenErrHelper(w, deviceTokenExpired, "", http.StatusBadRequest)
return
}
//Rate Limiting check
slowDown := false
pollInterval := deviceToken.PollIntervalSeconds
minRequestTime := deviceToken.LastRequestTime.Add(time.Second * time.Duration(pollInterval))
if now.Before(minRequestTime) {
slowDown = true
//Continually increase the poll interval until the user waits the proper time
pollInterval += 5
} else {
pollInterval = 5
}
switch deviceToken.Status {
case deviceTokenPending:
updater := func(old storage.DeviceToken) (storage.DeviceToken, error) {
old.PollIntervalSeconds = pollInterval
old.LastRequestTime = now
return old, nil
}
// Update device token last request time in storage
if err := s.storage.UpdateDeviceToken(deviceCode, updater); err != nil {
s.logger.Errorf("failed to update device token: %v", err)
s.renderError(r, w, http.StatusInternalServerError, "")
return
}
if slowDown {
s.tokenErrHelper(w, deviceTokenSlowDown, "", http.StatusBadRequest)
} else {
s.tokenErrHelper(w, deviceTokenPending, "", http.StatusUnauthorized)
}
case deviceTokenComplete:
w.Write([]byte(deviceToken.Token))
}
default:
s.renderError(r, w, http.StatusBadRequest, "Requested resource does not exist.")
}
}
func (s *Server) handleDeviceCallback(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
userCode := r.FormValue("state")
code := r.FormValue("code")
if userCode == "" || code == "" {
s.renderError(r, w, http.StatusBadRequest, "Request was missing parameters")
return
}
// Authorization redirect callback from OAuth2 auth flow.
if errMsg := r.FormValue("error"); errMsg != "" {
http.Error(w, errMsg+": "+r.FormValue("error_description"), http.StatusBadRequest)
return
}
authCode, err := s.storage.GetAuthCode(code)
if err != nil || s.now().After(authCode.Expiry) {
errCode := http.StatusBadRequest
if err != nil && err != storage.ErrNotFound {
s.logger.Errorf("failed to get auth code: %v", err)
errCode = http.StatusInternalServerError
}
s.renderError(r, w, errCode, "Invalid or expired auth code.")
return
}
//Grab the device request from storage
deviceReq, err := s.storage.GetDeviceRequest(userCode)
if err != nil || s.now().After(deviceReq.Expiry) {
errCode := http.StatusBadRequest
if err != nil && err != storage.ErrNotFound {
s.logger.Errorf("failed to get device code: %v", err)
errCode = http.StatusInternalServerError
}
s.renderError(r, w, errCode, "Invalid or expired user code.")
return
}
client, err := s.storage.GetClient(deviceReq.ClientID)
if err != nil {
if err != storage.ErrNotFound {
s.logger.Errorf("failed to get client: %v", err)
s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
} else {
s.tokenErrHelper(w, errInvalidClient, "Invalid client credentials.", http.StatusUnauthorized)
}
return
}
if client.Secret != deviceReq.ClientSecret {
s.tokenErrHelper(w, errInvalidClient, "Invalid client credentials.", http.StatusUnauthorized)
return
}
resp, err := s.exchangeAuthCode(w, authCode, client)
if err != nil {
s.logger.Errorf("Could not exchange auth code for client %q: %v", deviceReq.ClientID, err)
s.renderError(r, w, http.StatusInternalServerError, "Failed to exchange auth code.")
return
}
//Grab the device token from storage
old, err := s.storage.GetDeviceToken(deviceReq.DeviceCode)
if err != nil || s.now().After(old.Expiry) {
errCode := http.StatusBadRequest
if err != nil && err != storage.ErrNotFound {
s.logger.Errorf("failed to get device token: %v", err)
errCode = http.StatusInternalServerError
}
s.renderError(r, w, errCode, "Invalid or expired device code.")
return
}
updater := func(old storage.DeviceToken) (storage.DeviceToken, error) {
if old.Status == deviceTokenComplete {
return old, errors.New("device token already complete")
}
respStr, err := json.MarshalIndent(resp, "", " ")
if err != nil {
s.logger.Errorf("failed to marshal device token response: %v", err)
s.renderError(r, w, http.StatusInternalServerError, "")
return old, err
}
old.Token = string(respStr)
old.Status = deviceTokenComplete
return old, nil
}
// Update refresh token in the storage, store the token and mark as complete
if err := s.storage.UpdateDeviceToken(deviceReq.DeviceCode, updater); err != nil {
s.logger.Errorf("failed to update device token: %v", err)
s.renderError(r, w, http.StatusBadRequest, "")
return
}
if err := s.templates.deviceSuccess(r, w, client.Name); err != nil {
s.logger.Errorf("Server template error: %v", err)
s.renderError(r, w, http.StatusNotFound, "Page not found")
}
default:
http.Error(w, fmt.Sprintf("method not implemented: %s", r.Method), http.StatusBadRequest)
return
}
}
func (s *Server) verifyUserCode(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPost:
err := r.ParseForm()
if err != nil {
s.logger.Warnf("Could not parse user code verification request body : %v", err)
s.renderError(r, w, http.StatusBadRequest, "")
return
}
userCode := r.Form.Get("user_code")
if userCode == "" {
s.renderError(r, w, http.StatusBadRequest, "No user code received")
return
}
userCode = strings.ToUpper(userCode)
//Find the user code in the available requests
deviceRequest, err := s.storage.GetDeviceRequest(userCode)
if err != nil || s.now().After(deviceRequest.Expiry) {
if err != nil && err != storage.ErrNotFound {
s.logger.Errorf("failed to get device request: %v", err)
}
if err := s.templates.device(r, w, s.getDeviceVerificationURI(), userCode, true); err != nil {
s.logger.Errorf("Server template error: %v", err)
s.renderError(r, w, http.StatusNotFound, "Page not found")
}
return
}
//Redirect to Dex Auth Endpoint
authURL := path.Join(s.issuerURL.Path, "/auth")
u, err := url.Parse(authURL)
if err != nil {
s.renderError(r, w, http.StatusInternalServerError, "Invalid auth URI.")
return
}
q := u.Query()
q.Set("client_id", deviceRequest.ClientID)
q.Set("client_secret", deviceRequest.ClientSecret)
q.Set("state", deviceRequest.UserCode)
q.Set("response_type", "code")
q.Set("redirect_uri", "/device/callback")
q.Set("scope", strings.Join(deviceRequest.Scopes, " "))
u.RawQuery = q.Encode()
http.Redirect(w, r, u.String(), http.StatusFound)
default:
s.renderError(r, w, http.StatusBadRequest, "Requested resource does not exist.")
}
}

View file

@ -0,0 +1,678 @@
package server
import (
"bytes"
"context"
"encoding/json"
"io/ioutil"
"net/http"
"net/http/httptest"
"net/url"
"path"
"strings"
"testing"
"time"
"github.com/dexidp/dex/storage"
)
func TestDeviceVerificationURI(t *testing.T) {
t0 := time.Now()
now := func() time.Time { return t0 }
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Setup a dex server.
httpServer, s := newTestServer(ctx, t, func(c *Config) {
c.Issuer = c.Issuer + "/non-root-path"
c.Now = now
})
defer httpServer.Close()
u, err := url.Parse(s.issuerURL.String())
if err != nil {
t.Fatalf("Could not parse issuer URL %v", err)
}
u.Path = path.Join(u.Path, "/device/auth/verify_code")
uri := s.getDeviceVerificationURI()
if uri != u.Path {
t.Errorf("Invalid verification URI. Expected %v got %v", u.Path, uri)
}
}
func TestHandleDeviceCode(t *testing.T) {
t0 := time.Now()
now := func() time.Time { return t0 }
tests := []struct {
testName string
clientID string
requestType string
scopes []string
expectedResponseCode int
expectedServerResponse string
}{
{
testName: "New Code",
clientID: "test",
requestType: "POST",
scopes: []string{"openid", "profile", "email"},
expectedResponseCode: http.StatusOK,
},
{
testName: "Invalid request Type (GET)",
clientID: "test",
requestType: "GET",
scopes: []string{"openid", "profile", "email"},
expectedResponseCode: http.StatusBadRequest,
},
}
for _, tc := range tests {
t.Run(tc.testName, func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Setup a dex server.
httpServer, s := newTestServer(ctx, t, func(c *Config) {
c.Issuer = c.Issuer + "/non-root-path"
c.Now = now
})
defer httpServer.Close()
u, err := url.Parse(s.issuerURL.String())
if err != nil {
t.Fatalf("Could not parse issuer URL %v", err)
}
u.Path = path.Join(u.Path, "device/code")
data := url.Values{}
data.Set("client_id", tc.clientID)
for _, scope := range tc.scopes {
data.Add("scope", scope)
}
req, _ := http.NewRequest(tc.requestType, u.String(), bytes.NewBufferString(data.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded; param=value")
rr := httptest.NewRecorder()
s.ServeHTTP(rr, req)
if rr.Code != tc.expectedResponseCode {
t.Errorf("Unexpected Response Type. Expected %v got %v", tc.expectedResponseCode, rr.Code)
}
body, err := ioutil.ReadAll(rr.Body)
if err != nil {
t.Errorf("Could read token response %v", err)
}
if tc.expectedResponseCode == http.StatusOK {
var resp deviceCodeResponse
if err := json.Unmarshal(body, &resp); err != nil {
t.Errorf("Unexpected Device Code Response Format %v", string(body))
}
}
})
}
}
func TestDeviceCallback(t *testing.T) {
t0 := time.Now()
now := func() time.Time { return t0 }
type formValues struct {
state string
code string
error string
}
// Base "Control" test values
baseFormValues := formValues{
state: "XXXX-XXXX",
code: "somecode",
}
baseAuthCode := storage.AuthCode{
ID: "somecode",
ClientID: "testclient",
RedirectURI: deviceCallbackURI,
Nonce: "",
Scopes: []string{"openid", "profile", "email"},
ConnectorID: "mock",
ConnectorData: nil,
Claims: storage.Claims{},
Expiry: now().Add(5 * time.Minute),
}
baseDeviceRequest := storage.DeviceRequest{
UserCode: "XXXX-XXXX",
DeviceCode: "devicecode",
ClientID: "testclient",
ClientSecret: "",
Scopes: []string{"openid", "profile", "email"},
Expiry: now().Add(5 * time.Minute),
}
baseDeviceToken := storage.DeviceToken{
DeviceCode: "devicecode",
Status: deviceTokenPending,
Token: "",
Expiry: now().Add(5 * time.Minute),
LastRequestTime: time.Time{},
PollIntervalSeconds: 0,
}
tests := []struct {
testName string
expectedResponseCode int
values formValues
testAuthCode storage.AuthCode
testDeviceRequest storage.DeviceRequest
testDeviceToken storage.DeviceToken
}{
{
testName: "Missing State",
values: formValues{
state: "",
code: "somecode",
error: "",
},
expectedResponseCode: http.StatusBadRequest,
},
{
testName: "Missing Code",
values: formValues{
state: "XXXX-XXXX",
code: "",
error: "",
},
expectedResponseCode: http.StatusBadRequest,
},
{
testName: "Error During Authorization",
values: formValues{
state: "XXXX-XXXX",
code: "somecode",
error: "Error Condition",
},
expectedResponseCode: http.StatusBadRequest,
},
{
testName: "Expired Auth Code",
values: baseFormValues,
testAuthCode: storage.AuthCode{
ID: "somecode",
ClientID: "testclient",
RedirectURI: deviceCallbackURI,
Nonce: "",
Scopes: []string{"openid", "profile", "email"},
ConnectorID: "pic",
ConnectorData: nil,
Claims: storage.Claims{},
Expiry: now().Add(-5 * time.Minute),
},
expectedResponseCode: http.StatusBadRequest,
},
{
testName: "Invalid Auth Code",
values: baseFormValues,
testAuthCode: storage.AuthCode{
ID: "somecode",
ClientID: "testclient",
RedirectURI: deviceCallbackURI,
Nonce: "",
Scopes: []string{"openid", "profile", "email"},
ConnectorID: "pic",
ConnectorData: nil,
Claims: storage.Claims{},
Expiry: now().Add(5 * time.Minute),
},
expectedResponseCode: http.StatusBadRequest,
},
{
testName: "Expired Device Request",
values: baseFormValues,
testAuthCode: baseAuthCode,
testDeviceRequest: storage.DeviceRequest{
UserCode: "XXXX-XXXX",
DeviceCode: "devicecode",
ClientID: "testclient",
Scopes: []string{"openid", "profile", "email"},
Expiry: now().Add(-5 * time.Minute),
},
expectedResponseCode: http.StatusBadRequest,
},
{
testName: "Non-Existent User Code",
values: baseFormValues,
testAuthCode: baseAuthCode,
testDeviceRequest: storage.DeviceRequest{
UserCode: "ZZZZ-ZZZZ",
DeviceCode: "devicecode",
Scopes: []string{"openid", "profile", "email"},
Expiry: now().Add(5 * time.Minute),
},
expectedResponseCode: http.StatusBadRequest,
},
{
testName: "Bad Device Request Client",
values: baseFormValues,
testAuthCode: baseAuthCode,
testDeviceRequest: storage.DeviceRequest{
UserCode: "XXXX-XXXX",
DeviceCode: "devicecode",
Scopes: []string{"openid", "profile", "email"},
Expiry: now().Add(5 * time.Minute),
},
expectedResponseCode: http.StatusUnauthorized,
},
{
testName: "Bad Device Request Secret",
values: baseFormValues,
testAuthCode: baseAuthCode,
testDeviceRequest: storage.DeviceRequest{
UserCode: "XXXX-XXXX",
DeviceCode: "devicecode",
ClientSecret: "foobar",
Scopes: []string{"openid", "profile", "email"},
Expiry: now().Add(5 * time.Minute),
},
expectedResponseCode: http.StatusUnauthorized,
},
{
testName: "Expired Device Token",
values: baseFormValues,
testAuthCode: baseAuthCode,
testDeviceRequest: baseDeviceRequest,
testDeviceToken: storage.DeviceToken{
DeviceCode: "devicecode",
Status: deviceTokenPending,
Token: "",
Expiry: now().Add(-5 * time.Minute),
LastRequestTime: time.Time{},
PollIntervalSeconds: 0,
},
expectedResponseCode: http.StatusBadRequest,
},
{
testName: "Device Code Already Redeemed",
values: baseFormValues,
testAuthCode: baseAuthCode,
testDeviceRequest: baseDeviceRequest,
testDeviceToken: storage.DeviceToken{
DeviceCode: "devicecode",
Status: deviceTokenComplete,
Token: "",
Expiry: now().Add(5 * time.Minute),
LastRequestTime: time.Time{},
PollIntervalSeconds: 0,
},
expectedResponseCode: http.StatusBadRequest,
},
{
testName: "Successful Exchange",
values: baseFormValues,
testAuthCode: baseAuthCode,
testDeviceRequest: baseDeviceRequest,
testDeviceToken: baseDeviceToken,
expectedResponseCode: http.StatusOK,
},
}
for _, tc := range tests {
t.Run(tc.testName, func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Setup a dex server.
httpServer, s := newTestServer(ctx, t, func(c *Config) {
//c.Issuer = c.Issuer + "/non-root-path"
c.Now = now
})
defer httpServer.Close()
if err := s.storage.CreateAuthCode(tc.testAuthCode); err != nil {
t.Fatalf("failed to create auth code: %v", err)
}
if err := s.storage.CreateDeviceRequest(tc.testDeviceRequest); err != nil {
t.Fatalf("failed to create device request: %v", err)
}
if err := s.storage.CreateDeviceToken(tc.testDeviceToken); err != nil {
t.Fatalf("failed to create device token: %v", err)
}
client := storage.Client{
ID: "testclient",
Secret: "",
RedirectURIs: []string{deviceCallbackURI},
}
if err := s.storage.CreateClient(client); err != nil {
t.Fatalf("failed to create client: %v", err)
}
u, err := url.Parse(s.issuerURL.String())
if err != nil {
t.Fatalf("Could not parse issuer URL %v", err)
}
u.Path = path.Join(u.Path, "device/callback")
q := u.Query()
q.Set("state", tc.values.state)
q.Set("code", tc.values.code)
q.Set("error", tc.values.error)
u.RawQuery = q.Encode()
req, _ := http.NewRequest("GET", u.String(), nil)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded; param=value")
rr := httptest.NewRecorder()
s.ServeHTTP(rr, req)
if rr.Code != tc.expectedResponseCode {
t.Errorf("%s: Unexpected Response Type. Expected %v got %v", tc.testName, tc.expectedResponseCode, rr.Code)
}
})
}
}
func TestDeviceTokenResponse(t *testing.T) {
t0 := time.Now()
now := func() time.Time { return t0 }
baseDeviceRequest := storage.DeviceRequest{
UserCode: "ABCD-WXYZ",
DeviceCode: "foo",
ClientID: "testclient",
Scopes: []string{"openid", "profile", "offline_access"},
Expiry: now().Add(5 * time.Minute),
}
tests := []struct {
testName string
testDeviceRequest storage.DeviceRequest
testDeviceToken storage.DeviceToken
testGrantType string
testDeviceCode string
expectedServerResponse string
expectedResponseCode int
}{
{
testName: "Valid but pending token",
testDeviceRequest: baseDeviceRequest,
testDeviceToken: storage.DeviceToken{
DeviceCode: "f00bar",
Status: deviceTokenPending,
Token: "",
Expiry: now().Add(5 * time.Minute),
LastRequestTime: time.Time{},
PollIntervalSeconds: 0,
},
testDeviceCode: "f00bar",
expectedServerResponse: deviceTokenPending,
expectedResponseCode: http.StatusUnauthorized,
},
{
testName: "Invalid Grant Type",
testDeviceRequest: baseDeviceRequest,
testDeviceToken: storage.DeviceToken{
DeviceCode: "f00bar",
Status: deviceTokenPending,
Token: "",
Expiry: now().Add(5 * time.Minute),
LastRequestTime: time.Time{},
PollIntervalSeconds: 0,
},
testDeviceCode: "f00bar",
testGrantType: grantTypeAuthorizationCode,
expectedServerResponse: errInvalidGrant,
expectedResponseCode: http.StatusBadRequest,
},
{
testName: "Test Slow Down State",
testDeviceRequest: baseDeviceRequest,
testDeviceToken: storage.DeviceToken{
DeviceCode: "f00bar",
Status: deviceTokenPending,
Token: "",
Expiry: now().Add(5 * time.Minute),
LastRequestTime: now(),
PollIntervalSeconds: 10,
},
testDeviceCode: "f00bar",
expectedServerResponse: deviceTokenSlowDown,
expectedResponseCode: http.StatusBadRequest,
},
{
testName: "Test Expired Device Token",
testDeviceRequest: baseDeviceRequest,
testDeviceToken: storage.DeviceToken{
DeviceCode: "f00bar",
Status: deviceTokenPending,
Token: "",
Expiry: now().Add(-5 * time.Minute),
LastRequestTime: time.Time{},
PollIntervalSeconds: 0,
},
testDeviceCode: "f00bar",
expectedServerResponse: deviceTokenExpired,
expectedResponseCode: http.StatusBadRequest,
},
{
testName: "Test Non-existent Device Code",
testDeviceRequest: baseDeviceRequest,
testDeviceToken: storage.DeviceToken{
DeviceCode: "foo",
Status: deviceTokenPending,
Token: "",
Expiry: now().Add(-5 * time.Minute),
LastRequestTime: time.Time{},
PollIntervalSeconds: 0,
},
testDeviceCode: "bar",
expectedServerResponse: errInvalidRequest,
expectedResponseCode: http.StatusBadRequest,
},
{
testName: "Empty Device Code in Request",
testDeviceRequest: baseDeviceRequest,
testDeviceToken: storage.DeviceToken{
DeviceCode: "bar",
Status: deviceTokenPending,
Token: "",
Expiry: now().Add(-5 * time.Minute),
LastRequestTime: time.Time{},
PollIntervalSeconds: 0,
},
testDeviceCode: "",
expectedServerResponse: errInvalidRequest,
expectedResponseCode: http.StatusBadRequest,
},
{
testName: "Claim validated token from Device Code",
testDeviceRequest: baseDeviceRequest,
testDeviceToken: storage.DeviceToken{
DeviceCode: "foo",
Status: deviceTokenComplete,
Token: "{\"access_token\": \"foobar\"}",
Expiry: now().Add(5 * time.Minute),
LastRequestTime: time.Time{},
PollIntervalSeconds: 0,
},
testDeviceCode: "foo",
expectedServerResponse: "{\"access_token\": \"foobar\"}",
expectedResponseCode: http.StatusOK,
},
}
for _, tc := range tests {
t.Run(tc.testName, func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Setup a dex server.
httpServer, s := newTestServer(ctx, t, func(c *Config) {
c.Issuer = c.Issuer + "/non-root-path"
c.Now = now
})
defer httpServer.Close()
if err := s.storage.CreateDeviceRequest(tc.testDeviceRequest); err != nil {
t.Fatalf("Failed to store device token %v", err)
}
if err := s.storage.CreateDeviceToken(tc.testDeviceToken); err != nil {
t.Fatalf("Failed to store device token %v", err)
}
u, err := url.Parse(s.issuerURL.String())
if err != nil {
t.Fatalf("Could not parse issuer URL %v", err)
}
u.Path = path.Join(u.Path, "device/token")
data := url.Values{}
grantType := grantTypeDeviceCode
if tc.testGrantType != "" {
grantType = tc.testGrantType
}
data.Set("grant_type", grantType)
data.Set("device_code", tc.testDeviceCode)
req, _ := http.NewRequest("POST", u.String(), bytes.NewBufferString(data.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded; param=value")
rr := httptest.NewRecorder()
s.ServeHTTP(rr, req)
if rr.Code != tc.expectedResponseCode {
t.Errorf("Unexpected Response Type. Expected %v got %v", tc.expectedResponseCode, rr.Code)
}
body, err := ioutil.ReadAll(rr.Body)
if err != nil {
t.Errorf("Could read token response %v", err)
}
if tc.expectedResponseCode == http.StatusBadRequest || tc.expectedResponseCode == http.StatusUnauthorized {
expectJsonErrorResponse(tc.testName, body, tc.expectedServerResponse, t)
} else if string(body) != tc.expectedServerResponse {
t.Errorf("Unexpected Server Response. Expected %v got %v", tc.expectedServerResponse, string(body))
}
})
}
}
func expectJsonErrorResponse(testCase string, body []byte, expectedError string, t *testing.T) {
jsonMap := make(map[string]interface{})
err := json.Unmarshal(body, &jsonMap)
if err != nil {
t.Errorf("Unexpected error unmarshalling response: %v", err)
}
if jsonMap["error"] != expectedError {
t.Errorf("Test Case %s expected error %v, received %v", testCase, expectedError, jsonMap["error"])
}
}
func TestVerifyCodeResponse(t *testing.T) {
t0 := time.Now()
now := func() time.Time { return t0 }
tests := []struct {
testName string
testDeviceRequest storage.DeviceRequest
userCode string
expectedResponseCode int
expectedRedirectPath string
}{
{
testName: "Unknown user code",
testDeviceRequest: storage.DeviceRequest{
UserCode: "ABCD-WXYZ",
DeviceCode: "f00bar",
ClientID: "testclient",
Scopes: []string{"openid", "profile", "offline_access"},
Expiry: now().Add(5 * time.Minute),
},
userCode: "CODE-TEST",
expectedResponseCode: http.StatusBadRequest,
expectedRedirectPath: "",
},
{
testName: "Expired user code",
testDeviceRequest: storage.DeviceRequest{
UserCode: "ABCD-WXYZ",
DeviceCode: "f00bar",
ClientID: "testclient",
Scopes: []string{"openid", "profile", "offline_access"},
Expiry: now().Add(-5 * time.Minute),
},
userCode: "ABCD-WXYZ",
expectedResponseCode: http.StatusBadRequest,
expectedRedirectPath: "",
},
{
testName: "No user code",
testDeviceRequest: storage.DeviceRequest{
UserCode: "ABCD-WXYZ",
DeviceCode: "f00bar",
ClientID: "testclient",
Scopes: []string{"openid", "profile", "offline_access"},
Expiry: now().Add(-5 * time.Minute),
},
userCode: "",
expectedResponseCode: http.StatusBadRequest,
expectedRedirectPath: "",
},
{
testName: "Valid user code, expect redirect to auth endpoint",
testDeviceRequest: storage.DeviceRequest{
UserCode: "ABCD-WXYZ",
DeviceCode: "f00bar",
ClientID: "testclient",
Scopes: []string{"openid", "profile", "offline_access"},
Expiry: now().Add(5 * time.Minute),
},
userCode: "ABCD-WXYZ",
expectedResponseCode: http.StatusFound,
expectedRedirectPath: "/auth",
},
}
for _, tc := range tests {
t.Run(tc.testName, func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Setup a dex server.
httpServer, s := newTestServer(ctx, t, func(c *Config) {
c.Issuer = c.Issuer + "/non-root-path"
c.Now = now
})
defer httpServer.Close()
if err := s.storage.CreateDeviceRequest(tc.testDeviceRequest); err != nil {
t.Fatalf("Failed to store device token %v", err)
}
u, err := url.Parse(s.issuerURL.String())
if err != nil {
t.Fatalf("Could not parse issuer URL %v", err)
}
u.Path = path.Join(u.Path, "device/auth/verify_code")
data := url.Values{}
data.Set("user_code", tc.userCode)
req, _ := http.NewRequest("POST", u.String(), bytes.NewBufferString(data.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded; param=value")
rr := httptest.NewRecorder()
s.ServeHTTP(rr, req)
if rr.Code != tc.expectedResponseCode {
t.Errorf("Unexpected Response Type. Expected %v got %v", tc.expectedResponseCode, rr.Code)
}
u, err = url.Parse(s.issuerURL.String())
if err != nil {
t.Errorf("Could not parse issuer URL %v", err)
}
u.Path = path.Join(u.Path, tc.expectedRedirectPath)
location := rr.Header().Get("Location")
if rr.Code == http.StatusFound && !strings.HasPrefix(location, u.Path) {
t.Errorf("Invalid Redirect. Expected %v got %v", u.Path, location)
}
})
}
}

View file

@ -148,30 +148,34 @@ func (s *Server) handlePublicKeys(w http.ResponseWriter, r *http.Request) {
} }
type discovery struct { type discovery struct {
Issuer string `json:"issuer"` Issuer string `json:"issuer"`
Auth string `json:"authorization_endpoint"` Auth string `json:"authorization_endpoint"`
Token string `json:"token_endpoint"` Token string `json:"token_endpoint"`
Keys string `json:"jwks_uri"` Keys string `json:"jwks_uri"`
UserInfo string `json:"userinfo_endpoint"` UserInfo string `json:"userinfo_endpoint"`
ResponseTypes []string `json:"response_types_supported"` DeviceEndpoint string `json:"device_authorization_endpoint"`
Subjects []string `json:"subject_types_supported"` GrantTypes []string `json:"grant_types_supported"`
IDTokenAlgs []string `json:"id_token_signing_alg_values_supported"` ResponseTypes []string `json:"response_types_supported"`
Scopes []string `json:"scopes_supported"` Subjects []string `json:"subject_types_supported"`
AuthMethods []string `json:"token_endpoint_auth_methods_supported"` IDTokenAlgs []string `json:"id_token_signing_alg_values_supported"`
Claims []string `json:"claims_supported"` Scopes []string `json:"scopes_supported"`
AuthMethods []string `json:"token_endpoint_auth_methods_supported"`
Claims []string `json:"claims_supported"`
} }
func (s *Server) discoveryHandler() (http.HandlerFunc, error) { func (s *Server) discoveryHandler() (http.HandlerFunc, error) {
d := discovery{ d := discovery{
Issuer: s.issuerURL.String(), Issuer: s.issuerURL.String(),
Auth: s.absURL("/auth"), Auth: s.absURL("/auth"),
Token: s.absURL("/token"), Token: s.absURL("/token"),
Keys: s.absURL("/keys"), Keys: s.absURL("/keys"),
UserInfo: s.absURL("/userinfo"), UserInfo: s.absURL("/userinfo"),
Subjects: []string{"public"}, DeviceEndpoint: s.absURL("/device/code"),
IDTokenAlgs: []string{string(jose.RS256)}, Subjects: []string{"public"},
Scopes: []string{"openid", "email", "groups", "profile", "offline_access"}, GrantTypes: []string{grantTypeAuthorizationCode, grantTypeRefreshToken, grantTypeDeviceCode},
AuthMethods: []string{"client_secret_basic"}, IDTokenAlgs: []string{string(jose.RS256)},
Scopes: []string{"openid", "email", "groups", "profile", "offline_access"},
AuthMethods: []string{"client_secret_basic"},
Claims: []string{ Claims: []string{
"aud", "email", "email_verified", "exp", "aud", "email", "email_verified", "exp",
"iat", "iss", "locale", "name", "sub", "iat", "iss", "locale", "name", "sub",
@ -784,24 +788,33 @@ func (s *Server) handleAuthCode(w http.ResponseWriter, r *http.Request, client s
return return
} }
tokenResponse, err := s.exchangeAuthCode(w, authCode, client)
if err != nil {
s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
return
}
s.writeAccessToken(w, tokenResponse)
}
func (s *Server) exchangeAuthCode(w http.ResponseWriter, authCode storage.AuthCode, client storage.Client) (*accessTokenReponse, error) {
accessToken, err := s.newAccessToken(client.ID, authCode.Claims, authCode.Scopes, authCode.Nonce, authCode.ConnectorID) accessToken, err := s.newAccessToken(client.ID, authCode.Claims, authCode.Scopes, authCode.Nonce, authCode.ConnectorID)
if err != nil { if err != nil {
s.logger.Errorf("failed to create new access token: %v", err) s.logger.Errorf("failed to create new access token: %v", err)
s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
return return nil, err
} }
idToken, expiry, err := s.newIDToken(client.ID, authCode.Claims, authCode.Scopes, authCode.Nonce, accessToken, authCode.ConnectorID) idToken, expiry, err := s.newIDToken(client.ID, authCode.Claims, authCode.Scopes, authCode.Nonce, accessToken, authCode.ConnectorID)
if err != nil { if err != nil {
s.logger.Errorf("failed to create ID token: %v", err) s.logger.Errorf("failed to create ID token: %v", err)
s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
return return nil, err
} }
if err := s.storage.DeleteAuthCode(code); err != nil { if err := s.storage.DeleteAuthCode(authCode.ID); err != nil {
s.logger.Errorf("failed to delete auth code: %v", err) s.logger.Errorf("failed to delete auth code: %v", err)
s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
return return nil, err
} }
reqRefresh := func() bool { reqRefresh := func() bool {
@ -848,13 +861,13 @@ func (s *Server) handleAuthCode(w http.ResponseWriter, r *http.Request, client s
if refreshToken, err = internal.Marshal(token); err != nil { if refreshToken, err = internal.Marshal(token); err != nil {
s.logger.Errorf("failed to marshal refresh token: %v", err) s.logger.Errorf("failed to marshal refresh token: %v", err)
s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
return return nil, err
} }
if err := s.storage.CreateRefresh(refresh); err != nil { if err := s.storage.CreateRefresh(refresh); err != nil {
s.logger.Errorf("failed to create refresh token: %v", err) s.logger.Errorf("failed to create refresh token: %v", err)
s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
return return nil, err
} }
// deleteToken determines if we need to delete the newly created refresh token // deleteToken determines if we need to delete the newly created refresh token
@ -885,7 +898,7 @@ func (s *Server) handleAuthCode(w http.ResponseWriter, r *http.Request, client s
s.logger.Errorf("failed to get offline session: %v", err) s.logger.Errorf("failed to get offline session: %v", err)
s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
deleteToken = true deleteToken = true
return return nil, err
} }
offlineSessions := storage.OfflineSessions{ offlineSessions := storage.OfflineSessions{
UserID: refresh.Claims.UserID, UserID: refresh.Claims.UserID,
@ -900,7 +913,7 @@ func (s *Server) handleAuthCode(w http.ResponseWriter, r *http.Request, client s
s.logger.Errorf("failed to create offline session: %v", err) s.logger.Errorf("failed to create offline session: %v", err)
s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
deleteToken = true deleteToken = true
return return nil, err
} }
} else { } else {
if oldTokenRef, ok := session.Refresh[tokenRef.ClientID]; ok { if oldTokenRef, ok := session.Refresh[tokenRef.ClientID]; ok {
@ -909,7 +922,7 @@ func (s *Server) handleAuthCode(w http.ResponseWriter, r *http.Request, client s
s.logger.Errorf("failed to delete refresh token: %v", err) s.logger.Errorf("failed to delete refresh token: %v", err)
s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
deleteToken = true deleteToken = true
return return nil, err
} }
} }
@ -921,11 +934,11 @@ func (s *Server) handleAuthCode(w http.ResponseWriter, r *http.Request, client s
s.logger.Errorf("failed to update offline session: %v", err) s.logger.Errorf("failed to update offline session: %v", err)
s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError) s.tokenErrHelper(w, errServerError, "", http.StatusInternalServerError)
deleteToken = true deleteToken = true
return return nil, err
} }
} }
} }
s.writeAccessToken(w, idToken, accessToken, refreshToken, expiry) return s.toAccessTokenResponse(idToken, accessToken, refreshToken, expiry), nil
} }
// handle a refresh token request https://tools.ietf.org/html/rfc6749#section-6 // handle a refresh token request https://tools.ietf.org/html/rfc6749#section-6
@ -1121,7 +1134,8 @@ func (s *Server) handleRefreshToken(w http.ResponseWriter, r *http.Request, clie
return return
} }
s.writeAccessToken(w, idToken, accessToken, rawNewToken, expiry) resp := s.toAccessTokenResponse(idToken, accessToken, rawNewToken, expiry)
s.writeAccessToken(w, resp)
} }
func (s *Server) handleUserInfo(w http.ResponseWriter, r *http.Request) { func (s *Server) handleUserInfo(w http.ResponseWriter, r *http.Request) {
@ -1368,23 +1382,29 @@ func (s *Server) handlePasswordGrant(w http.ResponseWriter, r *http.Request, cli
} }
} }
s.writeAccessToken(w, idToken, accessToken, refreshToken, expiry) resp := s.toAccessTokenResponse(idToken, accessToken, refreshToken, expiry)
s.writeAccessToken(w, resp)
} }
func (s *Server) writeAccessToken(w http.ResponseWriter, idToken, accessToken, refreshToken string, expiry time.Time) { type accessTokenReponse struct {
resp := struct { AccessToken string `json:"access_token"`
AccessToken string `json:"access_token"` TokenType string `json:"token_type"`
TokenType string `json:"token_type"` ExpiresIn int `json:"expires_in"`
ExpiresIn int `json:"expires_in"` RefreshToken string `json:"refresh_token,omitempty"`
RefreshToken string `json:"refresh_token,omitempty"` IDToken string `json:"id_token"`
IDToken string `json:"id_token"` }
}{
func (s *Server) toAccessTokenResponse(idToken, accessToken, refreshToken string, expiry time.Time) *accessTokenReponse {
return &accessTokenReponse{
accessToken, accessToken,
"bearer", "bearer",
int(expiry.Sub(s.now()).Seconds()), int(expiry.Sub(s.now()).Seconds()),
refreshToken, refreshToken,
idToken, idToken,
} }
}
func (s *Server) writeAccessToken(w http.ResponseWriter, resp *accessTokenReponse) {
data, err := json.Marshal(resp) data, err := json.Marshal(resp)
if err != nil { if err != nil {
s.logger.Errorf("failed to marshal access token response: %v", err) s.logger.Errorf("failed to marshal access token response: %v", err)

View file

@ -114,6 +114,10 @@ const (
scopeCrossClientPrefix = "audience:server:client_id:" scopeCrossClientPrefix = "audience:server:client_id:"
) )
const (
deviceCallbackURI = "/device/callback"
)
const ( const (
redirectURIOOB = "urn:ietf:wg:oauth:2.0:oob" redirectURIOOB = "urn:ietf:wg:oauth:2.0:oob"
) )
@ -122,6 +126,7 @@ const (
grantTypeAuthorizationCode = "authorization_code" grantTypeAuthorizationCode = "authorization_code"
grantTypeRefreshToken = "refresh_token" grantTypeRefreshToken = "refresh_token"
grantTypePassword = "password" grantTypePassword = "password"
grantTypeDeviceCode = "urn:ietf:params:oauth:grant-type:device_code"
) )
const ( const (
@ -130,6 +135,13 @@ const (
responseTypeIDToken = "id_token" // ID Token in url fragment responseTypeIDToken = "id_token" // ID Token in url fragment
) )
const (
deviceTokenPending = "authorization_pending"
deviceTokenComplete = "complete"
deviceTokenSlowDown = "slow_down"
deviceTokenExpired = "expired_token"
)
func parseScopes(scopes []string) connector.Scopes { func parseScopes(scopes []string) connector.Scopes {
var s connector.Scopes var s connector.Scopes
for _, scope := range scopes { for _, scope := range scopes {
@ -425,6 +437,9 @@ func (s *Server) parseAuthorizationRequest(r *http.Request) (*storage.AuthReques
description := fmt.Sprintf("Unregistered redirect_uri (%q).", redirectURI) description := fmt.Sprintf("Unregistered redirect_uri (%q).", redirectURI)
return nil, &authErr{"", "", errInvalidRequest, description} return nil, &authErr{"", "", errInvalidRequest, description}
} }
if redirectURI == deviceCallbackURI && client.Public {
redirectURI = s.issuerURL.Path + deviceCallbackURI
}
// From here on out, we want to redirect back to the client with an error. // From here on out, we want to redirect back to the client with an error.
newErr := func(typ, format string, a ...interface{}) *authErr { newErr := func(typ, format string, a ...interface{}) *authErr {
@ -566,7 +581,7 @@ func validateRedirectURI(client storage.Client, redirectURI string) bool {
return false return false
} }
if redirectURI == redirectURIOOB { if redirectURI == redirectURIOOB || redirectURI == deviceCallbackURI {
return true return true
} }

View file

@ -75,9 +75,10 @@ type Config struct {
// If enabled, the connectors selection page will always be shown even if there's only one // If enabled, the connectors selection page will always be shown even if there's only one
AlwaysShowLoginScreen bool AlwaysShowLoginScreen bool
RotateKeysAfter time.Duration // Defaults to 6 hours. RotateKeysAfter time.Duration // Defaults to 6 hours.
IDTokensValidFor time.Duration // Defaults to 24 hours IDTokensValidFor time.Duration // Defaults to 24 hours
AuthRequestsValidFor time.Duration // Defaults to 24 hours AuthRequestsValidFor time.Duration // Defaults to 24 hours
DeviceRequestsValidFor time.Duration // Defaults to 5 minutes
// If set, the server will use this connector to handle password grants // If set, the server will use this connector to handle password grants
PasswordConnector string PasswordConnector string
@ -156,8 +157,9 @@ type Server struct {
now func() time.Time now func() time.Time
idTokensValidFor time.Duration idTokensValidFor time.Duration
authRequestsValidFor time.Duration authRequestsValidFor time.Duration
deviceRequestsValidFor time.Duration
logger log.Logger logger log.Logger
} }
@ -219,6 +221,7 @@ func newServer(ctx context.Context, c Config, rotationStrategy rotationStrategy)
supportedResponseTypes: supported, supportedResponseTypes: supported,
idTokensValidFor: value(c.IDTokensValidFor, 24*time.Hour), idTokensValidFor: value(c.IDTokensValidFor, 24*time.Hour),
authRequestsValidFor: value(c.AuthRequestsValidFor, 24*time.Hour), authRequestsValidFor: value(c.AuthRequestsValidFor, 24*time.Hour),
deviceRequestsValidFor: value(c.DeviceRequestsValidFor, 5*time.Minute),
skipApproval: c.SkipApprovalScreen, skipApproval: c.SkipApprovalScreen,
alwaysShowLogin: c.AlwaysShowLoginScreen, alwaysShowLogin: c.AlwaysShowLoginScreen,
now: now, now: now,
@ -302,6 +305,11 @@ func newServer(ctx context.Context, c Config, rotationStrategy rotationStrategy)
handleWithCORS("/userinfo", s.handleUserInfo) handleWithCORS("/userinfo", s.handleUserInfo)
handleFunc("/auth", s.handleAuthorization) handleFunc("/auth", s.handleAuthorization)
handleFunc("/auth/{connector}", s.handleConnectorLogin) handleFunc("/auth/{connector}", s.handleConnectorLogin)
handleFunc("/device", s.handleDeviceExchange)
handleFunc("/device/auth/verify_code", s.verifyUserCode)
handleFunc("/device/code", s.handleDeviceCode)
handleFunc("/device/token", s.handleDeviceToken)
handleFunc(deviceCallbackURI, s.handleDeviceCallback)
r.HandleFunc(path.Join(issuerURL.Path, "/callback"), func(w http.ResponseWriter, r *http.Request) { r.HandleFunc(path.Join(issuerURL.Path, "/callback"), func(w http.ResponseWriter, r *http.Request) {
// Strip the X-Remote-* headers to prevent security issues on // Strip the X-Remote-* headers to prevent security issues on
// misconfigured authproxy connector setups. // misconfigured authproxy connector setups.
@ -450,7 +458,8 @@ func (s *Server) startGarbageCollection(ctx context.Context, frequency time.Dura
if r, err := s.storage.GarbageCollect(now()); err != nil { if r, err := s.storage.GarbageCollect(now()); err != nil {
s.logger.Errorf("garbage collection failed: %v", err) s.logger.Errorf("garbage collection failed: %v", err)
} else if r.AuthRequests > 0 || r.AuthCodes > 0 { } else if r.AuthRequests > 0 || r.AuthCodes > 0 {
s.logger.Infof("garbage collection run, delete auth requests=%d, auth codes=%d", r.AuthRequests, r.AuthCodes) s.logger.Infof("garbage collection run, delete auth requests=%d, auth codes=%d, device requests =%d, device tokens=%d",
r.AuthRequests, r.AuthCodes, r.DeviceRequests, r.DeviceTokens)
} }
} }
} }

View file

@ -8,11 +8,13 @@ import (
"encoding/pem" "encoding/pem"
"errors" "errors"
"fmt" "fmt"
"io/ioutil"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/http/httputil" "net/http/httputil"
"net/url" "net/url"
"os" "os"
"path"
"reflect" "reflect"
"sort" "sort"
"strings" "strings"
@ -203,6 +205,274 @@ func TestDiscovery(t *testing.T) {
} }
} }
type oauth2Tests struct {
clientID string
tests []test
}
type test struct {
name string
// If specified these set of scopes will be used during the test case.
scopes []string
// handleToken provides the OAuth2 token response for the integration test.
handleToken func(context.Context, *oidc.Provider, *oauth2.Config, *oauth2.Token, *mock.Callback) error
}
func makeOAuth2Tests(clientID string, clientSecret string, now func() time.Time) oauth2Tests {
requestedScopes := []string{oidc.ScopeOpenID, "email", "profile", "groups", "offline_access"}
// Used later when configuring test servers to set how long id_tokens will be valid for.
//
// The actual value of 30s is completely arbitrary. We just need to set a value
// so tests can compute the expected "expires_in" field.
idTokensValidFor := time.Second * 30
oidcConfig := &oidc.Config{SkipClientIDCheck: true}
return oauth2Tests{
clientID: clientID,
tests: []test{
{
name: "verify ID Token",
handleToken: func(ctx context.Context, p *oidc.Provider, config *oauth2.Config, token *oauth2.Token, conn *mock.Callback) error {
idToken, ok := token.Extra("id_token").(string)
if !ok {
return fmt.Errorf("no id token found")
}
if _, err := p.Verifier(oidcConfig).Verify(ctx, idToken); err != nil {
return fmt.Errorf("failed to verify id token: %v", err)
}
return nil
},
},
{
name: "fetch userinfo",
handleToken: func(ctx context.Context, p *oidc.Provider, config *oauth2.Config, token *oauth2.Token, conn *mock.Callback) error {
ui, err := p.UserInfo(ctx, config.TokenSource(ctx, token))
if err != nil {
return fmt.Errorf("failed to fetch userinfo: %v", err)
}
if conn.Identity.Email != ui.Email {
return fmt.Errorf("expected email to be %v, got %v", conn.Identity.Email, ui.Email)
}
return nil
},
},
{
name: "verify id token and oauth2 token expiry",
handleToken: func(ctx context.Context, p *oidc.Provider, config *oauth2.Config, token *oauth2.Token, conn *mock.Callback) error {
expectedExpiry := now().Add(idTokensValidFor)
timeEq := func(t1, t2 time.Time, within time.Duration) bool {
return t1.Sub(t2) < within
}
if !timeEq(token.Expiry, expectedExpiry, time.Second) {
return fmt.Errorf("expected expired_in to be %s, got %s", expectedExpiry, token.Expiry)
}
rawIDToken, ok := token.Extra("id_token").(string)
if !ok {
return fmt.Errorf("no id token found")
}
idToken, err := p.Verifier(oidcConfig).Verify(ctx, rawIDToken)
if err != nil {
return fmt.Errorf("failed to verify id token: %v", err)
}
if !timeEq(idToken.Expiry, expectedExpiry, time.Second) {
return fmt.Errorf("expected id token expiry to be %s, got %s", expectedExpiry, token.Expiry)
}
return nil
},
},
{
name: "verify at_hash",
handleToken: func(ctx context.Context, p *oidc.Provider, config *oauth2.Config, token *oauth2.Token, conn *mock.Callback) error {
rawIDToken, ok := token.Extra("id_token").(string)
if !ok {
return fmt.Errorf("no id token found")
}
idToken, err := p.Verifier(oidcConfig).Verify(ctx, rawIDToken)
if err != nil {
return fmt.Errorf("failed to verify id token: %v", err)
}
var claims struct {
AtHash string `json:"at_hash"`
}
if err := idToken.Claims(&claims); err != nil {
return fmt.Errorf("failed to decode raw claims: %v", err)
}
if claims.AtHash == "" {
return errors.New("no at_hash value in id_token")
}
wantAtHash, err := accessTokenHash(jose.RS256, token.AccessToken)
if err != nil {
return fmt.Errorf("computed expected at hash: %v", err)
}
if wantAtHash != claims.AtHash {
return fmt.Errorf("expected at_hash=%q got=%q", wantAtHash, claims.AtHash)
}
return nil
},
},
{
name: "refresh token",
handleToken: func(ctx context.Context, p *oidc.Provider, config *oauth2.Config, token *oauth2.Token, conn *mock.Callback) error {
// have to use time.Now because the OAuth2 package uses it.
token.Expiry = time.Now().Add(time.Second * -10)
if token.Valid() {
return errors.New("token shouldn't be valid")
}
newToken, err := config.TokenSource(ctx, token).Token()
if err != nil {
return fmt.Errorf("failed to refresh token: %v", err)
}
if token.RefreshToken == newToken.RefreshToken {
return fmt.Errorf("old refresh token was the same as the new token %q", token.RefreshToken)
}
if _, err := config.TokenSource(ctx, token).Token(); err == nil {
return errors.New("was able to redeem the same refresh token twice")
}
return nil
},
},
{
name: "refresh with explicit scopes",
handleToken: func(ctx context.Context, p *oidc.Provider, config *oauth2.Config, token *oauth2.Token, conn *mock.Callback) error {
v := url.Values{}
v.Add("client_id", clientID)
v.Add("client_secret", clientSecret)
v.Add("grant_type", "refresh_token")
v.Add("refresh_token", token.RefreshToken)
v.Add("scope", strings.Join(requestedScopes, " "))
resp, err := http.PostForm(p.Endpoint().TokenURL, v)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
dump, err := httputil.DumpResponse(resp, true)
if err != nil {
panic(err)
}
return fmt.Errorf("unexpected response: %s", dump)
}
return nil
},
},
{
name: "refresh with extra spaces",
handleToken: func(ctx context.Context, p *oidc.Provider, config *oauth2.Config, token *oauth2.Token, conn *mock.Callback) error {
v := url.Values{}
v.Add("client_id", clientID)
v.Add("client_secret", clientSecret)
v.Add("grant_type", "refresh_token")
v.Add("refresh_token", token.RefreshToken)
// go-oidc adds an additional space before scopes when refreshing.
// Since we support that client we choose to be more relaxed about
// scope parsing, disregarding extra whitespace.
v.Add("scope", " "+strings.Join(requestedScopes, " "))
resp, err := http.PostForm(p.Endpoint().TokenURL, v)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
dump, err := httputil.DumpResponse(resp, true)
if err != nil {
panic(err)
}
return fmt.Errorf("unexpected response: %s", dump)
}
return nil
},
},
{
name: "refresh with unauthorized scopes",
scopes: []string{"openid", "email"},
handleToken: func(ctx context.Context, p *oidc.Provider, config *oauth2.Config, token *oauth2.Token, conn *mock.Callback) error {
v := url.Values{}
v.Add("client_id", clientID)
v.Add("client_secret", clientSecret)
v.Add("grant_type", "refresh_token")
v.Add("refresh_token", token.RefreshToken)
// Request a scope that wasn't requestd initially.
v.Add("scope", "oidc email profile")
resp, err := http.PostForm(p.Endpoint().TokenURL, v)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusOK {
dump, err := httputil.DumpResponse(resp, true)
if err != nil {
panic(err)
}
return fmt.Errorf("unexpected response: %s", dump)
}
return nil
},
},
{
// This test ensures that the connector.RefreshConnector interface is being
// used when clients request a refresh token.
name: "refresh with identity changes",
handleToken: func(ctx context.Context, p *oidc.Provider, config *oauth2.Config, token *oauth2.Token, conn *mock.Callback) error {
// have to use time.Now because the OAuth2 package uses it.
token.Expiry = time.Now().Add(time.Second * -10)
if token.Valid() {
return errors.New("token shouldn't be valid")
}
ident := connector.Identity{
UserID: "fooid",
Username: "foo",
Email: "foo@bar.com",
EmailVerified: true,
Groups: []string{"foo", "bar"},
}
conn.Identity = ident
type claims struct {
Username string `json:"name"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
Groups []string `json:"groups"`
}
want := claims{ident.Username, ident.Email, ident.EmailVerified, ident.Groups}
newToken, err := config.TokenSource(ctx, token).Token()
if err != nil {
return fmt.Errorf("failed to refresh token: %v", err)
}
rawIDToken, ok := newToken.Extra("id_token").(string)
if !ok {
return fmt.Errorf("no id_token in refreshed token")
}
idToken, err := p.Verifier(oidcConfig).Verify(ctx, rawIDToken)
if err != nil {
return fmt.Errorf("failed to verify id token: %v", err)
}
var got claims
if err := idToken.Claims(&got); err != nil {
return fmt.Errorf("failed to unmarshal claims: %v", err)
}
if diff := pretty.Compare(want, got); diff != "" {
return fmt.Errorf("got identity != want identity: %s", diff)
}
return nil
},
},
},
}
}
// TestOAuth2CodeFlow runs integration tests against a test server. The tests stand up a server // TestOAuth2CodeFlow runs integration tests against a test server. The tests stand up a server
// which requires no interaction to login, logs in through a test client, then passes the client // which requires no interaction to login, logs in through a test client, then passes the client
// and returned token to the test. // and returned token to the test.
@ -226,255 +496,8 @@ func TestOAuth2CodeFlow(t *testing.T) {
// Connector used by the tests. // Connector used by the tests.
var conn *mock.Callback var conn *mock.Callback
oidcConfig := &oidc.Config{SkipClientIDCheck: true} tests := makeOAuth2Tests(clientID, clientSecret, now)
for _, tc := range tests.tests {
tests := []struct {
name string
// If specified these set of scopes will be used during the test case.
scopes []string
// handleToken provides the OAuth2 token response for the integration test.
handleToken func(context.Context, *oidc.Provider, *oauth2.Config, *oauth2.Token) error
}{
{
name: "verify ID Token",
handleToken: func(ctx context.Context, p *oidc.Provider, config *oauth2.Config, token *oauth2.Token) error {
idToken, ok := token.Extra("id_token").(string)
if !ok {
return fmt.Errorf("no id token found")
}
if _, err := p.Verifier(oidcConfig).Verify(ctx, idToken); err != nil {
return fmt.Errorf("failed to verify id token: %v", err)
}
return nil
},
},
{
name: "fetch userinfo",
handleToken: func(ctx context.Context, p *oidc.Provider, config *oauth2.Config, token *oauth2.Token) error {
ui, err := p.UserInfo(ctx, config.TokenSource(ctx, token))
if err != nil {
return fmt.Errorf("failed to fetch userinfo: %v", err)
}
if conn.Identity.Email != ui.Email {
return fmt.Errorf("expected email to be %v, got %v", conn.Identity.Email, ui.Email)
}
return nil
},
},
{
name: "verify id token and oauth2 token expiry",
handleToken: func(ctx context.Context, p *oidc.Provider, config *oauth2.Config, token *oauth2.Token) error {
expectedExpiry := now().Add(idTokensValidFor)
timeEq := func(t1, t2 time.Time, within time.Duration) bool {
return t1.Sub(t2) < within
}
if !timeEq(token.Expiry, expectedExpiry, time.Second) {
return fmt.Errorf("expected expired_in to be %s, got %s", expectedExpiry, token.Expiry)
}
rawIDToken, ok := token.Extra("id_token").(string)
if !ok {
return fmt.Errorf("no id token found")
}
idToken, err := p.Verifier(oidcConfig).Verify(ctx, rawIDToken)
if err != nil {
return fmt.Errorf("failed to verify id token: %v", err)
}
if !timeEq(idToken.Expiry, expectedExpiry, time.Second) {
return fmt.Errorf("expected id token expiry to be %s, got %s", expectedExpiry, token.Expiry)
}
return nil
},
},
{
name: "verify at_hash",
handleToken: func(ctx context.Context, p *oidc.Provider, config *oauth2.Config, token *oauth2.Token) error {
rawIDToken, ok := token.Extra("id_token").(string)
if !ok {
return fmt.Errorf("no id token found")
}
idToken, err := p.Verifier(oidcConfig).Verify(ctx, rawIDToken)
if err != nil {
return fmt.Errorf("failed to verify id token: %v", err)
}
var claims struct {
AtHash string `json:"at_hash"`
}
if err := idToken.Claims(&claims); err != nil {
return fmt.Errorf("failed to decode raw claims: %v", err)
}
if claims.AtHash == "" {
return errors.New("no at_hash value in id_token")
}
wantAtHash, err := accessTokenHash(jose.RS256, token.AccessToken)
if err != nil {
return fmt.Errorf("computed expected at hash: %v", err)
}
if wantAtHash != claims.AtHash {
return fmt.Errorf("expected at_hash=%q got=%q", wantAtHash, claims.AtHash)
}
return nil
},
},
{
name: "refresh token",
handleToken: func(ctx context.Context, p *oidc.Provider, config *oauth2.Config, token *oauth2.Token) error {
// have to use time.Now because the OAuth2 package uses it.
token.Expiry = time.Now().Add(time.Second * -10)
if token.Valid() {
return errors.New("token shouldn't be valid")
}
newToken, err := config.TokenSource(ctx, token).Token()
if err != nil {
return fmt.Errorf("failed to refresh token: %v", err)
}
if token.RefreshToken == newToken.RefreshToken {
return fmt.Errorf("old refresh token was the same as the new token %q", token.RefreshToken)
}
if _, err := config.TokenSource(ctx, token).Token(); err == nil {
return errors.New("was able to redeem the same refresh token twice")
}
return nil
},
},
{
name: "refresh with explicit scopes",
handleToken: func(ctx context.Context, p *oidc.Provider, config *oauth2.Config, token *oauth2.Token) error {
v := url.Values{}
v.Add("client_id", clientID)
v.Add("client_secret", clientSecret)
v.Add("grant_type", "refresh_token")
v.Add("refresh_token", token.RefreshToken)
v.Add("scope", strings.Join(requestedScopes, " "))
resp, err := http.PostForm(p.Endpoint().TokenURL, v)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
dump, err := httputil.DumpResponse(resp, true)
if err != nil {
panic(err)
}
return fmt.Errorf("unexpected response: %s", dump)
}
return nil
},
},
{
name: "refresh with extra spaces",
handleToken: func(ctx context.Context, p *oidc.Provider, config *oauth2.Config, token *oauth2.Token) error {
v := url.Values{}
v.Add("client_id", clientID)
v.Add("client_secret", clientSecret)
v.Add("grant_type", "refresh_token")
v.Add("refresh_token", token.RefreshToken)
// go-oidc adds an additional space before scopes when refreshing.
// Since we support that client we choose to be more relaxed about
// scope parsing, disregarding extra whitespace.
v.Add("scope", " "+strings.Join(requestedScopes, " "))
resp, err := http.PostForm(p.Endpoint().TokenURL, v)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
dump, err := httputil.DumpResponse(resp, true)
if err != nil {
panic(err)
}
return fmt.Errorf("unexpected response: %s", dump)
}
return nil
},
},
{
name: "refresh with unauthorized scopes",
scopes: []string{"openid", "email"},
handleToken: func(ctx context.Context, p *oidc.Provider, config *oauth2.Config, token *oauth2.Token) error {
v := url.Values{}
v.Add("client_id", clientID)
v.Add("client_secret", clientSecret)
v.Add("grant_type", "refresh_token")
v.Add("refresh_token", token.RefreshToken)
// Request a scope that wasn't requestd initially.
v.Add("scope", "oidc email profile")
resp, err := http.PostForm(p.Endpoint().TokenURL, v)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusOK {
dump, err := httputil.DumpResponse(resp, true)
if err != nil {
panic(err)
}
return fmt.Errorf("unexpected response: %s", dump)
}
return nil
},
},
{
// This test ensures that the connector.RefreshConnector interface is being
// used when clients request a refresh token.
name: "refresh with identity changes",
handleToken: func(ctx context.Context, p *oidc.Provider, config *oauth2.Config, token *oauth2.Token) error {
// have to use time.Now because the OAuth2 package uses it.
token.Expiry = time.Now().Add(time.Second * -10)
if token.Valid() {
return errors.New("token shouldn't be valid")
}
ident := connector.Identity{
UserID: "fooid",
Username: "foo",
Email: "foo@bar.com",
EmailVerified: true,
Groups: []string{"foo", "bar"},
}
conn.Identity = ident
type claims struct {
Username string `json:"name"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
Groups []string `json:"groups"`
}
want := claims{ident.Username, ident.Email, ident.EmailVerified, ident.Groups}
newToken, err := config.TokenSource(ctx, token).Token()
if err != nil {
return fmt.Errorf("failed to refresh token: %v", err)
}
rawIDToken, ok := newToken.Extra("id_token").(string)
if !ok {
return fmt.Errorf("no id_token in refreshed token")
}
idToken, err := p.Verifier(oidcConfig).Verify(ctx, rawIDToken)
if err != nil {
return fmt.Errorf("failed to verify id token: %v", err)
}
var got claims
if err := idToken.Claims(&got); err != nil {
return fmt.Errorf("failed to unmarshal claims: %v", err)
}
if diff := pretty.Compare(want, got); diff != "" {
return fmt.Errorf("got identity != want identity: %s", diff)
}
return nil
},
},
}
for _, tc := range tests {
func() { func() {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
@ -540,7 +563,7 @@ func TestOAuth2CodeFlow(t *testing.T) {
t.Errorf("failed to exchange code for token: %v", err) t.Errorf("failed to exchange code for token: %v", err)
return return
} }
err = tc.handleToken(ctx, p, oauth2Config, token) err = tc.handleToken(ctx, p, oauth2Config, token, conn)
if err != nil { if err != nil {
t.Errorf("%s: %v", tc.name, err) t.Errorf("%s: %v", tc.name, err)
} }
@ -1253,3 +1276,157 @@ func TestRefreshTokenFlow(t *testing.T) {
t.Errorf("Token refreshed with invalid refresh token, error expected.") t.Errorf("Token refreshed with invalid refresh token, error expected.")
} }
} }
// TestOAuth2DeviceFlow runs device flow integration tests against a test server
func TestOAuth2DeviceFlow(t *testing.T) {
clientID := "testclient"
clientSecret := ""
requestedScopes := []string{oidc.ScopeOpenID, "email", "profile", "groups", "offline_access"}
t0 := time.Now()
// Always have the time function used by the server return the same time so
// we can predict expected values of "expires_in" fields exactly.
now := func() time.Time { return t0 }
// Connector used by the tests.
var conn *mock.Callback
idTokensValidFor := time.Second * 30
for _, tc := range makeOAuth2Tests(clientID, clientSecret, now).tests {
func() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Setup a dex server.
httpServer, s := newTestServer(ctx, t, func(c *Config) {
c.Issuer = c.Issuer + "/non-root-path"
c.Now = now
c.IDTokensValidFor = idTokensValidFor
})
defer httpServer.Close()
mockConn := s.connectors["mock"]
conn = mockConn.Connector.(*mock.Callback)
p, err := oidc.NewProvider(ctx, httpServer.URL)
if err != nil {
t.Fatalf("failed to get provider: %v", err)
}
//Add the Clients to the test server
client := storage.Client{
ID: clientID,
RedirectURIs: []string{deviceCallbackURI},
Public: true,
}
if err := s.storage.CreateClient(client); err != nil {
t.Fatalf("failed to create client: %v", err)
}
//Grab the issuer that we'll reuse for the different endpoints to hit
issuer, err := url.Parse(s.issuerURL.String())
if err != nil {
t.Errorf("Could not parse issuer URL %v", err)
}
//Send a new Device Request
codeURL, _ := url.Parse(issuer.String())
codeURL.Path = path.Join(codeURL.Path, "device/code")
data := url.Values{}
data.Set("client_id", clientID)
data.Add("scope", strings.Join(requestedScopes, " "))
resp, err := http.PostForm(codeURL.String(), data)
if err != nil {
t.Errorf("Could not request device code: %v", err)
}
defer resp.Body.Close()
responseBody, err := ioutil.ReadAll(resp.Body)
if err != nil {
t.Errorf("Could read device code response %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Errorf("%v - Unexpected Response Type. Expected 200 got %v. Response: %v", tc.name, resp.StatusCode, string(responseBody))
}
//Parse the code response
var deviceCode deviceCodeResponse
if err := json.Unmarshal(responseBody, &deviceCode); err != nil {
t.Errorf("Unexpected Device Code Response Format %v", string(responseBody))
}
//Mock the user hitting the verification URI and posting the form
verifyURL, _ := url.Parse(issuer.String())
verifyURL.Path = path.Join(verifyURL.Path, "/device/auth/verify_code")
urlData := url.Values{}
urlData.Set("user_code", deviceCode.UserCode)
resp, err = http.PostForm(verifyURL.String(), urlData)
if err != nil {
t.Errorf("Error Posting Form: %v", err)
}
defer resp.Body.Close()
responseBody, err = ioutil.ReadAll(resp.Body)
if err != nil {
t.Errorf("Could read verification response %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Errorf("%v - Unexpected Response Type. Expected 200 got %v. Response: %v", tc.name, resp.StatusCode, string(responseBody))
}
//Hit the Token Endpoint, and try and get an access token
tokenURL, _ := url.Parse(issuer.String())
tokenURL.Path = path.Join(tokenURL.Path, "/device/token")
v := url.Values{}
v.Add("grant_type", grantTypeDeviceCode)
v.Add("device_code", deviceCode.DeviceCode)
resp, err = http.PostForm(tokenURL.String(), v)
if err != nil {
t.Errorf("Could not request device token: %v", err)
}
defer resp.Body.Close()
responseBody, err = ioutil.ReadAll(resp.Body)
if err != nil {
t.Errorf("Could read device token response %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Errorf("%v - Unexpected Token Response Type. Expected 200 got %v. Response: %v", tc.name, resp.StatusCode, string(responseBody))
}
//Parse the response
var tokenRes accessTokenReponse
if err := json.Unmarshal(responseBody, &tokenRes); err != nil {
t.Errorf("Unexpected Device Access Token Response Format %v", string(responseBody))
}
token := &oauth2.Token{
AccessToken: tokenRes.AccessToken,
TokenType: tokenRes.TokenType,
RefreshToken: tokenRes.RefreshToken,
}
raw := make(map[string]interface{})
json.Unmarshal(responseBody, &raw) // no error checks for optional fields
token = token.WithExtra(raw)
if secs := tokenRes.ExpiresIn; secs > 0 {
token.Expiry = time.Now().Add(time.Duration(secs) * time.Second)
}
//Run token tests to validate info is correct
// Create the OAuth2 config.
oauth2Config := &oauth2.Config{
ClientID: client.ID,
ClientSecret: client.Secret,
Endpoint: p.Endpoint(),
Scopes: requestedScopes,
RedirectURL: deviceCallbackURI,
}
if len(tc.scopes) != 0 {
oauth2Config.Scopes = tc.scopes
}
err = tc.handleToken(ctx, p, oauth2Config, token, conn)
if err != nil {
t.Errorf("%s: %v", tc.name, err)
}
}()
}
}

View file

@ -15,11 +15,13 @@ import (
) )
const ( const (
tmplApproval = "approval.html" tmplApproval = "approval.html"
tmplLogin = "login.html" tmplLogin = "login.html"
tmplPassword = "password.html" tmplPassword = "password.html"
tmplOOB = "oob.html" tmplOOB = "oob.html"
tmplError = "error.html" tmplError = "error.html"
tmplDevice = "device.html"
tmplDeviceSuccess = "device_success.html"
) )
var requiredTmpls = []string{ var requiredTmpls = []string{
@ -28,14 +30,18 @@ var requiredTmpls = []string{
tmplPassword, tmplPassword,
tmplOOB, tmplOOB,
tmplError, tmplError,
tmplDevice,
tmplDeviceSuccess,
} }
type templates struct { type templates struct {
loginTmpl *template.Template loginTmpl *template.Template
approvalTmpl *template.Template approvalTmpl *template.Template
passwordTmpl *template.Template passwordTmpl *template.Template
oobTmpl *template.Template oobTmpl *template.Template
errorTmpl *template.Template errorTmpl *template.Template
deviceTmpl *template.Template
deviceSuccessTmpl *template.Template
} }
type webConfig struct { type webConfig struct {
@ -152,11 +158,13 @@ func loadTemplates(c webConfig, templatesDir string) (*templates, error) {
return nil, fmt.Errorf("missing template(s): %s", missingTmpls) return nil, fmt.Errorf("missing template(s): %s", missingTmpls)
} }
return &templates{ return &templates{
loginTmpl: tmpls.Lookup(tmplLogin), loginTmpl: tmpls.Lookup(tmplLogin),
approvalTmpl: tmpls.Lookup(tmplApproval), approvalTmpl: tmpls.Lookup(tmplApproval),
passwordTmpl: tmpls.Lookup(tmplPassword), passwordTmpl: tmpls.Lookup(tmplPassword),
oobTmpl: tmpls.Lookup(tmplOOB), oobTmpl: tmpls.Lookup(tmplOOB),
errorTmpl: tmpls.Lookup(tmplError), errorTmpl: tmpls.Lookup(tmplError),
deviceTmpl: tmpls.Lookup(tmplDevice),
deviceSuccessTmpl: tmpls.Lookup(tmplDeviceSuccess),
}, nil }, nil
} }
@ -242,6 +250,27 @@ func (n byName) Len() int { return len(n) }
func (n byName) Less(i, j int) bool { return n[i].Name < n[j].Name } func (n byName) Less(i, j int) bool { return n[i].Name < n[j].Name }
func (n byName) Swap(i, j int) { n[i], n[j] = n[j], n[i] } func (n byName) Swap(i, j int) { n[i], n[j] = n[j], n[i] }
func (t *templates) device(r *http.Request, w http.ResponseWriter, postURL string, userCode string, lastWasInvalid bool) error {
if lastWasInvalid {
w.WriteHeader(http.StatusBadRequest)
}
data := struct {
PostURL string
UserCode string
Invalid bool
ReqPath string
}{postURL, userCode, lastWasInvalid, r.URL.Path}
return renderTemplate(w, t.deviceTmpl, data)
}
func (t *templates) deviceSuccess(r *http.Request, w http.ResponseWriter, clientName string) error {
data := struct {
ClientName string
ReqPath string
}{clientName, r.URL.Path}
return renderTemplate(w, t.deviceSuccessTmpl, data)
}
func (t *templates) login(r *http.Request, w http.ResponseWriter, connectors []connectorInfo, reqPath string) error { func (t *templates) login(r *http.Request, w http.ResponseWriter, connectors []connectorInfo, reqPath string) error {
sort.Sort(byName(connectors)) sort.Sort(byName(connectors))
data := struct { data := struct {

View file

@ -49,6 +49,8 @@ func RunTests(t *testing.T, newStorage func() storage.Storage) {
{"ConnectorCRUD", testConnectorCRUD}, {"ConnectorCRUD", testConnectorCRUD},
{"GarbageCollection", testGC}, {"GarbageCollection", testGC},
{"TimezoneSupport", testTimezones}, {"TimezoneSupport", testTimezones},
{"DeviceRequestCRUD", testDeviceRequestCRUD},
{"DeviceTokenCRUD", testDeviceTokenCRUD},
}) })
} }
@ -834,6 +836,87 @@ func testGC(t *testing.T, s storage.Storage) {
} else if err != storage.ErrNotFound { } else if err != storage.ErrNotFound {
t.Errorf("expected storage.ErrNotFound, got %v", err) t.Errorf("expected storage.ErrNotFound, got %v", err)
} }
userCode, err := storage.NewUserCode()
if err != nil {
t.Errorf("Unexpected Error: %v", err)
}
d := storage.DeviceRequest{
UserCode: userCode,
DeviceCode: storage.NewID(),
ClientID: "client1",
ClientSecret: "secret1",
Scopes: []string{"openid", "email"},
Expiry: expiry,
}
if err := s.CreateDeviceRequest(d); err != nil {
t.Fatalf("failed creating device request: %v", err)
}
for _, tz := range []*time.Location{time.UTC, est, pst} {
result, err := s.GarbageCollect(expiry.Add(-time.Hour).In(tz))
if err != nil {
t.Errorf("garbage collection failed: %v", err)
} else {
if result.DeviceRequests != 0 {
t.Errorf("expected no device garbage collection results, got %#v", result)
}
}
if _, err := s.GetDeviceRequest(d.UserCode); err != nil {
t.Errorf("expected to be able to get auth request after GC: %v", err)
}
}
if r, err := s.GarbageCollect(expiry.Add(time.Hour)); err != nil {
t.Errorf("garbage collection failed: %v", err)
} else if r.DeviceRequests != 1 {
t.Errorf("expected to garbage collect 1 device request, got %d", r.DeviceRequests)
}
if _, err := s.GetDeviceRequest(d.UserCode); err == nil {
t.Errorf("expected device request to be GC'd")
} else if err != storage.ErrNotFound {
t.Errorf("expected storage.ErrNotFound, got %v", err)
}
dt := storage.DeviceToken{
DeviceCode: storage.NewID(),
Status: "pending",
Token: "foo",
Expiry: expiry,
LastRequestTime: time.Now(),
PollIntervalSeconds: 0,
}
if err := s.CreateDeviceToken(dt); err != nil {
t.Fatalf("failed creating device token: %v", err)
}
for _, tz := range []*time.Location{time.UTC, est, pst} {
result, err := s.GarbageCollect(expiry.Add(-time.Hour).In(tz))
if err != nil {
t.Errorf("garbage collection failed: %v", err)
} else {
if result.DeviceTokens != 0 {
t.Errorf("expected no device token garbage collection results, got %#v", result)
}
}
if _, err := s.GetDeviceToken(dt.DeviceCode); err != nil {
t.Errorf("expected to be able to get device token after GC: %v", err)
}
}
if r, err := s.GarbageCollect(expiry.Add(time.Hour)); err != nil {
t.Errorf("garbage collection failed: %v", err)
} else if r.DeviceTokens != 1 {
t.Errorf("expected to garbage collect 1 device token, got %d", r.DeviceTokens)
}
if _, err := s.GetDeviceToken(dt.DeviceCode); err == nil {
t.Errorf("expected device token to be GC'd")
} else if err != storage.ErrNotFound {
t.Errorf("expected storage.ErrNotFound, got %v", err)
}
} }
// testTimezones tests that backends either fully support timezones or // testTimezones tests that backends either fully support timezones or
@ -881,3 +964,72 @@ func testTimezones(t *testing.T, s storage.Storage) {
t.Fatalf("expected expiry %v got %v", wantTime, gotTime) t.Fatalf("expected expiry %v got %v", wantTime, gotTime)
} }
} }
func testDeviceRequestCRUD(t *testing.T, s storage.Storage) {
userCode, err := storage.NewUserCode()
if err != nil {
panic(err)
}
d1 := storage.DeviceRequest{
UserCode: userCode,
DeviceCode: storage.NewID(),
ClientID: "client1",
ClientSecret: "secret1",
Scopes: []string{"openid", "email"},
Expiry: neverExpire,
}
if err := s.CreateDeviceRequest(d1); err != nil {
t.Fatalf("failed creating device request: %v", err)
}
// Attempt to create same DeviceRequest twice.
err = s.CreateDeviceRequest(d1)
mustBeErrAlreadyExists(t, "device request", err)
//No manual deletes for device requests, will be handled by garbage collection routines
//see testGC
}
func testDeviceTokenCRUD(t *testing.T, s storage.Storage) {
//Create a Token
d1 := storage.DeviceToken{
DeviceCode: storage.NewID(),
Status: "pending",
Token: storage.NewID(),
Expiry: neverExpire,
LastRequestTime: time.Now(),
PollIntervalSeconds: 0,
}
if err := s.CreateDeviceToken(d1); err != nil {
t.Fatalf("failed creating device token: %v", err)
}
// Attempt to create same Device Token twice.
err := s.CreateDeviceToken(d1)
mustBeErrAlreadyExists(t, "device token", err)
//Update the device token, simulate a redemption
if err := s.UpdateDeviceToken(d1.DeviceCode, func(old storage.DeviceToken) (storage.DeviceToken, error) {
old.Token = "token data"
old.Status = "complete"
return old, nil
}); err != nil {
t.Fatalf("failed to update device token: %v", err)
}
//Retrieve the device token
got, err := s.GetDeviceToken(d1.DeviceCode)
if err != nil {
t.Fatalf("failed to get device token: %v", err)
}
//Validate expected result set
if got.Status != "complete" {
t.Fatalf("update failed, wanted token status=%v got %v", "complete", got.Status)
}
if got.Token != "token data" {
t.Fatalf("update failed, wanted token %v got %v", "token data", got.Token)
}
}

View file

@ -22,6 +22,8 @@ const (
offlineSessionPrefix = "offline_session/" offlineSessionPrefix = "offline_session/"
connectorPrefix = "connector/" connectorPrefix = "connector/"
keysName = "openid-connect-keys" keysName = "openid-connect-keys"
deviceRequestPrefix = "device_req/"
deviceTokenPrefix = "device_token/"
// defaultStorageTimeout will be applied to all storage's operations. // defaultStorageTimeout will be applied to all storage's operations.
defaultStorageTimeout = 5 * time.Second defaultStorageTimeout = 5 * time.Second
@ -72,6 +74,36 @@ func (c *conn) GarbageCollect(now time.Time) (result storage.GCResult, err error
result.AuthCodes++ result.AuthCodes++
} }
} }
deviceRequests, err := c.listDeviceRequests(ctx)
if err != nil {
return result, err
}
for _, deviceRequest := range deviceRequests {
if now.After(deviceRequest.Expiry) {
if err := c.deleteKey(ctx, keyID(deviceRequestPrefix, deviceRequest.UserCode)); err != nil {
c.logger.Errorf("failed to delete device request %v", err)
delErr = fmt.Errorf("failed to delete device request: %v", err)
}
result.DeviceRequests++
}
}
deviceTokens, err := c.listDeviceTokens(ctx)
if err != nil {
return result, err
}
for _, deviceToken := range deviceTokens {
if now.After(deviceToken.Expiry) {
if err := c.deleteKey(ctx, keyID(deviceTokenPrefix, deviceToken.DeviceCode)); err != nil {
c.logger.Errorf("failed to delete device token %v", err)
delErr = fmt.Errorf("failed to delete device token: %v", err)
}
result.DeviceTokens++
}
}
return result, delErr return result, delErr
} }
@ -531,3 +563,77 @@ func keyEmail(prefix, email string) string { return prefix + strings.ToLower(ema
func keySession(prefix, userID, connID string) string { func keySession(prefix, userID, connID string) string {
return prefix + strings.ToLower(userID+"|"+connID) return prefix + strings.ToLower(userID+"|"+connID)
} }
func (c *conn) CreateDeviceRequest(d storage.DeviceRequest) error {
ctx, cancel := context.WithTimeout(context.Background(), defaultStorageTimeout)
defer cancel()
return c.txnCreate(ctx, keyID(deviceRequestPrefix, d.UserCode), fromStorageDeviceRequest(d))
}
func (c *conn) GetDeviceRequest(userCode string) (r storage.DeviceRequest, err error) {
ctx, cancel := context.WithTimeout(context.Background(), defaultStorageTimeout)
defer cancel()
err = c.getKey(ctx, keyID(deviceRequestPrefix, userCode), &r)
return r, err
}
func (c *conn) listDeviceRequests(ctx context.Context) (requests []DeviceRequest, err error) {
res, err := c.db.Get(ctx, deviceRequestPrefix, clientv3.WithPrefix())
if err != nil {
return requests, err
}
for _, v := range res.Kvs {
var r DeviceRequest
if err = json.Unmarshal(v.Value, &r); err != nil {
return requests, err
}
requests = append(requests, r)
}
return requests, nil
}
func (c *conn) CreateDeviceToken(t storage.DeviceToken) error {
ctx, cancel := context.WithTimeout(context.Background(), defaultStorageTimeout)
defer cancel()
return c.txnCreate(ctx, keyID(deviceTokenPrefix, t.DeviceCode), fromStorageDeviceToken(t))
}
func (c *conn) GetDeviceToken(deviceCode string) (t storage.DeviceToken, err error) {
ctx, cancel := context.WithTimeout(context.Background(), defaultStorageTimeout)
defer cancel()
err = c.getKey(ctx, keyID(deviceTokenPrefix, deviceCode), &t)
return t, err
}
func (c *conn) listDeviceTokens(ctx context.Context) (deviceTokens []DeviceToken, err error) {
res, err := c.db.Get(ctx, deviceTokenPrefix, clientv3.WithPrefix())
if err != nil {
return deviceTokens, err
}
for _, v := range res.Kvs {
var dt DeviceToken
if err = json.Unmarshal(v.Value, &dt); err != nil {
return deviceTokens, err
}
deviceTokens = append(deviceTokens, dt)
}
return deviceTokens, nil
}
func (c *conn) UpdateDeviceToken(deviceCode string, updater func(old storage.DeviceToken) (storage.DeviceToken, error)) error {
ctx, cancel := context.WithTimeout(context.Background(), defaultStorageTimeout)
defer cancel()
return c.txnUpdate(ctx, keyID(deviceTokenPrefix, deviceCode), func(currentValue []byte) ([]byte, error) {
var current DeviceToken
if len(currentValue) > 0 {
if err := json.Unmarshal(currentValue, &current); err != nil {
return nil, err
}
}
updated, err := updater(toStorageDeviceToken(current))
if err != nil {
return nil, err
}
return json.Marshal(fromStorageDeviceToken(updated))
})
}

View file

@ -44,6 +44,8 @@ func cleanDB(c *conn) error {
passwordPrefix, passwordPrefix,
offlineSessionPrefix, offlineSessionPrefix,
connectorPrefix, connectorPrefix,
deviceRequestPrefix,
deviceTokenPrefix,
} { } {
_, err := c.db.Delete(ctx, prefix, clientv3.WithPrefix()) _, err := c.db.Delete(ctx, prefix, clientv3.WithPrefix())
if err != nil { if err != nil {

View file

@ -216,3 +216,56 @@ func toStorageOfflineSessions(o OfflineSessions) storage.OfflineSessions {
} }
return s return s
} }
// DeviceRequest is a mirrored struct from storage with JSON struct tags
type DeviceRequest struct {
UserCode string `json:"user_code"`
DeviceCode string `json:"device_code"`
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
Scopes []string `json:"scopes"`
Expiry time.Time `json:"expiry"`
}
func fromStorageDeviceRequest(d storage.DeviceRequest) DeviceRequest {
return DeviceRequest{
UserCode: d.UserCode,
DeviceCode: d.DeviceCode,
ClientID: d.ClientID,
ClientSecret: d.ClientSecret,
Scopes: d.Scopes,
Expiry: d.Expiry,
}
}
// DeviceToken is a mirrored struct from storage with JSON struct tags
type DeviceToken struct {
DeviceCode string `json:"device_code"`
Status string `json:"status"`
Token string `json:"token"`
Expiry time.Time `json:"expiry"`
LastRequestTime time.Time `json:"last_request"`
PollIntervalSeconds int `json:"poll_interval"`
}
func fromStorageDeviceToken(t storage.DeviceToken) DeviceToken {
return DeviceToken{
DeviceCode: t.DeviceCode,
Status: t.Status,
Token: t.Token,
Expiry: t.Expiry,
LastRequestTime: t.LastRequestTime,
PollIntervalSeconds: t.PollIntervalSeconds,
}
}
func toStorageDeviceToken(t DeviceToken) storage.DeviceToken {
return storage.DeviceToken{
DeviceCode: t.DeviceCode,
Status: t.Status,
Token: t.Token,
Expiry: t.Expiry,
LastRequestTime: t.LastRequestTime,
PollIntervalSeconds: t.PollIntervalSeconds,
}
}

View file

@ -21,6 +21,8 @@ const (
kindPassword = "Password" kindPassword = "Password"
kindOfflineSessions = "OfflineSessions" kindOfflineSessions = "OfflineSessions"
kindConnector = "Connector" kindConnector = "Connector"
kindDeviceRequest = "DeviceRequest"
kindDeviceToken = "DeviceToken"
) )
const ( const (
@ -32,6 +34,8 @@ const (
resourcePassword = "passwords" resourcePassword = "passwords"
resourceOfflineSessions = "offlinesessionses" // Again attempts to pluralize. resourceOfflineSessions = "offlinesessionses" // Again attempts to pluralize.
resourceConnector = "connectors" resourceConnector = "connectors"
resourceDeviceRequest = "devicerequests"
resourceDeviceToken = "devicetokens"
) )
// Config values for the Kubernetes storage type. // Config values for the Kubernetes storage type.
@ -593,5 +597,84 @@ func (cli *client) GarbageCollect(now time.Time) (result storage.GCResult, err e
result.AuthCodes++ result.AuthCodes++
} }
} }
var deviceRequests DeviceRequestList
if err := cli.list(resourceDeviceRequest, &deviceRequests); err != nil {
return result, fmt.Errorf("failed to list device requests: %v", err)
}
for _, deviceRequest := range deviceRequests.DeviceRequests {
if now.After(deviceRequest.Expiry) {
if err := cli.delete(resourceDeviceRequest, deviceRequest.ObjectMeta.Name); err != nil {
cli.logger.Errorf("failed to delete device request: %v", err)
delErr = fmt.Errorf("failed to delete device request: %v", err)
}
result.DeviceRequests++
}
}
var deviceTokens DeviceTokenList
if err := cli.list(resourceDeviceToken, &deviceTokens); err != nil {
return result, fmt.Errorf("failed to list device tokens: %v", err)
}
for _, deviceToken := range deviceTokens.DeviceTokens {
if now.After(deviceToken.Expiry) {
if err := cli.delete(resourceDeviceToken, deviceToken.ObjectMeta.Name); err != nil {
cli.logger.Errorf("failed to delete device token: %v", err)
delErr = fmt.Errorf("failed to delete device token: %v", err)
}
result.DeviceTokens++
}
}
if delErr != nil {
return result, delErr
}
return result, delErr return result, delErr
} }
func (cli *client) CreateDeviceRequest(d storage.DeviceRequest) error {
return cli.post(resourceDeviceRequest, cli.fromStorageDeviceRequest(d))
}
func (cli *client) GetDeviceRequest(userCode string) (storage.DeviceRequest, error) {
var req DeviceRequest
if err := cli.get(resourceDeviceRequest, strings.ToLower(userCode), &req); err != nil {
return storage.DeviceRequest{}, err
}
return toStorageDeviceRequest(req), nil
}
func (cli *client) CreateDeviceToken(t storage.DeviceToken) error {
return cli.post(resourceDeviceToken, cli.fromStorageDeviceToken(t))
}
func (cli *client) GetDeviceToken(deviceCode string) (storage.DeviceToken, error) {
var token DeviceToken
if err := cli.get(resourceDeviceToken, deviceCode, &token); err != nil {
return storage.DeviceToken{}, err
}
return toStorageDeviceToken(token), nil
}
func (cli *client) getDeviceToken(deviceCode string) (t DeviceToken, err error) {
err = cli.get(resourceDeviceToken, deviceCode, &t)
return
}
func (cli *client) UpdateDeviceToken(deviceCode string, updater func(old storage.DeviceToken) (storage.DeviceToken, error)) error {
r, err := cli.getDeviceToken(deviceCode)
if err != nil {
return err
}
updated, err := updater(toStorageDeviceToken(r))
if err != nil {
return err
}
updated.DeviceCode = deviceCode
newToken := cli.fromStorageDeviceToken(updated)
newToken.ObjectMeta = r.ObjectMeta
return cli.put(resourceDeviceToken, r.ObjectMeta.Name, newToken)
}

View file

@ -85,6 +85,8 @@ func (s *StorageTestSuite) TestStorage() {
for _, resource := range []string{ for _, resource := range []string{
resourceAuthCode, resourceAuthCode,
resourceAuthRequest, resourceAuthRequest,
resourceDeviceRequest,
resourceDeviceToken,
resourceClient, resourceClient,
resourceRefreshToken, resourceRefreshToken,
resourceKeys, resourceKeys,

View file

@ -143,6 +143,36 @@ var customResourceDefinitions = []k8sapi.CustomResourceDefinition{
}, },
}, },
}, },
{
ObjectMeta: k8sapi.ObjectMeta{
Name: "devicerequests.dex.coreos.com",
},
TypeMeta: crdMeta,
Spec: k8sapi.CustomResourceDefinitionSpec{
Group: apiGroup,
Version: "v1",
Names: k8sapi.CustomResourceDefinitionNames{
Plural: "devicerequests",
Singular: "devicerequest",
Kind: "DeviceRequest",
},
},
},
{
ObjectMeta: k8sapi.ObjectMeta{
Name: "devicetokens.dex.coreos.com",
},
TypeMeta: crdMeta,
Spec: k8sapi.CustomResourceDefinitionSpec{
Group: apiGroup,
Version: "v1",
Names: k8sapi.CustomResourceDefinitionNames{
Plural: "devicetokens",
Singular: "devicetoken",
Kind: "DeviceToken",
},
},
},
} }
// There will only ever be a single keys resource. Maintain this by setting a // There will only ever be a single keys resource. Maintain this by setting a
@ -635,3 +665,103 @@ type ConnectorList struct {
k8sapi.ListMeta `json:"metadata,omitempty"` k8sapi.ListMeta `json:"metadata,omitempty"`
Connectors []Connector `json:"items"` Connectors []Connector `json:"items"`
} }
// DeviceRequest is a mirrored struct from storage with JSON struct tags and
// Kubernetes type metadata.
type DeviceRequest struct {
k8sapi.TypeMeta `json:",inline"`
k8sapi.ObjectMeta `json:"metadata,omitempty"`
DeviceCode string `json:"device_code,omitempty"`
ClientID string `json:"client_id,omitempty"`
ClientSecret string `json:"client_secret,omitempty"`
Scopes []string `json:"scopes,omitempty"`
Expiry time.Time `json:"expiry"`
}
// AuthRequestList is a list of AuthRequests.
type DeviceRequestList struct {
k8sapi.TypeMeta `json:",inline"`
k8sapi.ListMeta `json:"metadata,omitempty"`
DeviceRequests []DeviceRequest `json:"items"`
}
func (cli *client) fromStorageDeviceRequest(a storage.DeviceRequest) DeviceRequest {
req := DeviceRequest{
TypeMeta: k8sapi.TypeMeta{
Kind: kindDeviceRequest,
APIVersion: cli.apiVersion,
},
ObjectMeta: k8sapi.ObjectMeta{
Name: strings.ToLower(a.UserCode),
Namespace: cli.namespace,
},
DeviceCode: a.DeviceCode,
ClientID: a.ClientID,
ClientSecret: a.ClientSecret,
Scopes: a.Scopes,
Expiry: a.Expiry,
}
return req
}
func toStorageDeviceRequest(req DeviceRequest) storage.DeviceRequest {
return storage.DeviceRequest{
UserCode: strings.ToUpper(req.ObjectMeta.Name),
DeviceCode: req.DeviceCode,
ClientID: req.ClientID,
ClientSecret: req.ClientSecret,
Scopes: req.Scopes,
Expiry: req.Expiry,
}
}
// DeviceToken is a mirrored struct from storage with JSON struct tags and
// Kubernetes type metadata.
type DeviceToken struct {
k8sapi.TypeMeta `json:",inline"`
k8sapi.ObjectMeta `json:"metadata,omitempty"`
Status string `json:"status,omitempty"`
Token string `json:"token,omitempty"`
Expiry time.Time `json:"expiry"`
LastRequestTime time.Time `json:"last_request"`
PollIntervalSeconds int `json:"poll_interval"`
}
// DeviceTokenList is a list of DeviceTokens.
type DeviceTokenList struct {
k8sapi.TypeMeta `json:",inline"`
k8sapi.ListMeta `json:"metadata,omitempty"`
DeviceTokens []DeviceToken `json:"items"`
}
func (cli *client) fromStorageDeviceToken(t storage.DeviceToken) DeviceToken {
req := DeviceToken{
TypeMeta: k8sapi.TypeMeta{
Kind: kindDeviceToken,
APIVersion: cli.apiVersion,
},
ObjectMeta: k8sapi.ObjectMeta{
Name: t.DeviceCode,
Namespace: cli.namespace,
},
Status: t.Status,
Token: t.Token,
Expiry: t.Expiry,
LastRequestTime: t.LastRequestTime,
PollIntervalSeconds: t.PollIntervalSeconds,
}
return req
}
func toStorageDeviceToken(t DeviceToken) storage.DeviceToken {
return storage.DeviceToken{
DeviceCode: t.ObjectMeta.Name,
Status: t.Status,
Token: t.Token,
Expiry: t.Expiry,
LastRequestTime: t.LastRequestTime,
PollIntervalSeconds: t.PollIntervalSeconds,
}
}

View file

@ -20,6 +20,8 @@ func New(logger log.Logger) storage.Storage {
passwords: make(map[string]storage.Password), passwords: make(map[string]storage.Password),
offlineSessions: make(map[offlineSessionID]storage.OfflineSessions), offlineSessions: make(map[offlineSessionID]storage.OfflineSessions),
connectors: make(map[string]storage.Connector), connectors: make(map[string]storage.Connector),
deviceRequests: make(map[string]storage.DeviceRequest),
deviceTokens: make(map[string]storage.DeviceToken),
logger: logger, logger: logger,
} }
} }
@ -46,6 +48,8 @@ type memStorage struct {
passwords map[string]storage.Password passwords map[string]storage.Password
offlineSessions map[offlineSessionID]storage.OfflineSessions offlineSessions map[offlineSessionID]storage.OfflineSessions
connectors map[string]storage.Connector connectors map[string]storage.Connector
deviceRequests map[string]storage.DeviceRequest
deviceTokens map[string]storage.DeviceToken
keys storage.Keys keys storage.Keys
@ -79,6 +83,18 @@ func (s *memStorage) GarbageCollect(now time.Time) (result storage.GCResult, err
result.AuthRequests++ result.AuthRequests++
} }
} }
for id, a := range s.deviceRequests {
if now.After(a.Expiry) {
delete(s.deviceRequests, id)
result.DeviceRequests++
}
}
for id, a := range s.deviceTokens {
if now.After(a.Expiry) {
delete(s.deviceTokens, id)
result.DeviceTokens++
}
}
}) })
return result, nil return result, nil
} }
@ -465,3 +481,61 @@ func (s *memStorage) UpdateConnector(id string, updater func(c storage.Connector
}) })
return return
} }
func (s *memStorage) CreateDeviceRequest(d storage.DeviceRequest) (err error) {
s.tx(func() {
if _, ok := s.deviceRequests[d.UserCode]; ok {
err = storage.ErrAlreadyExists
} else {
s.deviceRequests[d.UserCode] = d
}
})
return
}
func (s *memStorage) GetDeviceRequest(userCode string) (req storage.DeviceRequest, err error) {
s.tx(func() {
var ok bool
if req, ok = s.deviceRequests[userCode]; !ok {
err = storage.ErrNotFound
return
}
})
return
}
func (s *memStorage) CreateDeviceToken(t storage.DeviceToken) (err error) {
s.tx(func() {
if _, ok := s.deviceTokens[t.DeviceCode]; ok {
err = storage.ErrAlreadyExists
} else {
s.deviceTokens[t.DeviceCode] = t
}
})
return
}
func (s *memStorage) GetDeviceToken(deviceCode string) (t storage.DeviceToken, err error) {
s.tx(func() {
var ok bool
if t, ok = s.deviceTokens[deviceCode]; !ok {
err = storage.ErrNotFound
return
}
})
return
}
func (s *memStorage) UpdateDeviceToken(deviceCode string, updater func(p storage.DeviceToken) (storage.DeviceToken, error)) (err error) {
s.tx(func() {
r, ok := s.deviceTokens[deviceCode]
if !ok {
err = storage.ErrNotFound
return
}
if r, err = updater(r); err == nil {
s.deviceTokens[deviceCode] = r
}
})
return
}

View file

@ -100,6 +100,23 @@ func (c *conn) GarbageCollect(now time.Time) (result storage.GCResult, err error
if n, err := r.RowsAffected(); err == nil { if n, err := r.RowsAffected(); err == nil {
result.AuthCodes = n result.AuthCodes = n
} }
r, err = c.Exec(`delete from device_request where expiry < $1`, now)
if err != nil {
return result, fmt.Errorf("gc device_request: %v", err)
}
if n, err := r.RowsAffected(); err == nil {
result.DeviceRequests = n
}
r, err = c.Exec(`delete from device_token where expiry < $1`, now)
if err != nil {
return result, fmt.Errorf("gc device_token: %v", err)
}
if n, err := r.RowsAffected(); err == nil {
result.DeviceTokens = n
}
return return
} }
@ -867,3 +884,113 @@ func (c *conn) delete(table, field, id string) error {
} }
return nil return nil
} }
func (c *conn) CreateDeviceRequest(d storage.DeviceRequest) error {
_, err := c.Exec(`
insert into device_request (
user_code, device_code, client_id, client_secret, scopes, expiry
)
values (
$1, $2, $3, $4, $5, $6
);`,
d.UserCode, d.DeviceCode, d.ClientID, d.ClientSecret, encoder(d.Scopes), d.Expiry,
)
if err != nil {
if c.alreadyExistsCheck(err) {
return storage.ErrAlreadyExists
}
return fmt.Errorf("insert device request: %v", err)
}
return nil
}
func (c *conn) CreateDeviceToken(t storage.DeviceToken) error {
_, err := c.Exec(`
insert into device_token (
device_code, status, token, expiry, last_request, poll_interval
)
values (
$1, $2, $3, $4, $5, $6
);`,
t.DeviceCode, t.Status, t.Token, t.Expiry, t.LastRequestTime, t.PollIntervalSeconds,
)
if err != nil {
if c.alreadyExistsCheck(err) {
return storage.ErrAlreadyExists
}
return fmt.Errorf("insert device token: %v", err)
}
return nil
}
func (c *conn) GetDeviceRequest(userCode string) (storage.DeviceRequest, error) {
return getDeviceRequest(c, userCode)
}
func getDeviceRequest(q querier, userCode string) (d storage.DeviceRequest, err error) {
err = q.QueryRow(`
select
device_code, client_id, client_secret, scopes, expiry
from device_request where user_code = $1;
`, userCode).Scan(
&d.DeviceCode, &d.ClientID, &d.ClientSecret, decoder(&d.Scopes), &d.Expiry,
)
if err != nil {
if err == sql.ErrNoRows {
return d, storage.ErrNotFound
}
return d, fmt.Errorf("select device token: %v", err)
}
d.UserCode = userCode
return d, nil
}
func (c *conn) GetDeviceToken(deviceCode string) (storage.DeviceToken, error) {
return getDeviceToken(c, deviceCode)
}
func getDeviceToken(q querier, deviceCode string) (a storage.DeviceToken, err error) {
err = q.QueryRow(`
select
status, token, expiry, last_request, poll_interval
from device_token where device_code = $1;
`, deviceCode).Scan(
&a.Status, &a.Token, &a.Expiry, &a.LastRequestTime, &a.PollIntervalSeconds,
)
if err != nil {
if err == sql.ErrNoRows {
return a, storage.ErrNotFound
}
return a, fmt.Errorf("select device token: %v", err)
}
a.DeviceCode = deviceCode
return a, nil
}
func (c *conn) UpdateDeviceToken(deviceCode string, updater func(old storage.DeviceToken) (storage.DeviceToken, error)) error {
return c.ExecTx(func(tx *trans) error {
r, err := getDeviceToken(tx, deviceCode)
if err != nil {
return err
}
if r, err = updater(r); err != nil {
return err
}
_, err = tx.Exec(`
update device_token
set
status = $1,
token = $2,
last_request = $3,
poll_interval = $4
where
device_code = $5
`,
r.Status, r.Token, r.LastRequestTime, r.PollIntervalSeconds, r.DeviceCode,
)
if err != nil {
return fmt.Errorf("update device token: %v", err)
}
return nil
})
}

View file

@ -229,4 +229,25 @@ var migrations = []migration{
}, },
flavor: &flavorMySQL, flavor: &flavorMySQL,
}, },
{
stmts: []string{`
create table device_request (
user_code text not null primary key,
device_code text not null,
client_id text not null,
client_secret text ,
scopes bytea not null, -- JSON array of strings
expiry timestamptz not null
);`,
`
create table device_token (
device_code text not null primary key,
status text not null,
token bytea,
expiry timestamptz not null,
last_request timestamptz not null,
poll_interval integer not null
);`,
},
},
} }

View file

@ -5,6 +5,7 @@ import (
"encoding/base32" "encoding/base32"
"errors" "errors"
"io" "io"
"math/big"
"strings" "strings"
"time" "time"
@ -24,9 +25,21 @@ var (
// TODO(ericchiang): refactor ID creation onto the storage. // TODO(ericchiang): refactor ID creation onto the storage.
var encoding = base32.NewEncoding("abcdefghijklmnopqrstuvwxyz234567") var encoding = base32.NewEncoding("abcdefghijklmnopqrstuvwxyz234567")
//Valid characters for user codes
const validUserCharacters = "BCDFGHJKLMNPQRSTVWXZ"
// NewDeviceCode returns a 32 char alphanumeric cryptographically secure string
func NewDeviceCode() string {
return newSecureID(32)
}
// NewID returns a random string which can be used as an ID for objects. // NewID returns a random string which can be used as an ID for objects.
func NewID() string { func NewID() string {
buff := make([]byte, 16) // 128 bit random ID. return newSecureID(16)
}
func newSecureID(len int) string {
buff := make([]byte, len) // random ID.
if _, err := io.ReadFull(rand.Reader, buff); err != nil { if _, err := io.ReadFull(rand.Reader, buff); err != nil {
panic(err) panic(err)
} }
@ -36,8 +49,10 @@ func NewID() string {
// GCResult returns the number of objects deleted by garbage collection. // GCResult returns the number of objects deleted by garbage collection.
type GCResult struct { type GCResult struct {
AuthRequests int64 AuthRequests int64
AuthCodes int64 AuthCodes int64
DeviceRequests int64
DeviceTokens int64
} }
// Storage is the storage interface used by the server. Implementations are // Storage is the storage interface used by the server. Implementations are
@ -54,6 +69,8 @@ type Storage interface {
CreatePassword(p Password) error CreatePassword(p Password) error
CreateOfflineSessions(s OfflineSessions) error CreateOfflineSessions(s OfflineSessions) error
CreateConnector(c Connector) error CreateConnector(c Connector) error
CreateDeviceRequest(d DeviceRequest) error
CreateDeviceToken(d DeviceToken) error
// TODO(ericchiang): return (T, bool, error) so we can indicate not found // TODO(ericchiang): return (T, bool, error) so we can indicate not found
// requests that way instead of using ErrNotFound. // requests that way instead of using ErrNotFound.
@ -65,6 +82,8 @@ type Storage interface {
GetPassword(email string) (Password, error) GetPassword(email string) (Password, error)
GetOfflineSessions(userID string, connID string) (OfflineSessions, error) GetOfflineSessions(userID string, connID string) (OfflineSessions, error)
GetConnector(id string) (Connector, error) GetConnector(id string) (Connector, error)
GetDeviceRequest(userCode string) (DeviceRequest, error)
GetDeviceToken(deviceCode string) (DeviceToken, error)
ListClients() ([]Client, error) ListClients() ([]Client, error)
ListRefreshTokens() ([]RefreshToken, error) ListRefreshTokens() ([]RefreshToken, error)
@ -101,8 +120,10 @@ type Storage interface {
UpdatePassword(email string, updater func(p Password) (Password, error)) error UpdatePassword(email string, updater func(p Password) (Password, error)) error
UpdateOfflineSessions(userID string, connID string, updater func(s OfflineSessions) (OfflineSessions, error)) error UpdateOfflineSessions(userID string, connID string, updater func(s OfflineSessions) (OfflineSessions, error)) error
UpdateConnector(id string, updater func(c Connector) (Connector, error)) error UpdateConnector(id string, updater func(c Connector) (Connector, error)) error
UpdateDeviceToken(deviceCode string, updater func(t DeviceToken) (DeviceToken, error)) error
// GarbageCollect deletes all expired AuthCodes and AuthRequests. // GarbageCollect deletes all expired AuthCodes,
// AuthRequests, DeviceRequests, and DeviceTokens.
GarbageCollect(now time.Time) (GCResult, error) GarbageCollect(now time.Time) (GCResult, error)
} }
@ -342,3 +363,49 @@ type Keys struct {
// For caching purposes, implementations MUST NOT update keys before this time. // For caching purposes, implementations MUST NOT update keys before this time.
NextRotation time.Time NextRotation time.Time
} }
// NewUserCode returns a randomized 8 character user code for the device flow.
// No vowels are included to prevent accidental generation of words
func NewUserCode() (string, error) {
code, err := randomString(8)
if err != nil {
return "", err
}
return code[:4] + "-" + code[4:], nil
}
func randomString(n int) (string, error) {
v := big.NewInt(int64(len(validUserCharacters)))
bytes := make([]byte, n)
for i := 0; i < n; i++ {
c, _ := rand.Int(rand.Reader, v)
bytes[i] = validUserCharacters[c.Int64()]
}
return string(bytes), nil
}
//DeviceRequest represents an OIDC device authorization request. It holds the state of a device request until the user
//authenticates using their user code or the expiry time passes.
type DeviceRequest struct {
//The code the user will enter in a browser
UserCode string
//The unique device code for device authentication
DeviceCode string
//The client ID the code is for
ClientID string
//The Client Secret
ClientSecret string
//The scopes the device requests
Scopes []string
//The expire time
Expiry time.Time
}
type DeviceToken struct {
DeviceCode string
Status string
Token string
Expiry time.Time
LastRequestTime time.Time
PollIntervalSeconds int
}

23
web/templates/device.html Normal file
View file

@ -0,0 +1,23 @@
{{ template "header.html" . }}
<div class="theme-panel">
<h2 class="theme-heading">Enter User Code</h2>
<form method="post" action="{{ .PostURL }}" method="get">
<div class="theme-form-row">
{{ if( .UserCode )}}
<input tabindex="2" required id="user_code" name="user_code" type="text" class="theme-form-input" autocomplete="off" value="{{.UserCode}}" {{ if .Invalid }} autofocus {{ end }}/>
{{ else }}
<input tabindex="2" required id="user_code" name="user_code" type="text" class="theme-form-input" placeholder="XXXX-XXXX" autocomplete="off" {{ if .Invalid }} autofocus {{ end }}/>
{{ end }}
</div>
{{ if .Invalid }}
<div id="login-error" class="dex-error-box">
Invalid or Expired User Code
</div>
{{ end }}
<button tabindex="3" id="submit-login" type="submit" class="dex-btn theme-btn--primary">Submit</button>
</form>
</div>
{{ template "footer.html" . }}

View file

@ -0,0 +1,8 @@
{{ template "header.html" . }}
<div class="theme-panel">
<h2 class="theme-heading">Login Successful for {{ .ClientName }}</h2>
<p>Return to your device to continue</p>
</div>
{{ template "footer.html" . }}