Make all tests that deal with the store interfaces go through the Context. This
is mainly important so that pre- and post- save/retrieval/deletion/whatever
transforms can be done without doing them in every single implementation of the
store.
Change the Endpoint URI property to be a string, not a *url.URL. This makes
testing easier, JSON responses cleaner, and is all around just a better
strategy. Just because we turn it into a URL every now and then doesn't mean
that's how we need to store it.
Add JSON tags to the Client type and Endpoint type.
Create normalizeURI and normalizeURIString methods to... well, normalize the
Endpoint URIs. This makes it so that we can compare them, and forgive some
arbitrary user behaviour (like slashes, etc.)
Add a NormalizedURI property to the Endpoint type. This is where we store the
NormalizedURI, which is what we'll be using when we want to check if an endpoint
is valid or not. For the sake of tests and predictability, however, we always
want to redirect to the URI, not the NormalizedURI.
Add checks to the Client creation API endpoint to give better errors. Now
leaving out the Type won't be considered an invalid type, it will be considered
a missing parameter. An empty name will be reported as a missing parameter, a
name with too few characters will be reported as an insufficient name, and a
name with too many characters will be reported as an overflow name. We gather as
many of these errors as apply before returning.
Check if an Endpoint URI is absolute before adding it as an endpoint, or return
an invalid value error if it is not.
Always return the errors array when creating a client. We could succeed in
creating one or more things and still have errors. We should return anything
that's created _as well as_ any errors encountered.
Add unit testing for our CreateClientHandler.
Fix our oauth2 tests so that if there's an error in the body, it's in the test
logs. This should help debugging significantly.
Fix our oauth2 tests so that the Profile only requires 1 iteration for its
password hashing. This means each time we want to validate a session, it doesn't
add a full second to our test runs. This is a big speed improvement for our
tests.
Add test helper methods for comparing API errors, API responses, and filling in
server-generated information in a response that it's impossible to have an
expectation around (e.g., IDs) so that we can use our comparison helpers to
check if a response is as we expect it.
Fix a typo in our Context helpers that was reporting no sessionStore being set
_only_ when a sessionStore was set. So yes, the opposite of what we wanted.
Oops. This was discovered by passing all our tests through the context. methods
instead of operating on the stores themselves.
14 "github.com/PuerkitoBio/purell"
15 "github.com/gorilla/mux"
17 "code.secondbit.org/uuid.hg"
21 // ErrNoClientStore is returned when a Context tries to act on a clientStore without setting one first.
22 ErrNoClientStore = errors.New("no clientStore was specified for the Context")
23 // ErrClientNotFound is returned when a Client is requested but not found in a clientStore.
24 ErrClientNotFound = errors.New("client not found in clientStore")
25 // ErrClientAlreadyExists is returned when a Client is added to a clientStore, but another Client with
26 // the same ID already exists in the clientStore.
27 ErrClientAlreadyExists = errors.New("client already exists in clientStore")
29 // ErrEmptyChange is returned when a Change has all its properties set to nil.
30 ErrEmptyChange = errors.New("change must have at least one property set")
31 // ErrClientNameTooShort is returned when a Client's Name property is too short.
32 ErrClientNameTooShort = errors.New("client name must be at least 2 characters")
33 // ErrClientNameTooLong is returned when a Client's Name property is too long.
34 ErrClientNameTooLong = errors.New("client name must be at most 32 characters")
35 // ErrClientLogoTooLong is returned when a Client's Logo property is too long.
36 ErrClientLogoTooLong = errors.New("client logo must be at most 1024 characters")
37 // ErrClientLogoNotURL is returned when a Client's Logo property is not a valid absolute URL.
38 ErrClientLogoNotURL = errors.New("client logo must be a valid absolute URL")
39 // ErrClientWebsiteTooLong is returned when a Client's Website property is too long.
40 ErrClientWebsiteTooLong = errors.New("client website must be at most 1024 characters")
41 // ErrClientWebsiteNotURL is returned when a Client's Website property is not a valid absolute URL.
42 ErrClientWebsiteNotURL = errors.New("client website must be a valid absolute URL")
43 // ErrEndpointURINotURL is returned when an Endpoint's URI property is not a valid absolute URL.
44 ErrEndpointURINotURL = errors.New("endpoint URI must be a valid absolute URL")
48 clientTypePublic = "public"
49 clientTypeConfidential = "confidential"
54 // Client represents a client that grants access
55 // to the auth server, exchanging grants for tokens,
56 // and tokens for access.
58 ID uuid.ID `json:"id,omitempty"`
59 Secret string `json:"secret,omitempty"`
60 OwnerID uuid.ID `json:"owner_id,omitempty"`
61 Name string `json:"name,omitempty"`
62 Logo string `json:"logo,omitempty"`
63 Website string `json:"website,omitempty"`
64 Type string `json:"type,omitempty"`
67 // ApplyChange applies the properties of the passed
68 // ClientChange to the Client object it is called on.
69 func (c *Client) ApplyChange(change ClientChange) {
70 if change.Secret != nil {
71 c.Secret = *change.Secret
73 if change.OwnerID != nil {
74 c.OwnerID = change.OwnerID
76 if change.Name != nil {
79 if change.Logo != nil {
82 if change.Website != nil {
83 c.Website = *change.Website
87 // ClientChange represents a bundle of options for
88 // updating a Client's mutable data.
89 type ClientChange struct {
97 // Validate checks the ClientChange it is called on
98 // and asserts its internal validity, or lack thereof.
99 func (c ClientChange) Validate() error {
100 if c.Secret == nil && c.OwnerID == nil && c.Name == nil && c.Logo == nil && c.Website == nil {
101 return ErrEmptyChange
103 if c.Name != nil && len(*c.Name) < 2 {
104 return ErrClientNameTooShort
106 if c.Name != nil && len(*c.Name) > 32 {
107 return ErrClientNameTooLong
109 if c.Logo != nil && *c.Logo != "" {
110 if len(*c.Logo) > 1024 {
111 return ErrClientLogoTooLong
113 u, err := url.Parse(*c.Logo)
114 if err != nil || !u.IsAbs() {
115 return ErrClientLogoNotURL
118 if c.Website != nil && *c.Website != "" {
119 if len(*c.Website) > 140 {
120 return ErrClientWebsiteTooLong
122 u, err := url.Parse(*c.Website)
123 if err != nil || !u.IsAbs() {
124 return ErrClientWebsiteNotURL
130 func verifyClient(w http.ResponseWriter, r *http.Request, allowPublic bool, context Context) (uuid.ID, bool) {
131 enc := json.NewEncoder(w)
132 clientIDStr, clientSecret, fromAuthHeader := r.BasicAuth()
135 w.WriteHeader(http.StatusBadRequest)
136 renderJSONError(enc, "unauthorized_client")
139 clientIDStr = r.PostFormValue("client_id")
141 clientID, err := uuid.Parse(clientIDStr)
143 w.WriteHeader(http.StatusUnauthorized)
145 w.Header().Set("WWW-Authenticate", "Basic")
147 renderJSONError(enc, "invalid_client")
150 client, err := context.GetClient(clientID)
151 if err == ErrClientNotFound {
152 w.WriteHeader(http.StatusUnauthorized)
154 w.Header().Set("WWW-Authenticate", "Basic")
156 renderJSONError(enc, "invalid_client")
158 } else if err != nil {
159 w.WriteHeader(http.StatusInternalServerError)
160 renderJSONError(enc, "server_error")
163 if client.Secret != clientSecret { // it's important that any client deemed "public" is not issued a client secret.
164 w.WriteHeader(http.StatusUnauthorized)
166 w.Header().Set("WWW-Authenticate", "Basic")
168 renderJSONError(enc, "invalid_client")
171 return clientID, true
174 // Endpoint represents a single URI that a Client
175 // controls. Users will be redirected to these URIs
176 // following successful authorization grants and
177 // exchanges for access tokens.
178 type Endpoint struct {
179 ID uuid.ID `json:"id,omitempty"`
180 ClientID uuid.ID `json:"client_id,omitempty"`
181 URI string `json:"uri,omitempty"`
182 NormalizedURI string `json:"-"`
183 Added time.Time `json:"added,omitempty"`
186 func normalizeURIString(in string) (string, error) {
187 n, err := purell.NormalizeURLString(in, purell.FlagsUsuallySafeNonGreedy|purell.FlagSortQuery)
190 return in, ErrEndpointURINotURL
195 func normalizeURI(in *url.URL) string {
196 return purell.NormalizeURL(in, purell.FlagsUsuallySafeNonGreedy|purell.FlagSortQuery)
199 type sortedEndpoints []Endpoint
201 func (s sortedEndpoints) Len() int {
205 func (s sortedEndpoints) Less(i, j int) bool {
206 return s[i].Added.Before(s[j].Added)
209 func (s sortedEndpoints) Swap(i, j int) {
210 s[i], s[j] = s[j], s[i]
213 type clientStore interface {
214 getClient(id uuid.ID) (Client, error)
215 saveClient(client Client) error
216 updateClient(id uuid.ID, change ClientChange) error
217 deleteClient(id uuid.ID) error
218 listClientsByOwner(ownerID uuid.ID, num, offset int) ([]Client, error)
220 addEndpoints(client uuid.ID, endpoint []Endpoint) error
221 removeEndpoint(client, endpoint uuid.ID) error
222 checkEndpoint(client uuid.ID, endpoint string) (bool, error)
223 listEndpoints(client uuid.ID, num, offset int) ([]Endpoint, error)
224 countEndpoints(client uuid.ID) (int64, error)
227 func (m *memstore) getClient(id uuid.ID) (Client, error) {
229 defer m.clientLock.RUnlock()
230 c, ok := m.clients[id.String()]
232 return Client{}, ErrClientNotFound
237 func (m *memstore) saveClient(client Client) error {
239 defer m.clientLock.Unlock()
240 if _, ok := m.clients[client.ID.String()]; ok {
241 return ErrClientAlreadyExists
243 m.clients[client.ID.String()] = client
244 m.profileClientLookup[client.OwnerID.String()] = append(m.profileClientLookup[client.OwnerID.String()], client.ID)
248 func (m *memstore) updateClient(id uuid.ID, change ClientChange) error {
250 defer m.clientLock.Unlock()
251 c, ok := m.clients[id.String()]
253 return ErrClientNotFound
255 c.ApplyChange(change)
256 m.clients[id.String()] = c
260 func (m *memstore) deleteClient(id uuid.ID) error {
261 client, err := m.getClient(id)
266 defer m.clientLock.Unlock()
267 delete(m.clients, id.String())
269 for p, item := range m.profileClientLookup[client.OwnerID.String()] {
276 m.profileClientLookup[client.OwnerID.String()] = append(m.profileClientLookup[client.OwnerID.String()][:pos], m.profileClientLookup[client.OwnerID.String()][pos+1:]...)
281 func (m *memstore) listClientsByOwner(ownerID uuid.ID, num, offset int) ([]Client, error) {
282 ids := m.lookupClientsByProfileID(ownerID.String())
283 if len(ids) > num+offset {
284 ids = ids[offset : num+offset]
285 } else if len(ids) > offset {
288 return []Client{}, nil
290 clients := []Client{}
291 for _, id := range ids {
292 client, err := m.getClient(id)
294 return []Client{}, err
296 clients = append(clients, client)
301 func (m *memstore) addEndpoints(client uuid.ID, endpoints []Endpoint) error {
302 m.endpointLock.Lock()
303 defer m.endpointLock.Unlock()
304 m.endpoints[client.String()] = append(m.endpoints[client.String()], endpoints...)
308 func (m *memstore) removeEndpoint(client, endpoint uuid.ID) error {
309 m.endpointLock.Lock()
310 defer m.endpointLock.Unlock()
312 for p, item := range m.endpoints[client.String()] {
313 if item.ID.Equal(endpoint) {
319 m.endpoints[client.String()] = append(m.endpoints[client.String()][:pos], m.endpoints[client.String()][pos+1:]...)
324 func (m *memstore) checkEndpoint(client uuid.ID, endpoint string) (bool, error) {
325 m.endpointLock.RLock()
326 defer m.endpointLock.RUnlock()
327 for _, candidate := range m.endpoints[client.String()] {
328 if endpoint == candidate.NormalizedURI {
335 func (m *memstore) listEndpoints(client uuid.ID, num, offset int) ([]Endpoint, error) {
336 m.endpointLock.RLock()
337 defer m.endpointLock.RUnlock()
338 return m.endpoints[client.String()], nil
341 func (m *memstore) countEndpoints(client uuid.ID) (int64, error) {
342 m.endpointLock.RLock()
343 defer m.endpointLock.RUnlock()
344 return int64(len(m.endpoints[client.String()])), nil
347 type newClientReq struct {
348 Name string `json:"name"`
349 Logo string `json:"logo"`
350 Website string `json:"website"`
351 Type string `json:"type"`
352 Endpoints []string `json:"endpoints"`
355 func RegisterClientHandlers(r *mux.Router, context Context) {
356 r.Handle("/clients", wrap(context, CreateClientHandler)).Methods("POST")
359 func CreateClientHandler(w http.ResponseWriter, r *http.Request, c Context) {
360 errors := []requestError{}
361 username, password, ok := r.BasicAuth()
363 errors = append(errors, requestError{Slug: requestErrAccessDenied})
364 encode(w, r, http.StatusUnauthorized, response{Errors: errors})
367 profile, err := authenticate(username, password, c)
369 errors = append(errors, requestError{Slug: requestErrAccessDenied})
370 encode(w, r, http.StatusUnauthorized, response{Errors: errors})
374 decoder := json.NewDecoder(r.Body)
375 err = decoder.Decode(&req)
377 encode(w, r, http.StatusBadRequest, invalidFormatResponse)
381 errors = append(errors, requestError{Slug: requestErrMissing, Field: "/type"})
382 } else if req.Type != clientTypePublic && req.Type != clientTypeConfidential {
383 errors = append(errors, requestError{Slug: requestErrInvalidValue, Field: "/type"})
386 errors = append(errors, requestError{Slug: requestErrMissing, Field: "/name"})
387 } else if len(req.Name) < minClientNameLen {
388 errors = append(errors, requestError{Slug: requestErrInsufficient, Field: "/name"})
389 } else if len(req.Name) > maxClientNameLen {
390 errors = append(errors, requestError{Slug: requestErrOverflow, Field: "/name"})
393 encode(w, r, http.StatusBadRequest, response{Errors: errors})
401 Website: req.Website,
404 if client.Type == clientTypePublic {
405 secret := make([]byte, 32)
406 _, err = rand.Read(secret)
408 encode(w, r, http.StatusInternalServerError, actOfGodResponse)
411 client.Secret = hex.EncodeToString(secret)
413 err = c.SaveClient(client)
415 if err == ErrClientAlreadyExists {
416 errors = append(errors, requestError{Slug: requestErrConflict, Field: "/id"})
417 encode(w, r, http.StatusBadRequest, response{Errors: errors})
420 encode(w, r, http.StatusInternalServerError, actOfGodResponse)
423 endpoints := []Endpoint{}
424 for pos, u := range req.Endpoints {
425 uri, err := url.Parse(u)
427 errors = append(errors, requestError{Slug: requestErrInvalidFormat, Field: "/endpoints/" + strconv.Itoa(pos)})
431 errors = append(errors, requestError{Slug: requestErrInvalidValue, Field: "/endpoints/" + strconv.Itoa(pos)})
434 endpoint := Endpoint{
440 endpoints = append(endpoints, endpoint)
442 err = c.AddEndpoints(client.ID, endpoints)
444 errors = append(errors, requestError{Slug: requestErrActOfGod})
445 encode(w, r, http.StatusInternalServerError, response{Errors: errors, Clients: []Client{client}})
449 Clients: []Client{client},
450 Endpoints: endpoints,
453 encode(w, r, http.StatusCreated, resp)