Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ func init() {
f.StringVar(&rootArgs.config.ClientSecret, "client-secret", "", "Client secret for the OAuth")
f.StringVar(&rootArgs.config.DefaultAuthorizeResponseMode, "default-authorize-response-mode", constants.ResponseModeQuery, "Default response mode for the authorize endpoint")
f.StringVar(&rootArgs.config.DefaultAuthorizeResponseType, "default-authorize-response-type", constants.ResponseTypeToken, "Default response type for the authorize endpoint")
f.BoolVar(&rootArgs.config.OAuth21Strict, "oauth2-1-strict", false, "Enforce OAuth 2.1 restrictions: reject the implicit/hybrid-with-token response types (token, id_token token) and PKCE plain (require S256). Breaking; opt-in")

// Admin flags
f.StringVar(&rootArgs.config.AdminSecret, "admin-secret", "", "Secret for the admin (REQUIRED, must not be empty)")
Expand Down
6 changes: 6 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,12 @@ type Config struct {
DefaultAuthorizeResponseMode string
// Default Authorize response type
DefaultAuthorizeResponseType string
// OAuth21Strict opts into OAuth 2.1 breaking restrictions (default false).
// When true the authorization endpoint rejects the implicit/hybrid-with-token
// response types (response_type=token and id_token token) and the PKCE
// "plain" code_challenge_method (S256 required). Off keeps all current
// behavior unchanged.
OAuth21Strict bool

// Twilio Configurations
// TwilioAPISecret is the API secret for Twilio
Expand Down
97 changes: 95 additions & 2 deletions internal/http_handlers/authorize.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,14 @@ func (h *httpProvider) AuthorizeHandler() gin.HandlerFunc {
nonce := param("nonce")
screenHint := param("screen_hint")

// RFC 8707 resource indicator: optional. When present it binds the
// issued authorization code (and, at exchange time, the access token's
// `aud`) to the target resource server. RFC 8707 §2 permits multiple
// resource values, but this flow issues a single-audience access token
// (matching token_exchange.go), so exactly one is allowed — reject a
// repeated parameter rather than silently picking one.
resource := param("resource")

// OIDC Core §3.1.2.1 standard authorization request parameters.
loginHint := param("login_hint")
uiLocales := param("ui_locales")
Expand Down Expand Up @@ -175,6 +183,21 @@ func (h *httpProvider) AuthorizeHandler() gin.HandlerFunc {
}
}

// RFC 8707: reject a repeated resource parameter (single audience only).
// redirect_uri is validated above, so redirectErrorToRP is safe here.
if vals := gc.Request.Form["resource"]; len(vals) > 1 {
redirectErrorToRP(gc, responseMode, redirectURI, state, "invalid_request", "only one resource parameter is supported")
return
}

// RFC 8707 §2: the resource indicator MUST be an absolute URI and MUST
// NOT include a fragment component. The RFC-conventional error code for
// a rejected resource is invalid_target.
if resource != "" && !isValidResourceIndicator(resource) {
redirectErrorToRP(gc, responseMode, redirectURI, state, "invalid_target", "resource must be an absolute URI without a fragment")
return
}

// OIDCC-3.1.2.6 (JAR, RFC 9101): the server must either process a
// `request`/`request_uri` object or reject it with
// request_not_supported / request_uri_not_supported. We don't parse
Expand Down Expand Up @@ -221,6 +244,14 @@ func (h *httpProvider) AuthorizeHandler() gin.HandlerFunc {
})
return
}
// OAuth 2.1 strict mode: PKCE "plain" is removed — S256 only.
if h.Config.OAuth21Strict && codeChallengeMethod == "plain" && codeChallenge != "" {
gc.JSON(http.StatusBadRequest, gin.H{
"error": "invalid_request",
"error_description": "code_challenge_method=plain is not allowed; use S256",
})
return
}

canonical, ok := supportedResponseTypeSet(responseType)
if !ok {
Expand Down Expand Up @@ -260,6 +291,21 @@ func (h *httpProvider) AuthorizeHandler() gin.HandlerFunc {
}
}

// OAuth 2.1 strict mode: the implicit grant is removed. Reject EVERY
// response type that delivers a bearer access token into the URL
// fragment — not just "token" and "id_token token" but also the
// front-channel-token hybrids "code token" and "code id_token token",
// which equally return an access_token in the fragment (see the
// hasAccessToken dispatch below). responseType is already canonical
// (sorted/deduped/lowercased), so a bearer access token is present iff
// the space-separated components include the exact component "token".
// The check is component-exact (not a substring) so "id_token" — which
// contains "token" only as a substring — is NOT matched.
if h.Config.OAuth21Strict && responseTypeHasBareToken(responseType) {
redirectErrorToRP(gc, responseMode, redirectURI, state, "unsupported_response_type", "response_type="+responseType+" is not supported")
return
}

if errCode, errDesc := h.validateAuthorizeRequest(responseType, responseMode, state); errCode != "" {
log.Debug().Str("error", errCode).Str("error_description", errDesc).Msg("Invalid request")
gc.JSON(http.StatusBadRequest, gin.H{
Expand Down Expand Up @@ -332,7 +378,10 @@ func (h *httpProvider) AuthorizeHandler() gin.HandlerFunc {
if codeChallenge != "" {
challengeData = codeChallenge + "::" + codeChallengeMethod
}
if err := h.MemoryStoreProvider.SetState(state, code+"@@"+challengeData+"@@"+nonce+"@@"+url.QueryEscape(redirectURI)); err != nil {
// [4] carries the RFC 8707 resource (url-escaped, empty when absent)
// so the login/signup/session/auth_response services can rebind it
// to the code state they persist after a fresh login.
if err := h.MemoryStoreProvider.SetState(state, code+"@@"+challengeData+"@@"+nonce+"@@"+url.QueryEscape(redirectURI)+"@@"+url.QueryEscape(resource)); err != nil {
log.Debug().Err(err).Msg("Error setting temp code")
gc.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"})
return
Expand Down Expand Up @@ -715,7 +764,10 @@ func (h *httpProvider) AuthorizeHandler() gin.HandlerFunc {
if codeChallenge != "" {
codeChallengeData = codeChallenge + "::" + codeChallengeMethod
}
if err := h.MemoryStoreProvider.SetState(code, codeChallengeData+"@@"+newSessionToken+"@@"+nonce+"@@"+url.QueryEscape(redirectURI)); err != nil {
// [4] binds the RFC 8707 resource to the code (url-escaped, empty
// when absent); the token endpoint enforces the echoed resource
// matches this and sets the access token `aud` to it.
if err := h.MemoryStoreProvider.SetState(code, codeChallengeData+"@@"+newSessionToken+"@@"+nonce+"@@"+url.QueryEscape(redirectURI)+"@@"+url.QueryEscape(resource)); err != nil {
log.Debug().Err(err).Msg("Error setting temp code")
handleResponse(gc, responseMode, authURL, redirectURI, loginError, http.StatusOK)
return
Expand Down Expand Up @@ -936,6 +988,47 @@ func supportedResponseTypeSet(raw string) (string, bool) {
return "", false
}

// isValidResourceIndicator enforces RFC 8707 §2 on a resource indicator: it
// MUST be an absolute URI (scheme + hierarchical part) and MUST NOT contain a
// fragment component. A relative reference, an opaque non-URI string, or any
// value carrying a "#" is rejected. Callers must pass a non-empty value.
func isValidResourceIndicator(resource string) bool {
// A fragment delimiter is forbidden outright — check the raw string so a
// trailing "#" (empty fragment) is also rejected, not just a populated one.
if strings.Contains(resource, "#") {
return false
}
u, err := url.Parse(resource)
if err != nil {
return false
}
// Absolute URI: has a scheme and is not just an opaque scheme:string. Require
// a scheme and either a host or a rooted path so bare "mailto:" style opaque
// forms and relative references are rejected.
if !u.IsAbs() {
return false
}
if u.Host == "" && !strings.HasPrefix(u.Path, "/") {
return false
}
return true
}

// responseTypeHasBareToken reports whether the (already canonicalized,
// space-separated) response_type includes the exact component "token" — i.e.
// the flow delivers a bearer access token into the front channel (URL
// fragment). It splits on whitespace and compares each component exactly, so
// "id_token" (which contains "token" only as a substring) is NOT matched.
// Matches: "token", "code token", "id_token token", "code id_token token".
func responseTypeHasBareToken(responseType string) bool {
for _, c := range strings.Fields(responseType) {
if c == constants.ResponseTypeToken {
return true
}
}
return false
}

// validateAuthorizeRequest validates the authorize request parameters and
// returns RFC 6749 §4.1.2.1 compliant error code and description on failure.
// Returns empty strings when validation passes.
Expand Down
14 changes: 12 additions & 2 deletions internal/http_handlers/openid_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@ func (h *httpProvider) OpenIDConfigurationHandler() gin.HandlerFunc {
// configured a backchannel_logout_uri — avoids lying to RPs.
backchannelSupported := h.Config.BackchannelLogoutURI != ""

// OAuth 2.1 strict mode drops PKCE "plain" (S256 only). RFC 7636 §4.2
// otherwise requires advertising both.
codeChallengeMethods := []string{"S256", "plain"}
if h.Config.OAuth21Strict {
codeChallengeMethods = []string{"S256"}
}

resp := gin.H{
// REQUIRED fields (OIDC Discovery §3)
"issuer": issuer,
Expand All @@ -62,8 +69,11 @@ func (h *httpProvider) OpenIDConfigurationHandler() gin.HandlerFunc {
// code_verifier (PKCE) or client_secret — "none" without PKCE is
// rejected with invalid_request. "private_key_jwt" advertises the
// RFC 7523 client_assertion (JWT-bearer) workload-identity path.
"token_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post", "none", "private_key_jwt"},
"code_challenge_methods_supported": []string{"S256", "plain"},
"token_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post", "none", "private_key_jwt"},
"code_challenge_methods_supported": codeChallengeMethods,
// RFC 8707 resource indicators are honored on the authorization_code
// flow (resource query param → access token aud) and token-exchange.
"resource_indicators_supported": true,
"revocation_endpoint": issuer + "/oauth/revoke",
"revocation_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post"},
"introspection_endpoint": issuer + "/oauth/introspect",
Expand Down
Loading
Loading