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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 60 additions & 51 deletions coderd/coderd.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import (
"sync/atomic"
"time"

"github.com/coder/coder/v2/coderd/oauth2provider"
"github.com/coder/coder/v2/coderd/fositeprovider"
"github.com/coder/coder/v2/coderd/pproflabel"
"github.com/coder/coder/v2/coderd/prebuilds"
"github.com/coder/coder/v2/coderd/usage"
Expand Down Expand Up @@ -211,11 +211,6 @@ type Options struct {
HealthcheckRefresh time.Duration
WorkspaceProxiesFetchUpdater *atomic.Pointer[healthcheck.WorkspaceProxiesFetchUpdater]

// OAuthSigningKey is the crypto key used to sign and encrypt state strings
// related to OAuth. This is a symmetric secret key using hmac to sign payloads.
// So this secret should **never** be exposed to the client.
OAuthSigningKey [32]byte

// APIRateLimit is the minutely throughput rate limit per user or ip.
// Setting a rate limit <0 will disable the rate limiter across the entire
// app. Some specific routes have their own configurable rate limits.
Expand Down Expand Up @@ -590,6 +585,7 @@ func New(options *Options) *API {
Authorizer: options.Authorizer,
Logger: options.Logger,
},
OAuth2Provider: fositeprovider.New(ctx, options.Logger, options.Database),
metricsCache: metricsCache,
Auditor: atomic.Pointer[audit.Auditor]{},
ConnectionLogger: atomic.Pointer[connectionlog.ConnectionLogger]{},
Expand Down Expand Up @@ -950,55 +946,67 @@ func New(options *Options) *API {
// OAuth2 protected resource metadata endpoint for RFC 9728 discovery
r.Get("/.well-known/oauth-protected-resource", api.oauth2ProtectedResourceMetadata())

// OAuth2 linking routes do not make sense under the /api/v2 path. These are
// for an external application to use Coder as an OAuth2 provider, not for
// logging into Coder with an external OAuth2 provider.
// fosite oauth2 replacement
r.Route("/oauth2", func(r chi.Router) {
r.Use(
httpmw.RequireExperimentWithDevBypass(api.Experiments, codersdk.ExperimentOAuth2),
)
r.Route("/authorize", func(r chi.Router) {
r.Use(
// Fetch the app as system for the authorize endpoint
httpmw.AsAuthzSystem(httpmw.ExtractOAuth2ProviderAppWithOAuth2Errors(options.Database)),
apiKeyMiddlewareRedirect,
)
// GET shows the consent page, POST processes the consent
r.Get("/", api.getOAuth2ProviderAppAuthorize())
r.Post("/", api.postOAuth2ProviderAppAuthorize())
})
r.Route("/tokens", func(r chi.Router) {
r.Use(
// Use OAuth2-compliant error responses for the tokens endpoint
httpmw.AsAuthzSystem(httpmw.ExtractOAuth2ProviderAppWithOAuth2Errors(options.Database)),
)
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())
})

// RFC 7591 Dynamic Client Registration - Public endpoint
r.Post("/register", api.postOAuth2ClientRegistration())

// RFC 7592 Client Configuration Management - Protected by registration access token
r.Route("/clients/{client_id}", func(r chi.Router) {
r.Use(
// Middleware to validate registration access token
oauth2provider.RequireRegistrationAccessToken(api.Database),
)
r.Get("/", api.oauth2ClientConfiguration()) // Read client configuration
r.Put("/", api.putOAuth2ClientConfiguration()) // Update client configuration
r.Delete("/", api.deleteOAuth2ClientConfiguration()) // Delete client
r.Group(func(r chi.Router) {
r.Use(apiKeyMiddlewareRedirect)
r.Get("/authorize", api.OAuth2Provider.ShowAuthorizationPage(api.AccessURL))
r.Post("/authorize", api.OAuth2Provider.AuthEndpoint)
})
r.Get("/introspect", api.OAuth2Provider.IntrospectionEndpoint)
r.Post("/revoke", api.OAuth2Provider.RevokeEndpoint)
r.Post("/tokens", api.OAuth2Provider.TokenEndpoint)
})

// OAuth2 linking routes do not make sense under the /api/v2 path. These are
// for an external application to use Coder as an OAuth2 provider, not for
// logging into Coder with an external OAuth2 provider.
//r.Route("/oauth2", func(r chi.Router) {
// r.Use(
// httpmw.RequireExperimentWithDevBypass(api.Experiments, codersdk.ExperimentOAuth2),
// )
// r.Route("/authorize", func(r chi.Router) {
// r.Use(
// // Fetch the app as system for the authorize endpoint
// httpmw.AsAuthzSystem(httpmw.ExtractOAuth2ProviderAppWithOAuth2Errors(options.Database)),
// apiKeyMiddlewareRedirect,
// )
// // GET shows the consent page, POST processes the consent
// r.Get("/", api.getOAuth2ProviderAppAuthorize())
// r.Post("/", api.postOAuth2ProviderAppAuthorize())
// })
// r.Route("/tokens", func(r chi.Router) {
// r.Use(
// // Use OAuth2-compliant error responses for the tokens endpoint
// httpmw.AsAuthzSystem(httpmw.ExtractOAuth2ProviderAppWithOAuth2Errors(options.Database)),
// )
// 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())
// })
//
// // RFC 7591 Dynamic Client Registration - Public endpoint
// r.Post("/register", api.postOAuth2ClientRegistration())
//
// // RFC 7592 Client Configuration Management - Protected by registration access token
// r.Route("/clients/{client_id}", func(r chi.Router) {
// r.Use(
// // Middleware to validate registration access token
// oauth2provider.RequireRegistrationAccessToken(api.Database),
// )
// r.Get("/", api.oauth2ClientConfiguration()) // Read client configuration
// r.Put("/", api.putOAuth2ClientConfiguration()) // Update client configuration
// r.Delete("/", api.deleteOAuth2ClientConfiguration()) // Delete client
// })
//})

// Experimental routes are not guaranteed to be stable and may change at any time.
r.Route("/api/experimental", func(r chi.Router) {
r.Use(apiKeyMiddleware)
Expand Down Expand Up @@ -1705,6 +1713,7 @@ type API struct {
UsageInserter *atomic.Pointer[usage.Inserter]

UpdatesProvider tailnet.WorkspaceUpdatesProvider
OAuth2Provider *fositeprovider.Provider

HTTPAuth *HTTPAuthorizer

Expand Down
2 changes: 1 addition & 1 deletion coderd/database/models.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion coderd/database/querier.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion coderd/database/queries.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

141 changes: 141 additions & 0 deletions coderd/fositeprovider/authorize.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package fositeprovider

import (
"fmt"
"net/http"
"net/url"

"cdr.dev/slog"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/site"
)

func (p *Provider) ShowAuthorizationPage(accessURL *url.URL) http.HandlerFunc {
// TODO: Unsure how correct ths is.
return func(rw http.ResponseWriter, r *http.Request) {
logger := p.logger.With(slog.F("handler", "get_auth_endpoint"))

ctx := r.Context()

// TODO: Do we do coderd auth here?
ua := httpmw.UserAuthorization(r.Context())

// Let's create an AuthorizeRequest object!
// It will analyze the request and extract important information like scopes, response type and others.
ar, err := p.provider.NewAuthorizeRequest(ctx, r)
if err != nil {
logger.Error(ctx, "error occurred in ShowAuthorizationPage", slog.Error(err))
p.provider.WriteAuthorizeError(ctx, rw, ar, err)
return
}

app := ar.GetClient()
// primary redirect URI is always the first one
appRedirects := app.GetRedirectURIs()
if len(appRedirects) == 0 {
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{Status: http.StatusInternalServerError, HideStatus: false, Title: "Internal Server Error",
Description: fmt.Sprintf("No redirect URIs configured for app %s", app.GetID()),
RetryEnabled: false, DashboardURL: accessURL.String(), Warnings: nil})
return
}

// TODO: Probably only needed if there is no redirect URI in the request
callbackURL, err := url.Parse(appRedirects[0])
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
}

redirectURL := ar.GetRedirectURI()
if redirectURL == nil {
redirectURL = callbackURL
}

cancel := redirectURL
cancelQuery := redirectURL.Query()
cancelQuery.Add("error", "access_denied")
cancel.RawQuery = cancelQuery.Encode()

site.RenderOAuthAllowPage(rw, r, site.RenderOAuthAllowData{
// TODO: Extend fosite.DefaultClient to have our information
AppIcon: "", //app.Icon,
AppName: app.GetID(), // app.Name,
CancelURI: cancel.String(),
RedirectURI: r.URL.String(),
Username: ua.FriendlyName,
})
}
}

// https://github.com/ory/fosite-example/blob/master/authorizationserver/oauth2_auth.go#L9
func (p *Provider) AuthEndpoint(rw http.ResponseWriter, r *http.Request) {
// This context will be passed to all methods.
ctx := r.Context()
logger := p.logger.With(slog.F("handler", "post_auth_endpoint"))

// Let's create an AuthorizeRequest object!
// It will analyze the request and extract important information like scopes, response type and others.
ar, err := p.provider.NewAuthorizeRequest(ctx, r)
if err != nil {
logger.Error(ctx, "error occurred in NewAuthorizeRequest", slog.Error(err))
p.provider.WriteAuthorizeError(ctx, rw, ar, err)
return
}
// You have now access to authorizeRequest, Code ResponseTypes, Scopes ...

var requestedScopes string
for _, this := range ar.GetRequestedScopes() {
requestedScopes += fmt.Sprintf(`<li><input type="checkbox" name="scopes" value="%s">%s</li>`, this, this)
}

// This verifies the user is authenticated
ua := httpmw.APIKey(r)

// TODO: When we support scopes, this is how we can handle them.
// let's see what scopes the user gave consent to
//for _, scope := range r.PostForm["scopes"] {
// ar.GrantScope(scope)
//}

// Now that the user is authorized, we set up a session:
mySessionData := p.newSession(ua)

// When using the HMACSHA strategy you must use something that implements the HMACSessionContainer.
// It brings you the power of overriding the default values.
//
// mySessionData.HMACSession = &strategy.HMACSession{
// AccessTokenExpiry: time.Now().Add(time.Day),
// AuthorizeCodeExpiry: time.Now().Add(time.Day),
// }
//

// If you're using the JWT strategy, there's currently no distinction between access token and authorize code claims.
// Therefore, you both access token and authorize code will have the same "exp" claim. If this is something you
// need let us know on github.
//
// mySessionData.JWTClaims.ExpiresAt = time.Now().Add(time.Day)

// It's also wise to check the requested scopes, e.g.:
// if ar.GetRequestedScopes().Has("admin") {
// http.Error(rw, "you're not allowed to do that", http.StatusForbidden)
// return
// }

// Now we need to get a response. This is the place where the AuthorizeEndpointHandlers kick in and start processing the request.
// NewAuthorizeResponse is capable of running multiple response type handlers which in turn enables this library
// to support open id connect.
response, err := p.provider.NewAuthorizeResponse(ctx, ar, mySessionData)

// Catch any errors, e.g.:
// * unknown client
// * invalid redirect
// * ...
if err != nil {
logger.Error(ctx, "error occurred in NewAuthorizeResponse", slog.Error(err))
p.provider.WriteAuthorizeError(ctx, rw, ar, err)
return
}

// Last but not least, send the response!
p.provider.WriteAuthorizeResponse(ctx, rw, ar, response)
}
55 changes: 55 additions & 0 deletions coderd/fositeprovider/fositestorage/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package fositestorage

import (
"github.com/ory/fosite"

"github.com/coder/coder/v2/coderd/database"
)

var _ fosite.Client = (*Client)(nil)

// TODO: We can implement more client interfaces if needed.
//var _ fosite.ClientWithSecretRotation = (*Client)(nil)
//var _ fosite.OpenIDConnectClient = (*Client)(nil)
//var _ fosite.ResponseModeClient = (*Client)(nil)

// Client
// See fosite.DefaultClient for default implementation of most methods.
type Client struct {
App database.OAuth2ProviderApp
Secrets []database.OAuth2ProviderAppSecret
_ fosite.DefaultClient
}

func (c Client) GetID() string {
return c.App.ID.String()
}

func (c Client) GetHashedSecret() []byte {
// TODO: Why do we have more than one secret?
return c.Secrets[0].HashedSecret
}

func (c Client) GetRedirectURIs() []string {
return c.App.RedirectUris
}

func (c Client) GetGrantTypes() fosite.Arguments {
return c.App.GrantTypes
}

func (c Client) GetResponseTypes() fosite.Arguments {
return c.App.ResponseTypes
}

func (c Client) GetScopes() fosite.Arguments {
return []string{}
}

func (c Client) IsPublic() bool {
return false // Is this right?
}

func (c Client) GetAudience() fosite.Arguments {
return []string{"https://coder.com"} // TODO: should be access url
}
Loading