From 3a919335b53478aacaf23b259b4452c437ab7c9a Mon Sep 17 00:00:00 2001 From: Dave Dykstra <2129743+DrDaveD@users.noreply.github.com> Date: Thu, 2 Jul 2020 16:23:47 -0500 Subject: [PATCH 1/7] add oauth2device.go --- oauth2device.go | 166 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 oauth2device.go diff --git a/oauth2device.go b/oauth2device.go new file mode 100644 index 00000000..4b989475 --- /dev/null +++ b/oauth2device.go @@ -0,0 +1,166 @@ +// This is a small shim on golang's oauth2 library to add device flow. +// If the library adds its own support, this file can be eliminated. +// +// The below code was copied from +// https://raw.githubusercontent.com/rjw57/oauth2device/master/oauth2device.go +// on 16 June 2020. Documentation for the original code was available at +// https://godoc.org/github.com/rjw57/oauth2device +// The BSD license applied was this: +// +// Copyright (c) 2014, Rich Wareham rich.oauth2device@richwareham.com +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions +// are met: +// +// 1. Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// +// 2. Redistributions in binary form must reproduce the above +// copyright notice, this list of conditions and the following +// disclaimer in the documentation and/or other materials provided +// with the distribution. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +// HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +// INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +// BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS +// OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED +// AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +// LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY +// WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +// POSSIBILITY OF SUCH DAMAGE. + +package jwtauth + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "golang.org/x/oauth2" +) + +// A DeviceCode represents the user-visible code, verification URL and +// device-visible code used to allow for user authorisation of this app. The +// app should show UserCode and VerificationURL to the user. +type DeviceCode struct { + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + VerificationURL string `json:"verification_url"` + ExpiresIn int64 `json:"expires_in"` + Interval int64 `json:"interval"` +} + +// DeviceEndpoint contains the URLs required to initiate the OAuth2.0 flow for a +// provider's device flow. +type DeviceEndpoint struct { + CodeURL string +} + +// A version of oauth2.Config augmented with device endpoints +type Config struct { + *oauth2.Config + DeviceEndpoint DeviceEndpoint +} + +// A tokenOrError is either an OAuth2 Token response or an error indicating why +// such a response failed. +type tokenOrError struct { + *oauth2.Token + Error string `json:"error,omitempty"` +} + +var ( + // ErrAccessDenied is an error returned when the user has denied this + // app access to their account. + ErrAccessDenied = errors.New("access denied by user") +) + +const ( + deviceGrantType = "http://oauth.net/grant_type/device/1.0" +) + +// RequestDeviceCode will initiate the OAuth2 device authorization flow. It +// requests a device code and information on the code and URL to show to the +// user. Pass the returned DeviceCode to WaitForDeviceAuthorization. +func RequestDeviceCode(client *http.Client, config *Config) (*DeviceCode, error) { + scopes := strings.Join(config.Scopes, " ") + resp, err := client.PostForm(config.DeviceEndpoint.CodeURL, + url.Values{"client_id": {config.ClientID}, "scope": {scopes}}) + + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf( + "request for device code authorisation returned status %v (%v)", + resp.StatusCode, http.StatusText(resp.StatusCode)) + } + + // Unmarshal response + var dcr DeviceCode + dec := json.NewDecoder(resp.Body) + if err := dec.Decode(&dcr); err != nil { + return nil, err + } + + return &dcr, nil +} + +// WaitForDeviceAuthorization polls the token URL waiting for the user to +// authorize the app. Upon authorization, it returns the new token. If +// authorization fails then an error is returned. If that failure was due to a +// user explicitly denying access, the error is ErrAccessDenied. +func WaitForDeviceAuthorization(client *http.Client, config *Config, code *DeviceCode) (*oauth2.Token, error) { + for { + + resp, err := client.PostForm(config.Endpoint.TokenURL, + url.Values{ + "client_secret": {config.ClientSecret}, + "client_id": {config.ClientID}, + "code": {code.DeviceCode}, + "grant_type": {deviceGrantType}}) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP error %v (%v) when polling for OAuth token", + resp.StatusCode, http.StatusText(resp.StatusCode)) + } + + // Unmarshal response, checking for errors + var token tokenOrError + dec := json.NewDecoder(resp.Body) + if err := dec.Decode(&token); err != nil { + return nil, err + } + + switch token.Error { + case "": + + return token.Token, nil + case "authorization_pending": + + case "slow_down": + + code.Interval *= 2 + case "access_denied": + + return nil, ErrAccessDenied + default: + + return nil, fmt.Errorf("authorization failed: %v", token.Error) + } + + time.Sleep(time.Duration(code.Interval) * time.Second) + } +} From f60ce7bd0b4a1e43272ff1ddad8cd8bdd0044fd4 Mon Sep 17 00:00:00 2001 From: Dave Dykstra <2129743+DrDaveD@users.noreply.github.com> Date: Thu, 2 Jul 2020 16:24:00 -0500 Subject: [PATCH 2/7] update to RFC8628, and add extra data to returned token --- oauth2device.go | 63 +++++++++++++++++++++++++++++++++---------------- 1 file changed, 43 insertions(+), 20 deletions(-) diff --git a/oauth2device.go b/oauth2device.go index 4b989475..27f20626 100644 --- a/oauth2device.go +++ b/oauth2device.go @@ -3,7 +3,8 @@ // // The below code was copied from // https://raw.githubusercontent.com/rjw57/oauth2device/master/oauth2device.go -// on 16 June 2020. Documentation for the original code was available at +// on 16 June 2020 and updated according to the more recent RFC8628. +// Documentation for the original code was available at // https://godoc.org/github.com/rjw57/oauth2device // The BSD license applied was this: // @@ -41,6 +42,7 @@ import ( "encoding/json" "errors" "fmt" + "io/ioutil" "net/http" "net/url" "strings" @@ -49,15 +51,21 @@ import ( "golang.org/x/oauth2" ) -// A DeviceCode represents the user-visible code, verification URL and -// device-visible code used to allow for user authorisation of this app. The -// app should show UserCode and VerificationURL to the user. +// A DeviceCode represents the user-visible code, verification URI and +// device-visible code used to allow for user authorisation of this app. +// The VerificationURIComplete is optional and combines the user code +// and verification URI. If present, apps may choose to show to +// the user the VerificationURIComplete, otherwise the app should show +// the UserCode and VerificationURL to the user. ExpiresIn is how many +// seconds the user has to respond, and the optional Interval is how many +// seconds the app should wait in between polls (default 5). type DeviceCode struct { - DeviceCode string `json:"device_code"` - UserCode string `json:"user_code"` - VerificationURL string `json:"verification_url"` - ExpiresIn int64 `json:"expires_in"` - Interval int64 `json:"interval"` + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + VerificationURI string `json:"verification_uri"` + VerificationURIComplete string `json:"verification_uri_complete"` + ExpiresIn int64 `json:"expires_in"` + Interval int64 `json:"interval"` } // DeviceEndpoint contains the URLs required to initiate the OAuth2.0 flow for a @@ -67,7 +75,7 @@ type DeviceEndpoint struct { } // A version of oauth2.Config augmented with device endpoints -type Config struct { +type DeviceConfig struct { *oauth2.Config DeviceEndpoint DeviceEndpoint } @@ -86,13 +94,13 @@ var ( ) const ( - deviceGrantType = "http://oauth.net/grant_type/device/1.0" + deviceGrantType = "urn:ietf:params:oauth:grant-type:device_code" ) // RequestDeviceCode will initiate the OAuth2 device authorization flow. It // requests a device code and information on the code and URL to show to the // user. Pass the returned DeviceCode to WaitForDeviceAuthorization. -func RequestDeviceCode(client *http.Client, config *Config) (*DeviceCode, error) { +func RequestDeviceCode(client *http.Client, config *DeviceConfig) (*DeviceCode, error) { scopes := strings.Join(config.Scopes, " ") resp, err := client.PostForm(config.DeviceEndpoint.CodeURL, url.Values{"client_id": {config.ClientID}, "scope": {scopes}}) @@ -113,6 +121,10 @@ func RequestDeviceCode(client *http.Client, config *Config) (*DeviceCode, error) return nil, err } + if dcr.Interval == 0 { + dcr.Interval = 5 + } + return &dcr, nil } @@ -120,34 +132,45 @@ func RequestDeviceCode(client *http.Client, config *Config) (*DeviceCode, error) // authorize the app. Upon authorization, it returns the new token. If // authorization fails then an error is returned. If that failure was due to a // user explicitly denying access, the error is ErrAccessDenied. -func WaitForDeviceAuthorization(client *http.Client, config *Config, code *DeviceCode) (*oauth2.Token, error) { +func WaitForDeviceAuthorization(client *http.Client, config *DeviceConfig, code *DeviceCode) (*oauth2.Token, error) { for { resp, err := client.PostForm(config.Endpoint.TokenURL, url.Values{ "client_secret": {config.ClientSecret}, "client_id": {config.ClientID}, - "code": {code.DeviceCode}, + "device_code": {code.DeviceCode}, "grant_type": {deviceGrantType}}) if err != nil { - return nil, err + return nil, fmt.Errorf("post error while polling for OAuth token: %v", err) } - if resp.StatusCode != http.StatusOK { + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusBadRequest { return nil, fmt.Errorf("HTTP error %v (%v) when polling for OAuth token", resp.StatusCode, http.StatusText(resp.StatusCode)) } + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("error reading response body while polling for OAuth token: %v", err) + } + // Unmarshal response, checking for errors var token tokenOrError - dec := json.NewDecoder(resp.Body) - if err := dec.Decode(&token); err != nil { - return nil, err + if err := json.Unmarshal(body, &token); err != nil { + return nil, fmt.Errorf("error decoding response body while polling for OAuth token: %v", err) } + switch token.Error { case "": - return token.Token, nil + extra := make(map[string]interface{}) + err := json.Unmarshal(body, &extra) + if err != nil { + // already been unmarshalled once, unlikely + return nil, err + } + return token.Token.WithExtra(extra), nil case "authorization_pending": case "slow_down": From f1fd6be0ae2e7fbcb2161d48c2b06ccbc4a0875b Mon Sep 17 00:00:00 2001 From: Dave Dykstra <2129743+DrDaveD@users.noreply.github.com> Date: Thu, 2 Jul 2020 16:25:04 -0500 Subject: [PATCH 3/7] add device flow api --- backend.go | 1 + path_oidc.go | 191 +++++++++++++++++++++++++++++++++++++++++++++------ path_role.go | 14 +++- 3 files changed, 183 insertions(+), 23 deletions(-) diff --git a/backend.go b/backend.go index b9bc0799..ee9899f6 100644 --- a/backend.go +++ b/backend.go @@ -55,6 +55,7 @@ func backend() *jwtAuthBackend { "login", "oidc/auth_url", "oidc/callback", + "oidc/device_wait", // Uncomment to mount simple UI handler for local development // "ui", diff --git a/path_oidc.go b/path_oidc.go index 36345600..70a8a128 100644 --- a/path_oidc.go +++ b/path_oidc.go @@ -7,6 +7,7 @@ import ( "fmt" "net/http" "net/url" + "strconv" "strings" "time" @@ -33,8 +34,8 @@ const ( noCode = "no_code" ) -// oidcState is created when an authURL is requested. The state identifier is -// passed throughout the OAuth process. +// oidcState is created when an authURL is requested while not using the +// device flow. The state identifier is passed throughout the OAuth process. type oidcState struct { rolename string nonce string @@ -92,7 +93,7 @@ func pathOIDC(b *jwtAuthBackend) []*framework.Path { }, "redirect_uri": { Type: framework.TypeString, - Description: "The OAuth redirect_uri to use in the authorization URL.", + Description: "The OAuth redirect_uri to use in a code flow authorization URL.", }, "client_nonce": { Type: framework.TypeString, @@ -109,6 +110,31 @@ func pathOIDC(b *jwtAuthBackend) []*framework.Path { }, }, }, + { + Pattern: `oidc/device_wait`, + Fields: map[string]*framework.FieldSchema{ + "role": { + Type: framework.TypeLowerCaseString, + Description: "The role that initiated the OIDC device authorization flow.", + }, + "device_code": { + Type: framework.TypeString, + Description: "The device code returned from auth_url.", + }, + "interval": { + Type: framework.TypeString, + Description: "Interval in seconds between polls to the token endpoint.", + }, + }, + Operations: map[logical.Operation]framework.OperationHandler{ + logical.UpdateOperation: &framework.PathOperation{ + Callback: b.pathDeviceWait, + Summary: "Wait for a device flow authorization to complete.", + + ForwardPerformanceStandby: true, + }, + }, + }, } } @@ -242,6 +268,14 @@ func (b *jwtAuthBackend) pathCallback(ctx context.Context, req *logical.Request, } } + return b.processToken(ctx, config, provider, roleName, role, rawToken, oauth2Token, state.nonce) +} + + +// Continue processing a token after it has been received from the +// OIDC provider from either code or device authorization flows +func (b *jwtAuthBackend) processToken(ctx context.Context, config *jwtConfig, provider *oidc.Provider, roleName string, role *jwtRole, rawToken string, oauth2Token *oauth2.Token, nonce string) (*logical.Response, error) { + if role.VerboseOIDCLogging { b.Logger().Debug("OIDC provider response", "ID token", rawToken) } @@ -252,15 +286,22 @@ func (b *jwtAuthBackend) pathCallback(ctx context.Context, req *logical.Request, return logical.ErrorResponse("%s %s", errTokenVerification, err.Error()), nil } - if allClaims["nonce"] != state.nonce { - return logical.ErrorResponse(errTokenVerification + " Invalid ID token nonce."), nil + if nonce, ok := allClaims["nonce"]; ok { + if allClaims["nonce"] != nonce { + return logical.ErrorResponse(errTokenVerification + " Invalid ID token nonce."), nil + } + delete(allClaims, "nonce") } - delete(allClaims, "nonce") // If we have a token, attempt to fetch information from the /userinfo endpoint // and merge it with the existing claims data. A failure to fetch additional information // from this endpoint will not invalidate the authorization flow. if oauth2Token != nil { + oidcCtx, err := b.createCAContext(ctx, config.OIDCDiscoveryCAPEM) + if err != nil { + return nil, err + } + if userinfo, err := provider.UserInfo(oidcCtx, oauth2.StaticTokenSource(oauth2Token)); err == nil { _ = userinfo.Claims(&allClaims) } else { @@ -322,6 +363,90 @@ func (b *jwtAuthBackend) pathCallback(ctx context.Context, req *logical.Request, return resp, nil } +// lookup the role +func (b *jwtAuthBackend) lookupRole(ctx context.Context, req *logical.Request, config *jwtConfig, roleName string) (*jwtRole, *logical.Response, error) { + if roleName == "" { + roleName = config.DefaultRole + } + if roleName == "" { + return nil, logical.ErrorResponse("missing role"), nil + } + + role, err := b.role(ctx, req.Storage, roleName) + if err != nil { + return nil, nil, err + } + if role == nil { + return nil, logical.ErrorResponse("role %q could not be found", roleName), nil + } + return role, nil, nil +} + +// deviceWait does the second half of the device flow, waiting while the +// the user authorizes by entering a code into their web browser +func (b *jwtAuthBackend) pathDeviceWait(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { + config, err := b.config(ctx, req.Storage) + if err != nil { + return nil, err + } + if config == nil { + return logical.ErrorResponse(errLoginFailed + " Could not load configuration"), nil + } + + roleName := d.Get("role").(string) + role, errresp, err := b.lookupRole(ctx, req, config, roleName) + if err != nil { + return nil, err + } + if role == nil { + return errresp, nil + } + + deviceCodestr := d.Get("device_code").(string) + if deviceCodestr == "" { + return logical.ErrorResponse(errLoginFailed + " missing device_code"), nil + } + interval := int64(5) + intervalstr := d.Get("interval").(string) + if intervalstr != "" { + i, err := strconv.ParseInt(intervalstr, 10, 64) + if err != nil { + interval = i + } + } + + provider, err := b.getProvider(config) + if err != nil { + return nil, err + } + + httpClient := &http.Client{ + Timeout: time.Second * 5, + } + deviceConfig := DeviceConfig{ + Config: &oauth2.Config{ + ClientID: config.OIDCClientID, + ClientSecret: config.OIDCClientSecret, + Endpoint: provider.Endpoint(), + }, + } + deviceCode := DeviceCode { + DeviceCode: deviceCodestr, + Interval: interval, + } + + oauth2Token, err := WaitForDeviceAuthorization(httpClient, &deviceConfig, &deviceCode) + if err != nil { + return nil, err + } + rawToken, ok := oauth2Token.Extra("id_token").(string) + if !ok { + return logical.ErrorResponse(errTokenVerification + " No id_token found in response."), nil + } + + return b.processToken(ctx, config, provider, roleName, role, rawToken, oauth2Token, "") +} + // authURL returns a URL used for redirection to receive an authorization code. // This path requires a role name, or that a default_role has been configured. // Because this endpoint is unauthenticated, the response to invalid or non-OIDC @@ -349,11 +474,46 @@ func (b *jwtAuthBackend) authURL(ctx context.Context, req *logical.Request, d *f } roleName := d.Get("role").(string) - if roleName == "" { - roleName = config.DefaultRole + role, errresp, err := b.lookupRole(ctx, req, config, roleName) + if err != nil { + return nil, err } - if roleName == "" { - return logical.ErrorResponse("missing role"), nil + if role == nil { + return errresp, nil + } + + // "openid" is a required scope for OpenID Connect flows + scopes := append([]string{oidc.ScopeOpenID}, role.OIDCScopes...) + + if role.DeviceAuthURL != "" { + // start a device flow + deviceEndpoint := DeviceEndpoint{ + CodeURL: role.DeviceAuthURL, + } + deviceConfig := DeviceConfig{ + Config: &oauth2.Config{ + ClientID: config.OIDCClientID, + ClientSecret: config.OIDCClientSecret, + Scopes: scopes, + }, + DeviceEndpoint: deviceEndpoint, + } + httpClient := &http.Client{ + Timeout: time.Second * 5, + } + deviceCode, err := RequestDeviceCode(httpClient, &deviceConfig) + if err != nil { + return nil, err + } + if deviceCode.VerificationURIComplete != "" { + resp.Data["auth_url"] = deviceCode.VerificationURIComplete + } else { + resp.Data["auth_url"] = deviceCode.VerificationURI + resp.Data["user_code"] = deviceCode.UserCode + } + resp.Data["device_code"] = deviceCode.DeviceCode + resp.Data["interval"] = deviceCode.Interval + return resp, nil } redirectURI := d.Get("redirect_uri").(string) @@ -363,14 +523,6 @@ func (b *jwtAuthBackend) authURL(ctx context.Context, req *logical.Request, d *f clientNonce := d.Get("client_nonce").(string) - role, err := b.role(ctx, req.Storage, roleName) - if err != nil { - return nil, err - } - if role == nil { - return logical.ErrorResponse("role %q could not be found", roleName), nil - } - if !validRedirect(redirectURI, role.AllowedRedirectURIs) { logger.Warn("unauthorized redirect_uri", "redirect_uri", redirectURI) return resp, nil @@ -391,9 +543,6 @@ func (b *jwtAuthBackend) authURL(ctx context.Context, req *logical.Request, d *f return resp, nil } - // "openid" is a required scope for OpenID Connect flows - scopes := append([]string{oidc.ScopeOpenID}, role.OIDCScopes...) - // Configure an OpenID Connect aware OAuth2 client oauth2Config := oauth2.Config{ ClientID: config.OIDCClientID, diff --git a/path_role.go b/path_role.go index 8216b7e7..8ed9c965 100644 --- a/path_role.go +++ b/path_role.go @@ -130,6 +130,10 @@ Defaults to 60 (1 minute) if set to 0 and can be disabled if set to -1.`, Type: framework.TypeString, Description: `The claim to use for the Identity group alias names`, }, + "device_auth_url": { + Type: framework.TypeString, + Description: `URL of OIDC provider device endpoint`, + }, "oidc_scopes": { Type: framework.TypeCommaStringSlice, Description: `Comma-separated list of OIDC scopes`, @@ -199,6 +203,7 @@ type jwtRole struct { ClaimMappings map[string]string `json:"claim_mappings"` UserClaim string `json:"user_claim"` GroupsClaim string `json:"groups_claim"` + DeviceAuthURL string `json:"device_auth_url"` OIDCScopes []string `json:"oidc_scopes"` AllowedRedirectURIs []string `json:"allowed_redirect_uris"` VerboseOIDCLogging bool `json:"verbose_oidc_logging"` @@ -305,6 +310,7 @@ func (b *jwtAuthBackend) pathRoleRead(ctx context.Context, req *logical.Request, "claim_mappings": role.ClaimMappings, "user_claim": role.UserClaim, "groups_claim": role.GroupsClaim, + "device_auth_url": role.DeviceAuthURL, "allowed_redirect_uris": role.AllowedRedirectURIs, "oidc_scopes": role.OIDCScopes, "verbose_oidc_logging": role.VerboseOIDCLogging, @@ -500,6 +506,10 @@ func (b *jwtAuthBackend) pathRoleCreateUpdate(ctx context.Context, req *logical. role.GroupsClaim = groupsClaim.(string) } + if deviceAuthURL, ok := data.GetOk("device_auth_url"); ok { + role.DeviceAuthURL = deviceAuthURL.(string) + } + if oidcScopes, ok := data.GetOk("oidc_scopes"); ok { role.OIDCScopes = oidcScopes.([]string) } @@ -508,9 +518,9 @@ func (b *jwtAuthBackend) pathRoleCreateUpdate(ctx context.Context, req *logical. role.AllowedRedirectURIs = allowedRedirectURIs.([]string) } - if role.RoleType == "oidc" && len(role.AllowedRedirectURIs) == 0 { + if role.RoleType == "oidc" && len(role.AllowedRedirectURIs) == 0 && role.DeviceAuthURL == "" { return logical.ErrorResponse( - "'allowed_redirect_uris' must be set if 'role_type' is 'oidc' or unspecified."), nil + "'allowed_redirect_uris' must be set if 'role_type' is 'oidc' or unspecified and 'device_auth_url' is unspecified."), nil } // OIDC verification will enforce that the audience match the configured client_id. From 990d526e7b60a9738ae84211f7d8362b40df29b2 Mon Sep 17 00:00:00 2001 From: Dave Dykstra <2129743+DrDaveD@users.noreply.github.com> Date: Mon, 6 Jul 2020 12:56:34 -0500 Subject: [PATCH 4/7] fix comment --- path_oidc.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/path_oidc.go b/path_oidc.go index 70a8a128..a58fee4f 100644 --- a/path_oidc.go +++ b/path_oidc.go @@ -382,7 +382,7 @@ func (b *jwtAuthBackend) lookupRole(ctx context.Context, req *logical.Request, c return role, nil, nil } -// deviceWait does the second half of the device flow, waiting while the +// pathDeviceWait does the second half of the device flow, waiting while the // the user authorizes by entering a code into their web browser func (b *jwtAuthBackend) pathDeviceWait(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { config, err := b.config(ctx, req.Storage) From 9fe71ffe7f26863676f8d3ae416a6f52f4da4102 Mon Sep 17 00:00:00 2001 From: Dave Dykstra <2129743+DrDaveD@users.noreply.github.com> Date: Mon, 6 Jul 2020 13:20:01 -0500 Subject: [PATCH 5/7] support device flow in cli --- cli.go | 49 +++++++++++++++++++++++++++++++++++++------------ 1 file changed, 37 insertions(+), 12 deletions(-) diff --git a/cli.go b/cli.go index 91cb6b90..f1b79e5d 100644 --- a/cli.go +++ b/cli.go @@ -74,26 +74,51 @@ func (h *CLIHandler) Auth(c *api.Client, m map[string]string) (*api.Secret, erro role := m["role"] - authURL, clientNonce, err := fetchAuthURL(c, role, mount, callbackPort, callbackMethod, callbackHost) + authURL, clientNonce, secret, err := fetchAuthURL(c, role, mount, callbackPort, callbackMethod, callbackHost) if err != nil { return nil, err } - // Set up callback handler - http.HandleFunc("/oidc/callback", callbackHandler(c, mount, clientNonce, doneCh)) + var deviceCode string + var userCode string + var listener net.Listener - listener, err := net.Listen("tcp", listenAddress+":"+port) - if err != nil { - return nil, err + if secret != nil { + deviceCode, _ = secret.Data["device_code"].(string) + } + if deviceCode != "" { + userCode, _ = secret.Data["user_code"].(string) + } else { + // Set up callback handler + http.HandleFunc("/oidc/callback", callbackHandler(c, mount, clientNonce, doneCh)) + + listener, err = net.Listen("tcp", listenAddress+":"+port) + if err != nil { + return nil, err + } + defer listener.Close() } - defer listener.Close() // Open the default browser to the callback URL. fmt.Fprintf(os.Stderr, "Complete the login via your OIDC provider. Launching browser to:\n\n %s\n\n\n", authURL) + if userCode != "" { + fmt.Fprintf(os.Stderr, "When prompted, enter code %s\n\n", userCode) + } if err := openURL(authURL); err != nil { fmt.Fprintf(os.Stderr, "Error attempting to automatically open browser: '%s'.\nPlease visit the authorization URL manually.", err) } + if deviceCode != "" { + interval, _ := secret.Data["interval"].(string) + data := map[string]interface{}{ + "role": role, + "device_code": deviceCode, + "interval": interval, + } + + return c.Logical().Write(fmt.Sprintf("auth/%s/oidc/device_wait", mount), data) + } + // Start local server go func() { err := http.Serve(listener, nil) @@ -160,12 +185,12 @@ func callbackHandler(c *api.Client, mount string, clientNonce string, doneCh cha } } -func fetchAuthURL(c *api.Client, role, mount, callbackport string, callbackMethod string, callbackHost string) (string, string, error) { +func fetchAuthURL(c *api.Client, role, mount, callbackport string, callbackMethod string, callbackHost string) (string, string, *api.Secret, error) { var authURL string clientNonce, err := base62.Random(20) if err != nil { - return "", "", err + return "", "", nil, err } data := map[string]interface{}{ @@ -176,7 +201,7 @@ func fetchAuthURL(c *api.Client, role, mount, callbackport string, callbackMetho secret, err := c.Logical().Write(fmt.Sprintf("auth/%s/oidc/auth_url", mount), data) if err != nil { - return "", "", err + return "", "", nil, err } if secret != nil { @@ -184,10 +209,10 @@ func fetchAuthURL(c *api.Client, role, mount, callbackport string, callbackMetho } if authURL == "" { - return "", "", fmt.Errorf("Unable to authorize role %q. Check Vault logs for more information.", role) + return "", "", nil, fmt.Errorf("Unable to authorize role %q. Check Vault logs for more information.", role) } - return authURL, clientNonce, nil + return authURL, clientNonce, secret, nil } // isWSL tests if the binary is being run in Windows Subsystem for Linux From 0ecdc123c85d785ff07c6c476eb4e01aeb435e28 Mon Sep 17 00:00:00 2001 From: Dave Dykstra <2129743+DrDaveD@users.noreply.github.com> Date: Mon, 6 Jul 2020 13:34:15 -0500 Subject: [PATCH 6/7] remove hiding of nonce variable --- path_oidc.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/path_oidc.go b/path_oidc.go index a58fee4f..d44b4e15 100644 --- a/path_oidc.go +++ b/path_oidc.go @@ -286,8 +286,8 @@ func (b *jwtAuthBackend) processToken(ctx context.Context, config *jwtConfig, pr return logical.ErrorResponse("%s %s", errTokenVerification, err.Error()), nil } - if nonce, ok := allClaims["nonce"]; ok { - if allClaims["nonce"] != nonce { + if claimNonce, ok := allClaims["nonce"]; ok { + if claimNonce != nonce { return logical.ErrorResponse(errTokenVerification + " Invalid ID token nonce."), nil } delete(allClaims, "nonce") From bb8ad35e256bd9060af920b8eb8e1ce950ed18b7 Mon Sep 17 00:00:00 2001 From: Dave Dykstra <2129743+DrDaveD@users.noreply.github.com> Date: Tue, 28 Jul 2020 16:37:19 -0500 Subject: [PATCH 7/7] move device_auth_url from role to oidc_device_auth_url in config, add oidcdevice role_type --- path_config.go | 11 +++++++++++ path_login.go | 4 ++-- path_oidc.go | 4 ++-- path_role.go | 31 ++++++++++++++++--------------- 4 files changed, 31 insertions(+), 19 deletions(-) diff --git a/path_config.go b/path_config.go index d5f18861..273c3a9d 100644 --- a/path_config.go +++ b/path_config.go @@ -57,6 +57,10 @@ func pathConfig(b *jwtAuthBackend) *framework.Path { Type: framework.TypeCommaStringSlice, Description: "The response types to request. Allowed values are 'code' and 'id_token'. Defaults to 'code'.", }, + "oidc_device_auth_url": { + Type: framework.TypeString, + Description: `OIDC Device Flow authentication URL. May only be used with "oidc_discovery_url".`, + }, "jwks_url": { Type: framework.TypeString, Description: `JWKS URL to use to authenticate signatures. Cannot be used with "oidc_discovery_url" or "jwt_validation_pubkeys".`, @@ -151,6 +155,7 @@ func (b *jwtAuthBackend) pathConfigRead(ctx context.Context, req *logical.Reques "oidc_client_id": config.OIDCClientID, "oidc_response_mode": config.OIDCResponseMode, "oidc_response_types": config.OIDCResponseTypes, + "oidc_device_auth_url": config.OIDCDeviceAuthURL, "default_role": config.DefaultRole, "jwt_validation_pubkeys": config.JWTValidationPubKeys, "jwt_supported_algs": config.JWTSupportedAlgs, @@ -171,6 +176,7 @@ func (b *jwtAuthBackend) pathConfigWrite(ctx context.Context, req *logical.Reque OIDCClientSecret: d.Get("oidc_client_secret").(string), OIDCResponseMode: d.Get("oidc_response_mode").(string), OIDCResponseTypes: d.Get("oidc_response_types").([]string), + OIDCDeviceAuthURL: d.Get("oidc_device_auth_url").(string), JWKSURL: d.Get("jwks_url").(string), JWKSCAPEM: d.Get("jwks_ca_pem").(string), DefaultRole: d.Get("default_role").(string), @@ -208,6 +214,10 @@ func (b *jwtAuthBackend) pathConfigWrite(ctx context.Context, req *logical.Reque case config.OIDCClientID != "" && config.OIDCDiscoveryURL == "": return logical.ErrorResponse("'oidc_discovery_url' must be set for OIDC"), nil + case config.OIDCDeviceAuthURL != "" && config.OIDCDiscoveryURL == "": + return logical.ErrorResponse("'oidc_discovery_url' must be set when 'oidc_device_auth_url' is set"), nil + + case config.JWKSURL != "": case config.JWKSURL != "": ctx, err := b.createCAContext(context.Background(), config.JWKSCAPEM) if err != nil { @@ -327,6 +337,7 @@ type jwtConfig struct { OIDCClientSecret string `json:"oidc_client_secret"` OIDCResponseMode string `json:"oidc_response_mode"` OIDCResponseTypes []string `json:"oidc_response_types"` + OIDCDeviceAuthURL string `json:"oidc_device_auth_url"` JWKSURL string `json:"jwks_url"` JWKSCAPEM string `json:"jwks_ca_pem"` JWTValidationPubKeys []string `json:"jwt_validation_pubkeys"` diff --git a/path_login.go b/path_login.go index dd0810f8..a67f7c71 100644 --- a/path_login.go +++ b/path_login.go @@ -69,7 +69,7 @@ func (b *jwtAuthBackend) pathLogin(ctx context.Context, req *logical.Request, d return logical.ErrorResponse("role %q could not be found", roleName), nil } - if role.RoleType == "oidc" { + if role.RoleType == "oidc" || role.RoleType == "oidcdevice" { return logical.ErrorResponse("role with oidc role_type is not allowed"), nil } @@ -278,7 +278,7 @@ func (b *jwtAuthBackend) verifyOIDCToken(ctx context.Context, config *jwtConfig, SupportedSigningAlgs: config.JWTSupportedAlgs, } - if role.RoleType == "oidc" { + if role.RoleType == "oidc" || role.RoleType == "oidcdevice" { oidcConfig.ClientID = config.OIDCClientID } else { oidcConfig.SkipClientIDCheck = true diff --git a/path_oidc.go b/path_oidc.go index d44b4e15..c2819b1b 100644 --- a/path_oidc.go +++ b/path_oidc.go @@ -485,10 +485,10 @@ func (b *jwtAuthBackend) authURL(ctx context.Context, req *logical.Request, d *f // "openid" is a required scope for OpenID Connect flows scopes := append([]string{oidc.ScopeOpenID}, role.OIDCScopes...) - if role.DeviceAuthURL != "" { + if role.RoleType == "oidcdevice" { // start a device flow deviceEndpoint := DeviceEndpoint{ - CodeURL: role.DeviceAuthURL, + CodeURL: config.OIDCDeviceAuthURL, } deviceConfig := DeviceConfig{ Config: &oauth2.Config{ diff --git a/path_role.go b/path_role.go index 8ed9c965..ee5d72ad 100644 --- a/path_role.go +++ b/path_role.go @@ -50,7 +50,7 @@ func pathRole(b *jwtAuthBackend) *framework.Path { }, "role_type": { Type: framework.TypeString, - Description: "Type of the role, either 'jwt' or 'oidc'.", + Description: "Type of the role, either 'jwt', 'oidc', or 'oidcdevice'.", }, "policies": { @@ -130,10 +130,6 @@ Defaults to 60 (1 minute) if set to 0 and can be disabled if set to -1.`, Type: framework.TypeString, Description: `The claim to use for the Identity group alias names`, }, - "device_auth_url": { - Type: framework.TypeString, - Description: `URL of OIDC provider device endpoint`, - }, "oidc_scopes": { Type: framework.TypeCommaStringSlice, Description: `Comma-separated list of OIDC scopes`, @@ -203,7 +199,6 @@ type jwtRole struct { ClaimMappings map[string]string `json:"claim_mappings"` UserClaim string `json:"user_claim"` GroupsClaim string `json:"groups_claim"` - DeviceAuthURL string `json:"device_auth_url"` OIDCScopes []string `json:"oidc_scopes"` AllowedRedirectURIs []string `json:"allowed_redirect_uris"` VerboseOIDCLogging bool `json:"verbose_oidc_logging"` @@ -310,7 +305,6 @@ func (b *jwtAuthBackend) pathRoleRead(ctx context.Context, req *logical.Request, "claim_mappings": role.ClaimMappings, "user_claim": role.UserClaim, "groups_claim": role.GroupsClaim, - "device_auth_url": role.DeviceAuthURL, "allowed_redirect_uris": role.AllowedRedirectURIs, "oidc_scopes": role.OIDCScopes, "verbose_oidc_logging": role.VerboseOIDCLogging, @@ -383,7 +377,7 @@ func (b *jwtAuthBackend) pathRoleCreateUpdate(ctx context.Context, req *logical. if roleType == "" { roleType = "oidc" } - if roleType != "jwt" && roleType != "oidc" { + if roleType != "jwt" && roleType != "oidc" && roleType != "oidcdevice" { return logical.ErrorResponse("invalid 'role_type': %s", roleType), nil } role.RoleType = roleType @@ -506,10 +500,6 @@ func (b *jwtAuthBackend) pathRoleCreateUpdate(ctx context.Context, req *logical. role.GroupsClaim = groupsClaim.(string) } - if deviceAuthURL, ok := data.GetOk("device_auth_url"); ok { - role.DeviceAuthURL = deviceAuthURL.(string) - } - if oidcScopes, ok := data.GetOk("oidc_scopes"); ok { role.OIDCScopes = oidcScopes.([]string) } @@ -518,14 +508,25 @@ func (b *jwtAuthBackend) pathRoleCreateUpdate(ctx context.Context, req *logical. role.AllowedRedirectURIs = allowedRedirectURIs.([]string) } - if role.RoleType == "oidc" && len(role.AllowedRedirectURIs) == 0 && role.DeviceAuthURL == "" { + if role.RoleType == "oidc" && len(role.AllowedRedirectURIs) == 0 { return logical.ErrorResponse( - "'allowed_redirect_uris' must be set if 'role_type' is 'oidc' or unspecified and 'device_auth_url' is unspecified."), nil + "'allowed_redirect_uris' must be set if 'role_type' is 'oidc' or unspecified."), nil + } + + if roleType == "oidcdevice" { + config, err := b.config(ctx, req.Storage) + if err != nil { + return nil, err + } + if config == nil || config.OIDCDeviceAuthURL == "" { + return logical.ErrorResponse( + "'oidc_device_auth_url' config must be set if 'role_type' is 'oidcdevice'."), nil + } } // OIDC verification will enforce that the audience match the configured client_id. // For other methods, require at least one bound constraint. - if roleType != "oidc" { + if roleType != "oidc" && roleType != "oidcdevice" { if len(role.BoundAudiences) == 0 && len(role.TokenBoundCIDRs) == 0 && role.BoundSubject == "" &&