mirror of
https://github.com/coder/coder.git
synced 2025-07-18 14:17:22 +00:00
feat: make OAuth2 provider not enterprise-only (#12732)
This commit is contained in:
@ -153,16 +153,6 @@ func New(ctx context.Context, options *Options) (_ *API, err error) {
|
||||
SessionTokenFunc: nil, // Default behavior
|
||||
PostAuthAdditionalHeadersFunc: options.PostAuthAdditionalHeadersFunc,
|
||||
})
|
||||
// Same as above but it redirects to the login page.
|
||||
apiKeyMiddlewareRedirect := httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
|
||||
DB: options.Database,
|
||||
OAuth2Configs: oauthConfigs,
|
||||
RedirectToLogin: true,
|
||||
DisableSessionExpiryRefresh: options.DeploymentValues.DisableSessionExpiryRefresh.Value(),
|
||||
Optional: false,
|
||||
SessionTokenFunc: nil, // Default behavior
|
||||
PostAuthAdditionalHeadersFunc: options.PostAuthAdditionalHeadersFunc,
|
||||
})
|
||||
apiKeyMiddlewareOptional := httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
|
||||
DB: options.Database,
|
||||
OAuth2Configs: oauthConfigs,
|
||||
@ -178,33 +168,6 @@ func New(ctx context.Context, options *Options) (_ *API, err error) {
|
||||
return nil, xerrors.Errorf("failed to get deployment ID: %w", err)
|
||||
}
|
||||
|
||||
api.AGPL.RootHandler.Group(func(r chi.Router) {
|
||||
// OAuth2 linking routes do not make sense under the /api/v2 path.
|
||||
r.Route("/oauth2", func(r chi.Router) {
|
||||
r.Use(
|
||||
api.oAuth2ProviderMiddleware,
|
||||
// Fetch the app as system because in the /tokens route there will be no
|
||||
// authenticated user.
|
||||
httpmw.AsAuthzSystem(httpmw.ExtractOAuth2ProviderApp(options.Database)),
|
||||
)
|
||||
r.Route("/authorize", func(r chi.Router) {
|
||||
r.Use(apiKeyMiddlewareRedirect)
|
||||
r.Get("/", api.getOAuth2ProviderAppAuthorize())
|
||||
})
|
||||
r.Route("/tokens", func(r chi.Router) {
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(apiKeyMiddleware)
|
||||
// DELETE on /tokens is not part of the OAuth2 spec. It is our own
|
||||
// route used to revoke permissions from an application. It is here for
|
||||
// parity with POST on /tokens.
|
||||
r.Delete("/", api.deleteOAuth2ProviderAppTokens())
|
||||
})
|
||||
// The POST /tokens endpoint will be called from an unauthorized client so we
|
||||
// cannot require an API key.
|
||||
r.Post("/", api.postOAuth2ProviderAppToken())
|
||||
})
|
||||
})
|
||||
})
|
||||
api.AGPL.RefreshEntitlements = func(ctx context.Context) error {
|
||||
return api.refreshEntitlements(ctx)
|
||||
}
|
||||
@ -365,33 +328,6 @@ func New(ctx context.Context, options *Options) (_ *API, err error) {
|
||||
r.Get("/", api.userQuietHoursSchedule)
|
||||
r.Put("/", api.putUserQuietHoursSchedule)
|
||||
})
|
||||
r.Route("/oauth2-provider", func(r chi.Router) {
|
||||
r.Use(
|
||||
apiKeyMiddleware,
|
||||
api.oAuth2ProviderMiddleware,
|
||||
)
|
||||
r.Route("/apps", func(r chi.Router) {
|
||||
r.Get("/", api.oAuth2ProviderApps)
|
||||
r.Post("/", api.postOAuth2ProviderApp)
|
||||
|
||||
r.Route("/{app}", func(r chi.Router) {
|
||||
r.Use(httpmw.ExtractOAuth2ProviderApp(options.Database))
|
||||
r.Get("/", api.oAuth2ProviderApp)
|
||||
r.Put("/", api.putOAuth2ProviderApp)
|
||||
r.Delete("/", api.deleteOAuth2ProviderApp)
|
||||
|
||||
r.Route("/secrets", func(r chi.Router) {
|
||||
r.Get("/", api.oAuth2ProviderAppSecrets)
|
||||
r.Post("/", api.postOAuth2ProviderAppSecret)
|
||||
|
||||
r.Route("/{secretID}", func(r chi.Router) {
|
||||
r.Use(httpmw.ExtractOAuth2ProviderAppSecret(options.Database))
|
||||
r.Delete("/", api.deleteOAuth2ProviderAppSecret)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
r.Route("/integrations", func(r chi.Router) {
|
||||
r.Use(
|
||||
apiKeyMiddleware,
|
||||
@ -596,7 +532,6 @@ func (api *API) updateEntitlements(ctx context.Context) error {
|
||||
codersdk.FeatureBrowserOnly: api.BrowserOnly,
|
||||
codersdk.FeatureSCIM: len(api.SCIMAPIKey) != 0,
|
||||
codersdk.FeatureMultipleExternalAuth: len(api.ExternalAuthConfigs) > 1,
|
||||
codersdk.FeatureOAuth2Provider: true,
|
||||
codersdk.FeatureTemplateRBAC: api.RBAC,
|
||||
codersdk.FeatureExternalTokenEncryption: len(api.ExternalTokenEncryption) > 0,
|
||||
codersdk.FeatureExternalProvisionerDaemons: true,
|
||||
|
@ -1,140 +0,0 @@
|
||||
package identityprovider
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"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"
|
||||
)
|
||||
|
||||
type authorizeParams struct {
|
||||
clientID string
|
||||
redirectURL *url.URL
|
||||
responseType codersdk.OAuth2ProviderResponseType
|
||||
scope []string
|
||||
state string
|
||||
}
|
||||
|
||||
func extractAuthorizeParams(r *http.Request, callbackURL *url.URL) (authorizeParams, []codersdk.ValidationError, error) {
|
||||
p := httpapi.NewQueryParamParser()
|
||||
vals := r.URL.Query()
|
||||
|
||||
p.RequiredNotEmpty("state", "response_type", "client_id")
|
||||
|
||||
params := authorizeParams{
|
||||
clientID: p.String(vals, "", "client_id"),
|
||||
redirectURL: p.RedirectURL(vals, callbackURL, "redirect_uri"),
|
||||
responseType: httpapi.ParseCustom(p, vals, "", "response_type", httpapi.ParseEnum[codersdk.OAuth2ProviderResponseType]),
|
||||
scope: p.Strings(vals, []string{}, "scope"),
|
||||
state: p.String(vals, "", "state"),
|
||||
}
|
||||
|
||||
// We add "redirected" when coming from the authorize page.
|
||||
_ = p.String(vals, "", "redirected")
|
||||
|
||||
p.ErrorExcessParams(vals)
|
||||
if len(p.Errors) > 0 {
|
||||
return authorizeParams{}, p.Errors, xerrors.Errorf("invalid query params: %w", p.Errors)
|
||||
}
|
||||
return params, nil, nil
|
||||
}
|
||||
|
||||
// Authorize displays an HTML page for authorizing an application when the user
|
||||
// has first been redirected to this path and generates a code and redirects to
|
||||
// the app's callback URL after the user clicks "allow" on that page, which is
|
||||
// detected via the origin and referer headers.
|
||||
func Authorize(db database.Store, accessURL *url.URL) http.HandlerFunc {
|
||||
handler := 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.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to validate query parameters.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
params, validationErrs, err := extractAuthorizeParams(r, callbackURL)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Invalid query params.",
|
||||
Detail: err.Error(),
|
||||
Validations: validationErrs,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Ignoring scope for now, but should look into implementing.
|
||||
code, err := GenerateSecret()
|
||||
if err != nil {
|
||||
httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "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: []byte(code.Hashed),
|
||||
AppID: app.ID,
|
||||
UserID: apiKey.UserID,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("insert oauth2 authorization code: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}, nil)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to generate OAuth2 authorization code.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
newQuery := params.redirectURL.Query()
|
||||
newQuery.Add("code", code.Formatted)
|
||||
newQuery.Add("state", params.state)
|
||||
params.redirectURL.RawQuery = newQuery.Encode()
|
||||
|
||||
http.Redirect(rw, r, params.redirectURL.String(), http.StatusTemporaryRedirect)
|
||||
}
|
||||
|
||||
// Always wrap with its custom mw.
|
||||
return authorizeMW(accessURL)(http.HandlerFunc(handler)).ServeHTTP
|
||||
}
|
@ -1,149 +0,0 @@
|
||||
package identityprovider
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// authorizeMW serves to remove some code from the primary authorize handler.
|
||||
// It decides when to show the html allow page, and when to just continue.
|
||||
func authorizeMW(accessURL *url.URL) func(next http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||
origin := r.Header.Get(httpmw.OriginHeader)
|
||||
originU, err := url.Parse(origin)
|
||||
if err != nil {
|
||||
httpapi.Write(r.Context(), rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Invalid origin header.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
referer := r.Referer()
|
||||
refererU, err := url.Parse(referer)
|
||||
if err != nil {
|
||||
httpapi.Write(r.Context(), rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Invalid referer header.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
app := httpmw.OAuth2ProviderApp(r)
|
||||
ua := httpmw.UserAuthorization(r)
|
||||
|
||||
// url.Parse() allows empty URLs, which is fine because the origin is not
|
||||
// always set by browsers (or other tools like cURL). If the origin does
|
||||
// exist, we will make sure it matches. We require `referer` to be set at
|
||||
// a minimum in order to detect whether "allow" has been pressed, however.
|
||||
cameFromSelf := (origin == "" || originU.Hostname() == accessURL.Hostname()) &&
|
||||
refererU.Hostname() == accessURL.Hostname() &&
|
||||
refererU.Path == "/oauth2/authorize"
|
||||
|
||||
// If we were redirected here from this same page it means the user
|
||||
// pressed the allow button so defer to the authorize handler which
|
||||
// generates the code, otherwise show the HTML allow page.
|
||||
// TODO: Skip this step if the user has already clicked allow before, and
|
||||
// we can just reuse the token.
|
||||
if cameFromSelf {
|
||||
next.ServeHTTP(rw, r)
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: For now only browser-based auth flow is officially supported but
|
||||
// in a future PR we should support a cURL-based flow where we output text
|
||||
// instead of HTML.
|
||||
if r.URL.Query().Get("redirected") != "" {
|
||||
// When the user first comes into the page, referer might be blank which
|
||||
// is OK. But if they click "allow" and their browser has *still* not
|
||||
// sent the referer header, we have no way of telling whether they
|
||||
// actually clicked the button. "Redirected" means they *might* have
|
||||
// pressed it, but it could also mean an app added it for them as part
|
||||
// of their redirect, so we cannot use it as a replacement for referer
|
||||
// and the best we can do is error.
|
||||
if referer == "" {
|
||||
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
|
||||
Status: http.StatusInternalServerError,
|
||||
HideStatus: false,
|
||||
Title: "Referer header missing",
|
||||
Description: "We cannot continue authorization because your client has not sent the referer header.",
|
||||
RetryEnabled: false,
|
||||
DashboardURL: accessURL.String(),
|
||||
Warnings: nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
|
||||
Status: http.StatusInternalServerError,
|
||||
HideStatus: false,
|
||||
Title: "Oauth Redirect Loop",
|
||||
Description: "Oauth redirect loop detected.",
|
||||
RetryEnabled: false,
|
||||
DashboardURL: accessURL.String(),
|
||||
Warnings: nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
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(),
|
||||
RetryEnabled: false,
|
||||
DashboardURL: accessURL.String(),
|
||||
Warnings: nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Extract the form parameters for two reasons:
|
||||
// 1. We need the redirect URI to build the cancel URI.
|
||||
// 2. Since validation will run once the user clicks "allow", it is
|
||||
// better to validate now to avoid wasting the user's time clicking a
|
||||
// button that will just error anyway.
|
||||
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.",
|
||||
RetryEnabled: false,
|
||||
DashboardURL: accessURL.String(),
|
||||
Warnings: errStr,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
cancel := params.redirectURL
|
||||
cancelQuery := params.redirectURL.Query()
|
||||
cancelQuery.Add("error", "access_denied")
|
||||
cancel.RawQuery = cancelQuery.Encode()
|
||||
|
||||
redirect := r.URL
|
||||
vals := redirect.Query()
|
||||
vals.Add("redirected", "true") // For loop detection.
|
||||
r.URL.RawQuery = vals.Encode()
|
||||
site.RenderOAuthAllowPage(rw, r, site.RenderOAuthAllowData{
|
||||
AppIcon: app.Icon,
|
||||
AppName: app.Name,
|
||||
CancelURI: cancel.String(),
|
||||
RedirectURI: r.URL.String(),
|
||||
Username: ua.ActorName,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
@ -1,44 +0,0 @@
|
||||
package identityprovider
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
"github.com/coder/coder/v2/coderd/httpmw"
|
||||
)
|
||||
|
||||
func RevokeApp(db database.Store) http.HandlerFunc {
|
||||
return func(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
apiKey := httpmw.APIKey(r)
|
||||
app := httpmw.OAuth2ProviderApp(r)
|
||||
|
||||
err := db.InTx(func(tx database.Store) error {
|
||||
err := tx.DeleteOAuth2ProviderAppCodesByAppAndUserID(ctx, database.DeleteOAuth2ProviderAppCodesByAppAndUserIDParams{
|
||||
AppID: app.ID,
|
||||
UserID: apiKey.UserID,
|
||||
})
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
|
||||
err = tx.DeleteOAuth2ProviderAppTokensByAppAndUserID(ctx, database.DeleteOAuth2ProviderAppTokensByAppAndUserIDParams{
|
||||
AppID: app.ID,
|
||||
UserID: apiKey.UserID,
|
||||
})
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}, nil)
|
||||
if err != nil {
|
||||
httpapi.InternalServerError(rw, err)
|
||||
return
|
||||
}
|
||||
httpapi.Write(ctx, rw, http.StatusNoContent, nil)
|
||||
}
|
||||
}
|
@ -1,77 +0,0 @@
|
||||
package identityprovider
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/userpassword"
|
||||
"github.com/coder/coder/v2/cryptorand"
|
||||
)
|
||||
|
||||
type OAuth2ProviderAppSecret struct {
|
||||
// Formatted contains the secret. This value is owned by the client, not the
|
||||
// server. It is formatted to include the prefix.
|
||||
Formatted string
|
||||
// Prefix is the ID of this secret owned by the server. When a client uses a
|
||||
// secret, this is the matching string to do a lookup on the hashed value. We
|
||||
// cannot use the hashed value directly because the server does not store the
|
||||
// salt.
|
||||
Prefix string
|
||||
// Hashed is the server stored hash(secret,salt,...). Used for verifying a
|
||||
// secret.
|
||||
Hashed string
|
||||
}
|
||||
|
||||
// GenerateSecret generates a secret to be used as a client secret, refresh
|
||||
// token, or authorization code.
|
||||
func GenerateSecret() (OAuth2ProviderAppSecret, error) {
|
||||
// 40 characters matches the length of GitHub's client secrets.
|
||||
secret, err := cryptorand.String(40)
|
||||
if err != nil {
|
||||
return OAuth2ProviderAppSecret{}, err
|
||||
}
|
||||
|
||||
// This ID is prefixed to the secret so it can be used to look up the secret
|
||||
// when the user provides it, since we cannot just re-hash it to match as we
|
||||
// will not have the salt.
|
||||
prefix, err := cryptorand.String(10)
|
||||
if err != nil {
|
||||
return OAuth2ProviderAppSecret{}, err
|
||||
}
|
||||
|
||||
hashed, err := userpassword.Hash(secret)
|
||||
if err != nil {
|
||||
return OAuth2ProviderAppSecret{}, err
|
||||
}
|
||||
|
||||
return OAuth2ProviderAppSecret{
|
||||
Formatted: fmt.Sprintf("coder_%s_%s", prefix, secret),
|
||||
Prefix: prefix,
|
||||
Hashed: hashed,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type parsedSecret struct {
|
||||
prefix string
|
||||
secret string
|
||||
}
|
||||
|
||||
// parseSecret extracts the ID and original secret from a secret.
|
||||
func parseSecret(secret string) (parsedSecret, error) {
|
||||
parts := strings.Split(secret, "_")
|
||||
if len(parts) != 3 {
|
||||
return parsedSecret{}, xerrors.Errorf("incorrect number of parts: %d", len(parts))
|
||||
}
|
||||
if parts[0] != "coder" {
|
||||
return parsedSecret{}, xerrors.Errorf("incorrect scheme: %s", parts[0])
|
||||
}
|
||||
if len(parts[1]) == 0 {
|
||||
return parsedSecret{}, xerrors.Errorf("prefix is invalid")
|
||||
}
|
||||
if len(parts[2]) == 0 {
|
||||
return parsedSecret{}, xerrors.Errorf("invalid")
|
||||
}
|
||||
return parsedSecret{parts[1], parts[2]}, nil
|
||||
}
|
@ -1,378 +0,0 @@
|
||||
package identityprovider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/apikey"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||
"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/coderd/rbac"
|
||||
"github.com/coder/coder/v2/coderd/userpassword"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
var (
|
||||
// errBadSecret means the user provided a bad secret.
|
||||
errBadSecret = xerrors.New("Invalid client secret")
|
||||
// errBadCode means the user provided a bad code.
|
||||
errBadCode = xerrors.New("Invalid code")
|
||||
// errBadToken means the user provided a bad token.
|
||||
errBadToken = xerrors.New("Invalid token")
|
||||
)
|
||||
|
||||
type tokenParams struct {
|
||||
clientID string
|
||||
clientSecret string
|
||||
code string
|
||||
grantType codersdk.OAuth2ProviderGrantType
|
||||
redirectURL *url.URL
|
||||
refreshToken string
|
||||
}
|
||||
|
||||
func extractTokenParams(r *http.Request, callbackURL *url.URL) (tokenParams, []codersdk.ValidationError, error) {
|
||||
p := httpapi.NewQueryParamParser()
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
return tokenParams{}, nil, xerrors.Errorf("parse form: %w", err)
|
||||
}
|
||||
|
||||
vals := r.Form
|
||||
p.RequiredNotEmpty("grant_type")
|
||||
grantType := httpapi.ParseCustom(p, vals, "", "grant_type", httpapi.ParseEnum[codersdk.OAuth2ProviderGrantType])
|
||||
switch grantType {
|
||||
case codersdk.OAuth2ProviderGrantTypeRefreshToken:
|
||||
p.RequiredNotEmpty("refresh_token")
|
||||
case codersdk.OAuth2ProviderGrantTypeAuthorizationCode:
|
||||
p.RequiredNotEmpty("client_secret", "client_id", "code")
|
||||
}
|
||||
|
||||
params := tokenParams{
|
||||
clientID: p.String(vals, "", "client_id"),
|
||||
clientSecret: p.String(vals, "", "client_secret"),
|
||||
code: p.String(vals, "", "code"),
|
||||
grantType: grantType,
|
||||
redirectURL: p.RedirectURL(vals, callbackURL, "redirect_uri"),
|
||||
refreshToken: p.String(vals, "", "refresh_token"),
|
||||
}
|
||||
|
||||
p.ErrorExcessParams(vals)
|
||||
if len(p.Errors) > 0 {
|
||||
return tokenParams{}, p.Errors, xerrors.Errorf("invalid query params: %w", p.Errors)
|
||||
}
|
||||
return params, nil, nil
|
||||
}
|
||||
|
||||
func Tokens(db database.Store, defaultLifetime time.Duration) http.HandlerFunc {
|
||||
return func(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
app := httpmw.OAuth2ProviderApp(r)
|
||||
|
||||
callbackURL, err := url.Parse(app.CallbackURL)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to validate form values.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
params, validationErrs, err := extractTokenParams(r, callbackURL)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Invalid query params.",
|
||||
Detail: err.Error(),
|
||||
Validations: validationErrs,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var token oauth2.Token
|
||||
//nolint:gocritic,revive // More cases will be added later.
|
||||
switch params.grantType {
|
||||
// TODO: Client creds, device code.
|
||||
case codersdk.OAuth2ProviderGrantTypeRefreshToken:
|
||||
token, err = refreshTokenGrant(ctx, db, app, defaultLifetime, params)
|
||||
case codersdk.OAuth2ProviderGrantTypeAuthorizationCode:
|
||||
token, err = authorizationCodeGrant(ctx, db, app, defaultLifetime, params)
|
||||
default:
|
||||
// Grant types are validated by the parser, so getting through here means
|
||||
// the developer added a type but forgot to add a case here.
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Unhandled grant type.",
|
||||
Detail: fmt.Sprintf("Grant type %q is unhandled", params.grantType),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if errors.Is(err, errBadCode) || errors.Is(err, errBadSecret) {
|
||||
httpapi.Write(r.Context(), rw, http.StatusUnauthorized, codersdk.Response{
|
||||
Message: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to exchange token",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Some client libraries allow this to be "application/x-www-form-urlencoded". We can implement that upon
|
||||
// request. The same libraries should also accept JSON. If implemented, choose based on "Accept" header.
|
||||
httpapi.Write(ctx, rw, http.StatusOK, token)
|
||||
}
|
||||
}
|
||||
|
||||
func authorizationCodeGrant(ctx context.Context, db database.Store, app database.OAuth2ProviderApp, defaultLifetime time.Duration, params tokenParams) (oauth2.Token, error) {
|
||||
// Validate the client secret.
|
||||
secret, err := parseSecret(params.clientSecret)
|
||||
if err != nil {
|
||||
return oauth2.Token{}, errBadSecret
|
||||
}
|
||||
//nolint:gocritic // Users cannot read secrets so we must use the system.
|
||||
dbSecret, err := db.GetOAuth2ProviderAppSecretByPrefix(dbauthz.AsSystemRestricted(ctx), []byte(secret.prefix))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return oauth2.Token{}, errBadSecret
|
||||
}
|
||||
if err != nil {
|
||||
return oauth2.Token{}, err
|
||||
}
|
||||
equal, err := userpassword.Compare(string(dbSecret.HashedSecret), secret.secret)
|
||||
if err != nil {
|
||||
return oauth2.Token{}, xerrors.Errorf("unable to compare secret: %w", err)
|
||||
}
|
||||
if !equal {
|
||||
return oauth2.Token{}, errBadSecret
|
||||
}
|
||||
|
||||
// Validate the authorization code.
|
||||
code, err := parseSecret(params.code)
|
||||
if err != nil {
|
||||
return oauth2.Token{}, errBadCode
|
||||
}
|
||||
//nolint:gocritic // There is no user yet so we must use the system.
|
||||
dbCode, err := db.GetOAuth2ProviderAppCodeByPrefix(dbauthz.AsSystemRestricted(ctx), []byte(code.prefix))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return oauth2.Token{}, errBadCode
|
||||
}
|
||||
if err != nil {
|
||||
return oauth2.Token{}, err
|
||||
}
|
||||
equal, err = userpassword.Compare(string(dbCode.HashedSecret), code.secret)
|
||||
if err != nil {
|
||||
return oauth2.Token{}, xerrors.Errorf("unable to compare code: %w", err)
|
||||
}
|
||||
if !equal {
|
||||
return oauth2.Token{}, errBadCode
|
||||
}
|
||||
|
||||
// Ensure the code has not expired.
|
||||
if dbCode.ExpiresAt.Before(dbtime.Now()) {
|
||||
return oauth2.Token{}, errBadCode
|
||||
}
|
||||
|
||||
// Generate a refresh token.
|
||||
refreshToken, err := GenerateSecret()
|
||||
if err != nil {
|
||||
return oauth2.Token{}, err
|
||||
}
|
||||
|
||||
// Generate the API key we will swap for the code.
|
||||
// TODO: We are ignoring scopes for now.
|
||||
tokenName := fmt.Sprintf("%s_%s_oauth_session_token", dbCode.UserID, app.ID)
|
||||
key, sessionToken, err := apikey.Generate(apikey.CreateParams{
|
||||
UserID: dbCode.UserID,
|
||||
LoginType: database.LoginTypeOAuth2ProviderApp,
|
||||
// TODO: This is just the lifetime for api keys, maybe have its own config
|
||||
// settings. #11693
|
||||
DefaultLifetime: defaultLifetime,
|
||||
// For now, we allow only one token per app and user at a time.
|
||||
TokenName: tokenName,
|
||||
})
|
||||
if err != nil {
|
||||
return oauth2.Token{}, err
|
||||
}
|
||||
|
||||
// Grab the user roles so we can perform the exchange as the user.
|
||||
//nolint:gocritic // In the token exchange, there is no user actor.
|
||||
roles, err := db.GetAuthorizationUserRoles(dbauthz.AsSystemRestricted(ctx), dbCode.UserID)
|
||||
if err != nil {
|
||||
return oauth2.Token{}, err
|
||||
}
|
||||
userSubj := rbac.Subject{
|
||||
ID: dbCode.UserID.String(),
|
||||
Roles: rbac.RoleNames(roles.Roles),
|
||||
Groups: roles.Groups,
|
||||
Scope: rbac.ScopeAll,
|
||||
}
|
||||
|
||||
// Do the actual token exchange in the database.
|
||||
err = db.InTx(func(tx database.Store) error {
|
||||
ctx := dbauthz.As(ctx, userSubj)
|
||||
err = tx.DeleteOAuth2ProviderAppCodeByID(ctx, dbCode.ID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("delete oauth2 app code: %w", err)
|
||||
}
|
||||
|
||||
// Delete the previous key, if any.
|
||||
prevKey, err := tx.GetAPIKeyByName(ctx, database.GetAPIKeyByNameParams{
|
||||
UserID: dbCode.UserID,
|
||||
TokenName: tokenName,
|
||||
})
|
||||
if err == nil {
|
||||
err = tx.DeleteAPIKeyByID(ctx, prevKey.ID)
|
||||
}
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return xerrors.Errorf("delete api key by name: %w", err)
|
||||
}
|
||||
|
||||
newKey, err := tx.InsertAPIKey(ctx, key)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("insert oauth2 access token: %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.InsertOAuth2ProviderAppToken(ctx, database.InsertOAuth2ProviderAppTokenParams{
|
||||
ID: uuid.New(),
|
||||
CreatedAt: dbtime.Now(),
|
||||
ExpiresAt: key.ExpiresAt,
|
||||
HashPrefix: []byte(refreshToken.Prefix),
|
||||
RefreshHash: []byte(refreshToken.Hashed),
|
||||
AppSecretID: dbSecret.ID,
|
||||
APIKeyID: newKey.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("insert oauth2 refresh token: %w", err)
|
||||
}
|
||||
return nil
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return oauth2.Token{}, err
|
||||
}
|
||||
|
||||
return oauth2.Token{
|
||||
AccessToken: sessionToken,
|
||||
TokenType: "Bearer",
|
||||
RefreshToken: refreshToken.Formatted,
|
||||
Expiry: key.ExpiresAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func refreshTokenGrant(ctx context.Context, db database.Store, app database.OAuth2ProviderApp, defaultLifetime time.Duration, params tokenParams) (oauth2.Token, error) {
|
||||
// Validate the token.
|
||||
token, err := parseSecret(params.refreshToken)
|
||||
if err != nil {
|
||||
return oauth2.Token{}, errBadToken
|
||||
}
|
||||
//nolint:gocritic // There is no user yet so we must use the system.
|
||||
dbToken, err := db.GetOAuth2ProviderAppTokenByPrefix(dbauthz.AsSystemRestricted(ctx), []byte(token.prefix))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return oauth2.Token{}, errBadToken
|
||||
}
|
||||
if err != nil {
|
||||
return oauth2.Token{}, err
|
||||
}
|
||||
equal, err := userpassword.Compare(string(dbToken.RefreshHash), token.secret)
|
||||
if err != nil {
|
||||
return oauth2.Token{}, xerrors.Errorf("unable to compare token: %w", err)
|
||||
}
|
||||
if !equal {
|
||||
return oauth2.Token{}, errBadToken
|
||||
}
|
||||
|
||||
// Ensure the token has not expired.
|
||||
if dbToken.ExpiresAt.Before(dbtime.Now()) {
|
||||
return oauth2.Token{}, errBadToken
|
||||
}
|
||||
|
||||
// Grab the user roles so we can perform the refresh as the user.
|
||||
//nolint:gocritic // There is no user yet so we must use the system.
|
||||
prevKey, err := db.GetAPIKeyByID(dbauthz.AsSystemRestricted(ctx), dbToken.APIKeyID)
|
||||
if err != nil {
|
||||
return oauth2.Token{}, err
|
||||
}
|
||||
//nolint:gocritic // There is no user yet so we must use the system.
|
||||
roles, err := db.GetAuthorizationUserRoles(dbauthz.AsSystemRestricted(ctx), prevKey.UserID)
|
||||
if err != nil {
|
||||
return oauth2.Token{}, err
|
||||
}
|
||||
userSubj := rbac.Subject{
|
||||
ID: prevKey.UserID.String(),
|
||||
Roles: rbac.RoleNames(roles.Roles),
|
||||
Groups: roles.Groups,
|
||||
Scope: rbac.ScopeAll,
|
||||
}
|
||||
|
||||
// Generate a new refresh token.
|
||||
refreshToken, err := GenerateSecret()
|
||||
if err != nil {
|
||||
return oauth2.Token{}, err
|
||||
}
|
||||
|
||||
// Generate the new API key.
|
||||
// TODO: We are ignoring scopes for now.
|
||||
tokenName := fmt.Sprintf("%s_%s_oauth_session_token", prevKey.UserID, app.ID)
|
||||
key, sessionToken, err := apikey.Generate(apikey.CreateParams{
|
||||
UserID: prevKey.UserID,
|
||||
LoginType: database.LoginTypeOAuth2ProviderApp,
|
||||
// TODO: This is just the lifetime for api keys, maybe have its own config
|
||||
// settings. #11693
|
||||
DefaultLifetime: defaultLifetime,
|
||||
// For now, we allow only one token per app and user at a time.
|
||||
TokenName: tokenName,
|
||||
})
|
||||
if err != nil {
|
||||
return oauth2.Token{}, err
|
||||
}
|
||||
|
||||
// Replace the token.
|
||||
err = db.InTx(func(tx database.Store) error {
|
||||
ctx := dbauthz.As(ctx, userSubj)
|
||||
err = tx.DeleteAPIKeyByID(ctx, prevKey.ID) // This cascades to the token.
|
||||
if err != nil {
|
||||
return xerrors.Errorf("delete oauth2 app token: %w", err)
|
||||
}
|
||||
|
||||
newKey, err := tx.InsertAPIKey(ctx, key)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("insert oauth2 access token: %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.InsertOAuth2ProviderAppToken(ctx, database.InsertOAuth2ProviderAppTokenParams{
|
||||
ID: uuid.New(),
|
||||
CreatedAt: dbtime.Now(),
|
||||
ExpiresAt: key.ExpiresAt,
|
||||
HashPrefix: []byte(refreshToken.Prefix),
|
||||
RefreshHash: []byte(refreshToken.Hashed),
|
||||
AppSecretID: dbToken.AppSecretID,
|
||||
APIKeyID: newKey.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("insert oauth2 refresh token: %w", err)
|
||||
}
|
||||
return nil
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return oauth2.Token{}, err
|
||||
}
|
||||
|
||||
return oauth2.Token{
|
||||
AccessToken: sessionToken,
|
||||
TokenType: "Bearer",
|
||||
RefreshToken: refreshToken.Formatted,
|
||||
Expiry: key.ExpiresAt,
|
||||
}, nil
|
||||
}
|
@ -1,380 +0,0 @@
|
||||
package coderd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/coder/coder/v2/buildinfo"
|
||||
"github.com/coder/coder/v2/coderd/audit"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/db2sdk"
|
||||
"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/enterprise/coderd/identityprovider"
|
||||
)
|
||||
|
||||
func (api *API) oAuth2ProviderMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||
if !buildinfo.IsDev() {
|
||||
httpapi.Write(r.Context(), rw, http.StatusForbidden, codersdk.Response{
|
||||
Message: "OAuth2 provider is under development.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
api.entitlementsMu.RLock()
|
||||
entitled := api.entitlements.Features[codersdk.FeatureOAuth2Provider].Entitlement != codersdk.EntitlementNotEntitled
|
||||
api.entitlementsMu.RUnlock()
|
||||
|
||||
if !entitled {
|
||||
httpapi.Write(r.Context(), rw, http.StatusForbidden, codersdk.Response{
|
||||
Message: "OAuth2 provider is an Enterprise feature. Contact sales!",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(rw, r)
|
||||
})
|
||||
}
|
||||
|
||||
// @Summary Get OAuth2 applications.
|
||||
// @ID get-oauth2-applications
|
||||
// @Security CoderSessionToken
|
||||
// @Produce json
|
||||
// @Tags Enterprise
|
||||
// @Param user_id query string false "Filter by applications authorized for a user"
|
||||
// @Success 200 {array} codersdk.OAuth2ProviderApp
|
||||
// @Router /oauth2-provider/apps [get]
|
||||
func (api *API) oAuth2ProviderApps(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
rawUserID := r.URL.Query().Get("user_id")
|
||||
if rawUserID == "" {
|
||||
dbApps, err := api.Database.GetOAuth2ProviderApps(ctx)
|
||||
if err != nil {
|
||||
httpapi.InternalServerError(rw, err)
|
||||
return
|
||||
}
|
||||
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.OAuth2ProviderApps(api.AccessURL, dbApps))
|
||||
return
|
||||
}
|
||||
|
||||
userID, err := uuid.Parse(rawUserID)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Invalid user UUID",
|
||||
Detail: fmt.Sprintf("queried user_id=%q", userID),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
userApps, err := api.Database.GetOAuth2ProviderAppsByUserID(ctx, userID)
|
||||
if err != nil {
|
||||
httpapi.InternalServerError(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
var sdkApps []codersdk.OAuth2ProviderApp
|
||||
for _, app := range userApps {
|
||||
sdkApps = append(sdkApps, db2sdk.OAuth2ProviderApp(api.AccessURL, app.OAuth2ProviderApp))
|
||||
}
|
||||
httpapi.Write(ctx, rw, http.StatusOK, sdkApps)
|
||||
}
|
||||
|
||||
// @Summary Get OAuth2 application.
|
||||
// @ID get-oauth2-application
|
||||
// @Security CoderSessionToken
|
||||
// @Produce json
|
||||
// @Tags Enterprise
|
||||
// @Param app path string true "App ID"
|
||||
// @Success 200 {object} codersdk.OAuth2ProviderApp
|
||||
// @Router /oauth2-provider/apps/{app} [get]
|
||||
func (api *API) oAuth2ProviderApp(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
app := httpmw.OAuth2ProviderApp(r)
|
||||
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.OAuth2ProviderApp(api.AccessURL, app))
|
||||
}
|
||||
|
||||
// @Summary Create OAuth2 application.
|
||||
// @ID create-oauth2-application
|
||||
// @Security CoderSessionToken
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Tags Enterprise
|
||||
// @Param request body codersdk.PostOAuth2ProviderAppRequest true "The OAuth2 application to create."
|
||||
// @Success 200 {object} codersdk.OAuth2ProviderApp
|
||||
// @Router /oauth2-provider/apps [post]
|
||||
func (api *API) postOAuth2ProviderApp(rw http.ResponseWriter, r *http.Request) {
|
||||
var (
|
||||
ctx = r.Context()
|
||||
auditor = api.AGPL.Auditor.Load()
|
||||
aReq, commitAudit = audit.InitRequest[database.OAuth2ProviderApp](rw, &audit.RequestParams{
|
||||
Audit: *auditor,
|
||||
Log: api.Logger,
|
||||
Request: r,
|
||||
Action: database.AuditActionCreate,
|
||||
})
|
||||
)
|
||||
defer commitAudit()
|
||||
var req codersdk.PostOAuth2ProviderAppRequest
|
||||
if !httpapi.Read(ctx, rw, r, &req) {
|
||||
return
|
||||
}
|
||||
app, err := api.Database.InsertOAuth2ProviderApp(ctx, database.InsertOAuth2ProviderAppParams{
|
||||
ID: uuid.New(),
|
||||
CreatedAt: dbtime.Now(),
|
||||
UpdatedAt: dbtime.Now(),
|
||||
Name: req.Name,
|
||||
Icon: req.Icon,
|
||||
CallbackURL: req.CallbackURL,
|
||||
})
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error creating OAuth2 application.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
aReq.New = app
|
||||
httpapi.Write(ctx, rw, http.StatusCreated, db2sdk.OAuth2ProviderApp(api.AccessURL, app))
|
||||
}
|
||||
|
||||
// @Summary Update OAuth2 application.
|
||||
// @ID update-oauth2-application
|
||||
// @Security CoderSessionToken
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Tags Enterprise
|
||||
// @Param app path string true "App ID"
|
||||
// @Param request body codersdk.PutOAuth2ProviderAppRequest true "Update an OAuth2 application."
|
||||
// @Success 200 {object} codersdk.OAuth2ProviderApp
|
||||
// @Router /oauth2-provider/apps/{app} [put]
|
||||
func (api *API) putOAuth2ProviderApp(rw http.ResponseWriter, r *http.Request) {
|
||||
var (
|
||||
ctx = r.Context()
|
||||
app = httpmw.OAuth2ProviderApp(r)
|
||||
auditor = api.AGPL.Auditor.Load()
|
||||
aReq, commitAudit = audit.InitRequest[database.OAuth2ProviderApp](rw, &audit.RequestParams{
|
||||
Audit: *auditor,
|
||||
Log: api.Logger,
|
||||
Request: r,
|
||||
Action: database.AuditActionWrite,
|
||||
})
|
||||
)
|
||||
aReq.Old = app
|
||||
defer commitAudit()
|
||||
var req codersdk.PutOAuth2ProviderAppRequest
|
||||
if !httpapi.Read(ctx, rw, r, &req) {
|
||||
return
|
||||
}
|
||||
app, err := api.Database.UpdateOAuth2ProviderAppByID(ctx, database.UpdateOAuth2ProviderAppByIDParams{
|
||||
ID: app.ID,
|
||||
UpdatedAt: dbtime.Now(),
|
||||
Name: req.Name,
|
||||
Icon: req.Icon,
|
||||
CallbackURL: req.CallbackURL,
|
||||
})
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error updating OAuth2 application.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
aReq.New = app
|
||||
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.OAuth2ProviderApp(api.AccessURL, app))
|
||||
}
|
||||
|
||||
// @Summary Delete OAuth2 application.
|
||||
// @ID delete-oauth2-application
|
||||
// @Security CoderSessionToken
|
||||
// @Tags Enterprise
|
||||
// @Param app path string true "App ID"
|
||||
// @Success 204
|
||||
// @Router /oauth2-provider/apps/{app} [delete]
|
||||
func (api *API) deleteOAuth2ProviderApp(rw http.ResponseWriter, r *http.Request) {
|
||||
var (
|
||||
ctx = r.Context()
|
||||
app = httpmw.OAuth2ProviderApp(r)
|
||||
auditor = api.AGPL.Auditor.Load()
|
||||
aReq, commitAudit = audit.InitRequest[database.OAuth2ProviderApp](rw, &audit.RequestParams{
|
||||
Audit: *auditor,
|
||||
Log: api.Logger,
|
||||
Request: r,
|
||||
Action: database.AuditActionDelete,
|
||||
})
|
||||
)
|
||||
aReq.Old = app
|
||||
defer commitAudit()
|
||||
err := api.Database.DeleteOAuth2ProviderAppByID(ctx, app.ID)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error deleting OAuth2 application.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
httpapi.Write(ctx, rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
// @Summary Get OAuth2 application secrets.
|
||||
// @ID get-oauth2-application-secrets
|
||||
// @Security CoderSessionToken
|
||||
// @Produce json
|
||||
// @Tags Enterprise
|
||||
// @Param app path string true "App ID"
|
||||
// @Success 200 {array} codersdk.OAuth2ProviderAppSecret
|
||||
// @Router /oauth2-provider/apps/{app}/secrets [get]
|
||||
func (api *API) oAuth2ProviderAppSecrets(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
app := httpmw.OAuth2ProviderApp(r)
|
||||
dbSecrets, err := api.Database.GetOAuth2ProviderAppSecretsByAppID(ctx, app.ID)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error getting OAuth2 client secrets.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
secrets := []codersdk.OAuth2ProviderAppSecret{}
|
||||
for _, secret := range dbSecrets {
|
||||
secrets = append(secrets, codersdk.OAuth2ProviderAppSecret{
|
||||
ID: secret.ID,
|
||||
LastUsedAt: codersdk.NullTime{NullTime: secret.LastUsedAt},
|
||||
ClientSecretTruncated: secret.DisplaySecret,
|
||||
})
|
||||
}
|
||||
httpapi.Write(ctx, rw, http.StatusOK, secrets)
|
||||
}
|
||||
|
||||
// @Summary Create OAuth2 application secret.
|
||||
// @ID create-oauth2-application-secret
|
||||
// @Security CoderSessionToken
|
||||
// @Produce json
|
||||
// @Tags Enterprise
|
||||
// @Param app path string true "App ID"
|
||||
// @Success 200 {array} codersdk.OAuth2ProviderAppSecretFull
|
||||
// @Router /oauth2-provider/apps/{app}/secrets [post]
|
||||
func (api *API) postOAuth2ProviderAppSecret(rw http.ResponseWriter, r *http.Request) {
|
||||
var (
|
||||
ctx = r.Context()
|
||||
app = httpmw.OAuth2ProviderApp(r)
|
||||
auditor = api.AGPL.Auditor.Load()
|
||||
aReq, commitAudit = audit.InitRequest[database.OAuth2ProviderAppSecret](rw, &audit.RequestParams{
|
||||
Audit: *auditor,
|
||||
Log: api.Logger,
|
||||
Request: r,
|
||||
Action: database.AuditActionCreate,
|
||||
})
|
||||
)
|
||||
defer commitAudit()
|
||||
secret, err := identityprovider.GenerateSecret()
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to generate OAuth2 client secret.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
dbSecret, err := api.Database.InsertOAuth2ProviderAppSecret(ctx, database.InsertOAuth2ProviderAppSecretParams{
|
||||
ID: uuid.New(),
|
||||
CreatedAt: dbtime.Now(),
|
||||
SecretPrefix: []byte(secret.Prefix),
|
||||
HashedSecret: []byte(secret.Hashed),
|
||||
// DisplaySecret is the last six characters of the original unhashed secret.
|
||||
// This is done so they can be differentiated and it matches how GitHub
|
||||
// displays their client secrets.
|
||||
DisplaySecret: secret.Formatted[len(secret.Formatted)-6:],
|
||||
AppID: app.ID,
|
||||
})
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error creating OAuth2 client secret.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
aReq.New = dbSecret
|
||||
httpapi.Write(ctx, rw, http.StatusCreated, codersdk.OAuth2ProviderAppSecretFull{
|
||||
ID: dbSecret.ID,
|
||||
ClientSecretFull: secret.Formatted,
|
||||
})
|
||||
}
|
||||
|
||||
// @Summary Delete OAuth2 application secret.
|
||||
// @ID delete-oauth2-application-secret
|
||||
// @Security CoderSessionToken
|
||||
// @Tags Enterprise
|
||||
// @Param app path string true "App ID"
|
||||
// @Param secretID path string true "Secret ID"
|
||||
// @Success 204
|
||||
// @Router /oauth2-provider/apps/{app}/secrets/{secretID} [delete]
|
||||
func (api *API) deleteOAuth2ProviderAppSecret(rw http.ResponseWriter, r *http.Request) {
|
||||
var (
|
||||
ctx = r.Context()
|
||||
secret = httpmw.OAuth2ProviderAppSecret(r)
|
||||
auditor = api.AGPL.Auditor.Load()
|
||||
aReq, commitAudit = audit.InitRequest[database.OAuth2ProviderAppSecret](rw, &audit.RequestParams{
|
||||
Audit: *auditor,
|
||||
Log: api.Logger,
|
||||
Request: r,
|
||||
Action: database.AuditActionDelete,
|
||||
})
|
||||
)
|
||||
aReq.Old = secret
|
||||
defer commitAudit()
|
||||
err := api.Database.DeleteOAuth2ProviderAppSecretByID(ctx, secret.ID)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error deleting OAuth2 client secret.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
httpapi.Write(ctx, rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
// @Summary OAuth2 authorization request.
|
||||
// @ID oauth2-authorization-request
|
||||
// @Security CoderSessionToken
|
||||
// @Tags Enterprise
|
||||
// @Param client_id query string true "Client ID"
|
||||
// @Param state query string true "A random unguessable string"
|
||||
// @Param response_type query codersdk.OAuth2ProviderResponseType true "Response type"
|
||||
// @Param redirect_uri query string false "Redirect here after authorization"
|
||||
// @Param scope query string false "Token scopes (currently ignored)"
|
||||
// @Success 302
|
||||
// @Router /oauth2/authorize [post]
|
||||
func (api *API) getOAuth2ProviderAppAuthorize() http.HandlerFunc {
|
||||
return identityprovider.Authorize(api.Database, api.AccessURL)
|
||||
}
|
||||
|
||||
// @Summary OAuth2 token exchange.
|
||||
// @ID oauth2-token-exchange
|
||||
// @Produce json
|
||||
// @Tags Enterprise
|
||||
// @Param client_id formData string false "Client ID, required if grant_type=authorization_code"
|
||||
// @Param client_secret formData string false "Client secret, required if grant_type=authorization_code"
|
||||
// @Param code formData string false "Authorization code, required if grant_type=authorization_code"
|
||||
// @Param refresh_token formData string false "Refresh token, required if grant_type=refresh_token"
|
||||
// @Param grant_type formData codersdk.OAuth2ProviderGrantType true "Grant type"
|
||||
// @Success 200 {object} oauth2.Token
|
||||
// @Router /oauth2/tokens [post]
|
||||
func (api *API) postOAuth2ProviderAppToken() http.HandlerFunc {
|
||||
return identityprovider.Tokens(api.Database, api.DeploymentValues.SessionDuration.Value())
|
||||
}
|
||||
|
||||
// @Summary Delete OAuth2 application tokens.
|
||||
// @ID delete-oauth2-application-tokens
|
||||
// @Security CoderSessionToken
|
||||
// @Tags Enterprise
|
||||
// @Param client_id query string true "Client ID"
|
||||
// @Success 204
|
||||
// @Router /oauth2/tokens [delete]
|
||||
func (api *API) deleteOAuth2ProviderAppTokens() http.HandlerFunc {
|
||||
return identityprovider.RevokeApp(api.Database)
|
||||
}
|
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user