Merge pull request #471 from bobbyrullo/native

Implement Public Clients
This commit is contained in:
bobbyrullo 2016-06-20 17:03:39 -07:00 committed by GitHub
commit 3b8d704c9c
29 changed files with 763 additions and 116 deletions

58
Documentation/clients.md Normal file
View file

@ -0,0 +1,58 @@
# Clients (\*aka Relying Parties)
## Configuration
Clients can be created in two different ways:
1. Through the [bootstrap API.](https://github.com/coreos/dex/tree/master/schema/adminschema)
1. Through the [Dynamic Registration API.](https://openid.net/specs/openid-connect-registration-1_0.html) That endpoint is hosted at `/registration`
## Dex Features
Dex contains some client features that are not in any OIDC spec, but can be very useful.
## Cross Client Authorization
Inspired by Google's [Cross-Client Identity](https://developers.google.com/identity/protocols/CrossClientAuth), dex also has a way of having one client mint tokens for other ones, called Cross-client authorization.
A client can only mint JWTs for another client if it is a *trusted peer* of that other client. Currently the only way to set trusted peers is the [bootstrap API](https://github.com/coreos/dex/tree/master/schema/adminschema).
To initiate cross-client authentication, add one more scopes of the following form to the initial auth request:
```
audience:server:client_id:$OTHER_CLIENT_ID
```
OTHER\_CLIENT\_ID is the ID of the client for whom you want a token. You can have multiple such scopes in your request, one for each client whom you want the token to be valid for.
After proceeding as normal with the rest of the auth flow, the resulting ID token will have an `aud` field of only the client ID(s) specified by the scope(s). Note that this means this JWT will not have the initiating client's ID in the `aud`; if you want the client's own ID in the `aud`, you must explicitly request it. A client is always implicitly a trusted client of itself.
## Public Clients
There are times when the confidentiality of the client secret cannot be guaranteed; native mobile clients and command-line tools are common examples.
For these cases, *Public Clients* exist, which have certain restrictions:
1. `http://localhost:$PORT` and `urn:ietf:wg:oauth:2.0:oob` are the only valid redirect URIs.
1. A native client cannot obtain *client credentials* from the `/token` endpoint.
These restrictions are aimed at mitigating certain attacks that can arise as the result of having a non-confidenital client secert.
### Creating a public client.
The only way to create a public client is through the [bootstrap API.](https://github.com/coreos/dex/tree/master/schema/adminschema) There are also special requirements for creating a public client:
* A public client must have a client name specified. This is because client name is used in the creation of the client ID for public clients - in confidential clients, the name is dervied from a redirect URI, which public clients do not have.
* Redirect URIs must not be specified; they are implicit.
## Out-Of-Band Auth Flow
For situations in which an app does not have access to a browser, the out-of-band (oob) flow exists. If you specify "urn:ietf:wg:oauth:2.0:oob" as a redirect URI, after authentication, instead of being redirected to the client site, the user is presented with the auth code in a text field, which they must copy and paste ("out of band" as it were) into their app.
\* In OpenID Connect a client is called a "Relying Party", but "client" seems to
be the more common ter, has been around longer and is present in paramter names
like "client_id" so we prefer it over "Relying Party" usually.

View file

@ -69,10 +69,15 @@ func errorMaker(typ string, desc string, code int) func(internal error) Error {
var ( var (
ErrorMissingClient = errorMaker("bad_request", "The 'client' cannot be empty", http.StatusBadRequest)(nil) ErrorMissingClient = errorMaker("bad_request", "The 'client' cannot be empty", http.StatusBadRequest)(nil)
// Called when oidc.ClientMetadata.Valid() fails.
ErrorInvalidClientFunc = errorMaker("bad_request", "Your client could not be validated.", http.StatusBadRequest) ErrorInvalidClientFunc = errorMaker("bad_request", "Your client could not be validated.", http.StatusBadRequest)
errorMap = map[error]func(error) Error{ errorMap = map[error]func(error) Error{
client.ErrorMissingRedirectURI: errorMaker("bad_request", "Non-public clients must have at least one redirect URI", http.StatusBadRequest),
client.ErrorPublicClientRedirectURIs: errorMaker("bad_request", "Public clients cannot specify redirect URIs", http.StatusBadRequest),
client.ErrorPublicClientMissingName: errorMaker("bad_request", "Public clients require a ClientName", http.StatusBadRequest),
user.ErrorNotFound: errorMaker("resource_not_found", "Resource could not be found.", http.StatusNotFound), user.ErrorNotFound: errorMaker("resource_not_found", "Resource could not be found.", http.StatusNotFound),
user.ErrorDuplicateEmail: errorMaker("bad_request", "Email already in use.", http.StatusBadRequest), user.ErrorDuplicateEmail: errorMaker("bad_request", "Email already in use.", http.StatusBadRequest),
user.ErrorInvalidEmail: errorMaker("bad_request", "invalid email.", http.StatusBadRequest), user.ErrorInvalidEmail: errorMaker("bad_request", "invalid email.", http.StatusBadRequest),
@ -86,7 +91,6 @@ var (
func (a *AdminAPI) GetAdmin(id string) (adminschema.Admin, error) { func (a *AdminAPI) GetAdmin(id string) (adminschema.Admin, error) {
usr, err := a.userRepo.Get(nil, id) usr, err := a.userRepo.Get(nil, id)
if err != nil { if err != nil {
return adminschema.Admin{}, mapError(err) return adminschema.Admin{}, mapError(err)
} }
@ -136,15 +140,9 @@ func (a *AdminAPI) CreateClient(req adminschema.ClientCreateRequest) (adminschem
return adminschema.ClientCreateResponse{}, mapError(err) return adminschema.ClientCreateResponse{}, mapError(err)
} }
if err := cli.Metadata.Valid(); err != nil {
return adminschema.ClientCreateResponse{}, ErrorInvalidClientFunc(err)
}
// metadata is guaranteed to have at least one redirect_uri by earlier validation.
creds, err := a.clientManager.New(cli, &clientmanager.ClientOptions{ creds, err := a.clientManager.New(cli, &clientmanager.ClientOptions{
TrustedPeers: req.Client.TrustedPeers, TrustedPeers: req.Client.TrustedPeers,
}) })
if err != nil { if err != nil {
return adminschema.ClientCreateResponse{}, mapError(err) return adminschema.ClientCreateResponse{}, mapError(err)
} }
@ -165,6 +163,12 @@ func (a *AdminAPI) GetConnectors() ([]connector.ConnectorConfig, error) {
} }
func mapError(e error) error { func mapError(e error) error {
switch t := e.(type) {
case client.ValidationError:
return ErrorInvalidClientFunc(t)
default:
}
if mapped, ok := errorMap[e]; ok { if mapped, ok := errorMap[e]; ok {
return mapped(e) return mapped(e)
} }

View file

@ -7,6 +7,7 @@ import (
"io" "io"
"net/url" "net/url"
"reflect" "reflect"
"strings"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
@ -19,11 +20,27 @@ var (
ErrorInvalidRedirectURL = errors.New("not a valid redirect url for the given client") ErrorInvalidRedirectURL = errors.New("not a valid redirect url for the given client")
ErrorCantChooseRedirectURL = errors.New("must provide a redirect url; client has many") ErrorCantChooseRedirectURL = errors.New("must provide a redirect url; client has many")
ErrorNoValidRedirectURLs = errors.New("no valid redirect URLs for this client.") ErrorNoValidRedirectURLs = errors.New("no valid redirect URLs for this client.")
ErrorNotFound = errors.New("no data found")
ErrorPublicClientRedirectURIs = errors.New("public clients cannot have redirect URIs")
ErrorPublicClientMissingName = errors.New("public clients must have a name")
ErrorMissingRedirectURI = errors.New("no client redirect url given")
ErrorNotFound = errors.New("no data found")
) )
type ValidationError struct {
Err error
}
func (v ValidationError) Error() string {
return v.Err.Error()
}
const ( const (
bcryptHashCost = 10 bcryptHashCost = 10
OOBRedirectURI = "urn:ietf:wg:oauth:2.0:oob"
) )
func HashSecret(creds oidc.ClientCredentials) ([]byte, error) { func HashSecret(creds oidc.ClientCredentials) ([]byte, error) {
@ -44,6 +61,35 @@ type Client struct {
Credentials oidc.ClientCredentials Credentials oidc.ClientCredentials
Metadata oidc.ClientMetadata Metadata oidc.ClientMetadata
Admin bool Admin bool
Public bool
}
func (c Client) ValidRedirectURL(u *url.URL) (url.URL, error) {
if c.Public {
if u == nil {
return url.URL{}, ErrorInvalidRedirectURL
}
if u.String() == OOBRedirectURI {
return *u, nil
}
if u.Scheme != "http" {
return url.URL{}, ErrorInvalidRedirectURL
}
hostPort := strings.Split(u.Host, ":")
if len(hostPort) != 2 {
return url.URL{}, ErrorInvalidRedirectURL
}
if hostPort[0] != "localhost" || u.Path != "" || u.RawPath != "" || u.RawQuery != "" || u.Fragment != "" {
return url.URL{}, ErrorInvalidRedirectURL
}
return *u, nil
}
return ValidRedirectURL(u, c.Metadata.RedirectURIs)
} }
type ClientRepo interface { type ClientRepo interface {
@ -106,6 +152,7 @@ func ClientsFromReader(r io.Reader) ([]LoadableClient, error) {
Secret string `json:"secret"` Secret string `json:"secret"`
RedirectURLs []string `json:"redirectURLs"` RedirectURLs []string `json:"redirectURLs"`
Admin bool `json:"admin"` Admin bool `json:"admin"`
Public bool `json:"public"`
TrustedPeers []string `json:"trustedPeers"` TrustedPeers []string `json:"trustedPeers"`
} }
if err := json.NewDecoder(r).Decode(&c); err != nil { if err := json.NewDecoder(r).Decode(&c); err != nil {
@ -137,7 +184,8 @@ func ClientsFromReader(r io.Reader) ([]LoadableClient, error) {
Metadata: oidc.ClientMetadata{ Metadata: oidc.ClientMetadata{
RedirectURIs: redirectURIs, RedirectURIs: redirectURIs,
}, },
Admin: client.Admin, Admin: client.Admin,
Public: client.Public,
}, },
TrustedPeers: client.TrustedPeers, TrustedPeers: client.TrustedPeers,
} }

View file

@ -35,6 +35,13 @@ var (
"trustedPeers":["goodClient1", "goodClient2"] "trustedPeers":["goodClient1", "goodClient2"]
}` }`
publicClient = `{
"id": "public_client",
"secret": "` + goodSecret3 + `",
"redirectURLs": ["http://localhost:8080","urn:ietf:wg:oauth:2.0:oob"],
"public": true
}`
badURLClient = `{ badURLClient = `{
"id": "my_id", "id": "my_id",
"secret": "` + goodSecret1 + `", "secret": "` + goodSecret1 + `",
@ -139,6 +146,26 @@ func TestClientsFromReader(t *testing.T) {
}, },
}, },
}, },
{
json: "[" + publicClient + "]",
want: []LoadableClient{
{
Client: Client{
Credentials: oidc.ClientCredentials{
ID: "public_client",
Secret: goodSecret3,
},
Metadata: oidc.ClientMetadata{
RedirectURIs: []url.URL{
mustParseURL(t, "http://localhost:8080"),
mustParseURL(t, "urn:ietf:wg:oauth:2.0:oob"),
},
},
Public: true,
},
},
},
},
{ {
json: "[" + badURLClient + "]", json: "[" + badURLClient + "]",
wantErr: true, wantErr: true,
@ -177,7 +204,101 @@ func TestClientsFromReader(t *testing.T) {
} }
} }
} }
func TestClientValidRedirectURL(t *testing.T) {
makeClient := func(public bool, urls []string) Client {
cli := Client{
Metadata: oidc.ClientMetadata{
RedirectURIs: make([]url.URL, len(urls)),
},
Public: public,
}
for i, s := range urls {
cli.Metadata.RedirectURIs[i] = mustParseURL(t, s)
}
return cli
}
tests := []struct {
u string
cli Client
wantU string
wantErr bool
}{
{
u: "http://auth.example.com",
cli: makeClient(false, []string{"http://auth.example.com"}),
wantU: "http://auth.example.com",
},
{
u: "http://auth2.example.com",
cli: makeClient(false, []string{"http://auth.example.com", "http://auth2.example.com"}),
wantU: "http://auth2.example.com",
},
{
u: "",
cli: makeClient(false, []string{"http://auth.example.com"}),
wantU: "http://auth.example.com",
},
{
u: "",
cli: makeClient(false, []string{"http://auth.example.com", "http://auth2.example.com"}),
wantErr: true,
},
{
u: "http://localhost:8080",
cli: makeClient(true, []string{}),
wantU: "http://localhost:8080",
},
{
u: OOBRedirectURI,
cli: makeClient(true, []string{}),
wantU: OOBRedirectURI,
},
{
u: "",
cli: makeClient(true, []string{}),
wantErr: true,
},
{
u: "http://localhost:8080/hey_there",
cli: makeClient(true, []string{}),
wantErr: true,
},
{
u: "http://auth.google.com:8080",
cli: makeClient(true, []string{}),
wantErr: true,
},
}
for i, tt := range tests {
var testURL *url.URL
if tt.u == "" {
testURL = nil
} else {
u := mustParseURL(t, tt.u)
testURL = &u
}
u, err := tt.cli.ValidRedirectURL(testURL)
if tt.wantErr {
if err == nil {
t.Errorf("case %d: want non-nil error", i)
}
continue
}
if err != nil {
t.Errorf("case %d: unexpected error: %v", i, err)
}
if diff := pretty.Compare(mustParseURL(t, tt.wantU), u); diff != "" {
t.Fatalf("case %d: Compare(wantU, u): %v", i, diff)
}
}
}
func mustParseURL(t *testing.T, s string) url.URL { func mustParseURL(t *testing.T, s string) url.URL {
u, err := url.Parse(s) u, err := url.Parse(s)
if err != nil { if err != nil {

View file

@ -2,6 +2,7 @@ package manager
import ( import (
"encoding/base64" "encoding/base64"
"net/url"
"errors" "errors"
@ -21,6 +22,10 @@ const (
maxSecretLength = 72 maxSecretLength = 72
) )
var (
localHostRedirectURL = mustParseURL("http://localhost:0")
)
type ClientOptions struct { type ClientOptions struct {
TrustedPeers []string TrustedPeers []string
} }
@ -67,6 +72,10 @@ func NewClientManager(clientRepo client.ClientRepo, txnFactory repo.TransactionF
} }
} }
// New creates and persists a new client with the given options, returning the generated credentials.
// Any Credenials provided with the client are ignored and overwritten by the generated ID and Secret.
// "Normal" (i.e. non-Public) clients must have at least one valid RedirectURI in their Metadata.
// Public clients must not have any RedirectURIs and must have a client name.
func (m *ClientManager) New(cli client.Client, options *ClientOptions) (*oidc.ClientCredentials, error) { func (m *ClientManager) New(cli client.Client, options *ClientOptions) (*oidc.ClientCredentials, error) {
tx, err := m.begin() tx, err := m.begin()
if err != nil { if err != nil {
@ -74,11 +83,14 @@ func (m *ClientManager) New(cli client.Client, options *ClientOptions) (*oidc.Cl
} }
defer tx.Rollback() defer tx.Rollback()
if err := validateClient(cli); err != nil {
return nil, err
}
err = m.addClientCredentials(&cli) err = m.addClientCredentials(&cli)
if err != nil { if err != nil {
return nil, err return nil, err
} }
creds := cli.Credentials creds := cli.Credentials
// Save Client // Save Client
@ -156,7 +168,13 @@ func (m *ClientManager) SetDexAdmin(clientID string, isAdmin bool) error {
func (m *ClientManager) Authenticate(creds oidc.ClientCredentials) (bool, error) { func (m *ClientManager) Authenticate(creds oidc.ClientCredentials) (bool, error) {
clientSecret, err := m.clientRepo.GetSecret(nil, creds.ID) clientSecret, err := m.clientRepo.GetSecret(nil, creds.ID)
if err != nil || clientSecret == nil { if err != nil {
log.Errorf("error getting secret for client ID: %v: err: %v", creds.ID, err)
return false, nil
}
if clientSecret == nil {
log.Errorf("no secret found for client ID: %v", creds.ID)
return false, nil return false, nil
} }
@ -171,11 +189,15 @@ func (m *ClientManager) Authenticate(creds oidc.ClientCredentials) (bool, error)
} }
func (m *ClientManager) addClientCredentials(cli *client.Client) error { func (m *ClientManager) addClientCredentials(cli *client.Client) error {
// Generate Client ID var seed string
if len(cli.Metadata.RedirectURIs) < 1 { if cli.Public {
return errors.New("no client redirect url given") seed = cli.Metadata.ClientName
} else {
seed = cli.Metadata.RedirectURIs[0].Host
} }
clientID, err := m.clientIDGenerator(cli.Metadata.RedirectURIs[0].Host)
// Generate Client ID
clientID, err := m.clientIDGenerator(seed)
if err != nil { if err != nil {
return err return err
} }
@ -192,3 +214,37 @@ func (m *ClientManager) addClientCredentials(cli *client.Client) error {
} }
return nil return nil
} }
func validateClient(cli client.Client) error {
// NOTE: please be careful changing the errors returned here; they are used
// downstream (eg. in the admin API) to determine the http errors returned.
if cli.Public {
if len(cli.Metadata.RedirectURIs) > 0 {
return client.ErrorPublicClientRedirectURIs
}
if cli.Metadata.ClientName == "" {
return client.ErrorPublicClientMissingName
}
cli.Metadata.RedirectURIs = []url.URL{
localHostRedirectURL,
}
} else {
if len(cli.Metadata.RedirectURIs) < 1 {
return client.ErrorMissingRedirectURI
}
}
err := cli.Metadata.Valid()
if err != nil {
return client.ValidationError{Err: err}
}
return nil
}
func mustParseURL(s string) url.URL {
u, err := url.Parse(s)
if err != nil {
panic(err)
}
return *u
}

View file

@ -168,3 +168,53 @@ func TestAuthenticate(t *testing.T) {
} }
} }
} }
func TestValidateClient(t *testing.T) {
tests := []struct {
cli client.Client
wantErr error
}{
{
cli: client.Client{
Metadata: oidc.ClientMetadata{
RedirectURIs: []url.URL{mustParseURL("http://auth.google.com")},
},
},
},
{
cli: client.Client{},
wantErr: client.ErrorMissingRedirectURI,
},
{
cli: client.Client{
Metadata: oidc.ClientMetadata{
ClientName: "frank",
},
Public: true,
},
},
{
cli: client.Client{
Metadata: oidc.ClientMetadata{
RedirectURIs: []url.URL{mustParseURL("http://auth.google.com")},
ClientName: "frank",
},
Public: true,
},
wantErr: client.ErrorPublicClientRedirectURIs,
},
{
cli: client.Client{
Public: true,
},
wantErr: client.ErrorPublicClientMissingName,
},
}
for i, tt := range tests {
err := validateClient(tt.cli)
if err != tt.wantErr {
t.Errorf("case %d: want=%v, got=%v", i, tt.wantErr, err)
}
}
}

View file

@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"net/url"
"reflect" "reflect"
"github.com/coreos/go-oidc/oidc" "github.com/coreos/go-oidc/oidc"
@ -23,6 +24,10 @@ const (
pgErrorCodeUniqueViolation = "23505" // unique_violation pgErrorCodeUniqueViolation = "23505" // unique_violation
) )
var (
localHostRedirectURL = mustParseURL("http://localhost:0")
)
func init() { func init() {
register(table{ register(table{
name: clientTableName, name: clientTableName,
@ -44,6 +49,16 @@ func newClientModel(cli client.Client) (*clientModel, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
if cli.Public {
// Metadata.Valid(), and therefore json.Unmarshal(metadata) complains
// when there's no RedirectURIs, so we set them to a fixed value here,
// and remove it when translating back to a client.Client
cli.Metadata.RedirectURIs = []url.URL{
localHostRedirectURL,
}
}
bmeta, err := json.Marshal(&cli.Metadata) bmeta, err := json.Marshal(&cli.Metadata)
if err != nil { if err != nil {
return nil, err return nil, err
@ -54,6 +69,7 @@ func newClientModel(cli client.Client) (*clientModel, error) {
Secret: hashed, Secret: hashed,
Metadata: string(bmeta), Metadata: string(bmeta),
DexAdmin: cli.Admin, DexAdmin: cli.Admin,
Public: cli.Public,
} }
return &cim, nil return &cim, nil
@ -64,6 +80,7 @@ type clientModel struct {
Secret []byte `db:"secret"` Secret []byte `db:"secret"`
Metadata string `db:"metadata"` Metadata string `db:"metadata"`
DexAdmin bool `db:"dex_admin"` DexAdmin bool `db:"dex_admin"`
Public bool `db:"public"`
} }
type trustedPeerModel struct { type trustedPeerModel struct {
@ -76,13 +93,18 @@ func (m *clientModel) Client() (*client.Client, error) {
Credentials: oidc.ClientCredentials{ Credentials: oidc.ClientCredentials{
ID: m.ID, ID: m.ID,
}, },
Admin: m.DexAdmin, Admin: m.DexAdmin,
Public: m.Public,
} }
if err := json.Unmarshal([]byte(m.Metadata), &ci.Metadata); err != nil { if err := json.Unmarshal([]byte(m.Metadata), &ci.Metadata); err != nil {
return nil, err return nil, err
} }
if ci.Public {
ci.Metadata.RedirectURIs = []url.URL{}
}
return &ci, nil return &ci, nil
} }
@ -168,7 +190,6 @@ func isAlreadyExistsErr(err error) bool {
func (r *clientRepo) New(tx repo.Transaction, cli client.Client) (*oidc.ClientCredentials, error) { func (r *clientRepo) New(tx repo.Transaction, cli client.Client) (*oidc.ClientCredentials, error) {
cim, err := newClientModel(cli) cim, err := newClientModel(cli)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -328,3 +349,11 @@ func (r *clientRepo) SetTrustedPeers(tx repo.Transaction, clientID string, clien
return nil return nil
} }
func mustParseURL(s string) url.URL {
u, err := url.Parse(s)
if err != nil {
panic(err)
}
return *u
}

View file

@ -16,7 +16,8 @@ CREATE TABLE client_identity (
id text NOT NULL UNIQUE, id text NOT NULL UNIQUE,
secret blob, secret blob,
metadata text, metadata text,
dex_admin integer dex_admin integer,
public integer
); );
CREATE TABLE connector_config ( CREATE TABLE connector_config (

View file

@ -48,6 +48,22 @@ func TestGetPlannedMigrations(t *testing.T) {
} }
func TestMigrateClientMetadata(t *testing.T) { func TestMigrateClientMetadata(t *testing.T) {
// oldClientModel exists to model what the client model looked like at
// migration time. Without using this, the test fails because there's no
// columns for the new fields.
type oldClientModel struct {
ID string `db:"id"`
Secret []byte `db:"secret"`
Metadata string `db:"metadata"`
DexAdmin bool `db:"dex_admin"`
}
register(table{
name: clientTableName,
model: oldClientModel{},
autoinc: false,
pkey: []string{"id"},
})
dsn := os.Getenv("DEX_TEST_DSN") dsn := os.Getenv("DEX_TEST_DSN")
if dsn == "" { if dsn == "" {
t.Skip("Test will not run without DEX_TEST_DSN environment variable.") t.Skip("Test will not run without DEX_TEST_DSN environment variable.")
@ -88,7 +104,7 @@ func TestMigrateClientMetadata(t *testing.T) {
} }
for i, tt := range tests { for i, tt := range tests {
model := &clientModel{ model := &oldClientModel{
ID: strconv.Itoa(i), ID: strconv.Itoa(i),
Secret: []byte("verysecret"), Secret: []byte("verysecret"),
Metadata: tt.before, Metadata: tt.before,
@ -108,12 +124,12 @@ func TestMigrateClientMetadata(t *testing.T) {
for i, tt := range tests { for i, tt := range tests {
id := strconv.Itoa(i) id := strconv.Itoa(i)
m, err := dbMap.Get(clientModel{}, id) m, err := dbMap.Get(oldClientModel{}, id)
if err != nil { if err != nil {
t.Errorf("case %d: failed to get model: %v", i, err) t.Errorf("case %d: failed to get model: %v", i, err)
continue continue
} }
cim, ok := m.(*clientModel) cim, ok := m.(*oldClientModel)
if !ok { if !ok {
t.Errorf("case %d: unrecognized model type: %T", i, m) t.Errorf("case %d: unrecognized model type: %T", i, m)
continue continue

View file

@ -0,0 +1,4 @@
-- +migrate Up
ALTER TABLE client_identity ADD COLUMN "public" boolean;
UPDATE "client_identity" SET "public" = false;

View file

@ -78,6 +78,12 @@ var PostgresMigrations migrate.MigrationSource = &migrate.MemoryMigrationSource{
"-- +migrate Up\nCREATE TABLE IF NOT EXISTS \"trusted_peers\" (\n \"client_id\" text not null,\n \"trusted_client_id\" text not null,\n primary key (\"client_id\", \"trusted_client_id\")) ;\n", "-- +migrate Up\nCREATE TABLE IF NOT EXISTS \"trusted_peers\" (\n \"client_id\" text not null,\n \"trusted_client_id\" text not null,\n primary key (\"client_id\", \"trusted_client_id\")) ;\n",
}, },
}, },
{
Id: "0013_add_public_clients.sql",
Up: []string{
"-- +migrate Up\nALTER TABLE client_identity ADD COLUMN \"public\" boolean;\n\nUPDATE \"client_identity\" SET \"public\" = false;\n",
},
},
{ {
Id: "0013_add_scopes_to_refresh_tokens.sql", Id: "0013_add_scopes_to_refresh_tokens.sql",
Up: []string{ Up: []string{

View file

@ -68,7 +68,7 @@ func (fi bindataFileInfo) Sys() interface{} {
return nil return nil
} }
var _dataIndexHtml = []byte("\x1f\x8b\x08\x00\x00\x09\x6e\x88\x00\xff\x7c\x91\xbd\x6e\x03\x21\x10\x84\x7b\x3f\xc5\x8a\x2a\x29\x2c\xfa\x08\x9f\x94\x3e\x55\x5e\x20\xe2\xb8\xb5\xbd\x12\x3f\x27\x58\xa2\xf8\xed\xb3\x88\x38\xc7\x45\x91\xbb\x19\xc1\x7e\x03\xb3\xe6\xca\xc1\x4f\x07\x00\x33\xa7\xe5\xd6\x84\xc8\x73\xca\x01\xac\x63\x4a\xf1\xa4\xb4\x4f\x17\x8a\xaa\x1f\xc9\x21\xdb\xd9\xe3\xdd\x35\x9f\x37\xd3\xec\x32\xc1\x6b\xe5\x2b\x46\x26\x67\x19\x41\x60\x2f\xc3\x85\x96\xb4\x9b\x00\x78\x72\x29\x04\x7b\x2c\xb8\xda\x2c\x13\x0b\x78\x2a\x0c\xe9\x0c\xce\x93\x60\x8e\xb4\x94\xe7\x31\x42\x4b\xc6\xdf\x48\x43\x71\xad\x0c\x7c\x5b\xf1\xa4\x18\xbf\x58\x41\xb4\x41\xb4\xcb\xa9\x94\x8f\x4e\x52\xd3\xcf\xf0\x61\x60\xfd\x3e\x46\x74\xff\xda\xdd\x8f\xc8\x52\xe7\x40\x02\xfd\xb4\xbe\x8a\x7d\x1b\x3a\x31\xba\xf5\xf5\x6f\x75\x19\x2f\xf2\x15\xcc\x5b\x7b\x0f\x98\xef\xfb\xcb\x1b\xd6\xe8\xbe\x1b\xa3\xfb\xb2\xbe\x03\x00\x00\xff\xff\x27\x69\xf8\xf2\xb4\x01\x00\x00") var _dataIndexHtml = []byte("\x1f\x8b\x08\x00\x00\x09\x6e\x88\x00\xff\x94\x52\xcd\x4e\xc3\x30\x0c\xbe\xef\x29\xac\x9c\xe0\x30\x7a\x47\x6d\x25\x40\xdc\x90\x26\xf1\x02\x53\x9a\x78\x6d\xb4\xfc\x4c\x89\x8b\x36\x4d\x7b\x77\xdc\x96\xae\x5b\x81\x09\x6e\xfe\x14\xfb\xfb\x89\x9d\x37\xe4\x6c\xb9\x00\xc8\xab\xa0\x0f\xe5\x82\x2b\xae\x37\x21\x3a\x90\x8a\x4c\xf0\x85\xc8\x6c\xa8\x8d\x17\x65\xff\xc4\x8f\x24\x2b\x8b\x23\xea\x70\x9c\x40\x07\x75\x09\x4f\x2d\x35\xe8\xc9\x28\x49\x08\x4c\xf6\x78\xd1\xd0\x49\x5d\x4d\x00\xdc\xa9\xe0\x9c\x5c\x26\xdc\xc9\xc8\x13\x1a\xac\x49\x04\x61\x03\xca\x1a\xa6\x59\x1a\x9d\xee\x2f\x25\x32\xd6\x98\x4b\xe6\xc6\xef\x5a\x02\x3a\xec\xb0\x10\x84\x7b\x12\xe0\xa5\xe3\x5a\xc5\x90\xd2\x7a\x60\x12\x50\xce\xa6\x19\x9d\xcd\x70\x3d\x44\x3b\x1e\xc1\x6c\xe0\x61\xb5\x7a\x86\xd3\x69\x6a\xbd\x54\x48\x6d\xe5\x0c\xf3\x7d\x48\xdb\x32\x7c\xeb\xbf\xa8\x8b\xea\x48\xc6\x1a\xa9\x10\xeb\xca\x4a\xbf\x15\x3d\x1b\xda\x84\xff\xa4\x1a\xe6\xbc\x1e\xc7\xf2\xac\x23\xe7\x05\x7d\x37\x37\x5b\x97\x92\xd6\x56\x52\x6d\x05\x38\xa4\x26\xe8\x42\xb0\x9f\x8e\x70\xd0\x7e\x09\x1a\x17\x3f\xd8\xb8\xfa\x33\xee\x39\x1b\x9a\x36\x3f\xed\xed\x56\x80\xd7\xbd\x6a\xa4\xaf\xb1\x57\x1a\x75\x47\xfb\xd7\xa1\xbe\xc2\xf8\x40\xb7\x02\x45\xac\xf9\x1e\x30\x8a\xbf\xa8\xbf\x8f\xcd\x00\xd9\xef\xd2\x79\x36\x9c\x7b\x9e\x0d\xf7\xff\x19\x00\x00\xff\xff\xaf\x0b\xca\x75\x07\x03\x00\x00")
func dataIndexHtmlBytes() ([]byte, error) { func dataIndexHtmlBytes() ([]byte, error) {
return bindataRead( return bindataRead(
@ -83,7 +83,7 @@ func dataIndexHtml() (*asset, error) {
return nil, err return nil, err
} }
info := bindataFileInfo{name: "data/index.html", size: 436, mode: os.FileMode(420), modTime: time.Unix(1465417812, 0)} info := bindataFileInfo{name: "data/index.html", size: 775, mode: os.FileMode(420), modTime: time.Unix(1466378108, 0)}
a := &asset{bytes: bytes, info: info} a := &asset{bytes: bytes, info: info}
return a, nil return a, nil
} }

View file

@ -1,5 +1,6 @@
<html> <html>
<body> <body>
<form action="/login"> <form action="/login">
<table> <table>
<tr> <tr>
@ -7,15 +8,29 @@
<br> <br>
(comma-separated list of client-ids) (comma-separated list of client-ids)
</td> </td>
<td> <input type="text" name="cross_client"> </td> <td> <input type="text" name="cross_client" > </td>
</tr> </tr>
</table> </table>
{{ if .OOB }}
<input type="submit" value="Login" formtarget="_blank">
{{ else }}
<input type="submit" value="Login" >
{{ end }}
</form>
<input type="submit" value="Login"> {{ if .OOB }}
<form action="/callback" method="get" >
Code
<input type="text" name="code" value="">
<br>
<input type="submit" value="Exchange Code" >
</form> </form>
{{ end }}
{{ if not .OOB }}
<form action="/register"> <form action="/register">
<input type="submit" value="Register"> <input type="submit" value="Register" />
</form> </form>
{{ end }}
</body> </body>
</html> </html>

View file

@ -23,6 +23,7 @@ import (
"github.com/coreos/go-oidc/oauth2" "github.com/coreos/go-oidc/oauth2"
"github.com/coreos/go-oidc/oidc" "github.com/coreos/go-oidc/oidc"
"github.com/coreos/dex/client"
pflag "github.com/coreos/dex/pkg/flag" pflag "github.com/coreos/dex/pkg/flag"
phttp "github.com/coreos/dex/pkg/http" phttp "github.com/coreos/dex/pkg/http"
"github.com/coreos/dex/pkg/log" "github.com/coreos/dex/pkg/log"
@ -163,15 +164,21 @@ func main() {
func NewClientHandler(c *oidc.Client, issuer string, cbURL url.URL) http.Handler { func NewClientHandler(c *oidc.Client, issuer string, cbURL url.URL) http.Handler {
mux := http.NewServeMux() mux := http.NewServeMux()
oob := cbURL.String() == client.OOBRedirectURI
issuerURL, err := url.Parse(issuer) issuerURL, err := url.Parse(issuer)
if err != nil { if err != nil {
log.Fatalf("Could not parse issuer url: %v", err) log.Fatalf("Could not parse issuer url: %v", err)
} }
mux.HandleFunc("/", handleIndex) mux.HandleFunc("/", handleIndexFunc(oob))
mux.HandleFunc("/login", handleLoginFunc(c)) mux.HandleFunc("/login", handleLoginFunc(c))
mux.HandleFunc("/register", handleRegisterFunc(c)) mux.HandleFunc("/register", handleRegisterFunc(c))
mux.HandleFunc(cbURL.Path, handleCallbackFunc(c)) if cbURL.String() != client.OOBRedirectURI {
mux.HandleFunc(cbURL.Path, handleCallbackFunc(c))
} else {
mux.HandleFunc("/callback", handleCallbackFunc(c))
}
resendURL := *issuerURL resendURL := *issuerURL
resendURL.Path = "/resend-verify-email" resendURL.Path = "/resend-verify-email"
@ -180,12 +187,16 @@ func NewClientHandler(c *oidc.Client, issuer string, cbURL url.URL) http.Handler
return mux return mux
} }
func handleIndex(w http.ResponseWriter, r *http.Request) { func handleIndexFunc(oob bool) http.HandlerFunc {
err := indexTemplate.Execute(w, nil) return func(w http.ResponseWriter, r *http.Request) {
if err != nil { err := indexTemplate.Execute(w, map[string]interface{}{
phttp.WriteError(w, http.StatusInternalServerError, "OOB": oob,
fmt.Sprintf("unable to execute template: %v", err)) })
if err != nil {
phttp.WriteError(w, http.StatusInternalServerError,
fmt.Sprintf("unable to execute template: %v", err))
}
} }
} }

View file

@ -383,7 +383,11 @@ func TestCreateClient(t *testing.T) {
} }
addIDAndSecret := func(cli adminschema.Client) *adminschema.Client { addIDAndSecret := func(cli adminschema.Client) *adminschema.Client {
cli.Id = "client_auth.example.com" if cli.Public {
cli.Id = "client_" + cli.ClientName
} else {
cli.Id = "client_auth.example.com"
}
cli.Secret = base64.URLEncoding.EncodeToString([]byte("client_0")) cli.Secret = base64.URLEncoding.EncodeToString([]byte("client_0"))
return &cli return &cli
} }
@ -400,6 +404,23 @@ func TestCreateClient(t *testing.T) {
}, },
} }
clientPublicGood := clientGood
clientPublicGood.Public = true
clientPublicGood.Metadata.ClientName = "PublicName"
clientPublicGood.Metadata.RedirectURIs = []url.URL{}
clientPublicGood.Credentials.ID = "client_PublicName"
adminPublicClientGood := adminClientGood
adminPublicClientGood.Public = true
adminPublicClientGood.ClientName = "PublicName"
adminPublicClientGood.RedirectURIs = []string{}
adminPublicClientMissingName := adminPublicClientGood
adminPublicClientMissingName.ClientName = ""
adminPublicClientHasARedirect := adminPublicClientGood
adminPublicClientHasARedirect.RedirectURIs = []string{"https://auth.example.com/"}
adminAdminClient := adminClientGood adminAdminClient := adminClientGood
adminAdminClient.IsAdmin = true adminAdminClient.IsAdmin = true
clientGoodAdmin := clientGood clientGoodAdmin := clientGood
@ -479,6 +500,27 @@ func TestCreateClient(t *testing.T) {
wantClient: clientGood, wantClient: clientGood,
wantTrustedPeers: []string{"test_client_0"}, wantTrustedPeers: []string{"test_client_0"},
}, },
{
req: adminschema.ClientCreateRequest{
Client: &adminPublicClientGood,
},
want: adminschema.ClientCreateResponse{
Client: addIDAndSecret(adminPublicClientGood),
},
wantClient: clientPublicGood,
},
{
req: adminschema.ClientCreateRequest{
Client: &adminPublicClientMissingName,
},
wantError: http.StatusBadRequest,
},
{
req: adminschema.ClientCreateRequest{
Client: &adminPublicClientHasARedirect,
},
wantError: http.StatusBadRequest,
},
} }
for i, tt := range tests { for i, tt := range tests {
@ -530,6 +572,7 @@ func TestCreateClient(t *testing.T) {
repoClient, err := f.cr.Get(nil, resp.Client.Id) repoClient, err := f.cr.Get(nil, resp.Client.Id)
if err != nil { if err != nil {
t.Errorf("case %d: Unexpected error getting client: %v", i, err) t.Errorf("case %d: Unexpected error getting client: %v", i, err)
continue
} }
if diff := pretty.Compare(tt.wantClient, repoClient); diff != "" { if diff := pretty.Compare(tt.wantClient, repoClient); diff != "" {

View file

@ -303,30 +303,69 @@ func TestHTTPClientCredsToken(t *testing.T) {
}, },
}, },
} }
cis := []client.LoadableClient{{Client: ci}}
srv, err := mockServer(cis) ci2 := ci
if err != nil { ci2.Credentials.ID = "not_a_client"
t.Fatalf("Unexpected error setting up server: %v", err)
ciPublic := ci
ciPublic.Public = true
ciPublic.Credentials.ID = "public"
cis := []client.LoadableClient{{Client: ci}, {Client: ciPublic}}
tests := []struct {
cli client.Client
clients []client.LoadableClient
wantErr bool
}{
{
cli: ci,
clients: cis,
wantErr: false,
},
{
cli: ci2,
clients: cis,
wantErr: true,
},
{
cli: ciPublic,
clients: cis,
wantErr: true,
},
} }
cl, err := mockClient(srv, ci) for i, tt := range tests {
if err != nil { srv, err := mockServer(tt.clients)
t.Fatalf("Unexpected error setting up OIDC client: %v", err) if err != nil {
} t.Fatalf("case %d: Unexpected error setting up server: %v", i, err)
}
tok, err := cl.ClientCredsToken([]string{"openid"}) cl, err := mockClient(srv, tt.cli)
if err != nil { if err != nil {
t.Fatalf("Failed getting client token: %v", err) t.Fatalf("case %d: Unexpected error setting up OIDC client: %v", i, err)
} }
claims, err := tok.Claims() tok, err := cl.ClientCredsToken([]string{"openid"})
if err != nil { if tt.wantErr {
t.Fatalf("Failed parsing claims from client token: %v", err) if err == nil {
} t.Errorf("case %d: want non-nil error", i)
}
continue
}
if err := verifyUserClaims(claims, &ci, nil, srv.IssuerURL); err != nil { if err != nil {
t.Fatalf("Failed to verify claims: %v", err) t.Fatalf("case %d: Failed getting client token: %v", i, err)
continue
}
claims, err := tok.Claims()
if err != nil {
t.Fatalf("case %d: Failed parsing claims from client token: %v", i, err)
}
if err := verifyUserClaims(claims, &ci, nil, srv.IssuerURL); err != nil {
t.Fatalf("case %d: Failed to verify claims: %v", i, err)
}
} }
} }

View file

@ -26,11 +26,12 @@ __Version:__ v1
``` ```
{ {
clientName: string // OPTIONAL. Name of the Client to be presented to the End-User. If desired, representation of this Claim in different languages and scripts is represented as described in Section 2.1 ( Metadata Languages and Scripts ) ., clientName: string // OPTIONAL for normal cliens. Name of the Client to be presented to the End-User. If desired, representation of this Claim in different languages and scripts is represented as described in Section 2.1 ( Metadata Languages and Scripts ). REQUIRED for public clients,
clientURI: string // OPTIONAL. URL of the home page of the Client. The value of this field MUST point to a valid Web page. If present, the server SHOULD display this URL to the End-User in a followable fashion. If desired, representation of this Claim in different languages and scripts is represented as described in Section 2.1 ( Metadata Languages and Scripts ) ., clientURI: string // OPTIONAL. URL of the home page of the Client. The value of this field MUST point to a valid Web page. If present, the server SHOULD display this URL to the End-User in a followable fashion. If desired, representation of this Claim in different languages and scripts is represented as described in Section 2.1 ( Metadata Languages and Scripts ) .,
id: string // The client ID. Ignored in client create requests., id: string // The client ID. Ignored in client create requests.,
isAdmin: boolean, isAdmin: boolean,
logoURI: string // OPTIONAL. URL that references a logo for the Client application. If present, the server SHOULD display this image to the End-User during approval. The value of this field MUST point to a valid image file. If desired, representation of this Claim in different languages and scripts is represented as described in Section 2.1 ( Metadata Languages and Scripts ) ., logoURI: string // OPTIONAL. URL that references a logo for the Client application. If present, the server SHOULD display this image to the End-User during approval. The value of this field MUST point to a valid image file. If desired, representation of this Claim in different languages and scripts is represented as described in Section 2.1 ( Metadata Languages and Scripts ) .,
public: boolean // OPTIONAL. Determines if the client is public. Public clients have certain restrictions: They cannot use their credentials to obtain a client JWT. Their redirects URLs cannot be specified: they are always http://localhost:$PORT or urn:ietf:wg:oauth:2.0:oob,
redirectURIs: [ redirectURIs: [
string string
], ],

View file

@ -24,6 +24,7 @@ func MapSchemaClientToClient(sc Client) (client.Client, error) {
Metadata: oidc.ClientMetadata{ Metadata: oidc.ClientMetadata{
RedirectURIs: make([]url.URL, len(sc.RedirectURIs)), RedirectURIs: make([]url.URL, len(sc.RedirectURIs)),
}, },
Public: sc.Public,
} }
for i, ru := range sc.RedirectURIs { for i, ru := range sc.RedirectURIs {
if ru == "" { if ru == "" {
@ -65,6 +66,8 @@ func MapClientToSchemaClient(c client.Client) Client {
Id: c.Credentials.ID, Id: c.Credentials.ID,
Secret: c.Credentials.Secret, Secret: c.Credentials.Secret,
RedirectURIs: make([]string, len(c.Metadata.RedirectURIs)), RedirectURIs: make([]string, len(c.Metadata.RedirectURIs)),
IsAdmin: c.Admin,
Public: c.Public,
} }
for i, u := range c.Metadata.RedirectURIs { for i, u := range c.Metadata.RedirectURIs {
cl.RedirectURIs[i] = u.String() cl.RedirectURIs[i] = u.String()
@ -78,6 +81,5 @@ func MapClientToSchemaClient(c client.Client) Client {
if c.Metadata.ClientURI != nil { if c.Metadata.ClientURI != nil {
cl.ClientURI = c.Metadata.ClientURI.String() cl.ClientURI = c.Metadata.ClientURI.String()
} }
cl.IsAdmin = c.Admin
return cl return cl
} }

View file

@ -43,6 +43,23 @@ func TestMapSchemaClientToClient(t *testing.T) {
ClientURI: mustParseURL(t, "https://clientURI.example.com"), ClientURI: mustParseURL(t, "https://clientURI.example.com"),
}, },
}, },
}, {
sc: Client{
Id: "456",
Secret: "sec_456",
ClientName: "Dave",
Public: true,
},
want: client.Client{
Credentials: oidc.ClientCredentials{
ID: "456",
Secret: "sec_456",
},
Metadata: oidc.ClientMetadata{
ClientName: "Dave",
},
Public: true,
},
}, { }, {
sc: Client{ sc: Client{
Id: "123", Id: "123",
@ -108,6 +125,24 @@ func TestMapClientToClientSchema(t *testing.T) {
}, },
}, },
}, },
{
want: Client{
Id: "456",
Secret: "sec_456",
ClientName: "Dave",
Public: true,
},
c: client.Client{
Credentials: oidc.ClientCredentials{
ID: "456",
Secret: "sec_456",
},
Metadata: oidc.ClientMetadata{
ClientName: "Dave",
},
Public: true,
},
},
} }
for i, tt := range tests { for i, tt := range tests {

View file

@ -110,10 +110,11 @@ type Admin struct {
} }
type Client struct { type Client struct {
// ClientName: OPTIONAL. Name of the Client to be presented to the // ClientName: OPTIONAL for normal cliens. Name of the Client to be
// End-User. If desired, representation of this Claim in different // presented to the End-User. If desired, representation of this Claim
// languages and scripts is represented as described in Section 2.1 ( // in different languages and scripts is represented as described in
// Metadata Languages and Scripts ) . // Section 2.1 ( Metadata Languages and Scripts ). REQUIRED for public
// clients
ClientName string `json:"clientName,omitempty"` ClientName string `json:"clientName,omitempty"`
// ClientURI: OPTIONAL. URL of the home page of the Client. The value of // ClientURI: OPTIONAL. URL of the home page of the Client. The value of
@ -137,13 +138,20 @@ type Client struct {
// Section 2.1 ( Metadata Languages and Scripts ) . // Section 2.1 ( Metadata Languages and Scripts ) .
LogoURI string `json:"logoURI,omitempty"` LogoURI string `json:"logoURI,omitempty"`
// RedirectURIs: REQUIRED. Array of Redirection URI values used by the // Public: OPTIONAL. Determines if the client is public. Public clients
// Client. One of these registered Redirection URI values MUST exactly // have certain restrictions: They cannot use their credentials to
// match the redirect_uri parameter value used in each Authorization // obtain a client JWT. Their redirects URLs cannot be specified: they
// Request, with the matching performed as described in Section 6.2.1 of // are always http://localhost:$PORT or urn:ietf:wg:oauth:2.0:oob
// [RFC3986] ( Berners-Lee, T., Fielding, R., and L. Masinter, Public bool `json:"public,omitempty"`
// “Uniform Resource Identifier (URI): Generic Syntax,” January
// 2005. ) (Simple String Comparison). // RedirectURIs: REQUIRED for normal clients. Array of Redirection URI
// values used by the Client. One of these registered Redirection URI
// values MUST exactly match the redirect_uri parameter value used in
// each Authorization Request, with the matching performed as described
// in Section 6.2.1 of [RFC3986] ( Berners-Lee, T., Fielding, R., and L.
// Masinter, “Uniform Resource Identifier (URI): Generic Syntax,”
// January 2005. ) (Simple String Comparison). DISALLOWED for public
// clients.
RedirectURIs []string `json:"redirectURIs,omitempty"` RedirectURIs []string `json:"redirectURIs,omitempty"`
// Secret: The client secret. Ignored in client create requests. // Secret: The client secret. Ignored in client create requests.

View file

@ -72,11 +72,11 @@ const DiscoveryJSON = `{
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "REQUIRED. Array of Redirection URI values used by the Client. One of these registered Redirection URI values MUST exactly match the redirect_uri parameter value used in each Authorization Request, with the matching performed as described in Section 6.2.1 of [RFC3986] ( Berners-Lee, T., Fielding, R., and L. Masinter, “Uniform Resource Identifier (URI): Generic Syntax,” January 2005. ) (Simple String Comparison)." "description": "REQUIRED for normal clients. Array of Redirection URI values used by the Client. One of these registered Redirection URI values MUST exactly match the redirect_uri parameter value used in each Authorization Request, with the matching performed as described in Section 6.2.1 of [RFC3986] ( Berners-Lee, T., Fielding, R., and L. Masinter, “Uniform Resource Identifier (URI): Generic Syntax,” January 2005. ) (Simple String Comparison). DISALLOWED for public clients."
}, },
"clientName": { "clientName": {
"type": "string", "type": "string",
"description": "OPTIONAL. Name of the Client to be presented to the End-User. If desired, representation of this Claim in different languages and scripts is represented as described in Section 2.1 ( Metadata Languages and Scripts ) ." "description": "OPTIONAL for normal cliens. Name of the Client to be presented to the End-User. If desired, representation of this Claim in different languages and scripts is represented as described in Section 2.1 ( Metadata Languages and Scripts ). REQUIRED for public clients"
}, },
"logoURI": { "logoURI": {
"type": "string", "type": "string",
@ -92,6 +92,10 @@ const DiscoveryJSON = `{
"type": "string" "type": "string"
}, },
"description": "Array of ClientIDs of clients that are allowed to mint ID tokens for the client being created." "description": "Array of ClientIDs of clients that are allowed to mint ID tokens for the client being created."
},
"public": {
"type": "boolean",
"description": "OPTIONAL. Determines if the client is public. Public clients have certain restrictions: They cannot use their credentials to obtain a client JWT. Their redirects URLs cannot be specified: they are always http://localhost:$PORT or urn:ietf:wg:oauth:2.0:oob"
} }
} }
}, },

View file

@ -65,11 +65,11 @@
"items": { "items": {
"type": "string" "type": "string"
}, },
"description": "REQUIRED. Array of Redirection URI values used by the Client. One of these registered Redirection URI values MUST exactly match the redirect_uri parameter value used in each Authorization Request, with the matching performed as described in Section 6.2.1 of [RFC3986] ( Berners-Lee, T., Fielding, R., and L. Masinter, “Uniform Resource Identifier (URI): Generic Syntax,” January 2005. ) (Simple String Comparison)." "description": "REQUIRED for normal clients. Array of Redirection URI values used by the Client. One of these registered Redirection URI values MUST exactly match the redirect_uri parameter value used in each Authorization Request, with the matching performed as described in Section 6.2.1 of [RFC3986] ( Berners-Lee, T., Fielding, R., and L. Masinter, “Uniform Resource Identifier (URI): Generic Syntax,” January 2005. ) (Simple String Comparison). DISALLOWED for public clients."
}, },
"clientName": { "clientName": {
"type": "string", "type": "string",
"description": "OPTIONAL. Name of the Client to be presented to the End-User. If desired, representation of this Claim in different languages and scripts is represented as described in Section 2.1 ( Metadata Languages and Scripts ) ." "description": "OPTIONAL for normal cliens. Name of the Client to be presented to the End-User. If desired, representation of this Claim in different languages and scripts is represented as described in Section 2.1 ( Metadata Languages and Scripts ). REQUIRED for public clients"
}, },
"logoURI": { "logoURI": {
"type": "string", "type": "string",
@ -85,6 +85,10 @@
"type": "string" "type": "string"
}, },
"description": "Array of ClientIDs of clients that are allowed to mint ID tokens for the client being created." "description": "Array of ClientIDs of clients that are allowed to mint ID tokens for the client being created."
},
"public": {
"type": "boolean",
"description": "OPTIONAL. Determines if the client is public. Public clients have certain restrictions: They cannot use their credentials to obtain a client JWT. Their redirects URLs cannot be specified: they are always http://localhost:$PORT or urn:ietf:wg:oauth:2.0:oob."
} }
} }
}, },

View file

@ -299,35 +299,23 @@ func getTemplates(issuerName, issuerLogoURL string,
} }
func setTemplates(srv *Server, tpls *template.Template) error { func setTemplates(srv *Server, tpls *template.Template) error {
ltpl, err := findTemplate(LoginPageTemplateName, tpls) for _, t := range []struct {
if err != nil { templateName string
return err templatePtr **template.Template
}{
{LoginPageTemplateName, &srv.LoginTemplate},
{RegisterTemplateName, &srv.RegisterTemplate},
{VerifyEmailTemplateName, &srv.VerifyEmailTemplate},
{SendResetPasswordEmailTemplateName, &srv.SendResetPasswordEmailTemplate},
{ResetPasswordTemplateName, &srv.ResetPasswordTemplate},
{OOBTemplateName, &srv.OOBTemplate},
} {
tpl, err := findTemplate(t.templateName, tpls)
if err != nil {
return err
}
*t.templatePtr = tpl
} }
srv.LoginTemplate = ltpl
rtpl, err := findTemplate(RegisterTemplateName, tpls)
if err != nil {
return err
}
srv.RegisterTemplate = rtpl
vtpl, err := findTemplate(VerifyEmailTemplateName, tpls)
if err != nil {
return err
}
srv.VerifyEmailTemplate = vtpl
srtpl, err := findTemplate(SendResetPasswordEmailTemplateName, tpls)
if err != nil {
return err
}
srv.SendResetPasswordEmailTemplate = srtpl
rpwtpl, err := findTemplate(ResetPasswordTemplateName, tpls)
if err != nil {
return err
}
srv.ResetPasswordTemplate = rpwtpl
return nil return nil
} }

View file

@ -44,6 +44,7 @@ var (
httpPathAcceptInvitation = "/accept-invitation" httpPathAcceptInvitation = "/accept-invitation"
httpPathDebugVars = "/debug/vars" httpPathDebugVars = "/debug/vars"
httpPathClientRegistration = "/registration" httpPathClientRegistration = "/registration"
httpPathOOB = "/oob"
cookieLastSeen = "LastSeen" cookieLastSeen = "LastSeen"
cookieShowEmailVerifiedMessage = "ShowEmailVerifiedMessage" cookieShowEmailVerifiedMessage = "ShowEmailVerifiedMessage"
@ -188,7 +189,7 @@ func renderLoginPage(w http.ResponseWriter, r *http.Request, srv OIDCServer, idp
// Render error message if client id is invalid. // Render error message if client id is invalid.
clientID := q.Get("client_id") clientID := q.Get("client_id")
cm, err := srv.ClientMetadata(clientID) _, err := srv.Client(clientID)
if err != nil { if err != nil {
log.Errorf("Failed fetching client %q from repo: %v", clientID, err) log.Errorf("Failed fetching client %q from repo: %v", clientID, err)
td.Error = true td.Error = true
@ -196,7 +197,7 @@ func renderLoginPage(w http.ResponseWriter, r *http.Request, srv OIDCServer, idp
execTemplate(w, tpl, td) execTemplate(w, tpl, td)
return return
} }
if cm == nil { if err == client.ErrorNotFound {
td.Error = true td.Error = true
td.Message = "Authentication Error" td.Message = "Authentication Error"
td.Detail = "Invalid client ID" td.Detail = "Invalid client ID"
@ -299,25 +300,19 @@ func handleAuthFunc(srv OIDCServer, idpcs []connector.Connector, tpl *template.T
return return
} }
cm, err := srv.ClientMetadata(acr.ClientID) cli, err := srv.Client(acr.ClientID)
if err != nil { if err != nil {
log.Errorf("Failed fetching client %q from repo: %v", acr.ClientID, err) log.Errorf("Failed fetching client %q from repo: %v", acr.ClientID, err)
writeAuthError(w, oauth2.NewError(oauth2.ErrorServerError), acr.State) writeAuthError(w, oauth2.NewError(oauth2.ErrorServerError), acr.State)
return return
} }
if cm == nil { if err == client.ErrorNotFound {
log.Errorf("Client %q not found", acr.ClientID) log.Errorf("Client %q not found", acr.ClientID)
writeAuthError(w, oauth2.NewError(oauth2.ErrorInvalidRequest), acr.State) writeAuthError(w, oauth2.NewError(oauth2.ErrorInvalidRequest), acr.State)
return return
} }
if len(cm.RedirectURIs) == 0 { redirectURL, err := cli.ValidRedirectURL(acr.RedirectURL)
log.Errorf("Client %q has no redirect URLs", acr.ClientID)
writeAuthError(w, oauth2.NewError(oauth2.ErrorServerError), acr.State)
return
}
redirectURL, err := client.ValidRedirectURL(acr.RedirectURL, cm.RedirectURIs)
if err != nil { if err != nil {
switch err { switch err {
case (client.ErrorCantChooseRedirectURL): case (client.ErrorCantChooseRedirectURL):
@ -554,6 +549,37 @@ func handleTokenFunc(srv OIDCServer) http.HandlerFunc {
} }
} }
func handleOOBFunc(s *Server, tpl *template.Template) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
w.Header().Set("Allow", "GET")
phttp.WriteError(w, http.StatusMethodNotAllowed, "GET only acceptable method")
return
}
key := r.URL.Query().Get("code")
if key == "" {
phttp.WriteError(w, http.StatusBadRequest, "Invalid Session")
return
}
sessionID, err := s.SessionManager.ExchangeKey(key)
if err != nil {
phttp.WriteError(w, http.StatusBadRequest, "Invalid Session")
return
}
code, err := s.SessionManager.NewSessionKey(sessionID)
if err != nil {
log.Errorf("problem getting NewSessionKey: %v", err)
phttp.WriteError(w, http.StatusInternalServerError, "Internal Server Error")
return
}
execTemplate(w, tpl, map[string]string{
"code": code,
})
}
}
func makeHealthHandler(checks []health.Checkable) http.Handler { func makeHealthHandler(checks []health.Checkable) http.Handler {
return health.Checker{ return health.Checker{
Checks: checks, Checks: checks,

View file

@ -105,6 +105,30 @@ func TestHandleAuthFuncResponsesSingleRedirectURL(t *testing.T) {
wantLocation: "http://fake.example.com", wantLocation: "http://fake.example.com",
}, },
// valid redirect_uri for public client
{
query: url.Values{
"response_type": []string{"code"},
"redirect_uri": []string{"http://localhost:8080"},
"client_id": []string{testPublicClientID},
"connector_id": []string{"fake"},
"scope": []string{"openid"},
},
wantCode: http.StatusFound,
wantLocation: "http://fake.example.com",
},
// valid OOB redirect_uri for public client
{
query: url.Values{
"response_type": []string{"code"},
"redirect_uri": []string{client.OOBRedirectURI},
"client_id": []string{testPublicClientID},
"connector_id": []string{"fake"},
"scope": []string{"openid"},
},
wantCode: http.StatusFound,
wantLocation: "http://fake.example.com",
},
// provided redirect_uri does not match client // provided redirect_uri does not match client
{ {
query: url.Values{ query: url.Values{
@ -173,6 +197,17 @@ func TestHandleAuthFuncResponsesSingleRedirectURL(t *testing.T) {
}, },
wantCode: http.StatusBadRequest, wantCode: http.StatusBadRequest,
}, },
// invalid redirect_uri for public client
{
query: url.Values{
"response_type": []string{"code"},
"redirect_uri": []string{client.OOBRedirectURI + "oops"},
"client_id": []string{testPublicClientID},
"connector_id": []string{"fake"},
"scope": []string{"openid"},
},
wantCode: http.StatusBadRequest,
},
} }
for i, tt := range tests { for i, tt := range tests {

View file

@ -38,12 +38,12 @@ const (
VerifyEmailTemplateName = "verify-email.html" VerifyEmailTemplateName = "verify-email.html"
SendResetPasswordEmailTemplateName = "send-reset-password.html" SendResetPasswordEmailTemplateName = "send-reset-password.html"
ResetPasswordTemplateName = "reset-password.html" ResetPasswordTemplateName = "reset-password.html"
OOBTemplateName = "oob-template.html"
APIVersion = "v1" APIVersion = "v1"
) )
type OIDCServer interface { type OIDCServer interface {
ClientMetadata(string) (*oidc.ClientMetadata, error) Client(string) (client.Client, 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)
@ -72,6 +72,7 @@ type Server struct {
VerifyEmailTemplate *template.Template VerifyEmailTemplate *template.Template
SendResetPasswordEmailTemplate *template.Template SendResetPasswordEmailTemplate *template.Template
ResetPasswordTemplate *template.Template ResetPasswordTemplate *template.Template
OOBTemplate *template.Template
HealthChecks []health.Checkable HealthChecks []health.Checkable
Connectors []connector.Connector Connectors []connector.Connector
@ -214,6 +215,7 @@ func (s *Server) HTTPHandler() http.Handler {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc(httpPathDiscovery, handleDiscoveryFunc(s.ProviderConfig())) mux.HandleFunc(httpPathDiscovery, handleDiscoveryFunc(s.ProviderConfig()))
mux.HandleFunc(httpPathAuth, handleAuthFunc(s, s.Connectors, s.LoginTemplate, s.EnableRegistration)) mux.HandleFunc(httpPathAuth, handleAuthFunc(s, s.Connectors, s.LoginTemplate, s.EnableRegistration))
mux.HandleFunc(httpPathOOB, handleOOBFunc(s, s.OOBTemplate))
mux.HandleFunc(httpPathToken, handleTokenFunc(s)) mux.HandleFunc(httpPathToken, handleTokenFunc(s))
mux.HandleFunc(httpPathKeys, handleKeysFunc(s.KeyManager, clock)) mux.HandleFunc(httpPathKeys, handleKeysFunc(s.KeyManager, clock))
mux.Handle(httpPathHealth, makeHealthHandler(checks)) mux.Handle(httpPathHealth, makeHealthHandler(checks))
@ -290,8 +292,8 @@ func (s *Server) NewClientTokenAuthHandler(handler http.Handler) http.Handler {
} }
} }
func (s *Server) ClientMetadata(clientID string) (*oidc.ClientMetadata, error) { func (s *Server) Client(clientID string) (client.Client, error) {
return s.ClientManager.Metadata(clientID) return s.ClientManager.Get(clientID)
} }
func (s *Server) NewSession(ipdcID, clientID, clientState string, redirectURL url.URL, nonce string, register bool, scope []string) (string, error) { func (s *Server) NewSession(ipdcID, clientID, clientState string, redirectURL url.URL, nonce string, register bool, scope []string) (string, error) {
@ -399,6 +401,9 @@ func (s *Server) Login(ident oidc.Identity, key string) (string, error) {
} }
ru := ses.RedirectURL ru := ses.RedirectURL
if ru.String() == client.OOBRedirectURI {
ru = s.absURL(httpPathOOB)
}
q := ru.Query() q := ru.Query()
q.Set("code", code) q.Set("code", code)
q.Set("state", ses.ClientState) q.Set("state", ses.ClientState)
@ -408,6 +413,15 @@ func (s *Server) Login(ident oidc.Identity, key string) (string, error) {
} }
func (s *Server) ClientCredsToken(creds oidc.ClientCredentials) (*jose.JWT, error) { func (s *Server) ClientCredsToken(creds oidc.ClientCredentials) (*jose.JWT, error) {
cli, err := s.Client(creds.ID)
if err != nil {
return nil, err
}
if cli.Public {
return nil, oauth2.NewError(oauth2.ErrorInvalidClient)
}
ok, err := s.ClientManager.Authenticate(creds) ok, err := s.ClientManager.Authenticate(creds)
if err != nil { if err != nil {
log.Errorf("Failed fetching client %s from manager: %v", creds.ID, err) log.Errorf("Failed fetching client %s from manager: %v", creds.ID, err)

View file

@ -39,6 +39,13 @@ var (
ID: testClientID, ID: testClientID,
Secret: clientTestSecret, Secret: clientTestSecret,
} }
testPublicClientID = "publicclient.example.com"
publicClientTestSecret = base64.URLEncoding.EncodeToString([]byte("secret"))
testPublicClientCredentials = oidc.ClientCredentials{
ID: testPublicClientID,
Secret: publicClientTestSecret,
}
testClients = []client.LoadableClient{ testClients = []client.LoadableClient{
{ {
Client: client.Client{ Client: client.Client{
@ -50,6 +57,12 @@ var (
}, },
}, },
}, },
{
Client: client.Client{
Credentials: testPublicClientCredentials,
Public: true,
},
},
} }
testConnectorID1 = "IDPC-1" testConnectorID1 = "IDPC-1"

View file

@ -3,7 +3,7 @@
"id": "XXX", "id": "XXX",
"secret": "c2VjcmV0ZQ==", "secret": "c2VjcmV0ZQ==",
"redirectURLs": ["http://127.0.0.1:5555/callback"], "redirectURLs": ["http://127.0.0.1:5555/callback"],
"trustedPeers": ["example-app"] "trustedPeers": ["example-app", "public"]
}, },
{ {
"id": "example-app", "id": "example-app",
@ -15,6 +15,11 @@
"secret": "ZXhhbXBsZS1jbGktc2VjcmV0", "secret": "ZXhhbXBsZS1jbGktc2VjcmV0",
"redirectURLs": ["http://127.0.0.1:8000/admin/v1/oauth/login"] "redirectURLs": ["http://127.0.0.1:8000/admin/v1/oauth/login"]
}, },
{
"id": "public",
"secret": "ZXhhbXBsZS1hcHAtc2VjcmV0",
"public": true
},
{ {
"id": "oauth2_proxy", "id": "oauth2_proxy",
"secret": "cHJveHk=", "secret": "cHJveHk=",

View file

@ -0,0 +1,11 @@
{{ template "header.html" }}
<div class="panel">
<h2 class="heading">Login Successful</h2>
Please copy this code, switch to your application and paste it there:
<br/>
<input type="text" value="{{ .code }}" />
</div>
{{ template "footer.html" }}