forked from mystiq/dex
server: /auth accepts, validates X-client scopes
This commit is contained in:
parent
e6e04be297
commit
9b4740862c
4 changed files with 402 additions and 26 deletions
202
server/cross_client_test.go
Normal file
202
server/cross_client_test.go
Normal file
|
@ -0,0 +1,202 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/coreos/go-oidc/oidc"
|
||||||
|
|
||||||
|
"github.com/coreos/dex/client"
|
||||||
|
"github.com/coreos/dex/connector"
|
||||||
|
)
|
||||||
|
|
||||||
|
func makeCrossClientTestFixtures() (*testFixtures, error) {
|
||||||
|
f, err := makeTestFixtures()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("couldn't make test fixtures: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
creds := map[string]oidc.ClientCredentials{}
|
||||||
|
for _, cliData := range []struct {
|
||||||
|
id string
|
||||||
|
authorized []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
id: "client_a",
|
||||||
|
}, {
|
||||||
|
id: "client_b",
|
||||||
|
authorized: []string{"client_a"},
|
||||||
|
}, {
|
||||||
|
id: "client_c",
|
||||||
|
authorized: []string{"client_a", "client_b"},
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
u := url.URL{
|
||||||
|
Scheme: "https://",
|
||||||
|
Path: cliData.id,
|
||||||
|
Host: "auth.example.com",
|
||||||
|
}
|
||||||
|
cliCreds, err := f.clientRepo.New(client.Client{
|
||||||
|
Credentials: oidc.ClientCredentials{
|
||||||
|
ID: cliData.id,
|
||||||
|
},
|
||||||
|
Metadata: oidc.ClientMetadata{
|
||||||
|
RedirectURIs: []url.URL{u},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Unexpected error creating clients: %v", err)
|
||||||
|
}
|
||||||
|
creds[cliData.id] = *cliCreds
|
||||||
|
err = f.clientRepo.SetTrustedPeers(cliData.id, cliData.authorized)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Unexpected error setting cross-client authorizers: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServerCrossClientAuthAllowed(t *testing.T) {
|
||||||
|
f, err := makeCrossClientTestFixtures()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("couldn't make test fixtures: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
reqClient string
|
||||||
|
authClient string
|
||||||
|
wantAuthorized bool
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
reqClient: "client_b",
|
||||||
|
authClient: "client_a",
|
||||||
|
wantAuthorized: false,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
reqClient: "client_a",
|
||||||
|
authClient: "client_b",
|
||||||
|
wantAuthorized: true,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
reqClient: "client_a",
|
||||||
|
authClient: "client_c",
|
||||||
|
wantAuthorized: true,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
reqClient: "client_c",
|
||||||
|
authClient: "client_b",
|
||||||
|
wantAuthorized: false,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
reqClient: "client_c",
|
||||||
|
authClient: "nope",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for i, tt := range tests {
|
||||||
|
got, err := f.srv.CrossClientAuthAllowed(tt.reqClient, tt.authClient)
|
||||||
|
if tt.wantErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("case %d: want non-nil err", i)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("case %d: unexpected err %v: ", i, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got != tt.wantAuthorized {
|
||||||
|
t.Errorf("case %d: want=%v, got=%v", i, tt.wantAuthorized, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleAuthCrossClient(t *testing.T) {
|
||||||
|
f, err := makeCrossClientTestFixtures()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("couldn't make test fixtures: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
scopes []string
|
||||||
|
clientID string
|
||||||
|
wantCode int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
scopes: []string{ScopeGoogleCrossClient + "client_a"},
|
||||||
|
clientID: "client_b",
|
||||||
|
wantCode: http.StatusBadRequest,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scopes: []string{ScopeGoogleCrossClient + "client_b"},
|
||||||
|
clientID: "client_a",
|
||||||
|
wantCode: http.StatusFound,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scopes: []string{ScopeGoogleCrossClient + "client_b"},
|
||||||
|
clientID: "client_a",
|
||||||
|
wantCode: http.StatusFound,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scopes: []string{ScopeGoogleCrossClient + "client_c"},
|
||||||
|
clientID: "client_a",
|
||||||
|
wantCode: http.StatusFound,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Two clients that client_a is authorized to mint tokens for.
|
||||||
|
scopes: []string{
|
||||||
|
ScopeGoogleCrossClient + "client_c",
|
||||||
|
ScopeGoogleCrossClient + "client_b",
|
||||||
|
},
|
||||||
|
clientID: "client_a",
|
||||||
|
wantCode: http.StatusFound,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Two clients that client_a is authorized to mint tokens for.
|
||||||
|
scopes: []string{
|
||||||
|
ScopeGoogleCrossClient + "client_c",
|
||||||
|
ScopeGoogleCrossClient + "client_a",
|
||||||
|
},
|
||||||
|
clientID: "client_b",
|
||||||
|
wantCode: http.StatusBadRequest,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
idpcs := []connector.Connector{
|
||||||
|
&fakeConnector{loginURL: "http://fake.example.com"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, tt := range tests {
|
||||||
|
hdlr := handleAuthFunc(f.srv, idpcs, nil, true)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
query := url.Values{
|
||||||
|
"response_type": []string{"code"},
|
||||||
|
"client_id": []string{tt.clientID},
|
||||||
|
"connector_id": []string{"fake"},
|
||||||
|
"scope": []string{strings.Join(append([]string{"openid"}, tt.scopes...), " ")},
|
||||||
|
}
|
||||||
|
u := fmt.Sprintf("http://server.example.com?%s", query.Encode())
|
||||||
|
req, err := http.NewRequest("GET", u, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("case %d: unable to form HTTP request: %v", i, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
hdlr.ServeHTTP(w, req)
|
||||||
|
if tt.wantCode != w.Code {
|
||||||
|
t.Errorf("case %d: HTTP code mismatch: want=%d got=%d", i, tt.wantCode, w.Code)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
@ -263,7 +264,7 @@ func renderLoginPage(w http.ResponseWriter, r *http.Request, srv OIDCServer, idp
|
||||||
execTemplate(w, tpl, td)
|
execTemplate(w, tpl, td)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleAuthFunc(srv OIDCServer, idpcs []connector.Connector, tpl *template.Template, registrationEnabled bool) http.HandlerFunc {
|
func handleAuthFunc(srv DexServer, idpcs []connector.Connector, tpl *template.Template, registrationEnabled bool) http.HandlerFunc {
|
||||||
idx := makeConnectorMap(idpcs)
|
idx := makeConnectorMap(idpcs)
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != "GET" {
|
if r.Method != "GET" {
|
||||||
|
@ -341,30 +342,9 @@ func handleAuthFunc(srv OIDCServer, idpcs []connector.Connector, tpl *template.T
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check scopes.
|
// Check scopes.
|
||||||
var scopes []string
|
if scopeErr := validateScopes(srv, acr.ClientID, acr.Scope); scopeErr != nil {
|
||||||
foundOpenIDScope := false
|
log.Error(scopeErr)
|
||||||
for _, scope := range acr.Scope {
|
writeAuthError(w, scopeErr, acr.State)
|
||||||
switch scope {
|
|
||||||
case "openid":
|
|
||||||
foundOpenIDScope = true
|
|
||||||
scopes = append(scopes, scope)
|
|
||||||
case "offline_access":
|
|
||||||
// According to the spec, for offline_access scope, the client must
|
|
||||||
// use a response_type value that would result in an Authorization Code.
|
|
||||||
// Currently oauth2.ResponseTypeCode is the only supported response type,
|
|
||||||
// and it's been checked above, so we don't need to check it again here.
|
|
||||||
//
|
|
||||||
// TODO(yifan): Verify that 'consent' should be in 'prompt'.
|
|
||||||
scopes = append(scopes, scope)
|
|
||||||
default:
|
|
||||||
// Pass all other scopes.
|
|
||||||
scopes = append(scopes, scope)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !foundOpenIDScope {
|
|
||||||
log.Errorf("Invalid auth request: missing 'openid' in 'scope'")
|
|
||||||
writeAuthError(w, oauth2.NewError(oauth2.ErrorInvalidRequest), acr.State)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -410,6 +390,69 @@ func handleAuthFunc(srv OIDCServer, idpcs []connector.Connector, tpl *template.T
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateScopes(srv DexServer, clientID string, scopes []string) error {
|
||||||
|
foundOpenIDScope := false
|
||||||
|
sort.Strings(scopes)
|
||||||
|
for i, scope := range scopes {
|
||||||
|
if i > 0 && scope == scopes[i-1] {
|
||||||
|
err := oauth2.NewError(oauth2.ErrorInvalidRequest)
|
||||||
|
err.Description = fmt.Sprintf(
|
||||||
|
"Duplicate scopes are not allowed: %q",
|
||||||
|
scope)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(scope, ScopeGoogleCrossClient):
|
||||||
|
otherClient := scope[len(ScopeGoogleCrossClient):]
|
||||||
|
|
||||||
|
var allowed bool
|
||||||
|
var err error
|
||||||
|
if otherClient == clientID {
|
||||||
|
allowed = true
|
||||||
|
} else {
|
||||||
|
allowed, err = srv.CrossClientAuthAllowed(clientID, otherClient)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !allowed {
|
||||||
|
err := oauth2.NewError(oauth2.ErrorInvalidRequest)
|
||||||
|
err.Description = fmt.Sprintf(
|
||||||
|
"%q is not authorized to perform cross-client requests for %q",
|
||||||
|
clientID, otherClient)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case scope == "openid":
|
||||||
|
foundOpenIDScope = true
|
||||||
|
case scope == "profile":
|
||||||
|
case scope == "email":
|
||||||
|
case scope == "offline_access":
|
||||||
|
// According to the spec, for offline_access scope, the client must
|
||||||
|
// use a response_type value that would result in an Authorization
|
||||||
|
// Code. Currently oauth2.ResponseTypeCode is the only supported
|
||||||
|
// response type, and it's been checked above, so we don't need to
|
||||||
|
// check it again here.
|
||||||
|
//
|
||||||
|
// TODO(yifan): Verify that 'consent' should be in 'prompt'.
|
||||||
|
default:
|
||||||
|
// Reject all other scopes.
|
||||||
|
err := oauth2.NewError(oauth2.ErrorInvalidRequest)
|
||||||
|
err.Description = fmt.Sprintf("%q is not a recognized scope", scope)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !foundOpenIDScope {
|
||||||
|
log.Errorf("Invalid auth request: missing 'openid' in 'scope'")
|
||||||
|
err := oauth2.NewError(oauth2.ErrorInvalidRequest)
|
||||||
|
err.Description = "Invalid auth request: missing 'openid' in 'scope'"
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func handleTokenFunc(srv OIDCServer) http.HandlerFunc {
|
func handleTokenFunc(srv OIDCServer) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != "POST" {
|
if r.Method != "POST" {
|
||||||
|
|
|
@ -308,8 +308,110 @@ func TestHandleAuthFuncResponsesMultipleRedirectURLs(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHandleTokenFunc(t *testing.T) {
|
func TestValidateScopes(t *testing.T) {
|
||||||
|
f, err := makeCrossClientTestFixtures()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("couldn't make test fixtures: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
clientID string
|
||||||
|
scopes []string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
// ERR: no openid scope
|
||||||
|
clientID: "XXX",
|
||||||
|
scopes: []string{},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// OK: minimum scopes
|
||||||
|
clientID: "XXX",
|
||||||
|
scopes: []string{"openid"},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// OK: offline_access
|
||||||
|
clientID: "XXX",
|
||||||
|
scopes: []string{"openid", "offline_access"},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// ERR: unknown scope
|
||||||
|
clientID: "XXX",
|
||||||
|
scopes: []string{"openid", "wat"},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// ERR: invalid cross client auth
|
||||||
|
clientID: "XXX",
|
||||||
|
scopes: []string{"openid", ScopeGoogleCrossClient + "client_a"},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// OK: valid cross client auth (though perverse - a client
|
||||||
|
// requesting cross-client auth for itself)
|
||||||
|
clientID: "client_a",
|
||||||
|
scopes: []string{"openid", ScopeGoogleCrossClient + "client_a"},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
|
||||||
|
// OK: valid cross client auth
|
||||||
|
clientID: "client_a",
|
||||||
|
scopes: []string{"openid", ScopeGoogleCrossClient + "client_b"},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
|
||||||
|
// ERR: valid cross client auth...but duplicated scope.
|
||||||
|
clientID: "client_a",
|
||||||
|
scopes: []string{"openid",
|
||||||
|
ScopeGoogleCrossClient + "client_b",
|
||||||
|
ScopeGoogleCrossClient + "client_b",
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// OK: valid cross client auth with >1 clients including itself
|
||||||
|
clientID: "client_a",
|
||||||
|
scopes: []string{
|
||||||
|
"openid",
|
||||||
|
ScopeGoogleCrossClient + "client_a",
|
||||||
|
ScopeGoogleCrossClient + "client_b",
|
||||||
|
ScopeGoogleCrossClient + "client_c",
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// ERR: valid cross client auth with >1 clients including itself...but no openid!
|
||||||
|
clientID: "client_a",
|
||||||
|
scopes: []string{
|
||||||
|
ScopeGoogleCrossClient + "client_a",
|
||||||
|
ScopeGoogleCrossClient + "client_b",
|
||||||
|
ScopeGoogleCrossClient + "client_c",
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, tt := range tests {
|
||||||
|
err := validateScopes(f.srv, tt.clientID, tt.scopes)
|
||||||
|
if tt.wantErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("case %d: want non-nil err", i)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("case %d: unexpected err: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleTokenFunc(t *testing.T) {
|
||||||
fx, err := makeTestFixtures()
|
fx, err := makeTestFixtures()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("could not run test fixtures: %v", err)
|
t.Fatalf("could not run test fixtures: %v", err)
|
||||||
|
|
|
@ -39,21 +39,37 @@ const (
|
||||||
ResetPasswordTemplateName = "reset-password.html"
|
ResetPasswordTemplateName = "reset-password.html"
|
||||||
|
|
||||||
APIVersion = "v1"
|
APIVersion = "v1"
|
||||||
|
|
||||||
|
// Scope prefix which indicates initiation of a cross-client authentication flow.
|
||||||
|
// See https://developers.google.com/identity/protocols/CrossClientAuth
|
||||||
|
ScopeGoogleCrossClient = "audience:server:client_id:"
|
||||||
)
|
)
|
||||||
|
|
||||||
type OIDCServer interface {
|
type OIDCServer interface {
|
||||||
ClientMetadata(string) (*oidc.ClientMetadata, error)
|
ClientMetadata(string) (*oidc.ClientMetadata, error)
|
||||||
NewSession(connectorID, clientID, clientState string, redirectURL url.URL, nonce string, register bool, scope []string) (string, error)
|
NewSession(connectorID, clientID, clientState string, redirectURL url.URL, nonce string, register bool, scope []string) (string, error)
|
||||||
Login(oidc.Identity, string) (string, error)
|
Login(oidc.Identity, string) (string, error)
|
||||||
|
|
||||||
// CodeToken exchanges a code for an ID token and a refresh token string on success.
|
// CodeToken exchanges a code for an ID token and a refresh token string on success.
|
||||||
CodeToken(creds oidc.ClientCredentials, sessionKey string) (*jose.JWT, string, error)
|
CodeToken(creds oidc.ClientCredentials, sessionKey string) (*jose.JWT, string, error)
|
||||||
|
|
||||||
ClientCredsToken(creds oidc.ClientCredentials) (*jose.JWT, error)
|
ClientCredsToken(creds oidc.ClientCredentials) (*jose.JWT, error)
|
||||||
|
|
||||||
// RefreshToken takes a previously generated refresh token and returns a new ID token
|
// RefreshToken takes a previously generated refresh token and returns a new ID token
|
||||||
// if the token is valid.
|
// if the token is valid.
|
||||||
RefreshToken(creds oidc.ClientCredentials, token string) (*jose.JWT, error)
|
RefreshToken(creds oidc.ClientCredentials, token string) (*jose.JWT, error)
|
||||||
|
|
||||||
KillSession(string) error
|
KillSession(string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DexServer is an OIDCServer that also has dex-specific features.
|
||||||
|
type DexServer interface {
|
||||||
|
OIDCServer
|
||||||
|
|
||||||
|
// CrossClientAuthAllowed
|
||||||
|
CrossClientAuthAllowed(requestingClientID, authorizingClientID string) (bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
type JWTVerifierFactory func(clientID string) oidc.JWTVerifier
|
type JWTVerifierFactory func(clientID string) oidc.JWTVerifier
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
|
@ -521,6 +537,19 @@ func (s *Server) RefreshToken(creds oidc.ClientCredentials, token string) (*jose
|
||||||
return jwt, nil
|
return jwt, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) CrossClientAuthAllowed(requestingClientID, authorizingClientID string) (bool, error) {
|
||||||
|
alloweds, err := s.ClientRepo.GetTrustedPeers(authorizingClientID)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
for _, allowed := range alloweds {
|
||||||
|
if requestingClientID == allowed {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) JWTVerifierFactory() JWTVerifierFactory {
|
func (s *Server) JWTVerifierFactory() JWTVerifierFactory {
|
||||||
noop := func() error { return nil }
|
noop := func() error { return nil }
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue