feat: make OAuth2 provider not enterprise-only (#12732)

This commit is contained in:
Asher
2024-03-25 11:52:22 -08:00
committed by GitHub
parent 60f335113c
commit 40e5ad5499
14 changed files with 92 additions and 168 deletions

View File

@ -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,

View File

@ -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
}

View File

@ -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,
})
})
}
}

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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