Merge pull request #471 from bobbyrullo/native
Implement Public Clients
This commit is contained in:
commit
3b8d704c9c
29 changed files with 763 additions and 116 deletions
58
Documentation/clients.md
Normal file
58
Documentation/clients.md
Normal 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.
|
20
admin/api.go
20
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)
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
33
db/client.go
33
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
|
||||
}
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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
|
||||
|
|
4
db/migrations/0013_add_public_clients.sql
Normal file
4
db/migrations/0013_add_public_clients.sql
Normal file
|
@ -0,0 +1,4 @@
|
|||
-- +migrate Up
|
||||
ALTER TABLE client_identity ADD COLUMN "public" boolean;
|
||||
|
||||
UPDATE "client_identity" SET "public" = false;
|
|
@ -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{
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
<html>
|
||||
<body>
|
||||
|
||||
<form action="/login">
|
||||
<table>
|
||||
<tr>
|
||||
|
@ -7,15 +8,29 @@
|
|||
<br>
|
||||
(comma-separated list of client-ids)
|
||||
</td>
|
||||
<td> <input type="text" name="cross_client"> </td>
|
||||
|
||||
<td> <input type="text" name="cross_client" > </td>
|
||||
</tr>
|
||||
</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>
|
||||
{{ end }}
|
||||
|
||||
{{ if not .OOB }}
|
||||
<form action="/register">
|
||||
<input type="submit" value="Register">
|
||||
<input type="submit" value="Register" />
|
||||
</form>
|
||||
{{ end }}
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -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))
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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 != "" {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
],
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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=",
|
||||
|
|
11
static/html/oob-template.html
Normal file
11
static/html/oob-template.html
Normal 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" }}
|
Reference in a new issue