mirror of
https://github.com/coder/coder.git
synced 2026-06-04 05:28:20 +00:00
83fd4cf5c2
Go's html/template has a built-in security filter (urlFilter) that only allows http, https, and mailto URL schemes. Any other scheme gets replaced with #ZgotmplZ. The OAuth2 app's callback URL uses custom URI scheme which the filter considers unsafe. For example the Coder JetBrains plugin exposes a callback URI with the scheme jetbrains:// - which was effectively changed by the template engine into #ZgotmplZ. Of course this is not an actual callback. When users clicked the cancel button nothing happened. The fix was simple - we now wrap the apps registered callback URI into htmltemplate.URL. Usually this needs some validation otherwise the linter will complain about it. The callback URI used by the Cancel logic is actually validated by our backend when the client app programmatically registered via the dynamic OAuth2 registration endpoints, so we refactored the validation around that code and re-used some of it in the Cancel handling to make sure we don't allow URIs like `javascript` and `data`, even though in theory these URIs were already validated. In addition, while testing this PR with https://github.com/coder/coder-jetbrains-toolbox/pull/209 I discovered that we are also not compliant with https://www.rfc-editor.org/rfc/rfc6749#section-4.1.2.1 which requires the server to attach the local state if it was provided by the client in the original request. Also it is optional but generally a good practice to include `error_description` in the error responses. In fact we follow this pattern for the other types of error responses. So this is not a one off. - resolves #20323 <img width="1485" height="771" alt="Cancel_page_with_invalid_uri" src="https://github.com/user-attachments/assets/5539d234-9ce3-4dda-b421-d023fc9aa99e" /> <img width="486" height="746" alt="Coder Toolbox handling the Cancel button" src="https://github.com/user-attachments/assets/acab71a6-d29c-4fa9-80ba-3c0095bbdc8f" /> <!-- If you have used AI to produce some or all of this PR, please ensure you have read our [AI Contribution guidelines](https://coder.com/docs/about/contributing/AI_CONTRIBUTING) before submitting. -->
299 lines
10 KiB
Go
299 lines
10 KiB
Go
package oauth2provider
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"errors"
|
|
htmltemplate "html/template"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/justinas/nosurf"
|
|
"golang.org/x/xerrors"
|
|
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"github.com/coder/coder/v2/coderd/database/dbtime"
|
|
"github.com/coder/coder/v2/coderd/httpapi"
|
|
"github.com/coder/coder/v2/coderd/httpmw"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"github.com/coder/coder/v2/site"
|
|
)
|
|
|
|
type authorizeParams struct {
|
|
clientID string
|
|
redirectURL *url.URL
|
|
redirectURIProvided bool
|
|
responseType codersdk.OAuth2ProviderResponseType
|
|
scope []string
|
|
state string
|
|
resource string // RFC 8707 resource indicator
|
|
codeChallenge string // PKCE code challenge
|
|
codeChallengeMethod string // PKCE challenge method
|
|
}
|
|
|
|
func extractAuthorizeParams(r *http.Request, callbackURL *url.URL) (authorizeParams, []codersdk.ValidationError, error) {
|
|
p := httpapi.NewQueryParamParser()
|
|
vals := r.URL.Query()
|
|
|
|
// response_type and client_id are always required.
|
|
p.RequiredNotEmpty("response_type", "client_id")
|
|
|
|
params := authorizeParams{
|
|
clientID: p.String(vals, "", "client_id"),
|
|
redirectURL: p.RedirectURL(vals, callbackURL, "redirect_uri"),
|
|
redirectURIProvided: vals.Get("redirect_uri") != "",
|
|
responseType: httpapi.ParseCustom(p, vals, "", "response_type", httpapi.ParseEnum[codersdk.OAuth2ProviderResponseType]),
|
|
scope: strings.Fields(strings.TrimSpace(p.String(vals, "", "scope"))),
|
|
state: p.String(vals, "", "state"),
|
|
resource: p.String(vals, "", "resource"),
|
|
codeChallenge: p.String(vals, "", "code_challenge"),
|
|
codeChallengeMethod: p.String(vals, "", "code_challenge_method"),
|
|
}
|
|
|
|
// PKCE is required for authorization code flow requests.
|
|
if params.responseType == codersdk.OAuth2ProviderResponseTypeCode && params.codeChallenge == "" {
|
|
p.Errors = append(p.Errors, codersdk.ValidationError{
|
|
Field: "code_challenge",
|
|
Detail: `Query param "code_challenge" is required and cannot be empty`,
|
|
})
|
|
}
|
|
|
|
// Validate resource indicator syntax (RFC 8707): must be absolute URI without fragment
|
|
if err := validateResourceParameter(params.resource); err != nil {
|
|
p.Errors = append(p.Errors, codersdk.ValidationError{
|
|
Field: "resource",
|
|
Detail: "must be an absolute URI without fragment",
|
|
})
|
|
}
|
|
|
|
p.ErrorExcessParams(vals)
|
|
if len(p.Errors) > 0 {
|
|
// Create a readable error message with validation details
|
|
var errorDetails []string
|
|
for _, err := range p.Errors {
|
|
errorDetails = append(errorDetails, err.Error())
|
|
}
|
|
errorMsg := "Invalid query params: " + strings.Join(errorDetails, ", ")
|
|
return authorizeParams{}, p.Errors, xerrors.Errorf(errorMsg)
|
|
}
|
|
return params, nil, nil
|
|
}
|
|
|
|
// ShowAuthorizePage handles GET /oauth2/authorize requests to display the HTML authorization page.
|
|
func ShowAuthorizePage(accessURL *url.URL) http.HandlerFunc {
|
|
return func(rw http.ResponseWriter, r *http.Request) {
|
|
app := httpmw.OAuth2ProviderApp(r)
|
|
ua := httpmw.UserAuthorization(r.Context())
|
|
|
|
callbackURL, err := url.Parse(app.CallbackURL)
|
|
if err != nil {
|
|
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
|
|
Status: http.StatusInternalServerError,
|
|
HideStatus: false,
|
|
Title: "Internal Server Error",
|
|
Description: err.Error(),
|
|
Actions: []site.Action{
|
|
{
|
|
URL: accessURL.String(),
|
|
Text: "Back to site",
|
|
},
|
|
},
|
|
})
|
|
return
|
|
}
|
|
|
|
params, validationErrs, err := extractAuthorizeParams(r, callbackURL)
|
|
if err != nil {
|
|
errStr := make([]string, len(validationErrs))
|
|
for i, err := range validationErrs {
|
|
errStr[i] = err.Detail
|
|
}
|
|
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
|
|
Status: http.StatusBadRequest,
|
|
HideStatus: false,
|
|
Title: "Invalid Query Parameters",
|
|
Description: "One or more query parameters are missing or invalid.",
|
|
Warnings: errStr,
|
|
Actions: []site.Action{
|
|
{
|
|
URL: accessURL.String(),
|
|
Text: "Back to site",
|
|
},
|
|
},
|
|
})
|
|
return
|
|
}
|
|
|
|
if params.responseType != codersdk.OAuth2ProviderResponseTypeCode {
|
|
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
|
|
Status: http.StatusBadRequest,
|
|
HideStatus: false,
|
|
Title: "Unsupported Response Type",
|
|
Description: "Only response_type=code is supported.",
|
|
Actions: []site.Action{
|
|
{
|
|
URL: accessURL.String(),
|
|
Text: "Back to site",
|
|
},
|
|
},
|
|
})
|
|
return
|
|
}
|
|
|
|
cancel := params.redirectURL
|
|
cancelQuery := params.redirectURL.Query()
|
|
cancelQuery.Add("error", "access_denied")
|
|
cancelQuery.Add("error_description", "The resource owner or authorization server denied the request")
|
|
if params.state != "" {
|
|
cancelQuery.Add("state", params.state)
|
|
}
|
|
cancel.RawQuery = cancelQuery.Encode()
|
|
|
|
cancelURI := cancel.String()
|
|
if err := codersdk.ValidateRedirectURIScheme(cancel); err != nil {
|
|
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
|
|
Status: http.StatusBadRequest,
|
|
HideStatus: false,
|
|
Title: "Invalid Callback URL",
|
|
Description: "The application's registered callback URL has an invalid scheme.",
|
|
Actions: []site.Action{
|
|
{
|
|
URL: accessURL.String(),
|
|
Text: "Back to site",
|
|
},
|
|
},
|
|
})
|
|
return
|
|
}
|
|
|
|
site.RenderOAuthAllowPage(rw, r, site.RenderOAuthAllowData{
|
|
AppIcon: app.Icon,
|
|
AppName: app.Name,
|
|
// #nosec G203 -- The scheme is validated by
|
|
// codersdk.ValidateRedirectURIScheme above.
|
|
CancelURI: htmltemplate.URL(cancelURI),
|
|
RedirectURI: r.URL.String(),
|
|
CSRFToken: nosurf.Token(r),
|
|
Username: ua.FriendlyName,
|
|
})
|
|
}
|
|
}
|
|
|
|
// ProcessAuthorize handles POST /oauth2/authorize requests to process the user's authorization decision
|
|
// and generate an authorization code.
|
|
func ProcessAuthorize(db database.Store) http.HandlerFunc {
|
|
return func(rw http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
apiKey := httpmw.APIKey(r)
|
|
app := httpmw.OAuth2ProviderApp(r)
|
|
|
|
callbackURL, err := url.Parse(app.CallbackURL)
|
|
if err != nil {
|
|
httpapi.WriteOAuth2Error(r.Context(), rw, http.StatusInternalServerError, codersdk.OAuth2ErrorCodeServerError, "Failed to validate query parameters")
|
|
return
|
|
}
|
|
|
|
params, _, err := extractAuthorizeParams(r, callbackURL)
|
|
if err != nil {
|
|
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeInvalidRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
// OAuth 2.1 removes the implicit grant. Only
|
|
// authorization code flow is supported.
|
|
if params.responseType != codersdk.OAuth2ProviderResponseTypeCode {
|
|
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest,
|
|
codersdk.OAuth2ErrorCodeUnsupportedResponseType,
|
|
"Only response_type=code is supported")
|
|
return
|
|
}
|
|
|
|
// code_challenge is required (enforced by RequiredNotEmpty above),
|
|
// but default the method to S256 if omitted.
|
|
if params.codeChallengeMethod == "" {
|
|
params.codeChallengeMethod = string(codersdk.OAuth2PKCECodeChallengeMethodS256)
|
|
}
|
|
if err := codersdk.ValidatePKCECodeChallengeMethod(params.codeChallengeMethod); err != nil {
|
|
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, codersdk.OAuth2ErrorCodeInvalidRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
// TODO: Ignoring scope for now, but should look into implementing.
|
|
code, err := GenerateSecret()
|
|
if err != nil {
|
|
httpapi.WriteOAuth2Error(r.Context(), rw, http.StatusInternalServerError, codersdk.OAuth2ErrorCodeServerError, "Failed to generate OAuth2 app authorization code")
|
|
return
|
|
}
|
|
err = db.InTx(func(tx database.Store) error {
|
|
// Delete any previous codes.
|
|
err = tx.DeleteOAuth2ProviderAppCodesByAppAndUserID(ctx, database.DeleteOAuth2ProviderAppCodesByAppAndUserIDParams{
|
|
AppID: app.ID,
|
|
UserID: apiKey.UserID,
|
|
})
|
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
return xerrors.Errorf("delete oauth2 app codes: %w", err)
|
|
}
|
|
|
|
// Insert the new code.
|
|
_, err = tx.InsertOAuth2ProviderAppCode(ctx, database.InsertOAuth2ProviderAppCodeParams{
|
|
ID: uuid.New(),
|
|
CreatedAt: dbtime.Now(),
|
|
// TODO: Configurable expiration? Ten minutes matches GitHub.
|
|
// This timeout is only for the code that will be exchanged for the
|
|
// access token, not the access token itself. It does not need to be
|
|
// long-lived because normally it will be exchanged immediately after it
|
|
// is received. If the application does wait before exchanging the
|
|
// token (for example suppose they ask the user to confirm and the user
|
|
// has left) then they can just retry immediately and get a new code.
|
|
ExpiresAt: dbtime.Now().Add(time.Duration(10) * time.Minute),
|
|
SecretPrefix: []byte(code.Prefix),
|
|
HashedSecret: code.Hashed,
|
|
AppID: app.ID,
|
|
UserID: apiKey.UserID,
|
|
ResourceUri: sql.NullString{String: params.resource, Valid: params.resource != ""},
|
|
CodeChallenge: sql.NullString{String: params.codeChallenge, Valid: params.codeChallenge != ""},
|
|
CodeChallengeMethod: sql.NullString{String: params.codeChallengeMethod, Valid: params.codeChallengeMethod != ""},
|
|
StateHash: hashOAuth2State(params.state),
|
|
RedirectUri: sql.NullString{String: params.redirectURL.String(), Valid: params.redirectURIProvided},
|
|
})
|
|
if err != nil {
|
|
return xerrors.Errorf("insert oauth2 authorization code: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}, nil)
|
|
if err != nil {
|
|
httpapi.WriteOAuth2Error(ctx, rw, http.StatusInternalServerError, codersdk.OAuth2ErrorCodeServerError, "Failed to generate OAuth2 authorization code")
|
|
return
|
|
}
|
|
|
|
newQuery := params.redirectURL.Query()
|
|
newQuery.Add("code", code.Formatted)
|
|
if params.state != "" {
|
|
newQuery.Add("state", params.state)
|
|
}
|
|
params.redirectURL.RawQuery = newQuery.Encode()
|
|
|
|
// (ThomasK33): Use a 302 redirect as some (external) OAuth 2 apps and browsers
|
|
// do not work with the 307.
|
|
http.Redirect(rw, r, params.redirectURL.String(), http.StatusFound)
|
|
}
|
|
}
|
|
|
|
// hashOAuth2State returns a SHA-256 hash of the OAuth2 state parameter. If
|
|
// the state is empty, it returns a null string.
|
|
func hashOAuth2State(state string) sql.NullString {
|
|
if state == "" {
|
|
return sql.NullString{}
|
|
}
|
|
hash := sha256.Sum256([]byte(state))
|
|
return sql.NullString{
|
|
String: hex.EncodeToString(hash[:]),
|
|
Valid: true,
|
|
}
|
|
}
|