diff --git a/Documentation/clients.md b/Documentation/clients.md new file mode 100644 index 00000000..c034c540 --- /dev/null +++ b/Documentation/clients.md @@ -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. diff --git a/admin/api.go b/admin/api.go index bb805803..2b51672d 100644 --- a/admin/api.go +++ b/admin/api.go @@ -69,10 +69,15 @@ func errorMaker(typ string, desc string, code int) func(internal error) Error { var ( 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) 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.ErrorDuplicateEmail: errorMaker("bad_request", "Email already in use.", 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) { usr, err := a.userRepo.Get(nil, id) - if err != nil { return adminschema.Admin{}, mapError(err) } @@ -136,15 +140,9 @@ func (a *AdminAPI) CreateClient(req adminschema.ClientCreateRequest) (adminschem 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{ TrustedPeers: req.Client.TrustedPeers, }) - if err != nil { return adminschema.ClientCreateResponse{}, mapError(err) } @@ -165,6 +163,12 @@ func (a *AdminAPI) GetConnectors() ([]connector.ConnectorConfig, error) { } func mapError(e error) error { + switch t := e.(type) { + case client.ValidationError: + return ErrorInvalidClientFunc(t) + default: + } + if mapped, ok := errorMap[e]; ok { return mapped(e) } diff --git a/client/client.go b/client/client.go index 84073677..55590995 100644 --- a/client/client.go +++ b/client/client.go @@ -7,6 +7,7 @@ import ( "io" "net/url" "reflect" + "strings" "golang.org/x/crypto/bcrypt" @@ -19,11 +20,27 @@ var ( ErrorInvalidRedirectURL = errors.New("not a valid redirect url for the given client") ErrorCantChooseRedirectURL = errors.New("must provide a redirect url; client has many") 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 ( bcryptHashCost = 10 + + OOBRedirectURI = "urn:ietf:wg:oauth:2.0:oob" ) func HashSecret(creds oidc.ClientCredentials) ([]byte, error) { @@ -44,6 +61,35 @@ type Client struct { Credentials oidc.ClientCredentials Metadata oidc.ClientMetadata 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 { @@ -106,6 +152,7 @@ func ClientsFromReader(r io.Reader) ([]LoadableClient, error) { Secret string `json:"secret"` RedirectURLs []string `json:"redirectURLs"` Admin bool `json:"admin"` + Public bool `json:"public"` TrustedPeers []string `json:"trustedPeers"` } if err := json.NewDecoder(r).Decode(&c); err != nil { @@ -137,7 +184,8 @@ func ClientsFromReader(r io.Reader) ([]LoadableClient, error) { Metadata: oidc.ClientMetadata{ RedirectURIs: redirectURIs, }, - Admin: client.Admin, + Admin: client.Admin, + Public: client.Public, }, TrustedPeers: client.TrustedPeers, } diff --git a/client/client_test.go b/client/client_test.go index 289bc101..bd984f76 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -35,6 +35,13 @@ var ( "trustedPeers":["goodClient1", "goodClient2"] }` + publicClient = `{ + "id": "public_client", + "secret": "` + goodSecret3 + `", + "redirectURLs": ["http://localhost:8080","urn:ietf:wg:oauth:2.0:oob"], + "public": true +}` + badURLClient = `{ "id": "my_id", "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 + "]", 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 { u, err := url.Parse(s) if err != nil { diff --git a/client/manager/manager.go b/client/manager/manager.go index 5ee0790b..3fefff41 100644 --- a/client/manager/manager.go +++ b/client/manager/manager.go @@ -2,6 +2,7 @@ package manager import ( "encoding/base64" + "net/url" "errors" @@ -21,6 +22,10 @@ const ( maxSecretLength = 72 ) +var ( + localHostRedirectURL = mustParseURL("http://localhost:0") +) + type ClientOptions struct { 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) { tx, err := m.begin() if err != nil { @@ -74,11 +83,14 @@ func (m *ClientManager) New(cli client.Client, options *ClientOptions) (*oidc.Cl } defer tx.Rollback() + if err := validateClient(cli); err != nil { + return nil, err + } + err = m.addClientCredentials(&cli) if err != nil { return nil, err } - creds := cli.Credentials // Save Client @@ -156,7 +168,13 @@ func (m *ClientManager) SetDexAdmin(clientID string, isAdmin bool) error { func (m *ClientManager) Authenticate(creds oidc.ClientCredentials) (bool, error) { 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 } @@ -171,11 +189,15 @@ func (m *ClientManager) Authenticate(creds oidc.ClientCredentials) (bool, error) } func (m *ClientManager) addClientCredentials(cli *client.Client) error { - // Generate Client ID - if len(cli.Metadata.RedirectURIs) < 1 { - return errors.New("no client redirect url given") + var seed string + if cli.Public { + 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 { return err } @@ -192,3 +214,37 @@ func (m *ClientManager) addClientCredentials(cli *client.Client) error { } 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 +} diff --git a/client/manager/manager_test.go b/client/manager/manager_test.go index 1d489631..c6db2185 100644 --- a/client/manager/manager_test.go +++ b/client/manager/manager_test.go @@ -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) + } + } +} diff --git a/db/client.go b/db/client.go index eeb80c70..e1a65292 100644 --- a/db/client.go +++ b/db/client.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "net/url" "reflect" "github.com/coreos/go-oidc/oidc" @@ -23,6 +24,10 @@ const ( pgErrorCodeUniqueViolation = "23505" // unique_violation ) +var ( + localHostRedirectURL = mustParseURL("http://localhost:0") +) + func init() { register(table{ name: clientTableName, @@ -44,6 +49,16 @@ func newClientModel(cli client.Client) (*clientModel, error) { if err != nil { 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) if err != nil { return nil, err @@ -54,6 +69,7 @@ func newClientModel(cli client.Client) (*clientModel, error) { Secret: hashed, Metadata: string(bmeta), DexAdmin: cli.Admin, + Public: cli.Public, } return &cim, nil @@ -64,6 +80,7 @@ type clientModel struct { Secret []byte `db:"secret"` Metadata string `db:"metadata"` DexAdmin bool `db:"dex_admin"` + Public bool `db:"public"` } type trustedPeerModel struct { @@ -76,13 +93,18 @@ func (m *clientModel) Client() (*client.Client, error) { Credentials: oidc.ClientCredentials{ ID: m.ID, }, - Admin: m.DexAdmin, + Admin: m.DexAdmin, + Public: m.Public, } if err := json.Unmarshal([]byte(m.Metadata), &ci.Metadata); err != nil { return nil, err } + if ci.Public { + ci.Metadata.RedirectURIs = []url.URL{} + } + 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) { cim, err := newClientModel(cli) - if err != nil { return nil, err } @@ -328,3 +349,11 @@ func (r *clientRepo) SetTrustedPeers(tx repo.Transaction, clientID string, clien return nil } + +func mustParseURL(s string) url.URL { + u, err := url.Parse(s) + if err != nil { + panic(err) + } + return *u +} diff --git a/db/migrate_sqlite3.go b/db/migrate_sqlite3.go index 523cce64..07c64546 100644 --- a/db/migrate_sqlite3.go +++ b/db/migrate_sqlite3.go @@ -16,7 +16,8 @@ CREATE TABLE client_identity ( id text NOT NULL UNIQUE, secret blob, metadata text, - dex_admin integer + dex_admin integer, + public integer ); CREATE TABLE connector_config ( diff --git a/db/migrate_test.go b/db/migrate_test.go index 1c81adc9..3902d54b 100644 --- a/db/migrate_test.go +++ b/db/migrate_test.go @@ -48,6 +48,22 @@ func TestGetPlannedMigrations(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") if dsn == "" { 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 { - model := &clientModel{ + model := &oldClientModel{ ID: strconv.Itoa(i), Secret: []byte("verysecret"), Metadata: tt.before, @@ -108,12 +124,12 @@ func TestMigrateClientMetadata(t *testing.T) { for i, tt := range tests { id := strconv.Itoa(i) - m, err := dbMap.Get(clientModel{}, id) + m, err := dbMap.Get(oldClientModel{}, id) if err != nil { t.Errorf("case %d: failed to get model: %v", i, err) continue } - cim, ok := m.(*clientModel) + cim, ok := m.(*oldClientModel) if !ok { t.Errorf("case %d: unrecognized model type: %T", i, m) continue diff --git a/db/migrations/0013_add_public_clients.sql b/db/migrations/0013_add_public_clients.sql new file mode 100644 index 00000000..e4f1d3dc --- /dev/null +++ b/db/migrations/0013_add_public_clients.sql @@ -0,0 +1,4 @@ +-- +migrate Up +ALTER TABLE client_identity ADD COLUMN "public" boolean; + +UPDATE "client_identity" SET "public" = false; diff --git a/db/migrations/assets.go b/db/migrations/assets.go index 6798acc1..1a4b5f89 100644 --- a/db/migrations/assets.go +++ b/db/migrations/assets.go @@ -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", }, }, + { + 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", Up: []string{ diff --git a/examples/app/assets.go b/examples/app/assets.go index 68712955..17f63658 100644 --- a/examples/app/assets.go +++ b/examples/app/assets.go @@ -68,7 +68,7 @@ func (fi bindataFileInfo) Sys() interface{} { 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) { return bindataRead( @@ -83,7 +83,7 @@ func dataIndexHtml() (*asset, error) { 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} return a, nil } diff --git a/examples/app/data/index.html b/examples/app/data/index.html index 826bc686..a9b1e422 100644 --- a/examples/app/data/index.html +++ b/examples/app/data/index.html @@ -1,5 +1,6 @@
+ - +{{ if .OOB }} + +{{ end }} + +{{ if not .OOB }} +{{ end }} diff --git a/examples/app/main.go b/examples/app/main.go index 46dcca1b..fd4427a2 100644 --- a/examples/app/main.go +++ b/examples/app/main.go @@ -23,6 +23,7 @@ import ( "github.com/coreos/go-oidc/oauth2" "github.com/coreos/go-oidc/oidc" + "github.com/coreos/dex/client" pflag "github.com/coreos/dex/pkg/flag" phttp "github.com/coreos/dex/pkg/http" "github.com/coreos/dex/pkg/log" @@ -163,15 +164,21 @@ func main() { func NewClientHandler(c *oidc.Client, issuer string, cbURL url.URL) http.Handler { mux := http.NewServeMux() + oob := cbURL.String() == client.OOBRedirectURI + issuerURL, err := url.Parse(issuer) if err != nil { log.Fatalf("Could not parse issuer url: %v", err) } - mux.HandleFunc("/", handleIndex) + mux.HandleFunc("/", handleIndexFunc(oob)) mux.HandleFunc("/login", handleLoginFunc(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.Path = "/resend-verify-email" @@ -180,12 +187,16 @@ func NewClientHandler(c *oidc.Client, issuer string, cbURL url.URL) http.Handler return mux } -func handleIndex(w http.ResponseWriter, r *http.Request) { - err := indexTemplate.Execute(w, nil) - if err != nil { - phttp.WriteError(w, http.StatusInternalServerError, - fmt.Sprintf("unable to execute template: %v", err)) +func handleIndexFunc(oob bool) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + err := indexTemplate.Execute(w, map[string]interface{}{ + "OOB": oob, + }) + if err != nil { + phttp.WriteError(w, http.StatusInternalServerError, + fmt.Sprintf("unable to execute template: %v", err)) + } } } diff --git a/integration/admin_api_test.go b/integration/admin_api_test.go index ef51b06a..4fc7ac30 100644 --- a/integration/admin_api_test.go +++ b/integration/admin_api_test.go @@ -383,7 +383,11 @@ func TestCreateClient(t *testing.T) { } 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")) 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.IsAdmin = true clientGoodAdmin := clientGood @@ -479,6 +500,27 @@ func TestCreateClient(t *testing.T) { wantClient: clientGood, 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 { @@ -530,6 +572,7 @@ func TestCreateClient(t *testing.T) { repoClient, err := f.cr.Get(nil, resp.Client.Id) if err != nil { t.Errorf("case %d: Unexpected error getting client: %v", i, err) + continue } if diff := pretty.Compare(tt.wantClient, repoClient); diff != "" { diff --git a/integration/oidc_test.go b/integration/oidc_test.go index 64c404b5..c7f5377c 100644 --- a/integration/oidc_test.go +++ b/integration/oidc_test.go @@ -303,30 +303,69 @@ func TestHTTPClientCredsToken(t *testing.T) { }, }, } - cis := []client.LoadableClient{{Client: ci}} - srv, err := mockServer(cis) - if err != nil { - t.Fatalf("Unexpected error setting up server: %v", err) + ci2 := ci + ci2.Credentials.ID = "not_a_client" + + 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) - if err != nil { - t.Fatalf("Unexpected error setting up OIDC client: %v", err) - } + for i, tt := range tests { + srv, err := mockServer(tt.clients) + if err != nil { + t.Fatalf("case %d: Unexpected error setting up server: %v", i, err) + } - tok, err := cl.ClientCredsToken([]string{"openid"}) - if err != nil { - t.Fatalf("Failed getting client token: %v", err) - } + cl, err := mockClient(srv, tt.cli) + if err != nil { + t.Fatalf("case %d: Unexpected error setting up OIDC client: %v", i, err) + } - claims, err := tok.Claims() - if err != nil { - t.Fatalf("Failed parsing claims from client token: %v", err) - } + tok, err := cl.ClientCredsToken([]string{"openid"}) + if tt.wantErr { + if err == nil { + t.Errorf("case %d: want non-nil error", i) + } + continue + } - if err := verifyUserClaims(claims, &ci, nil, srv.IssuerURL); err != nil { - t.Fatalf("Failed to verify claims: %v", err) + if err != nil { + 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) + } } } diff --git a/schema/adminschema/README.md b/schema/adminschema/README.md index a3f0650b..133df9ba 100644 --- a/schema/adminschema/README.md +++ b/schema/adminschema/README.md @@ -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 ) ., id: string // The client ID. Ignored in client create requests., 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 ) ., + 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: [ string ], diff --git a/schema/adminschema/mapper.go b/schema/adminschema/mapper.go index f360750a..d116742d 100644 --- a/schema/adminschema/mapper.go +++ b/schema/adminschema/mapper.go @@ -24,6 +24,7 @@ func MapSchemaClientToClient(sc Client) (client.Client, error) { Metadata: oidc.ClientMetadata{ RedirectURIs: make([]url.URL, len(sc.RedirectURIs)), }, + Public: sc.Public, } for i, ru := range sc.RedirectURIs { if ru == "" { @@ -65,6 +66,8 @@ func MapClientToSchemaClient(c client.Client) Client { Id: c.Credentials.ID, Secret: c.Credentials.Secret, RedirectURIs: make([]string, len(c.Metadata.RedirectURIs)), + IsAdmin: c.Admin, + Public: c.Public, } for i, u := range c.Metadata.RedirectURIs { cl.RedirectURIs[i] = u.String() @@ -78,6 +81,5 @@ func MapClientToSchemaClient(c client.Client) Client { if c.Metadata.ClientURI != nil { cl.ClientURI = c.Metadata.ClientURI.String() } - cl.IsAdmin = c.Admin return cl } diff --git a/schema/adminschema/mapper_test.go b/schema/adminschema/mapper_test.go index e1b17b32..5ce2a115 100644 --- a/schema/adminschema/mapper_test.go +++ b/schema/adminschema/mapper_test.go @@ -43,6 +43,23 @@ func TestMapSchemaClientToClient(t *testing.T) { 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{ 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 { diff --git a/schema/adminschema/v1-gen.go b/schema/adminschema/v1-gen.go index 2f897d09..c441eae5 100644 --- a/schema/adminschema/v1-gen.go +++ b/schema/adminschema/v1-gen.go @@ -110,10 +110,11 @@ type Admin struct { } type Client struct { - // ClientName: 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: 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 ClientName string `json:"clientName,omitempty"` // 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 ) . LogoURI string `json:"logoURI,omitempty"` - // RedirectURIs: 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). + // Public: 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 + Public bool `json:"public,omitempty"` + + // 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"` // Secret: The client secret. Ignored in client create requests. diff --git a/schema/adminschema/v1-json.go b/schema/adminschema/v1-json.go index aebdbfae..acb2345d 100644 --- a/schema/adminschema/v1-json.go +++ b/schema/adminschema/v1-json.go @@ -72,11 +72,11 @@ const DiscoveryJSON = `{ "items": { "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": { "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": { "type": "string", @@ -92,6 +92,10 @@ const DiscoveryJSON = `{ "type": "string" }, "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" } } }, diff --git a/schema/adminschema/v1.json b/schema/adminschema/v1.json index 6e868461..7ca75c1d 100644 --- a/schema/adminschema/v1.json +++ b/schema/adminschema/v1.json @@ -65,11 +65,11 @@ "items": { "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": { "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": { "type": "string", @@ -85,6 +85,10 @@ "type": "string" }, "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." } } }, diff --git a/server/config.go b/server/config.go index 0f329d04..8698e253 100644 --- a/server/config.go +++ b/server/config.go @@ -299,35 +299,23 @@ func getTemplates(issuerName, issuerLogoURL string, } func setTemplates(srv *Server, tpls *template.Template) error { - ltpl, err := findTemplate(LoginPageTemplateName, tpls) - if err != nil { - return err + for _, t := range []struct { + templateName string + 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 } diff --git a/server/http.go b/server/http.go index 4e4cd531..35e0d54e 100644 --- a/server/http.go +++ b/server/http.go @@ -44,6 +44,7 @@ var ( httpPathAcceptInvitation = "/accept-invitation" httpPathDebugVars = "/debug/vars" httpPathClientRegistration = "/registration" + httpPathOOB = "/oob" cookieLastSeen = "LastSeen" 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. clientID := q.Get("client_id") - cm, err := srv.ClientMetadata(clientID) + _, err := srv.Client(clientID) if err != nil { log.Errorf("Failed fetching client %q from repo: %v", clientID, err) td.Error = true @@ -196,7 +197,7 @@ func renderLoginPage(w http.ResponseWriter, r *http.Request, srv OIDCServer, idp execTemplate(w, tpl, td) return } - if cm == nil { + if err == client.ErrorNotFound { td.Error = true td.Message = "Authentication Error" td.Detail = "Invalid client ID" @@ -299,25 +300,19 @@ func handleAuthFunc(srv OIDCServer, idpcs []connector.Connector, tpl *template.T return } - cm, err := srv.ClientMetadata(acr.ClientID) + cli, err := srv.Client(acr.ClientID) if err != nil { log.Errorf("Failed fetching client %q from repo: %v", acr.ClientID, err) writeAuthError(w, oauth2.NewError(oauth2.ErrorServerError), acr.State) return } - if cm == nil { + if err == client.ErrorNotFound { log.Errorf("Client %q not found", acr.ClientID) writeAuthError(w, oauth2.NewError(oauth2.ErrorInvalidRequest), acr.State) return } - if len(cm.RedirectURIs) == 0 { - 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) + redirectURL, err := cli.ValidRedirectURL(acr.RedirectURL) if err != nil { switch err { 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 { return health.Checker{ Checks: checks, diff --git a/server/http_test.go b/server/http_test.go index 1a2de802..637f53b7 100644 --- a/server/http_test.go +++ b/server/http_test.go @@ -105,6 +105,30 @@ func TestHandleAuthFuncResponsesSingleRedirectURL(t *testing.T) { 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 { query: url.Values{ @@ -173,6 +197,17 @@ func TestHandleAuthFuncResponsesSingleRedirectURL(t *testing.T) { }, 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 { diff --git a/server/server.go b/server/server.go index 172998b5..c993431f 100644 --- a/server/server.go +++ b/server/server.go @@ -38,12 +38,12 @@ const ( VerifyEmailTemplateName = "verify-email.html" SendResetPasswordEmailTemplateName = "send-reset-password.html" ResetPasswordTemplateName = "reset-password.html" - - APIVersion = "v1" + OOBTemplateName = "oob-template.html" + APIVersion = "v1" ) 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) Login(oidc.Identity, string) (string, error) @@ -72,6 +72,7 @@ type Server struct { VerifyEmailTemplate *template.Template SendResetPasswordEmailTemplate *template.Template ResetPasswordTemplate *template.Template + OOBTemplate *template.Template HealthChecks []health.Checkable Connectors []connector.Connector @@ -214,6 +215,7 @@ func (s *Server) HTTPHandler() http.Handler { mux := http.NewServeMux() mux.HandleFunc(httpPathDiscovery, handleDiscoveryFunc(s.ProviderConfig())) 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(httpPathKeys, handleKeysFunc(s.KeyManager, clock)) 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) { - return s.ClientManager.Metadata(clientID) +func (s *Server) Client(clientID string) (client.Client, error) { + return s.ClientManager.Get(clientID) } 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 + if ru.String() == client.OOBRedirectURI { + ru = s.absURL(httpPathOOB) + } q := ru.Query() q.Set("code", code) 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) { + 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) if err != nil { log.Errorf("Failed fetching client %s from manager: %v", creds.ID, err) diff --git a/server/testutil_test.go b/server/testutil_test.go index 16317a03..212a30ae 100644 --- a/server/testutil_test.go +++ b/server/testutil_test.go @@ -39,6 +39,13 @@ var ( ID: testClientID, Secret: clientTestSecret, } + + testPublicClientID = "publicclient.example.com" + publicClientTestSecret = base64.URLEncoding.EncodeToString([]byte("secret")) + testPublicClientCredentials = oidc.ClientCredentials{ + ID: testPublicClientID, + Secret: publicClientTestSecret, + } testClients = []client.LoadableClient{ { Client: client.Client{ @@ -50,6 +57,12 @@ var ( }, }, }, + { + Client: client.Client{ + Credentials: testPublicClientCredentials, + Public: true, + }, + }, } testConnectorID1 = "IDPC-1" diff --git a/static/fixtures/clients.json.sample b/static/fixtures/clients.json.sample index 4fb9b62e..50d9d649 100644 --- a/static/fixtures/clients.json.sample +++ b/static/fixtures/clients.json.sample @@ -3,7 +3,7 @@ "id": "XXX", "secret": "c2VjcmV0ZQ==", "redirectURLs": ["http://127.0.0.1:5555/callback"], - "trustedPeers": ["example-app"] + "trustedPeers": ["example-app", "public"] }, { "id": "example-app", @@ -15,6 +15,11 @@ "secret": "ZXhhbXBsZS1jbGktc2VjcmV0", "redirectURLs": ["http://127.0.0.1:8000/admin/v1/oauth/login"] }, + { + "id": "public", + "secret": "ZXhhbXBsZS1hcHAtc2VjcmV0", + "public": true + }, { "id": "oauth2_proxy", "secret": "cHJveHk=", diff --git a/static/html/oob-template.html b/static/html/oob-template.html new file mode 100644 index 00000000..0f33fa4d --- /dev/null +++ b/static/html/oob-template.html @@ -0,0 +1,11 @@ +{{ template "header.html" }} + +