auth
auth/oauth2.go
Jot out plans for refactoring GetTokenHandler. Basically, the GetTokenHandler we have set up is too specific and not extensible enough. We should treat grant types as pluggable and separate them a bit more from token issuance. The comment has a few ideas about how that could be achieved.
| paddy@51 | 1 package auth |
| paddy@51 | 2 |
| paddy@51 | 3 import ( |
| paddy@82 | 4 "crypto/sha256" |
| paddy@79 | 5 "encoding/hex" |
| paddy@69 | 6 "encoding/json" |
| paddy@69 | 7 "errors" |
| paddy@61 | 8 "html/template" |
| paddy@77 | 9 "log" |
| paddy@51 | 10 "net/http" |
| paddy@60 | 11 "net/url" |
| paddy@60 | 12 "time" |
| paddy@56 | 13 |
| paddy@69 | 14 "code.secondbit.org/pass" |
| paddy@56 | 15 "code.secondbit.org/uuid" |
| paddy@82 | 16 |
| paddy@82 | 17 "github.com/gorilla/mux" |
| paddy@51 | 18 ) |
| paddy@51 | 19 |
| paddy@60 | 20 const ( |
| paddy@69 | 21 authCookieName = "auth" |
| paddy@69 | 22 defaultGrantExpiration = 600 // default to ten minute grant expirations |
| paddy@60 | 23 getGrantTemplateName = "get_grant" |
| paddy@60 | 24 ) |
| paddy@51 | 25 |
| paddy@69 | 26 var ( |
| paddy@69 | 27 // ErrNoAuth is returned when an Authorization header is not present or is empty. |
| paddy@69 | 28 ErrNoAuth = errors.New("no authorization header supplied") |
| paddy@69 | 29 // ErrInvalidAuthFormat is returned when an Authorization header is present but not the correct format. |
| paddy@69 | 30 ErrInvalidAuthFormat = errors.New("authorization header is not in a valid format") |
| paddy@69 | 31 // ErrIncorrectAuth is returned when a user authentication attempt does not match the stored values. |
| paddy@69 | 32 ErrIncorrectAuth = errors.New("invalid authentication") |
| paddy@69 | 33 // ErrInvalidPassphraseScheme is returned when an undefined passphrase scheme is used. |
| paddy@69 | 34 ErrInvalidPassphraseScheme = errors.New("invalid passphrase scheme") |
| paddy@69 | 35 // ErrNoSession is returned when no session ID is passed with a request. |
| paddy@69 | 36 ErrNoSession = errors.New("no session ID found") |
| paddy@69 | 37 ) |
| paddy@69 | 38 |
| paddy@69 | 39 type tokenResponse struct { |
| paddy@69 | 40 AccessToken string `json:"access_token"` |
| paddy@69 | 41 TokenType string `json:"token_type,omitempty"` |
| paddy@69 | 42 ExpiresIn int32 `json:"expires_in,omitempty"` |
| paddy@69 | 43 RefreshToken string `json:"refresh_token,omitempty"` |
| paddy@69 | 44 } |
| paddy@69 | 45 |
| paddy@82 | 46 type errorResponse struct { |
| paddy@82 | 47 Error string `json:"error"` |
| paddy@82 | 48 Description string `json:"error_description,omitempty"` |
| paddy@82 | 49 URI string `json:"error_uri,omitempty"` |
| paddy@82 | 50 } |
| paddy@82 | 51 |
| paddy@82 | 52 func renderJSONError(enc *json.Encoder, errorType string) { |
| paddy@82 | 53 err := enc.Encode(errorResponse{ |
| paddy@82 | 54 Error: errorType, |
| paddy@82 | 55 }) |
| paddy@82 | 56 if err != nil { |
| paddy@82 | 57 // TODO(paddy): log this or something |
| paddy@69 | 58 } |
| paddy@69 | 59 } |
| paddy@69 | 60 |
| paddy@69 | 61 func checkCookie(r *http.Request, context Context) (Session, error) { |
| paddy@69 | 62 cookie, err := r.Cookie(authCookieName) |
| paddy@77 | 63 if err == http.ErrNoCookie { |
| paddy@77 | 64 return Session{}, ErrNoSession |
| paddy@77 | 65 } else if err != nil { |
| paddy@77 | 66 log.Println(err) |
| paddy@69 | 67 return Session{}, err |
| paddy@69 | 68 } |
| paddy@69 | 69 sess, err := context.GetSession(cookie.Value) |
| paddy@69 | 70 if err == ErrSessionNotFound { |
| paddy@69 | 71 return Session{}, ErrInvalidSession |
| paddy@69 | 72 } else if err != nil { |
| paddy@69 | 73 return Session{}, err |
| paddy@69 | 74 } |
| paddy@69 | 75 if !sess.Active { |
| paddy@69 | 76 return Session{}, ErrInvalidSession |
| paddy@69 | 77 } |
| paddy@69 | 78 return sess, nil |
| paddy@69 | 79 } |
| paddy@69 | 80 |
| paddy@77 | 81 func buildLoginRedirect(r *http.Request, context Context) string { |
| paddy@77 | 82 if context.loginURI == nil { |
| paddy@77 | 83 return "" |
| paddy@77 | 84 } |
| paddy@77 | 85 uri := *context.loginURI |
| paddy@77 | 86 q := uri.Query() |
| paddy@78 | 87 q.Set("from", r.URL.String()) |
| paddy@77 | 88 uri.RawQuery = q.Encode() |
| paddy@77 | 89 return uri.String() |
| paddy@77 | 90 } |
| paddy@77 | 91 |
| paddy@69 | 92 func authenticate(user, passphrase string, context Context) (Profile, error) { |
| paddy@69 | 93 profile, err := context.GetProfileByLogin(user) |
| paddy@69 | 94 if err != nil { |
| paddy@79 | 95 if err == ErrProfileNotFound || err == ErrLoginNotFound { |
| paddy@69 | 96 return Profile{}, ErrIncorrectAuth |
| paddy@69 | 97 } |
| paddy@69 | 98 return Profile{}, err |
| paddy@69 | 99 } |
| paddy@69 | 100 switch profile.PassphraseScheme { |
| paddy@69 | 101 case 1: |
| paddy@79 | 102 realPass, err := hex.DecodeString(profile.Passphrase) |
| paddy@79 | 103 if err != nil { |
| paddy@79 | 104 return Profile{}, err |
| paddy@79 | 105 } |
| paddy@69 | 106 candidate := pass.Check(sha256.New, profile.Iterations, []byte(passphrase), []byte(profile.Salt)) |
| paddy@79 | 107 if !pass.Compare(candidate, realPass) { |
| paddy@69 | 108 return Profile{}, ErrIncorrectAuth |
| paddy@69 | 109 } |
| paddy@69 | 110 default: |
| paddy@69 | 111 return Profile{}, ErrInvalidPassphraseScheme |
| paddy@69 | 112 } |
| paddy@69 | 113 return profile, nil |
| paddy@69 | 114 } |
| paddy@69 | 115 |
| paddy@77 | 116 func wrap(context Context, f func(w http.ResponseWriter, r *http.Request, context Context)) http.Handler { |
| paddy@77 | 117 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| paddy@77 | 118 f(w, r, context) |
| paddy@77 | 119 }) |
| paddy@77 | 120 } |
| paddy@77 | 121 |
| paddy@77 | 122 // RegisterOAuth2 adds handlers to the passed router to handle the OAuth2 endpoints. |
| paddy@77 | 123 func RegisterOAuth2(r *mux.Router, context Context) { |
| paddy@77 | 124 r.Handle("/authorize", wrap(context, GetGrantHandler)) |
| paddy@77 | 125 r.Handle("/token", wrap(context, GetTokenHandler)) |
| paddy@77 | 126 } |
| paddy@77 | 127 |
| paddy@57 | 128 // GetGrantHandler presents and processes the page for asking a user to grant access |
| paddy@57 | 129 // to their data. See RFC 6749, Section 4.1. |
| paddy@51 | 130 func GetGrantHandler(w http.ResponseWriter, r *http.Request, context Context) { |
| paddy@69 | 131 session, err := checkCookie(r, context) |
| paddy@69 | 132 if err != nil { |
| paddy@76 | 133 if err == ErrNoSession || err == ErrInvalidSession { |
| paddy@77 | 134 redir := buildLoginRedirect(r, context) |
| paddy@77 | 135 if redir == "" { |
| paddy@77 | 136 log.Println("No login URL configured.") |
| paddy@77 | 137 w.WriteHeader(http.StatusInternalServerError) |
| paddy@77 | 138 context.Render(w, getGrantTemplateName, map[string]interface{}{ |
| paddy@77 | 139 "internal_error": template.HTML("Missing login URL."), |
| paddy@77 | 140 }) |
| paddy@77 | 141 return |
| paddy@77 | 142 } |
| paddy@77 | 143 http.Redirect(w, r, redir, http.StatusFound) |
| paddy@77 | 144 return |
| paddy@69 | 145 } |
| paddy@77 | 146 log.Println(err.Error()) |
| paddy@77 | 147 w.WriteHeader(http.StatusInternalServerError) |
| paddy@77 | 148 context.Render(w, getGrantTemplateName, map[string]interface{}{ |
| paddy@77 | 149 "internal_error": template.HTML(err.Error()), |
| paddy@77 | 150 }) |
| paddy@77 | 151 return |
| paddy@69 | 152 } |
| paddy@56 | 153 if r.URL.Query().Get("client_id") == "" { |
| paddy@56 | 154 w.WriteHeader(http.StatusBadRequest) |
| paddy@56 | 155 context.Render(w, getGrantTemplateName, map[string]interface{}{ |
| paddy@61 | 156 "error": template.HTML("Client ID must be specified in the request."), |
| paddy@56 | 157 }) |
| paddy@56 | 158 return |
| paddy@56 | 159 } |
| paddy@56 | 160 clientID, err := uuid.Parse(r.URL.Query().Get("client_id")) |
| paddy@56 | 161 if err != nil { |
| paddy@56 | 162 w.WriteHeader(http.StatusBadRequest) |
| paddy@56 | 163 context.Render(w, getGrantTemplateName, map[string]interface{}{ |
| paddy@61 | 164 "error": template.HTML("client_id is not a valid Client ID."), |
| paddy@56 | 165 }) |
| paddy@56 | 166 return |
| paddy@56 | 167 } |
| paddy@64 | 168 redirectURI := r.URL.Query().Get("redirect_uri") |
| paddy@64 | 169 redirectURL, err := url.Parse(redirectURI) |
| paddy@64 | 170 if err != nil { |
| paddy@64 | 171 w.WriteHeader(http.StatusBadRequest) |
| paddy@64 | 172 context.Render(w, getGrantTemplateName, map[string]interface{}{ |
| paddy@64 | 173 "error": template.HTML("The redirect_uri specified is not valid."), |
| paddy@64 | 174 }) |
| paddy@64 | 175 return |
| paddy@64 | 176 } |
| paddy@56 | 177 client, err := context.GetClient(clientID) |
| paddy@56 | 178 if err != nil { |
| paddy@59 | 179 if err == ErrClientNotFound { |
| paddy@59 | 180 w.WriteHeader(http.StatusBadRequest) |
| paddy@59 | 181 context.Render(w, getGrantTemplateName, map[string]interface{}{ |
| paddy@61 | 182 "error": template.HTML("The specified Client couldn’t be found."), |
| paddy@59 | 183 }) |
| paddy@59 | 184 } else { |
| paddy@77 | 185 log.Println(err.Error()) |
| paddy@59 | 186 w.WriteHeader(http.StatusInternalServerError) |
| paddy@59 | 187 context.Render(w, getGrantTemplateName, map[string]interface{}{ |
| paddy@61 | 188 "internal_error": template.HTML(err.Error()), |
| paddy@59 | 189 }) |
| paddy@59 | 190 } |
| paddy@56 | 191 return |
| paddy@56 | 192 } |
| paddy@56 | 193 // whether a redirect URI is valid or not depends on the number of endpoints |
| paddy@56 | 194 // the client has registered |
| paddy@56 | 195 numEndpoints, err := context.CountEndpoints(clientID) |
| paddy@56 | 196 if err != nil { |
| paddy@77 | 197 log.Println(err.Error()) |
| paddy@56 | 198 w.WriteHeader(http.StatusInternalServerError) |
| paddy@56 | 199 context.Render(w, getGrantTemplateName, map[string]interface{}{ |
| paddy@61 | 200 "internal_error": template.HTML(err.Error()), |
| paddy@56 | 201 }) |
| paddy@56 | 202 return |
| paddy@56 | 203 } |
| paddy@56 | 204 var validURI bool |
| paddy@58 | 205 if redirectURI != "" { |
| paddy@58 | 206 // BUG(paddy): We really should normalize URIs before trying to compare them. |
| paddy@58 | 207 validURI, err = context.CheckEndpoint(clientID, redirectURI) |
| paddy@56 | 208 if err != nil { |
| paddy@77 | 209 log.Println(err.Error()) |
| paddy@56 | 210 w.WriteHeader(http.StatusInternalServerError) |
| paddy@56 | 211 context.Render(w, getGrantTemplateName, map[string]interface{}{ |
| paddy@61 | 212 "internal_error": template.HTML(err.Error()), |
| paddy@56 | 213 }) |
| paddy@56 | 214 return |
| paddy@56 | 215 } |
| paddy@56 | 216 } else if redirectURI == "" && numEndpoints == 1 { |
| paddy@56 | 217 // if we don't specify the endpoint and there's only one endpoint, the |
| paddy@56 | 218 // request is valid, and we're redirecting to that one endpoint |
| paddy@56 | 219 validURI = true |
| paddy@56 | 220 endpoints, err := context.ListEndpoints(clientID, 1, 0) |
| paddy@56 | 221 if err != nil { |
| paddy@77 | 222 log.Println(err.Error()) |
| paddy@56 | 223 w.WriteHeader(http.StatusInternalServerError) |
| paddy@56 | 224 context.Render(w, getGrantTemplateName, map[string]interface{}{ |
| paddy@61 | 225 "internal_error": template.HTML(err.Error()), |
| paddy@56 | 226 }) |
| paddy@56 | 227 return |
| paddy@56 | 228 } |
| paddy@56 | 229 if len(endpoints) != 1 { |
| paddy@56 | 230 validURI = false |
| paddy@56 | 231 } else { |
| paddy@66 | 232 u := endpoints[0].URI // Copy it here to avoid grabbing a pointer to the memstore |
| paddy@66 | 233 redirectURI = u.String() |
| paddy@66 | 234 redirectURL = &u |
| paddy@56 | 235 } |
| paddy@56 | 236 } else { |
| paddy@56 | 237 validURI = false |
| paddy@56 | 238 } |
| paddy@56 | 239 if !validURI { |
| paddy@56 | 240 w.WriteHeader(http.StatusBadRequest) |
| paddy@56 | 241 context.Render(w, getGrantTemplateName, map[string]interface{}{ |
| paddy@61 | 242 "error": template.HTML("The redirect_uri specified is not valid."), |
| paddy@56 | 243 }) |
| paddy@56 | 244 return |
| paddy@56 | 245 } |
| paddy@60 | 246 scope := r.URL.Query().Get("scope") |
| paddy@60 | 247 state := r.URL.Query().Get("state") |
| paddy@56 | 248 if r.URL.Query().Get("response_type") != "code" { |
| paddy@65 | 249 q := redirectURL.Query() |
| paddy@65 | 250 q.Add("error", "invalid_request") |
| paddy@65 | 251 q.Add("state", state) |
| paddy@65 | 252 redirectURL.RawQuery = q.Encode() |
| paddy@60 | 253 http.Redirect(w, r, redirectURL.String(), http.StatusFound) |
| paddy@60 | 254 return |
| paddy@56 | 255 } |
| paddy@56 | 256 if r.Method == "POST" { |
| paddy@63 | 257 // BUG(paddy): We need to implement CSRF protection when obtaining a grant code. |
| paddy@56 | 258 if r.PostFormValue("grant") == "approved" { |
| paddy@60 | 259 code := uuid.NewID().String() |
| paddy@60 | 260 grant := Grant{ |
| paddy@60 | 261 Code: code, |
| paddy@60 | 262 Created: time.Now(), |
| paddy@60 | 263 ExpiresIn: defaultGrantExpiration, |
| paddy@60 | 264 ClientID: clientID, |
| paddy@60 | 265 Scope: scope, |
| paddy@69 | 266 RedirectURI: r.URL.Query().Get("redirect_uri"), |
| paddy@60 | 267 State: state, |
| paddy@69 | 268 ProfileID: session.ProfileID, |
| paddy@60 | 269 } |
| paddy@60 | 270 err := context.SaveGrant(grant) |
| paddy@60 | 271 if err != nil { |
| paddy@66 | 272 q := redirectURL.Query() |
| paddy@66 | 273 q.Add("error", "server_error") |
| paddy@66 | 274 q.Add("state", state) |
| paddy@66 | 275 redirectURL.RawQuery = q.Encode() |
| paddy@60 | 276 http.Redirect(w, r, redirectURL.String(), http.StatusFound) |
| paddy@60 | 277 return |
| paddy@60 | 278 } |
| paddy@66 | 279 q := redirectURL.Query() |
| paddy@66 | 280 q.Add("code", code) |
| paddy@66 | 281 q.Add("state", state) |
| paddy@66 | 282 redirectURL.RawQuery = q.Encode() |
| paddy@60 | 283 http.Redirect(w, r, redirectURL.String(), http.StatusFound) |
| paddy@60 | 284 return |
| paddy@56 | 285 } |
| paddy@66 | 286 q := redirectURL.Query() |
| paddy@66 | 287 q.Add("error", "access_denied") |
| paddy@66 | 288 q.Add("state", state) |
| paddy@66 | 289 redirectURL.RawQuery = q.Encode() |
| paddy@60 | 290 http.Redirect(w, r, redirectURL.String(), http.StatusFound) |
| paddy@60 | 291 return |
| paddy@56 | 292 } |
| paddy@51 | 293 w.WriteHeader(http.StatusOK) |
| paddy@56 | 294 context.Render(w, getGrantTemplateName, map[string]interface{}{ |
| paddy@56 | 295 "client": client, |
| paddy@56 | 296 }) |
| paddy@51 | 297 } |
| paddy@68 | 298 |
| paddy@69 | 299 // GetTokenHandler allows a client to exchange an authorization grant for an |
| paddy@69 | 300 // access token. See RFC 6749 Section 4.1.3. |
| paddy@69 | 301 func GetTokenHandler(w http.ResponseWriter, r *http.Request, context Context) { |
| paddy@83 | 302 // BUG(paddy): this function is an absolute mess. Honestly, it should be more general purpose, with each grant mode being called based on the grant_type POST form value. Basically, each grant type could have its own function, accepting the Request and ResponseWriter, and returning a boolean if the request should continue being processed or not. The function is in charge of validating the grant, which offers more flexible extensibiliy when adding grant types and easier testing, while also making the token distribution code easier to reuse in an elegant way. There is a minor problem that the token distribution code has some dependencies on the grant type being used (some grant types don't issue refresh tokens, for example) but that's a minor issue. Something like a map of string -> custom grantType struct would fix that. The struct could hold the function to call to validate the grant type and booleans that impact the token issuance. Then you do a map lookup based on the POST form value, and call the function or read the booleans as needed. If we use the same "register" pattern found in database/sql drivers, allowing grant types to register themselves, it'll be possible to add a grant type without even touching this function. |
| paddy@69 | 303 enc := json.NewEncoder(w) |
| paddy@69 | 304 grantType := r.PostFormValue("grant_type") |
| paddy@69 | 305 if grantType != "authorization_code" { |
| paddy@82 | 306 w.WriteHeader(http.StatusBadRequest) |
| paddy@82 | 307 renderJSONError(enc, "invalid_request") |
| paddy@69 | 308 return |
| paddy@69 | 309 } |
| paddy@69 | 310 code := r.PostFormValue("code") |
| paddy@69 | 311 if code == "" { |
| paddy@82 | 312 w.WriteHeader(http.StatusBadRequest) |
| paddy@82 | 313 renderJSONError(enc, "invalid_request") |
| paddy@69 | 314 return |
| paddy@69 | 315 } |
| paddy@69 | 316 redirectURI := r.PostFormValue("redirect_uri") |
| paddy@82 | 317 clientIDStr, clientSecret, fromAuthHeader := r.BasicAuth() |
| paddy@82 | 318 if !fromAuthHeader { |
| paddy@69 | 319 clientIDStr = r.PostFormValue("client_id") |
| paddy@69 | 320 } |
| paddy@69 | 321 clientID, err := uuid.Parse(clientIDStr) |
| paddy@69 | 322 if err != nil { |
| paddy@82 | 323 w.WriteHeader(http.StatusUnauthorized) |
| paddy@82 | 324 if fromAuthHeader { |
| paddy@82 | 325 w.Header().Set("WWW-Authenticate", "Basic") |
| paddy@82 | 326 } |
| paddy@82 | 327 renderJSONError(enc, "invalid_client") |
| paddy@69 | 328 return |
| paddy@69 | 329 } |
| paddy@69 | 330 client, err := context.GetClient(clientID) |
| paddy@69 | 331 if err != nil { |
| paddy@69 | 332 if err == ErrClientNotFound { |
| paddy@82 | 333 w.WriteHeader(http.StatusUnauthorized) |
| paddy@82 | 334 renderJSONError(enc, "invalid_client") |
| paddy@69 | 335 } else { |
| paddy@82 | 336 w.WriteHeader(http.StatusInternalServerError) |
| paddy@82 | 337 renderJSONError(enc, "server_error") |
| paddy@69 | 338 } |
| paddy@69 | 339 return |
| paddy@69 | 340 } |
| paddy@69 | 341 if client.Secret != clientSecret { |
| paddy@82 | 342 w.WriteHeader(http.StatusUnauthorized) |
| paddy@82 | 343 if fromAuthHeader { |
| paddy@82 | 344 w.Header().Set("WWW-Authenticate", "Basic") |
| paddy@82 | 345 } |
| paddy@82 | 346 renderJSONError(enc, "invalid_client") |
| paddy@69 | 347 return |
| paddy@69 | 348 } |
| paddy@69 | 349 grant, err := context.GetGrant(code) |
| paddy@69 | 350 if err != nil { |
| paddy@69 | 351 if err == ErrGrantNotFound { |
| paddy@82 | 352 w.WriteHeader(http.StatusBadRequest) |
| paddy@82 | 353 renderJSONError(enc, "invalid_grant") |
| paddy@69 | 354 return |
| paddy@69 | 355 } |
| paddy@82 | 356 w.WriteHeader(http.StatusInternalServerError) |
| paddy@82 | 357 renderJSONError(enc, "server_error") |
| paddy@81 | 358 return |
| paddy@69 | 359 } |
| paddy@69 | 360 if grant.RedirectURI != redirectURI { |
| paddy@82 | 361 w.WriteHeader(http.StatusBadRequest) |
| paddy@82 | 362 renderJSONError(enc, "invalid_grant") |
| paddy@81 | 363 return |
| paddy@69 | 364 } |
| paddy@69 | 365 if !grant.ClientID.Equal(clientID) { |
| paddy@82 | 366 w.WriteHeader(http.StatusBadRequest) |
| paddy@82 | 367 renderJSONError(enc, "invalid_grant") |
| paddy@81 | 368 return |
| paddy@69 | 369 } |
| paddy@69 | 370 token := Token{ |
| paddy@69 | 371 AccessToken: uuid.NewID().String(), |
| paddy@69 | 372 RefreshToken: uuid.NewID().String(), |
| paddy@69 | 373 Created: time.Now(), |
| paddy@69 | 374 ExpiresIn: defaultTokenExpiration, |
| paddy@81 | 375 TokenType: "bearer", |
| paddy@69 | 376 Scope: grant.Scope, |
| paddy@69 | 377 ProfileID: grant.ProfileID, |
| paddy@69 | 378 } |
| paddy@69 | 379 err = context.SaveToken(token) |
| paddy@69 | 380 if err != nil { |
| paddy@82 | 381 w.WriteHeader(http.StatusInternalServerError) |
| paddy@82 | 382 renderJSONError(enc, "server_error") |
| paddy@81 | 383 return |
| paddy@69 | 384 } |
| paddy@69 | 385 resp := tokenResponse{ |
| paddy@69 | 386 AccessToken: token.AccessToken, |
| paddy@69 | 387 RefreshToken: token.RefreshToken, |
| paddy@69 | 388 ExpiresIn: token.ExpiresIn, |
| paddy@69 | 389 TokenType: token.TokenType, |
| paddy@69 | 390 } |
| paddy@69 | 391 err = enc.Encode(resp) |
| paddy@69 | 392 if err != nil { |
| paddy@69 | 393 // TODO(paddy): log this or something |
| paddy@69 | 394 return |
| paddy@69 | 395 } |
| paddy@81 | 396 // BUG(paddy): we need to invalidate the grant for future requests |
| paddy@69 | 397 } |
| paddy@69 | 398 |
| paddy@68 | 399 // TODO(paddy): exchange user credentials for access token |
| paddy@68 | 400 // TODO(paddy): exchange client credentials for access token |
| paddy@68 | 401 // TODO(paddy): implicit grant for access token |
| paddy@68 | 402 // TODO(paddy): exchange refresh token for access token |