Skip to content

Commit 36530b6

Browse files
committed
refactor(server): untangle oauth2.go into cohesive files
oauth2.go had become a grab-bag. Redistribute its contents to where they belong, without behaviour changes: - authorize.go gets parseAuthorizationRequest, the auth-error rendering types (displayedAuthErr/redirectedAuthErr), and the request validation helpers (validateRedirectURI/isHostLocal/validateConnectorID). - session.go gets validateIDTokenHint, sessionMatchesHint and the signerKeySet verification adapter (all id_token_hint concerns). - token.go gets tokenErr. oauth2.go is left with the OAuth2 protocol constants, parseScopes and validateCrossClientTrust (553 -> 114 lines). Signed-off-by: maksim.nabokikh <max.nabokih@gmail.com>
1 parent 69045b4 commit 36530b6

4 files changed

Lines changed: 424 additions & 438 deletions

File tree

server/authorize.go

Lines changed: 338 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,18 @@ package server
55

66
import (
77
"context"
8+
"crypto"
9+
"fmt"
810
"html/template"
11+
"net"
912
"net/http"
1013
"net/url"
14+
"slices"
15+
"strconv"
1116
"strings"
1217

1318
"github.com/dexidp/dex/server/templates"
19+
"github.com/dexidp/dex/server/tokens"
1420
"github.com/dexidp/dex/storage"
1521
)
1622

@@ -179,3 +185,335 @@ func (s *Server) renderError(r *http.Request, w http.ResponseWriter, status int,
179185
s.logger.ErrorContext(r.Context(), "server template error", "err", err)
180186
}
181187
}
188+
189+
type displayedAuthErr struct {
190+
Status int
191+
Description string
192+
}
193+
194+
func (err *displayedAuthErr) Error() string {
195+
return err.Description
196+
}
197+
198+
func newDisplayedErr(status int, format string, a ...interface{}) *displayedAuthErr {
199+
return &displayedAuthErr{status, fmt.Sprintf(format, a...)}
200+
}
201+
202+
// redirectWithError redirects back to the client with an OAuth2 error response.
203+
// Used for prompt=none when login or consent is required.
204+
func (s *Server) redirectWithError(w http.ResponseWriter, r *http.Request, authReq *storage.AuthRequest, errType, description string) {
205+
err := &redirectedAuthErr{
206+
State: authReq.State,
207+
RedirectURI: authReq.RedirectURI,
208+
Type: errType,
209+
Description: description,
210+
}
211+
err.Handler().ServeHTTP(w, r)
212+
}
213+
214+
// redirectedAuthErr is an error that should be reported back to the client by 302 redirect
215+
type redirectedAuthErr struct {
216+
State string
217+
RedirectURI string
218+
Type string
219+
Description string
220+
}
221+
222+
func (err *redirectedAuthErr) Error() string {
223+
return err.Description
224+
}
225+
226+
func (err *redirectedAuthErr) Handler() http.Handler {
227+
hf := func(w http.ResponseWriter, r *http.Request) {
228+
v := url.Values{}
229+
v.Add("state", err.State)
230+
v.Add("error", err.Type)
231+
if err.Description != "" {
232+
v.Add("error_description", err.Description)
233+
}
234+
235+
// Parse the redirect URI to ensure it's valid before redirecting
236+
u, parseErr := url.Parse(err.RedirectURI)
237+
if parseErr != nil {
238+
// If URI parsing fails, respond with an error instead of redirecting
239+
http.Error(w, "Invalid redirect URI", http.StatusBadRequest)
240+
return
241+
}
242+
243+
// Add error parameters to the URL
244+
query := u.Query()
245+
for key, values := range v {
246+
for _, value := range values {
247+
query.Add(key, value)
248+
}
249+
}
250+
u.RawQuery = query.Encode()
251+
252+
http.Redirect(w, r, u.String(), http.StatusSeeOther)
253+
}
254+
return http.HandlerFunc(hf)
255+
}
256+
257+
// parseAuthorizationRequest parses the initial request from the OAuth2 client.
258+
// Returns the auth request, the raw subject from id_token_hint (empty if not provided), and any error.
259+
func (s *Server) parseAuthorizationRequest(r *http.Request) (*storage.AuthRequest, string, error) {
260+
ctx := r.Context()
261+
if err := r.ParseForm(); err != nil {
262+
return nil, "", newDisplayedErr(http.StatusBadRequest, "Failed to parse request.")
263+
}
264+
q := r.Form
265+
// r.ParseForm already URL-decodes query values once; decoding redirect_uri a
266+
// second time created a normalization differential with the token endpoint.
267+
redirectURI := q.Get("redirect_uri")
268+
269+
clientID := q.Get("client_id")
270+
state := q.Get("state")
271+
nonce := q.Get("nonce")
272+
connectorID := q.Get("connector_id")
273+
// Some clients, like the old go-oidc, provide extra whitespace. Tolerate this.
274+
scopes := strings.Fields(q.Get("scope"))
275+
responseTypes := strings.Fields(q.Get("response_type"))
276+
277+
codeChallenge := q.Get("code_challenge")
278+
codeChallengeMethod := q.Get("code_challenge_method")
279+
280+
if codeChallengeMethod == "" {
281+
codeChallengeMethod = codeChallengeMethodPlain
282+
}
283+
284+
client, err := s.storage.GetClient(ctx, clientID)
285+
if err != nil {
286+
if err == storage.ErrNotFound {
287+
s.logger.ErrorContext(r.Context(), "invalid client_id provided", "client_id", clientID)
288+
return nil, "", newDisplayedErr(http.StatusNotFound, "Invalid client_id.")
289+
}
290+
s.logger.ErrorContext(r.Context(), "failed to get client", "err", err)
291+
return nil, "", newDisplayedErr(http.StatusInternalServerError, "Database error.")
292+
}
293+
294+
if !validateRedirectURI(client, redirectURI) {
295+
s.logger.ErrorContext(r.Context(), "unregistered redirect_uri", "redirect_uri", redirectURI, "client_id", clientID)
296+
return nil, "", newDisplayedErr(http.StatusBadRequest, "Unregistered redirect_uri.")
297+
}
298+
if redirectURI == deviceCallbackURI && client.Public {
299+
redirectURI = s.absPath(deviceCallbackURI)
300+
}
301+
302+
// From here on out, we want to redirect back to the client with an error.
303+
newRedirectedErr := func(typ, format string, a ...interface{}) *redirectedAuthErr {
304+
return &redirectedAuthErr{state, redirectURI, typ, fmt.Sprintf(format, a...)}
305+
}
306+
307+
if connectorID != "" {
308+
connectors, err := s.storage.ListConnectors(ctx)
309+
if err != nil {
310+
s.logger.ErrorContext(r.Context(), "failed to list connectors", "err", err)
311+
return nil, "", newRedirectedErr(errServerError, "Unable to retrieve connectors")
312+
}
313+
if !validateConnectorID(connectors, connectorID) {
314+
return nil, "", newRedirectedErr(errInvalidRequest, "Invalid ConnectorID")
315+
}
316+
if !isConnectorAllowed(client.AllowedConnectors, connectorID) {
317+
return nil, "", newRedirectedErr(errInvalidRequest, "Connector not allowed for this client")
318+
}
319+
}
320+
321+
// dex doesn't support request parameter and must return request_not_supported error
322+
// https://openid.net/specs/openid-connect-core-1_0.html#6.1
323+
if q.Get("request") != "" {
324+
return nil, "", newRedirectedErr(errRequestNotSupported, "Server does not support request parameter.")
325+
}
326+
327+
if codeChallenge != "" && !slices.Contains(s.pkce.CodeChallengeMethodsSupported, codeChallengeMethod) {
328+
return nil, "", newRedirectedErr(errInvalidRequest, "Unsupported PKCE challenge method (%q).", codeChallengeMethod)
329+
}
330+
331+
// Enforce PKCE if configured.
332+
// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-12#section-4.1.1
333+
if s.pkce.Enforce && codeChallenge == "" {
334+
return nil, "", newRedirectedErr(errInvalidRequest, "PKCE is required. The code_challenge parameter must be provided.")
335+
}
336+
337+
var (
338+
unrecognized []string
339+
invalidScopes []string
340+
)
341+
hasOpenIDScope := false
342+
for _, scope := range scopes {
343+
switch scope {
344+
case tokens.ScopeOpenID:
345+
hasOpenIDScope = true
346+
case tokens.ScopeOfflineAccess, tokens.ScopeEmail, tokens.ScopeProfile, tokens.ScopeGroups, tokens.ScopeFederatedID:
347+
default:
348+
peerID, ok := tokens.ParseCrossClientScope(scope)
349+
if !ok {
350+
unrecognized = append(unrecognized, scope)
351+
continue
352+
}
353+
354+
isTrusted, err := s.validateCrossClientTrust(r.Context(), clientID, peerID)
355+
if err != nil {
356+
return nil, "", newRedirectedErr(errServerError, "Internal server error.")
357+
}
358+
if !isTrusted {
359+
invalidScopes = append(invalidScopes, scope)
360+
}
361+
}
362+
}
363+
if !hasOpenIDScope {
364+
return nil, "", newRedirectedErr(errInvalidScope, `Missing required scope(s) ["openid"].`)
365+
}
366+
if len(unrecognized) > 0 {
367+
return nil, "", newRedirectedErr(errInvalidScope, "Unrecognized scope(s) %q", unrecognized)
368+
}
369+
if len(invalidScopes) > 0 {
370+
return nil, "", newRedirectedErr(errInvalidScope, "Client can't request scope(s) %q", invalidScopes)
371+
}
372+
373+
var rt struct {
374+
code bool
375+
idToken bool
376+
token bool
377+
}
378+
379+
for _, responseType := range responseTypes {
380+
switch responseType {
381+
case responseTypeCode:
382+
rt.code = true
383+
case responseTypeIDToken:
384+
rt.idToken = true
385+
case responseTypeToken:
386+
rt.token = true
387+
default:
388+
return nil, "", newRedirectedErr(errInvalidRequest, "Invalid response type %q", responseType)
389+
}
390+
391+
if !s.supportedResponseTypes[responseType] {
392+
return nil, "", newRedirectedErr(errUnsupportedResponseType, "Unsupported response type %q", responseType)
393+
}
394+
}
395+
396+
if len(responseTypes) == 0 {
397+
return nil, "", newRedirectedErr(errInvalidRequest, "No response_type provided")
398+
}
399+
400+
if rt.token && !rt.code && !rt.idToken {
401+
// "token" can't be provided by its own.
402+
//
403+
// https://openid.net/specs/openid-connect-core-1_0.html#Authentication
404+
return nil, "", newRedirectedErr(errInvalidRequest, "Response type 'token' must be provided with type 'id_token' and/or 'code'")
405+
}
406+
if !rt.code {
407+
// Either "id_token token" or "id_token" has been provided which implies the
408+
// implicit flow. Implicit flow requires a nonce value.
409+
//
410+
// https://openid.net/specs/openid-connect-core-1_0.html#ImplicitAuthRequest
411+
if nonce == "" {
412+
return nil, "", newRedirectedErr(errInvalidRequest, "Response type 'token' requires a 'nonce' value.")
413+
}
414+
}
415+
if rt.token {
416+
if redirectURI == redirectURIOOB {
417+
return nil, "", newRedirectedErr(errInvalidRequest, "Cannot use response type 'token' with redirect_uri '%s'.", redirectURIOOB)
418+
}
419+
}
420+
421+
prompt, err := ParsePrompt(q.Get("prompt"))
422+
if err != nil {
423+
return nil, "", newRedirectedErr(errInvalidRequest, "Invalid prompt parameter: %v", err)
424+
}
425+
426+
// Parse max_age: -1 means not specified.
427+
maxAge := -1
428+
if maxAgeStr := q.Get("max_age"); maxAgeStr != "" {
429+
v, err := strconv.Atoi(maxAgeStr)
430+
if err != nil || v < 0 {
431+
return nil, "", newRedirectedErr(errInvalidRequest, "Invalid max_age value %q", maxAgeStr)
432+
}
433+
maxAge = v
434+
}
435+
436+
// OIDC prompt=consent implies force approval.
437+
forceApproval := q.Get("approval_prompt") == "force" || prompt.Consent()
438+
439+
// Validate id_token_hint if provided (OIDC Core 1.0 §3.1.2.1).
440+
var idTokenHintSubject string
441+
if hint := q.Get("id_token_hint"); hint != "" {
442+
idToken, err := s.validateIDTokenHint(ctx, hint)
443+
if err != nil {
444+
return nil, "", newRedirectedErr(errInvalidRequest, "Invalid id_token_hint.")
445+
}
446+
idTokenHintSubject = idToken.Subject
447+
}
448+
449+
return &storage.AuthRequest{
450+
ID: storage.NewID(),
451+
ClientID: client.ID,
452+
State: state,
453+
Nonce: nonce,
454+
ForceApprovalPrompt: forceApproval,
455+
Prompt: prompt.String(),
456+
MaxAge: maxAge,
457+
Scopes: scopes,
458+
RedirectURI: redirectURI,
459+
ResponseTypes: responseTypes,
460+
ConnectorID: connectorID,
461+
PKCE: storage.PKCE{
462+
CodeChallenge: codeChallenge,
463+
CodeChallengeMethod: codeChallengeMethod,
464+
},
465+
HMACKey: storage.NewHMACKey(crypto.SHA256),
466+
}, idTokenHintSubject, nil
467+
}
468+
469+
func validateRedirectURI(client storage.Client, redirectURI string) bool {
470+
// Allow named RedirectURIs for both public and non-public clients.
471+
// This is required make PKCE-enabled web apps work, when configured as public clients.
472+
for _, uri := range client.RedirectURIs {
473+
if redirectURI == uri {
474+
return true
475+
}
476+
}
477+
// For non-public clients or when RedirectURIs is set, we allow only explicitly named RedirectURIs.
478+
// Otherwise, we check below for special URIs used for desktop or mobile apps.
479+
if !client.Public || len(client.RedirectURIs) > 0 {
480+
return false
481+
}
482+
483+
if redirectURI == redirectURIOOB || redirectURI == deviceCallbackURI {
484+
return true
485+
}
486+
487+
// verify that the host is of form "http://localhost:(port)(path)", "http://localhost(path)" or numeric form like
488+
// "http://127.0.0.1:(port)(path)"
489+
u, err := url.Parse(redirectURI)
490+
if err != nil {
491+
return false
492+
}
493+
if u.Scheme != "http" {
494+
return false
495+
}
496+
return isHostLocal(u.Host)
497+
}
498+
499+
func isHostLocal(host string) bool {
500+
if host == "localhost" || net.ParseIP(host).IsLoopback() {
501+
return true
502+
}
503+
504+
host, _, err := net.SplitHostPort(host)
505+
if err != nil {
506+
return false
507+
}
508+
509+
return host == "localhost" || net.ParseIP(host).IsLoopback()
510+
}
511+
512+
func validateConnectorID(connectors []storage.Connector, connectorID string) bool {
513+
for _, c := range connectors {
514+
if c.ID == connectorID {
515+
return true
516+
}
517+
}
518+
return false
519+
}

0 commit comments

Comments
 (0)