mirror of
https://github.com/coder/coder.git
synced 2025-07-13 21:36:50 +00:00
refactor: move OAuth2 provider code to dedicated package (#18746)
# Refactor OAuth2 Provider Code into Dedicated Package This PR refactors the OAuth2 provider functionality by moving it from the main `coderd` package into a dedicated `oauth2provider` package. The change improves code organization and maintainability without changing functionality. Key changes: - Created a new `oauth2provider` package to house all OAuth2 provider-related code - Moved existing OAuth2 provider functionality from `coderd/identityprovider` to the new package - Refactored handler functions to follow a consistent pattern of returning `http.HandlerFunc` instead of being handlers directly - Split large files into smaller, more focused files organized by functionality: - `app_secrets.go` - Manages OAuth2 application secrets - `apps.go` - Handles OAuth2 application CRUD operations - `authorize.go` - Implements the authorization flow - `metadata.go` - Provides OAuth2 metadata endpoints - `registration.go` - Handles dynamic client registration - `revoke.go` - Implements token revocation - `secrets.go` - Manages secret generation and validation - `tokens.go` - Handles token issuance and validation This refactoring improves code organization and makes the OAuth2 provider functionality more maintainable while preserving all existing behavior.
This commit is contained in:
@ -19,6 +19,7 @@ import (
|
|||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/coder/coder/v2/coderd/oauth2provider"
|
||||||
"github.com/coder/coder/v2/coderd/prebuilds"
|
"github.com/coder/coder/v2/coderd/prebuilds"
|
||||||
|
|
||||||
"github.com/andybalholm/brotli"
|
"github.com/andybalholm/brotli"
|
||||||
@ -913,9 +914,9 @@ func New(options *Options) *API {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// OAuth2 metadata endpoint for RFC 8414 discovery
|
// OAuth2 metadata endpoint for RFC 8414 discovery
|
||||||
r.Get("/.well-known/oauth-authorization-server", api.oauth2AuthorizationServerMetadata)
|
r.Get("/.well-known/oauth-authorization-server", api.oauth2AuthorizationServerMetadata())
|
||||||
// OAuth2 protected resource metadata endpoint for RFC 9728 discovery
|
// OAuth2 protected resource metadata endpoint for RFC 9728 discovery
|
||||||
r.Get("/.well-known/oauth-protected-resource", api.oauth2ProtectedResourceMetadata)
|
r.Get("/.well-known/oauth-protected-resource", api.oauth2ProtectedResourceMetadata())
|
||||||
|
|
||||||
// OAuth2 linking routes do not make sense under the /api/v2 path. These are
|
// 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
|
// for an external application to use Coder as an OAuth2 provider, not for
|
||||||
@ -952,17 +953,17 @@ func New(options *Options) *API {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// RFC 7591 Dynamic Client Registration - Public endpoint
|
// RFC 7591 Dynamic Client Registration - Public endpoint
|
||||||
r.Post("/register", api.postOAuth2ClientRegistration)
|
r.Post("/register", api.postOAuth2ClientRegistration())
|
||||||
|
|
||||||
// RFC 7592 Client Configuration Management - Protected by registration access token
|
// RFC 7592 Client Configuration Management - Protected by registration access token
|
||||||
r.Route("/clients/{client_id}", func(r chi.Router) {
|
r.Route("/clients/{client_id}", func(r chi.Router) {
|
||||||
r.Use(
|
r.Use(
|
||||||
// Middleware to validate registration access token
|
// Middleware to validate registration access token
|
||||||
api.requireRegistrationAccessToken,
|
oauth2provider.RequireRegistrationAccessToken(api.Database),
|
||||||
)
|
)
|
||||||
r.Get("/", api.oauth2ClientConfiguration) // Read client configuration
|
r.Get("/", api.oauth2ClientConfiguration()) // Read client configuration
|
||||||
r.Put("/", api.putOAuth2ClientConfiguration) // Update client configuration
|
r.Put("/", api.putOAuth2ClientConfiguration()) // Update client configuration
|
||||||
r.Delete("/", api.deleteOAuth2ClientConfiguration) // Delete client
|
r.Delete("/", api.deleteOAuth2ClientConfiguration()) // Delete client
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -1479,22 +1480,22 @@ func New(options *Options) *API {
|
|||||||
httpmw.RequireExperimentWithDevBypass(api.Experiments, codersdk.ExperimentOAuth2),
|
httpmw.RequireExperimentWithDevBypass(api.Experiments, codersdk.ExperimentOAuth2),
|
||||||
)
|
)
|
||||||
r.Route("/apps", func(r chi.Router) {
|
r.Route("/apps", func(r chi.Router) {
|
||||||
r.Get("/", api.oAuth2ProviderApps)
|
r.Get("/", api.oAuth2ProviderApps())
|
||||||
r.Post("/", api.postOAuth2ProviderApp)
|
r.Post("/", api.postOAuth2ProviderApp())
|
||||||
|
|
||||||
r.Route("/{app}", func(r chi.Router) {
|
r.Route("/{app}", func(r chi.Router) {
|
||||||
r.Use(httpmw.ExtractOAuth2ProviderApp(options.Database))
|
r.Use(httpmw.ExtractOAuth2ProviderApp(options.Database))
|
||||||
r.Get("/", api.oAuth2ProviderApp)
|
r.Get("/", api.oAuth2ProviderApp())
|
||||||
r.Put("/", api.putOAuth2ProviderApp)
|
r.Put("/", api.putOAuth2ProviderApp())
|
||||||
r.Delete("/", api.deleteOAuth2ProviderApp)
|
r.Delete("/", api.deleteOAuth2ProviderApp())
|
||||||
|
|
||||||
r.Route("/secrets", func(r chi.Router) {
|
r.Route("/secrets", func(r chi.Router) {
|
||||||
r.Get("/", api.oAuth2ProviderAppSecrets)
|
r.Get("/", api.oAuth2ProviderAppSecrets())
|
||||||
r.Post("/", api.postOAuth2ProviderAppSecret)
|
r.Post("/", api.postOAuth2ProviderAppSecret())
|
||||||
|
|
||||||
r.Route("/{secretID}", func(r chi.Router) {
|
r.Route("/{secretID}", func(r chi.Router) {
|
||||||
r.Use(httpmw.ExtractOAuth2ProviderAppSecret(options.Database))
|
r.Use(httpmw.ExtractOAuth2ProviderAppSecret(options.Database))
|
||||||
r.Delete("/", api.deleteOAuth2ProviderAppSecret)
|
r.Delete("/", api.deleteOAuth2ProviderAppSecret())
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
880
coderd/oauth2.go
880
coderd/oauth2.go
@ -1,39 +1,9 @@
|
|||||||
package coderd
|
package coderd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/coder/coder/v2/coderd/oauth2provider"
|
||||||
"github.com/google/uuid"
|
|
||||||
"golang.org/x/xerrors"
|
|
||||||
|
|
||||||
"cdr.dev/slog"
|
|
||||||
|
|
||||||
"github.com/sqlc-dev/pqtype"
|
|
||||||
|
|
||||||
"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/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/identityprovider"
|
|
||||||
"github.com/coder/coder/v2/coderd/userpassword"
|
|
||||||
"github.com/coder/coder/v2/codersdk"
|
|
||||||
"github.com/coder/coder/v2/cryptorand"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Constants for OAuth2 secret generation (RFC 7591)
|
|
||||||
const (
|
|
||||||
secretLength = 40 // Length of the actual secret part
|
|
||||||
secretPrefixLength = 10 // Length of the prefix for database lookup
|
|
||||||
displaySecretLength = 6 // Length of visible part in UI (last 6 characters)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// @Summary Get OAuth2 applications.
|
// @Summary Get OAuth2 applications.
|
||||||
@ -44,40 +14,8 @@ const (
|
|||||||
// @Param user_id query string false "Filter by applications authorized for a user"
|
// @Param user_id query string false "Filter by applications authorized for a user"
|
||||||
// @Success 200 {array} codersdk.OAuth2ProviderApp
|
// @Success 200 {array} codersdk.OAuth2ProviderApp
|
||||||
// @Router /oauth2-provider/apps [get]
|
// @Router /oauth2-provider/apps [get]
|
||||||
func (api *API) oAuth2ProviderApps(rw http.ResponseWriter, r *http.Request) {
|
func (api *API) oAuth2ProviderApps() http.HandlerFunc {
|
||||||
ctx := r.Context()
|
return oauth2provider.ListApps(api.Database, api.AccessURL)
|
||||||
|
|
||||||
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.
|
// @Summary Get OAuth2 application.
|
||||||
@ -88,10 +26,8 @@ func (api *API) oAuth2ProviderApps(rw http.ResponseWriter, r *http.Request) {
|
|||||||
// @Param app path string true "App ID"
|
// @Param app path string true "App ID"
|
||||||
// @Success 200 {object} codersdk.OAuth2ProviderApp
|
// @Success 200 {object} codersdk.OAuth2ProviderApp
|
||||||
// @Router /oauth2-provider/apps/{app} [get]
|
// @Router /oauth2-provider/apps/{app} [get]
|
||||||
func (api *API) oAuth2ProviderApp(rw http.ResponseWriter, r *http.Request) {
|
func (api *API) oAuth2ProviderApp() http.HandlerFunc {
|
||||||
ctx := r.Context()
|
return oauth2provider.GetApp(api.AccessURL)
|
||||||
app := httpmw.OAuth2ProviderApp(r)
|
|
||||||
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.OAuth2ProviderApp(api.AccessURL, app))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// @Summary Create OAuth2 application.
|
// @Summary Create OAuth2 application.
|
||||||
@ -103,59 +39,8 @@ func (api *API) oAuth2ProviderApp(rw http.ResponseWriter, r *http.Request) {
|
|||||||
// @Param request body codersdk.PostOAuth2ProviderAppRequest true "The OAuth2 application to create."
|
// @Param request body codersdk.PostOAuth2ProviderAppRequest true "The OAuth2 application to create."
|
||||||
// @Success 200 {object} codersdk.OAuth2ProviderApp
|
// @Success 200 {object} codersdk.OAuth2ProviderApp
|
||||||
// @Router /oauth2-provider/apps [post]
|
// @Router /oauth2-provider/apps [post]
|
||||||
func (api *API) postOAuth2ProviderApp(rw http.ResponseWriter, r *http.Request) {
|
func (api *API) postOAuth2ProviderApp() http.HandlerFunc {
|
||||||
var (
|
return oauth2provider.CreateApp(api.Database, api.AccessURL, api.Auditor.Load(), api.Logger)
|
||||||
ctx = r.Context()
|
|
||||||
auditor = api.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,
|
|
||||||
RedirectUris: []string{},
|
|
||||||
ClientType: sql.NullString{String: "confidential", Valid: true},
|
|
||||||
DynamicallyRegistered: sql.NullBool{Bool: false, Valid: true},
|
|
||||||
ClientIDIssuedAt: sql.NullTime{},
|
|
||||||
ClientSecretExpiresAt: sql.NullTime{},
|
|
||||||
GrantTypes: []string{"authorization_code", "refresh_token"},
|
|
||||||
ResponseTypes: []string{"code"},
|
|
||||||
TokenEndpointAuthMethod: sql.NullString{String: "client_secret_post", Valid: true},
|
|
||||||
Scope: sql.NullString{},
|
|
||||||
Contacts: []string{},
|
|
||||||
ClientUri: sql.NullString{},
|
|
||||||
LogoUri: sql.NullString{},
|
|
||||||
TosUri: sql.NullString{},
|
|
||||||
PolicyUri: sql.NullString{},
|
|
||||||
JwksUri: sql.NullString{},
|
|
||||||
Jwks: pqtype.NullRawMessage{},
|
|
||||||
SoftwareID: sql.NullString{},
|
|
||||||
SoftwareVersion: sql.NullString{},
|
|
||||||
RegistrationAccessToken: sql.NullString{},
|
|
||||||
RegistrationClientUri: sql.NullString{},
|
|
||||||
})
|
|
||||||
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.
|
// @Summary Update OAuth2 application.
|
||||||
@ -168,57 +53,8 @@ func (api *API) postOAuth2ProviderApp(rw http.ResponseWriter, r *http.Request) {
|
|||||||
// @Param request body codersdk.PutOAuth2ProviderAppRequest true "Update an OAuth2 application."
|
// @Param request body codersdk.PutOAuth2ProviderAppRequest true "Update an OAuth2 application."
|
||||||
// @Success 200 {object} codersdk.OAuth2ProviderApp
|
// @Success 200 {object} codersdk.OAuth2ProviderApp
|
||||||
// @Router /oauth2-provider/apps/{app} [put]
|
// @Router /oauth2-provider/apps/{app} [put]
|
||||||
func (api *API) putOAuth2ProviderApp(rw http.ResponseWriter, r *http.Request) {
|
func (api *API) putOAuth2ProviderApp() http.HandlerFunc {
|
||||||
var (
|
return oauth2provider.UpdateApp(api.Database, api.AccessURL, api.Auditor.Load(), api.Logger)
|
||||||
ctx = r.Context()
|
|
||||||
app = httpmw.OAuth2ProviderApp(r)
|
|
||||||
auditor = api.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,
|
|
||||||
RedirectUris: app.RedirectUris, // Keep existing value
|
|
||||||
ClientType: app.ClientType, // Keep existing value
|
|
||||||
DynamicallyRegistered: app.DynamicallyRegistered, // Keep existing value
|
|
||||||
ClientSecretExpiresAt: app.ClientSecretExpiresAt, // Keep existing value
|
|
||||||
GrantTypes: app.GrantTypes, // Keep existing value
|
|
||||||
ResponseTypes: app.ResponseTypes, // Keep existing value
|
|
||||||
TokenEndpointAuthMethod: app.TokenEndpointAuthMethod, // Keep existing value
|
|
||||||
Scope: app.Scope, // Keep existing value
|
|
||||||
Contacts: app.Contacts, // Keep existing value
|
|
||||||
ClientUri: app.ClientUri, // Keep existing value
|
|
||||||
LogoUri: app.LogoUri, // Keep existing value
|
|
||||||
TosUri: app.TosUri, // Keep existing value
|
|
||||||
PolicyUri: app.PolicyUri, // Keep existing value
|
|
||||||
JwksUri: app.JwksUri, // Keep existing value
|
|
||||||
Jwks: app.Jwks, // Keep existing value
|
|
||||||
SoftwareID: app.SoftwareID, // Keep existing value
|
|
||||||
SoftwareVersion: app.SoftwareVersion, // Keep existing value
|
|
||||||
})
|
|
||||||
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.
|
// @Summary Delete OAuth2 application.
|
||||||
@ -228,29 +64,8 @@ func (api *API) putOAuth2ProviderApp(rw http.ResponseWriter, r *http.Request) {
|
|||||||
// @Param app path string true "App ID"
|
// @Param app path string true "App ID"
|
||||||
// @Success 204
|
// @Success 204
|
||||||
// @Router /oauth2-provider/apps/{app} [delete]
|
// @Router /oauth2-provider/apps/{app} [delete]
|
||||||
func (api *API) deleteOAuth2ProviderApp(rw http.ResponseWriter, r *http.Request) {
|
func (api *API) deleteOAuth2ProviderApp() http.HandlerFunc {
|
||||||
var (
|
return oauth2provider.DeleteApp(api.Database, api.Auditor.Load(), api.Logger)
|
||||||
ctx = r.Context()
|
|
||||||
app = httpmw.OAuth2ProviderApp(r)
|
|
||||||
auditor = api.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
|
|
||||||
}
|
|
||||||
rw.WriteHeader(http.StatusNoContent)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// @Summary Get OAuth2 application secrets.
|
// @Summary Get OAuth2 application secrets.
|
||||||
@ -261,26 +76,8 @@ func (api *API) deleteOAuth2ProviderApp(rw http.ResponseWriter, r *http.Request)
|
|||||||
// @Param app path string true "App ID"
|
// @Param app path string true "App ID"
|
||||||
// @Success 200 {array} codersdk.OAuth2ProviderAppSecret
|
// @Success 200 {array} codersdk.OAuth2ProviderAppSecret
|
||||||
// @Router /oauth2-provider/apps/{app}/secrets [get]
|
// @Router /oauth2-provider/apps/{app}/secrets [get]
|
||||||
func (api *API) oAuth2ProviderAppSecrets(rw http.ResponseWriter, r *http.Request) {
|
func (api *API) oAuth2ProviderAppSecrets() http.HandlerFunc {
|
||||||
ctx := r.Context()
|
return oauth2provider.GetAppSecrets(api.Database)
|
||||||
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.
|
// @Summary Create OAuth2 application secret.
|
||||||
@ -291,50 +88,8 @@ func (api *API) oAuth2ProviderAppSecrets(rw http.ResponseWriter, r *http.Request
|
|||||||
// @Param app path string true "App ID"
|
// @Param app path string true "App ID"
|
||||||
// @Success 200 {array} codersdk.OAuth2ProviderAppSecretFull
|
// @Success 200 {array} codersdk.OAuth2ProviderAppSecretFull
|
||||||
// @Router /oauth2-provider/apps/{app}/secrets [post]
|
// @Router /oauth2-provider/apps/{app}/secrets [post]
|
||||||
func (api *API) postOAuth2ProviderAppSecret(rw http.ResponseWriter, r *http.Request) {
|
func (api *API) postOAuth2ProviderAppSecret() http.HandlerFunc {
|
||||||
var (
|
return oauth2provider.CreateAppSecret(api.Database, api.Auditor.Load(), api.Logger)
|
||||||
ctx = r.Context()
|
|
||||||
app = httpmw.OAuth2ProviderApp(r)
|
|
||||||
auditor = api.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.
|
// @Summary Delete OAuth2 application secret.
|
||||||
@ -345,29 +100,8 @@ func (api *API) postOAuth2ProviderAppSecret(rw http.ResponseWriter, r *http.Requ
|
|||||||
// @Param secretID path string true "Secret ID"
|
// @Param secretID path string true "Secret ID"
|
||||||
// @Success 204
|
// @Success 204
|
||||||
// @Router /oauth2-provider/apps/{app}/secrets/{secretID} [delete]
|
// @Router /oauth2-provider/apps/{app}/secrets/{secretID} [delete]
|
||||||
func (api *API) deleteOAuth2ProviderAppSecret(rw http.ResponseWriter, r *http.Request) {
|
func (api *API) deleteOAuth2ProviderAppSecret() http.HandlerFunc {
|
||||||
var (
|
return oauth2provider.DeleteAppSecret(api.Database, api.Auditor.Load(), api.Logger)
|
||||||
ctx = r.Context()
|
|
||||||
secret = httpmw.OAuth2ProviderAppSecret(r)
|
|
||||||
auditor = api.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
|
|
||||||
}
|
|
||||||
rw.WriteHeader(http.StatusNoContent)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// @Summary OAuth2 authorization request (GET - show authorization page).
|
// @Summary OAuth2 authorization request (GET - show authorization page).
|
||||||
@ -382,7 +116,7 @@ func (api *API) deleteOAuth2ProviderAppSecret(rw http.ResponseWriter, r *http.Re
|
|||||||
// @Success 200 "Returns HTML authorization page"
|
// @Success 200 "Returns HTML authorization page"
|
||||||
// @Router /oauth2/authorize [get]
|
// @Router /oauth2/authorize [get]
|
||||||
func (api *API) getOAuth2ProviderAppAuthorize() http.HandlerFunc {
|
func (api *API) getOAuth2ProviderAppAuthorize() http.HandlerFunc {
|
||||||
return identityprovider.ShowAuthorizePage(api.Database, api.AccessURL)
|
return oauth2provider.ShowAuthorizePage(api.Database, api.AccessURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
// @Summary OAuth2 authorization request (POST - process authorization).
|
// @Summary OAuth2 authorization request (POST - process authorization).
|
||||||
@ -397,7 +131,7 @@ func (api *API) getOAuth2ProviderAppAuthorize() http.HandlerFunc {
|
|||||||
// @Success 302 "Returns redirect with authorization code"
|
// @Success 302 "Returns redirect with authorization code"
|
||||||
// @Router /oauth2/authorize [post]
|
// @Router /oauth2/authorize [post]
|
||||||
func (api *API) postOAuth2ProviderAppAuthorize() http.HandlerFunc {
|
func (api *API) postOAuth2ProviderAppAuthorize() http.HandlerFunc {
|
||||||
return identityprovider.ProcessAuthorize(api.Database, api.AccessURL)
|
return oauth2provider.ProcessAuthorize(api.Database, api.AccessURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
// @Summary OAuth2 token exchange.
|
// @Summary OAuth2 token exchange.
|
||||||
@ -412,7 +146,7 @@ func (api *API) postOAuth2ProviderAppAuthorize() http.HandlerFunc {
|
|||||||
// @Success 200 {object} oauth2.Token
|
// @Success 200 {object} oauth2.Token
|
||||||
// @Router /oauth2/tokens [post]
|
// @Router /oauth2/tokens [post]
|
||||||
func (api *API) postOAuth2ProviderAppToken() http.HandlerFunc {
|
func (api *API) postOAuth2ProviderAppToken() http.HandlerFunc {
|
||||||
return identityprovider.Tokens(api.Database, api.DeploymentValues.Sessions)
|
return oauth2provider.Tokens(api.Database, api.DeploymentValues.Sessions)
|
||||||
}
|
}
|
||||||
|
|
||||||
// @Summary Delete OAuth2 application tokens.
|
// @Summary Delete OAuth2 application tokens.
|
||||||
@ -423,7 +157,7 @@ func (api *API) postOAuth2ProviderAppToken() http.HandlerFunc {
|
|||||||
// @Success 204
|
// @Success 204
|
||||||
// @Router /oauth2/tokens [delete]
|
// @Router /oauth2/tokens [delete]
|
||||||
func (api *API) deleteOAuth2ProviderAppTokens() http.HandlerFunc {
|
func (api *API) deleteOAuth2ProviderAppTokens() http.HandlerFunc {
|
||||||
return identityprovider.RevokeApp(api.Database)
|
return oauth2provider.RevokeApp(api.Database)
|
||||||
}
|
}
|
||||||
|
|
||||||
// @Summary OAuth2 authorization server metadata.
|
// @Summary OAuth2 authorization server metadata.
|
||||||
@ -432,21 +166,8 @@ func (api *API) deleteOAuth2ProviderAppTokens() http.HandlerFunc {
|
|||||||
// @Tags Enterprise
|
// @Tags Enterprise
|
||||||
// @Success 200 {object} codersdk.OAuth2AuthorizationServerMetadata
|
// @Success 200 {object} codersdk.OAuth2AuthorizationServerMetadata
|
||||||
// @Router /.well-known/oauth-authorization-server [get]
|
// @Router /.well-known/oauth-authorization-server [get]
|
||||||
func (api *API) oauth2AuthorizationServerMetadata(rw http.ResponseWriter, r *http.Request) {
|
func (api *API) oauth2AuthorizationServerMetadata() http.HandlerFunc {
|
||||||
ctx := r.Context()
|
return oauth2provider.GetAuthorizationServerMetadata(api.AccessURL)
|
||||||
metadata := codersdk.OAuth2AuthorizationServerMetadata{
|
|
||||||
Issuer: api.AccessURL.String(),
|
|
||||||
AuthorizationEndpoint: api.AccessURL.JoinPath("/oauth2/authorize").String(),
|
|
||||||
TokenEndpoint: api.AccessURL.JoinPath("/oauth2/tokens").String(),
|
|
||||||
RegistrationEndpoint: api.AccessURL.JoinPath("/oauth2/register").String(), // RFC 7591
|
|
||||||
ResponseTypesSupported: []string{"code"},
|
|
||||||
GrantTypesSupported: []string{"authorization_code", "refresh_token"},
|
|
||||||
CodeChallengeMethodsSupported: []string{"S256"},
|
|
||||||
// TODO: Implement scope system
|
|
||||||
ScopesSupported: []string{},
|
|
||||||
TokenEndpointAuthMethodsSupported: []string{"client_secret_post"},
|
|
||||||
}
|
|
||||||
httpapi.Write(ctx, rw, http.StatusOK, metadata)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// @Summary OAuth2 protected resource metadata.
|
// @Summary OAuth2 protected resource metadata.
|
||||||
@ -455,17 +176,8 @@ func (api *API) oauth2AuthorizationServerMetadata(rw http.ResponseWriter, r *htt
|
|||||||
// @Tags Enterprise
|
// @Tags Enterprise
|
||||||
// @Success 200 {object} codersdk.OAuth2ProtectedResourceMetadata
|
// @Success 200 {object} codersdk.OAuth2ProtectedResourceMetadata
|
||||||
// @Router /.well-known/oauth-protected-resource [get]
|
// @Router /.well-known/oauth-protected-resource [get]
|
||||||
func (api *API) oauth2ProtectedResourceMetadata(rw http.ResponseWriter, r *http.Request) {
|
func (api *API) oauth2ProtectedResourceMetadata() http.HandlerFunc {
|
||||||
ctx := r.Context()
|
return oauth2provider.GetProtectedResourceMetadata(api.AccessURL)
|
||||||
metadata := codersdk.OAuth2ProtectedResourceMetadata{
|
|
||||||
Resource: api.AccessURL.String(),
|
|
||||||
AuthorizationServers: []string{api.AccessURL.String()},
|
|
||||||
// TODO: Implement scope system based on RBAC permissions
|
|
||||||
ScopesSupported: []string{},
|
|
||||||
// RFC 6750 Bearer Token methods supported as fallback methods in api key middleware
|
|
||||||
BearerMethodsSupported: []string{"header", "query"},
|
|
||||||
}
|
|
||||||
httpapi.Write(ctx, rw, http.StatusOK, metadata)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// @Summary OAuth2 dynamic client registration (RFC 7591)
|
// @Summary OAuth2 dynamic client registration (RFC 7591)
|
||||||
@ -476,225 +188,10 @@ func (api *API) oauth2ProtectedResourceMetadata(rw http.ResponseWriter, r *http.
|
|||||||
// @Param request body codersdk.OAuth2ClientRegistrationRequest true "Client registration request"
|
// @Param request body codersdk.OAuth2ClientRegistrationRequest true "Client registration request"
|
||||||
// @Success 201 {object} codersdk.OAuth2ClientRegistrationResponse
|
// @Success 201 {object} codersdk.OAuth2ClientRegistrationResponse
|
||||||
// @Router /oauth2/register [post]
|
// @Router /oauth2/register [post]
|
||||||
func (api *API) postOAuth2ClientRegistration(rw http.ResponseWriter, r *http.Request) {
|
func (api *API) postOAuth2ClientRegistration() http.HandlerFunc {
|
||||||
ctx := r.Context()
|
return oauth2provider.CreateDynamicClientRegistration(api.Database, api.AccessURL, api.Auditor.Load(), api.Logger)
|
||||||
auditor := *api.Auditor.Load()
|
|
||||||
aReq, commitAudit := audit.InitRequest[database.OAuth2ProviderApp](rw, &audit.RequestParams{
|
|
||||||
Audit: auditor,
|
|
||||||
Log: api.Logger,
|
|
||||||
Request: r,
|
|
||||||
Action: database.AuditActionCreate,
|
|
||||||
})
|
|
||||||
defer commitAudit()
|
|
||||||
|
|
||||||
// Parse request
|
|
||||||
var req codersdk.OAuth2ClientRegistrationRequest
|
|
||||||
if !httpapi.Read(ctx, rw, r, &req) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate request
|
|
||||||
if err := req.Validate(); err != nil {
|
|
||||||
writeOAuth2RegistrationError(ctx, rw, http.StatusBadRequest,
|
|
||||||
"invalid_client_metadata", err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply defaults
|
|
||||||
req = req.ApplyDefaults()
|
|
||||||
|
|
||||||
// Generate client credentials
|
|
||||||
clientID := uuid.New()
|
|
||||||
clientSecret, hashedSecret, err := generateClientCredentials()
|
|
||||||
if err != nil {
|
|
||||||
writeOAuth2RegistrationError(ctx, rw, http.StatusInternalServerError,
|
|
||||||
"server_error", "Failed to generate client credentials")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate registration access token for RFC 7592 management
|
|
||||||
registrationToken, hashedRegToken, err := generateRegistrationAccessToken()
|
|
||||||
if err != nil {
|
|
||||||
writeOAuth2RegistrationError(ctx, rw, http.StatusInternalServerError,
|
|
||||||
"server_error", "Failed to generate registration token")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store in database - use system context since this is a public endpoint
|
|
||||||
now := dbtime.Now()
|
|
||||||
clientName := req.GenerateClientName()
|
|
||||||
//nolint:gocritic // Dynamic client registration is a public endpoint, system access required
|
|
||||||
app, err := api.Database.InsertOAuth2ProviderApp(dbauthz.AsSystemRestricted(ctx), database.InsertOAuth2ProviderAppParams{
|
|
||||||
ID: clientID,
|
|
||||||
CreatedAt: now,
|
|
||||||
UpdatedAt: now,
|
|
||||||
Name: clientName,
|
|
||||||
Icon: req.LogoURI,
|
|
||||||
CallbackURL: req.RedirectURIs[0], // Primary redirect URI
|
|
||||||
RedirectUris: req.RedirectURIs,
|
|
||||||
ClientType: sql.NullString{String: req.DetermineClientType(), Valid: true},
|
|
||||||
DynamicallyRegistered: sql.NullBool{Bool: true, Valid: true},
|
|
||||||
ClientIDIssuedAt: sql.NullTime{Time: now, Valid: true},
|
|
||||||
ClientSecretExpiresAt: sql.NullTime{}, // No expiration for now
|
|
||||||
GrantTypes: req.GrantTypes,
|
|
||||||
ResponseTypes: req.ResponseTypes,
|
|
||||||
TokenEndpointAuthMethod: sql.NullString{String: req.TokenEndpointAuthMethod, Valid: true},
|
|
||||||
Scope: sql.NullString{String: req.Scope, Valid: true},
|
|
||||||
Contacts: req.Contacts,
|
|
||||||
ClientUri: sql.NullString{String: req.ClientURI, Valid: req.ClientURI != ""},
|
|
||||||
LogoUri: sql.NullString{String: req.LogoURI, Valid: req.LogoURI != ""},
|
|
||||||
TosUri: sql.NullString{String: req.TOSURI, Valid: req.TOSURI != ""},
|
|
||||||
PolicyUri: sql.NullString{String: req.PolicyURI, Valid: req.PolicyURI != ""},
|
|
||||||
JwksUri: sql.NullString{String: req.JWKSURI, Valid: req.JWKSURI != ""},
|
|
||||||
Jwks: pqtype.NullRawMessage{RawMessage: req.JWKS, Valid: len(req.JWKS) > 0},
|
|
||||||
SoftwareID: sql.NullString{String: req.SoftwareID, Valid: req.SoftwareID != ""},
|
|
||||||
SoftwareVersion: sql.NullString{String: req.SoftwareVersion, Valid: req.SoftwareVersion != ""},
|
|
||||||
RegistrationAccessToken: sql.NullString{String: hashedRegToken, Valid: true},
|
|
||||||
RegistrationClientUri: sql.NullString{String: fmt.Sprintf("%s/oauth2/clients/%s", api.AccessURL.String(), clientID), Valid: true},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
api.Logger.Error(ctx, "failed to store oauth2 client registration",
|
|
||||||
slog.Error(err),
|
|
||||||
slog.F("client_name", clientName),
|
|
||||||
slog.F("client_id", clientID.String()),
|
|
||||||
slog.F("redirect_uris", req.RedirectURIs))
|
|
||||||
writeOAuth2RegistrationError(ctx, rw, http.StatusInternalServerError,
|
|
||||||
"server_error", "Failed to store client registration")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create client secret - parse the formatted secret to get components
|
|
||||||
parsedSecret, err := parseFormattedSecret(clientSecret)
|
|
||||||
if err != nil {
|
|
||||||
writeOAuth2RegistrationError(ctx, rw, http.StatusInternalServerError,
|
|
||||||
"server_error", "Failed to parse generated secret")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
//nolint:gocritic // Dynamic client registration is a public endpoint, system access required
|
|
||||||
_, err = api.Database.InsertOAuth2ProviderAppSecret(dbauthz.AsSystemRestricted(ctx), database.InsertOAuth2ProviderAppSecretParams{
|
|
||||||
ID: uuid.New(),
|
|
||||||
CreatedAt: now,
|
|
||||||
SecretPrefix: []byte(parsedSecret.prefix),
|
|
||||||
HashedSecret: []byte(hashedSecret),
|
|
||||||
DisplaySecret: createDisplaySecret(clientSecret),
|
|
||||||
AppID: clientID,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
writeOAuth2RegistrationError(ctx, rw, http.StatusInternalServerError,
|
|
||||||
"server_error", "Failed to store client secret")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set audit log data
|
|
||||||
aReq.New = app
|
|
||||||
|
|
||||||
// Return response
|
|
||||||
response := codersdk.OAuth2ClientRegistrationResponse{
|
|
||||||
ClientID: app.ID.String(),
|
|
||||||
ClientSecret: clientSecret,
|
|
||||||
ClientIDIssuedAt: app.ClientIDIssuedAt.Time.Unix(),
|
|
||||||
ClientSecretExpiresAt: 0, // No expiration
|
|
||||||
RedirectURIs: app.RedirectUris,
|
|
||||||
ClientName: app.Name,
|
|
||||||
ClientURI: app.ClientUri.String,
|
|
||||||
LogoURI: app.LogoUri.String,
|
|
||||||
TOSURI: app.TosUri.String,
|
|
||||||
PolicyURI: app.PolicyUri.String,
|
|
||||||
JWKSURI: app.JwksUri.String,
|
|
||||||
JWKS: app.Jwks.RawMessage,
|
|
||||||
SoftwareID: app.SoftwareID.String,
|
|
||||||
SoftwareVersion: app.SoftwareVersion.String,
|
|
||||||
GrantTypes: app.GrantTypes,
|
|
||||||
ResponseTypes: app.ResponseTypes,
|
|
||||||
TokenEndpointAuthMethod: app.TokenEndpointAuthMethod.String,
|
|
||||||
Scope: app.Scope.String,
|
|
||||||
Contacts: app.Contacts,
|
|
||||||
RegistrationAccessToken: registrationToken,
|
|
||||||
RegistrationClientURI: app.RegistrationClientUri.String,
|
|
||||||
}
|
|
||||||
|
|
||||||
httpapi.Write(ctx, rw, http.StatusCreated, response)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper functions for RFC 7591 Dynamic Client Registration
|
|
||||||
|
|
||||||
// generateClientCredentials generates a client secret for OAuth2 apps
|
|
||||||
func generateClientCredentials() (plaintext, hashed string, err error) {
|
|
||||||
// Use the same pattern as existing OAuth2 app secrets
|
|
||||||
secret, err := identityprovider.GenerateSecret()
|
|
||||||
if err != nil {
|
|
||||||
return "", "", xerrors.Errorf("generate secret: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return secret.Formatted, secret.Hashed, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// generateRegistrationAccessToken generates a registration access token for RFC 7592
|
|
||||||
func generateRegistrationAccessToken() (plaintext, hashed string, err error) {
|
|
||||||
token, err := cryptorand.String(secretLength)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", xerrors.Errorf("generate registration token: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hash the token for storage
|
|
||||||
hashedToken, err := userpassword.Hash(token)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", xerrors.Errorf("hash registration token: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return token, hashedToken, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeOAuth2RegistrationError writes RFC 7591 compliant error responses
|
|
||||||
func writeOAuth2RegistrationError(_ context.Context, rw http.ResponseWriter, status int, errorCode, description string) {
|
|
||||||
// RFC 7591 error response format
|
|
||||||
errorResponse := map[string]string{
|
|
||||||
"error": errorCode,
|
|
||||||
}
|
|
||||||
if description != "" {
|
|
||||||
errorResponse["error_description"] = description
|
|
||||||
}
|
|
||||||
|
|
||||||
rw.Header().Set("Content-Type", "application/json")
|
|
||||||
rw.WriteHeader(status)
|
|
||||||
_ = json.NewEncoder(rw).Encode(errorResponse)
|
|
||||||
}
|
|
||||||
|
|
||||||
// parsedSecret represents the components of a formatted OAuth2 secret
|
|
||||||
type parsedSecret struct {
|
|
||||||
prefix string
|
|
||||||
secret string
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseFormattedSecret parses a formatted secret like "coder_prefix_secret"
|
|
||||||
func parseFormattedSecret(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])
|
|
||||||
}
|
|
||||||
return parsedSecret{
|
|
||||||
prefix: parts[1],
|
|
||||||
secret: parts[2],
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// createDisplaySecret creates a display version of the secret showing only the last few characters
|
|
||||||
func createDisplaySecret(secret string) string {
|
|
||||||
if len(secret) <= displaySecretLength {
|
|
||||||
return secret
|
|
||||||
}
|
|
||||||
|
|
||||||
visiblePart := secret[len(secret)-displaySecretLength:]
|
|
||||||
hiddenLength := len(secret) - displaySecretLength
|
|
||||||
return strings.Repeat("*", hiddenLength) + visiblePart
|
|
||||||
}
|
|
||||||
|
|
||||||
// RFC 7592 Client Configuration Management Endpoints
|
|
||||||
|
|
||||||
// @Summary Get OAuth2 client configuration (RFC 7592)
|
// @Summary Get OAuth2 client configuration (RFC 7592)
|
||||||
// @ID get-oauth2-client-configuration
|
// @ID get-oauth2-client-configuration
|
||||||
// @Accept json
|
// @Accept json
|
||||||
@ -703,64 +200,8 @@ func createDisplaySecret(secret string) string {
|
|||||||
// @Param client_id path string true "Client ID"
|
// @Param client_id path string true "Client ID"
|
||||||
// @Success 200 {object} codersdk.OAuth2ClientConfiguration
|
// @Success 200 {object} codersdk.OAuth2ClientConfiguration
|
||||||
// @Router /oauth2/clients/{client_id} [get]
|
// @Router /oauth2/clients/{client_id} [get]
|
||||||
func (api *API) oauth2ClientConfiguration(rw http.ResponseWriter, r *http.Request) {
|
func (api *API) oauth2ClientConfiguration() http.HandlerFunc {
|
||||||
ctx := r.Context()
|
return oauth2provider.GetClientConfiguration(api.Database)
|
||||||
|
|
||||||
// Extract client ID from URL path
|
|
||||||
clientIDStr := chi.URLParam(r, "client_id")
|
|
||||||
clientID, err := uuid.Parse(clientIDStr)
|
|
||||||
if err != nil {
|
|
||||||
writeOAuth2RegistrationError(ctx, rw, http.StatusBadRequest,
|
|
||||||
"invalid_client_metadata", "Invalid client ID format")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get app by client ID
|
|
||||||
//nolint:gocritic // RFC 7592 endpoints need system access to retrieve dynamically registered clients
|
|
||||||
app, err := api.Database.GetOAuth2ProviderAppByClientID(dbauthz.AsSystemRestricted(ctx), clientID)
|
|
||||||
if err != nil {
|
|
||||||
if xerrors.Is(err, sql.ErrNoRows) {
|
|
||||||
writeOAuth2RegistrationError(ctx, rw, http.StatusUnauthorized,
|
|
||||||
"invalid_token", "Client not found")
|
|
||||||
} else {
|
|
||||||
writeOAuth2RegistrationError(ctx, rw, http.StatusInternalServerError,
|
|
||||||
"server_error", "Failed to retrieve client")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if client was dynamically registered
|
|
||||||
if !app.DynamicallyRegistered.Bool {
|
|
||||||
writeOAuth2RegistrationError(ctx, rw, http.StatusUnauthorized,
|
|
||||||
"invalid_token", "Client was not dynamically registered")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return client configuration (without client_secret for security)
|
|
||||||
response := codersdk.OAuth2ClientConfiguration{
|
|
||||||
ClientID: app.ID.String(),
|
|
||||||
ClientIDIssuedAt: app.ClientIDIssuedAt.Time.Unix(),
|
|
||||||
ClientSecretExpiresAt: 0, // No expiration for now
|
|
||||||
RedirectURIs: app.RedirectUris,
|
|
||||||
ClientName: app.Name,
|
|
||||||
ClientURI: app.ClientUri.String,
|
|
||||||
LogoURI: app.LogoUri.String,
|
|
||||||
TOSURI: app.TosUri.String,
|
|
||||||
PolicyURI: app.PolicyUri.String,
|
|
||||||
JWKSURI: app.JwksUri.String,
|
|
||||||
JWKS: app.Jwks.RawMessage,
|
|
||||||
SoftwareID: app.SoftwareID.String,
|
|
||||||
SoftwareVersion: app.SoftwareVersion.String,
|
|
||||||
GrantTypes: app.GrantTypes,
|
|
||||||
ResponseTypes: app.ResponseTypes,
|
|
||||||
TokenEndpointAuthMethod: app.TokenEndpointAuthMethod.String,
|
|
||||||
Scope: app.Scope.String,
|
|
||||||
Contacts: app.Contacts,
|
|
||||||
RegistrationAccessToken: "", // RFC 7592: Not returned in GET responses for security
|
|
||||||
RegistrationClientURI: app.RegistrationClientUri.String,
|
|
||||||
}
|
|
||||||
|
|
||||||
httpapi.Write(ctx, rw, http.StatusOK, response)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// @Summary Update OAuth2 client configuration (RFC 7592)
|
// @Summary Update OAuth2 client configuration (RFC 7592)
|
||||||
@ -772,126 +213,8 @@ func (api *API) oauth2ClientConfiguration(rw http.ResponseWriter, r *http.Reques
|
|||||||
// @Param request body codersdk.OAuth2ClientRegistrationRequest true "Client update request"
|
// @Param request body codersdk.OAuth2ClientRegistrationRequest true "Client update request"
|
||||||
// @Success 200 {object} codersdk.OAuth2ClientConfiguration
|
// @Success 200 {object} codersdk.OAuth2ClientConfiguration
|
||||||
// @Router /oauth2/clients/{client_id} [put]
|
// @Router /oauth2/clients/{client_id} [put]
|
||||||
func (api *API) putOAuth2ClientConfiguration(rw http.ResponseWriter, r *http.Request) {
|
func (api *API) putOAuth2ClientConfiguration() http.HandlerFunc {
|
||||||
ctx := r.Context()
|
return oauth2provider.UpdateClientConfiguration(api.Database, api.Auditor.Load(), api.Logger)
|
||||||
auditor := *api.Auditor.Load()
|
|
||||||
aReq, commitAudit := audit.InitRequest[database.OAuth2ProviderApp](rw, &audit.RequestParams{
|
|
||||||
Audit: auditor,
|
|
||||||
Log: api.Logger,
|
|
||||||
Request: r,
|
|
||||||
Action: database.AuditActionWrite,
|
|
||||||
})
|
|
||||||
defer commitAudit()
|
|
||||||
|
|
||||||
// Extract client ID from URL path
|
|
||||||
clientIDStr := chi.URLParam(r, "client_id")
|
|
||||||
clientID, err := uuid.Parse(clientIDStr)
|
|
||||||
if err != nil {
|
|
||||||
writeOAuth2RegistrationError(ctx, rw, http.StatusBadRequest,
|
|
||||||
"invalid_client_metadata", "Invalid client ID format")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse request
|
|
||||||
var req codersdk.OAuth2ClientRegistrationRequest
|
|
||||||
if !httpapi.Read(ctx, rw, r, &req) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate request
|
|
||||||
if err := req.Validate(); err != nil {
|
|
||||||
writeOAuth2RegistrationError(ctx, rw, http.StatusBadRequest,
|
|
||||||
"invalid_client_metadata", err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply defaults
|
|
||||||
req = req.ApplyDefaults()
|
|
||||||
|
|
||||||
// Get existing app to verify it exists and is dynamically registered
|
|
||||||
//nolint:gocritic // RFC 7592 endpoints need system access to retrieve dynamically registered clients
|
|
||||||
existingApp, err := api.Database.GetOAuth2ProviderAppByClientID(dbauthz.AsSystemRestricted(ctx), clientID)
|
|
||||||
if err == nil {
|
|
||||||
aReq.Old = existingApp
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
if xerrors.Is(err, sql.ErrNoRows) {
|
|
||||||
writeOAuth2RegistrationError(ctx, rw, http.StatusUnauthorized,
|
|
||||||
"invalid_token", "Client not found")
|
|
||||||
} else {
|
|
||||||
writeOAuth2RegistrationError(ctx, rw, http.StatusInternalServerError,
|
|
||||||
"server_error", "Failed to retrieve client")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if client was dynamically registered
|
|
||||||
if !existingApp.DynamicallyRegistered.Bool {
|
|
||||||
writeOAuth2RegistrationError(ctx, rw, http.StatusForbidden,
|
|
||||||
"invalid_token", "Client was not dynamically registered")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update app in database
|
|
||||||
now := dbtime.Now()
|
|
||||||
//nolint:gocritic // RFC 7592 endpoints need system access to update dynamically registered clients
|
|
||||||
updatedApp, err := api.Database.UpdateOAuth2ProviderAppByClientID(dbauthz.AsSystemRestricted(ctx), database.UpdateOAuth2ProviderAppByClientIDParams{
|
|
||||||
ID: clientID,
|
|
||||||
UpdatedAt: now,
|
|
||||||
Name: req.GenerateClientName(),
|
|
||||||
Icon: req.LogoURI,
|
|
||||||
CallbackURL: req.RedirectURIs[0], // Primary redirect URI
|
|
||||||
RedirectUris: req.RedirectURIs,
|
|
||||||
ClientType: sql.NullString{String: req.DetermineClientType(), Valid: true},
|
|
||||||
ClientSecretExpiresAt: sql.NullTime{}, // No expiration for now
|
|
||||||
GrantTypes: req.GrantTypes,
|
|
||||||
ResponseTypes: req.ResponseTypes,
|
|
||||||
TokenEndpointAuthMethod: sql.NullString{String: req.TokenEndpointAuthMethod, Valid: true},
|
|
||||||
Scope: sql.NullString{String: req.Scope, Valid: true},
|
|
||||||
Contacts: req.Contacts,
|
|
||||||
ClientUri: sql.NullString{String: req.ClientURI, Valid: req.ClientURI != ""},
|
|
||||||
LogoUri: sql.NullString{String: req.LogoURI, Valid: req.LogoURI != ""},
|
|
||||||
TosUri: sql.NullString{String: req.TOSURI, Valid: req.TOSURI != ""},
|
|
||||||
PolicyUri: sql.NullString{String: req.PolicyURI, Valid: req.PolicyURI != ""},
|
|
||||||
JwksUri: sql.NullString{String: req.JWKSURI, Valid: req.JWKSURI != ""},
|
|
||||||
Jwks: pqtype.NullRawMessage{RawMessage: req.JWKS, Valid: len(req.JWKS) > 0},
|
|
||||||
SoftwareID: sql.NullString{String: req.SoftwareID, Valid: req.SoftwareID != ""},
|
|
||||||
SoftwareVersion: sql.NullString{String: req.SoftwareVersion, Valid: req.SoftwareVersion != ""},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
writeOAuth2RegistrationError(ctx, rw, http.StatusInternalServerError,
|
|
||||||
"server_error", "Failed to update client")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set audit log data
|
|
||||||
aReq.New = updatedApp
|
|
||||||
|
|
||||||
// Return updated client configuration
|
|
||||||
response := codersdk.OAuth2ClientConfiguration{
|
|
||||||
ClientID: updatedApp.ID.String(),
|
|
||||||
ClientIDIssuedAt: updatedApp.ClientIDIssuedAt.Time.Unix(),
|
|
||||||
ClientSecretExpiresAt: 0, // No expiration for now
|
|
||||||
RedirectURIs: updatedApp.RedirectUris,
|
|
||||||
ClientName: updatedApp.Name,
|
|
||||||
ClientURI: updatedApp.ClientUri.String,
|
|
||||||
LogoURI: updatedApp.LogoUri.String,
|
|
||||||
TOSURI: updatedApp.TosUri.String,
|
|
||||||
PolicyURI: updatedApp.PolicyUri.String,
|
|
||||||
JWKSURI: updatedApp.JwksUri.String,
|
|
||||||
JWKS: updatedApp.Jwks.RawMessage,
|
|
||||||
SoftwareID: updatedApp.SoftwareID.String,
|
|
||||||
SoftwareVersion: updatedApp.SoftwareVersion.String,
|
|
||||||
GrantTypes: updatedApp.GrantTypes,
|
|
||||||
ResponseTypes: updatedApp.ResponseTypes,
|
|
||||||
TokenEndpointAuthMethod: updatedApp.TokenEndpointAuthMethod.String,
|
|
||||||
Scope: updatedApp.Scope.String,
|
|
||||||
Contacts: updatedApp.Contacts,
|
|
||||||
RegistrationAccessToken: updatedApp.RegistrationAccessToken.String,
|
|
||||||
RegistrationClientURI: updatedApp.RegistrationClientUri.String,
|
|
||||||
}
|
|
||||||
|
|
||||||
httpapi.Write(ctx, rw, http.StatusOK, response)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// @Summary Delete OAuth2 client registration (RFC 7592)
|
// @Summary Delete OAuth2 client registration (RFC 7592)
|
||||||
@ -900,143 +223,6 @@ func (api *API) putOAuth2ClientConfiguration(rw http.ResponseWriter, r *http.Req
|
|||||||
// @Param client_id path string true "Client ID"
|
// @Param client_id path string true "Client ID"
|
||||||
// @Success 204
|
// @Success 204
|
||||||
// @Router /oauth2/clients/{client_id} [delete]
|
// @Router /oauth2/clients/{client_id} [delete]
|
||||||
func (api *API) deleteOAuth2ClientConfiguration(rw http.ResponseWriter, r *http.Request) {
|
func (api *API) deleteOAuth2ClientConfiguration() http.HandlerFunc {
|
||||||
ctx := r.Context()
|
return oauth2provider.DeleteClientConfiguration(api.Database, api.Auditor.Load(), api.Logger)
|
||||||
auditor := *api.Auditor.Load()
|
|
||||||
aReq, commitAudit := audit.InitRequest[database.OAuth2ProviderApp](rw, &audit.RequestParams{
|
|
||||||
Audit: auditor,
|
|
||||||
Log: api.Logger,
|
|
||||||
Request: r,
|
|
||||||
Action: database.AuditActionDelete,
|
|
||||||
})
|
|
||||||
defer commitAudit()
|
|
||||||
|
|
||||||
// Extract client ID from URL path
|
|
||||||
clientIDStr := chi.URLParam(r, "client_id")
|
|
||||||
clientID, err := uuid.Parse(clientIDStr)
|
|
||||||
if err != nil {
|
|
||||||
writeOAuth2RegistrationError(ctx, rw, http.StatusBadRequest,
|
|
||||||
"invalid_client_metadata", "Invalid client ID format")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get existing app to verify it exists and is dynamically registered
|
|
||||||
//nolint:gocritic // RFC 7592 endpoints need system access to retrieve dynamically registered clients
|
|
||||||
existingApp, err := api.Database.GetOAuth2ProviderAppByClientID(dbauthz.AsSystemRestricted(ctx), clientID)
|
|
||||||
if err == nil {
|
|
||||||
aReq.Old = existingApp
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
if xerrors.Is(err, sql.ErrNoRows) {
|
|
||||||
writeOAuth2RegistrationError(ctx, rw, http.StatusUnauthorized,
|
|
||||||
"invalid_token", "Client not found")
|
|
||||||
} else {
|
|
||||||
writeOAuth2RegistrationError(ctx, rw, http.StatusInternalServerError,
|
|
||||||
"server_error", "Failed to retrieve client")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if client was dynamically registered
|
|
||||||
if !existingApp.DynamicallyRegistered.Bool {
|
|
||||||
writeOAuth2RegistrationError(ctx, rw, http.StatusForbidden,
|
|
||||||
"invalid_token", "Client was not dynamically registered")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete the client and all associated data (tokens, secrets, etc.)
|
|
||||||
//nolint:gocritic // RFC 7592 endpoints need system access to delete dynamically registered clients
|
|
||||||
err = api.Database.DeleteOAuth2ProviderAppByClientID(dbauthz.AsSystemRestricted(ctx), clientID)
|
|
||||||
if err != nil {
|
|
||||||
writeOAuth2RegistrationError(ctx, rw, http.StatusInternalServerError,
|
|
||||||
"server_error", "Failed to delete client")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Note: audit data already set above with aReq.Old = existingApp
|
|
||||||
|
|
||||||
// Return 204 No Content as per RFC 7592
|
|
||||||
rw.WriteHeader(http.StatusNoContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
// requireRegistrationAccessToken middleware validates the registration access token for RFC 7592 endpoints
|
|
||||||
func (api *API) requireRegistrationAccessToken(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
|
||||||
ctx := r.Context()
|
|
||||||
|
|
||||||
// Extract client ID from URL path
|
|
||||||
clientIDStr := chi.URLParam(r, "client_id")
|
|
||||||
clientID, err := uuid.Parse(clientIDStr)
|
|
||||||
if err != nil {
|
|
||||||
writeOAuth2RegistrationError(ctx, rw, http.StatusBadRequest,
|
|
||||||
"invalid_client_id", "Invalid client ID format")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract registration access token from Authorization header
|
|
||||||
authHeader := r.Header.Get("Authorization")
|
|
||||||
if authHeader == "" {
|
|
||||||
writeOAuth2RegistrationError(ctx, rw, http.StatusUnauthorized,
|
|
||||||
"invalid_token", "Missing Authorization header")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.HasPrefix(authHeader, "Bearer ") {
|
|
||||||
writeOAuth2RegistrationError(ctx, rw, http.StatusUnauthorized,
|
|
||||||
"invalid_token", "Authorization header must use Bearer scheme")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
token := strings.TrimPrefix(authHeader, "Bearer ")
|
|
||||||
if token == "" {
|
|
||||||
writeOAuth2RegistrationError(ctx, rw, http.StatusUnauthorized,
|
|
||||||
"invalid_token", "Missing registration access token")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the client and verify the registration access token
|
|
||||||
//nolint:gocritic // RFC 7592 endpoints need system access to validate dynamically registered clients
|
|
||||||
app, err := api.Database.GetOAuth2ProviderAppByClientID(dbauthz.AsSystemRestricted(ctx), clientID)
|
|
||||||
if err != nil {
|
|
||||||
if xerrors.Is(err, sql.ErrNoRows) {
|
|
||||||
// Return 401 for authentication-related issues, not 404
|
|
||||||
writeOAuth2RegistrationError(ctx, rw, http.StatusUnauthorized,
|
|
||||||
"invalid_token", "Client not found")
|
|
||||||
} else {
|
|
||||||
writeOAuth2RegistrationError(ctx, rw, http.StatusInternalServerError,
|
|
||||||
"server_error", "Failed to retrieve client")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if client was dynamically registered
|
|
||||||
if !app.DynamicallyRegistered.Bool {
|
|
||||||
writeOAuth2RegistrationError(ctx, rw, http.StatusForbidden,
|
|
||||||
"invalid_token", "Client was not dynamically registered")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify the registration access token
|
|
||||||
if !app.RegistrationAccessToken.Valid {
|
|
||||||
writeOAuth2RegistrationError(ctx, rw, http.StatusInternalServerError,
|
|
||||||
"server_error", "Client has no registration access token")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compare the provided token with the stored hash
|
|
||||||
valid, err := userpassword.Compare(app.RegistrationAccessToken.String, token)
|
|
||||||
if err != nil {
|
|
||||||
writeOAuth2RegistrationError(ctx, rw, http.StatusInternalServerError,
|
|
||||||
"server_error", "Failed to verify registration access token")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !valid {
|
|
||||||
writeOAuth2RegistrationError(ctx, rw, http.StatusUnauthorized,
|
|
||||||
"invalid_token", "Invalid registration access token")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Token is valid, continue to the next handler
|
|
||||||
next.ServeHTTP(rw, r)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
@ -22,7 +22,7 @@ import (
|
|||||||
"github.com/coder/coder/v2/coderd/database"
|
"github.com/coder/coder/v2/coderd/database"
|
||||||
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
"github.com/coder/coder/v2/coderd/database/dbtestutil"
|
||||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||||
"github.com/coder/coder/v2/coderd/identityprovider"
|
"github.com/coder/coder/v2/coderd/oauth2provider"
|
||||||
"github.com/coder/coder/v2/coderd/userpassword"
|
"github.com/coder/coder/v2/coderd/userpassword"
|
||||||
"github.com/coder/coder/v2/coderd/util/ptr"
|
"github.com/coder/coder/v2/coderd/util/ptr"
|
||||||
"github.com/coder/coder/v2/codersdk"
|
"github.com/coder/coder/v2/codersdk"
|
||||||
@ -865,7 +865,7 @@ func TestOAuth2ProviderTokenRefresh(t *testing.T) {
|
|||||||
newKey, err := db.InsertAPIKey(ctx, key)
|
newKey, err := db.InsertAPIKey(ctx, key)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
token, err := identityprovider.GenerateSecret()
|
token, err := oauth2provider.GenerateSecret()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
expires := test.expires
|
expires := test.expires
|
||||||
|
116
coderd/oauth2provider/app_secrets.go
Normal file
116
coderd/oauth2provider/app_secrets.go
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
package oauth2provider
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"cdr.dev/slog"
|
||||||
|
|
||||||
|
"github.com/coder/coder/v2/coderd/audit"
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetAppSecrets returns an http.HandlerFunc that handles GET /oauth2-provider/apps/{app}/secrets
|
||||||
|
func GetAppSecrets(db database.Store) http.HandlerFunc {
|
||||||
|
return func(rw http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
app := httpmw.OAuth2ProviderApp(r)
|
||||||
|
dbSecrets, err := db.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateAppSecret returns an http.HandlerFunc that handles POST /oauth2-provider/apps/{app}/secrets
|
||||||
|
func CreateAppSecret(db database.Store, auditor *audit.Auditor, logger slog.Logger) http.HandlerFunc {
|
||||||
|
return func(rw http.ResponseWriter, r *http.Request) {
|
||||||
|
var (
|
||||||
|
ctx = r.Context()
|
||||||
|
app = httpmw.OAuth2ProviderApp(r)
|
||||||
|
aReq, commitAudit = audit.InitRequest[database.OAuth2ProviderAppSecret](rw, &audit.RequestParams{
|
||||||
|
Audit: *auditor,
|
||||||
|
Log: logger,
|
||||||
|
Request: r,
|
||||||
|
Action: database.AuditActionCreate,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
defer commitAudit()
|
||||||
|
secret, err := 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 := db.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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteAppSecret returns an http.HandlerFunc that handles DELETE /oauth2-provider/apps/{app}/secrets/{secretID}
|
||||||
|
func DeleteAppSecret(db database.Store, auditor *audit.Auditor, logger slog.Logger) http.HandlerFunc {
|
||||||
|
return func(rw http.ResponseWriter, r *http.Request) {
|
||||||
|
var (
|
||||||
|
ctx = r.Context()
|
||||||
|
secret = httpmw.OAuth2ProviderAppSecret(r)
|
||||||
|
aReq, commitAudit = audit.InitRequest[database.OAuth2ProviderAppSecret](rw, &audit.RequestParams{
|
||||||
|
Audit: *auditor,
|
||||||
|
Log: logger,
|
||||||
|
Request: r,
|
||||||
|
Action: database.AuditActionDelete,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
aReq.Old = secret
|
||||||
|
defer commitAudit()
|
||||||
|
err := db.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
|
||||||
|
}
|
||||||
|
rw.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
208
coderd/oauth2provider/apps.go
Normal file
208
coderd/oauth2provider/apps.go
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
package oauth2provider
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/sqlc-dev/pqtype"
|
||||||
|
|
||||||
|
"cdr.dev/slog"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListApps returns an http.HandlerFunc that handles GET /oauth2-provider/apps
|
||||||
|
func ListApps(db database.Store, accessURL *url.URL) http.HandlerFunc {
|
||||||
|
return func(rw http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
rawUserID := r.URL.Query().Get("user_id")
|
||||||
|
if rawUserID == "" {
|
||||||
|
dbApps, err := db.GetOAuth2ProviderApps(ctx)
|
||||||
|
if err != nil {
|
||||||
|
httpapi.InternalServerError(rw, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.OAuth2ProviderApps(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 := db.GetOAuth2ProviderAppsByUserID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
httpapi.InternalServerError(rw, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var sdkApps []codersdk.OAuth2ProviderApp
|
||||||
|
for _, app := range userApps {
|
||||||
|
sdkApps = append(sdkApps, db2sdk.OAuth2ProviderApp(accessURL, app.OAuth2ProviderApp))
|
||||||
|
}
|
||||||
|
httpapi.Write(ctx, rw, http.StatusOK, sdkApps)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetApp returns an http.HandlerFunc that handles GET /oauth2-provider/apps/{app}
|
||||||
|
func GetApp(accessURL *url.URL) http.HandlerFunc {
|
||||||
|
return func(rw http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
app := httpmw.OAuth2ProviderApp(r)
|
||||||
|
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.OAuth2ProviderApp(accessURL, app))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateApp returns an http.HandlerFunc that handles POST /oauth2-provider/apps
|
||||||
|
func CreateApp(db database.Store, accessURL *url.URL, auditor *audit.Auditor, logger slog.Logger) http.HandlerFunc {
|
||||||
|
return func(rw http.ResponseWriter, r *http.Request) {
|
||||||
|
var (
|
||||||
|
ctx = r.Context()
|
||||||
|
aReq, commitAudit = audit.InitRequest[database.OAuth2ProviderApp](rw, &audit.RequestParams{
|
||||||
|
Audit: *auditor,
|
||||||
|
Log: logger,
|
||||||
|
Request: r,
|
||||||
|
Action: database.AuditActionCreate,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
defer commitAudit()
|
||||||
|
var req codersdk.PostOAuth2ProviderAppRequest
|
||||||
|
if !httpapi.Read(ctx, rw, r, &req) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
app, err := db.InsertOAuth2ProviderApp(ctx, database.InsertOAuth2ProviderAppParams{
|
||||||
|
ID: uuid.New(),
|
||||||
|
CreatedAt: dbtime.Now(),
|
||||||
|
UpdatedAt: dbtime.Now(),
|
||||||
|
Name: req.Name,
|
||||||
|
Icon: req.Icon,
|
||||||
|
CallbackURL: req.CallbackURL,
|
||||||
|
RedirectUris: []string{},
|
||||||
|
ClientType: sql.NullString{String: "confidential", Valid: true},
|
||||||
|
DynamicallyRegistered: sql.NullBool{Bool: false, Valid: true},
|
||||||
|
ClientIDIssuedAt: sql.NullTime{},
|
||||||
|
ClientSecretExpiresAt: sql.NullTime{},
|
||||||
|
GrantTypes: []string{"authorization_code", "refresh_token"},
|
||||||
|
ResponseTypes: []string{"code"},
|
||||||
|
TokenEndpointAuthMethod: sql.NullString{String: "client_secret_post", Valid: true},
|
||||||
|
Scope: sql.NullString{},
|
||||||
|
Contacts: []string{},
|
||||||
|
ClientUri: sql.NullString{},
|
||||||
|
LogoUri: sql.NullString{},
|
||||||
|
TosUri: sql.NullString{},
|
||||||
|
PolicyUri: sql.NullString{},
|
||||||
|
JwksUri: sql.NullString{},
|
||||||
|
Jwks: pqtype.NullRawMessage{},
|
||||||
|
SoftwareID: sql.NullString{},
|
||||||
|
SoftwareVersion: sql.NullString{},
|
||||||
|
RegistrationAccessToken: sql.NullString{},
|
||||||
|
RegistrationClientUri: sql.NullString{},
|
||||||
|
})
|
||||||
|
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(accessURL, app))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateApp returns an http.HandlerFunc that handles PUT /oauth2-provider/apps/{app}
|
||||||
|
func UpdateApp(db database.Store, accessURL *url.URL, auditor *audit.Auditor, logger slog.Logger) http.HandlerFunc {
|
||||||
|
return func(rw http.ResponseWriter, r *http.Request) {
|
||||||
|
var (
|
||||||
|
ctx = r.Context()
|
||||||
|
app = httpmw.OAuth2ProviderApp(r)
|
||||||
|
aReq, commitAudit = audit.InitRequest[database.OAuth2ProviderApp](rw, &audit.RequestParams{
|
||||||
|
Audit: *auditor,
|
||||||
|
Log: 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 := db.UpdateOAuth2ProviderAppByID(ctx, database.UpdateOAuth2ProviderAppByIDParams{
|
||||||
|
ID: app.ID,
|
||||||
|
UpdatedAt: dbtime.Now(),
|
||||||
|
Name: req.Name,
|
||||||
|
Icon: req.Icon,
|
||||||
|
CallbackURL: req.CallbackURL,
|
||||||
|
RedirectUris: app.RedirectUris, // Keep existing value
|
||||||
|
ClientType: app.ClientType, // Keep existing value
|
||||||
|
DynamicallyRegistered: app.DynamicallyRegistered, // Keep existing value
|
||||||
|
ClientSecretExpiresAt: app.ClientSecretExpiresAt, // Keep existing value
|
||||||
|
GrantTypes: app.GrantTypes, // Keep existing value
|
||||||
|
ResponseTypes: app.ResponseTypes, // Keep existing value
|
||||||
|
TokenEndpointAuthMethod: app.TokenEndpointAuthMethod, // Keep existing value
|
||||||
|
Scope: app.Scope, // Keep existing value
|
||||||
|
Contacts: app.Contacts, // Keep existing value
|
||||||
|
ClientUri: app.ClientUri, // Keep existing value
|
||||||
|
LogoUri: app.LogoUri, // Keep existing value
|
||||||
|
TosUri: app.TosUri, // Keep existing value
|
||||||
|
PolicyUri: app.PolicyUri, // Keep existing value
|
||||||
|
JwksUri: app.JwksUri, // Keep existing value
|
||||||
|
Jwks: app.Jwks, // Keep existing value
|
||||||
|
SoftwareID: app.SoftwareID, // Keep existing value
|
||||||
|
SoftwareVersion: app.SoftwareVersion, // Keep existing value
|
||||||
|
})
|
||||||
|
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(accessURL, app))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteApp returns an http.HandlerFunc that handles DELETE /oauth2-provider/apps/{app}
|
||||||
|
func DeleteApp(db database.Store, auditor *audit.Auditor, logger slog.Logger) http.HandlerFunc {
|
||||||
|
return func(rw http.ResponseWriter, r *http.Request) {
|
||||||
|
var (
|
||||||
|
ctx = r.Context()
|
||||||
|
app = httpmw.OAuth2ProviderApp(r)
|
||||||
|
aReq, commitAudit = audit.InitRequest[database.OAuth2ProviderApp](rw, &audit.RequestParams{
|
||||||
|
Audit: *auditor,
|
||||||
|
Log: logger,
|
||||||
|
Request: r,
|
||||||
|
Action: database.AuditActionDelete,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
aReq.Old = app
|
||||||
|
defer commitAudit()
|
||||||
|
err := db.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
|
||||||
|
}
|
||||||
|
rw.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
package identityprovider
|
package oauth2provider
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
45
coderd/oauth2provider/metadata.go
Normal file
45
coderd/oauth2provider/metadata.go
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
package oauth2provider
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
"github.com/coder/coder/v2/coderd/httpapi"
|
||||||
|
"github.com/coder/coder/v2/codersdk"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetAuthorizationServerMetadata returns an http.HandlerFunc that handles GET /.well-known/oauth-authorization-server
|
||||||
|
func GetAuthorizationServerMetadata(accessURL *url.URL) http.HandlerFunc {
|
||||||
|
return func(rw http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
metadata := codersdk.OAuth2AuthorizationServerMetadata{
|
||||||
|
Issuer: accessURL.String(),
|
||||||
|
AuthorizationEndpoint: accessURL.JoinPath("/oauth2/authorize").String(),
|
||||||
|
TokenEndpoint: accessURL.JoinPath("/oauth2/tokens").String(),
|
||||||
|
RegistrationEndpoint: accessURL.JoinPath("/oauth2/register").String(), // RFC 7591
|
||||||
|
ResponseTypesSupported: []string{"code"},
|
||||||
|
GrantTypesSupported: []string{"authorization_code", "refresh_token"},
|
||||||
|
CodeChallengeMethodsSupported: []string{"S256"},
|
||||||
|
// TODO: Implement scope system
|
||||||
|
ScopesSupported: []string{},
|
||||||
|
TokenEndpointAuthMethodsSupported: []string{"client_secret_post"},
|
||||||
|
}
|
||||||
|
httpapi.Write(ctx, rw, http.StatusOK, metadata)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetProtectedResourceMetadata returns an http.HandlerFunc that handles GET /.well-known/oauth-protected-resource
|
||||||
|
func GetProtectedResourceMetadata(accessURL *url.URL) http.HandlerFunc {
|
||||||
|
return func(rw http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
metadata := codersdk.OAuth2ProtectedResourceMetadata{
|
||||||
|
Resource: accessURL.String(),
|
||||||
|
AuthorizationServers: []string{accessURL.String()},
|
||||||
|
// TODO: Implement scope system based on RBAC permissions
|
||||||
|
ScopesSupported: []string{},
|
||||||
|
// RFC 6750 Bearer Token methods supported as fallback methods in api key middleware
|
||||||
|
BearerMethodsSupported: []string{"header", "query"},
|
||||||
|
}
|
||||||
|
httpapi.Write(ctx, rw, http.StatusOK, metadata)
|
||||||
|
}
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
package identityprovider
|
package oauth2provider
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
@ -1,4 +1,4 @@
|
|||||||
package identityprovidertest
|
package oauth2providertest
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
@ -1,7 +1,7 @@
|
|||||||
// Package identityprovidertest provides comprehensive testing utilities for OAuth2 identity provider functionality.
|
// Package oauth2providertest provides comprehensive testing utilities for OAuth2 identity provider functionality.
|
||||||
// It includes helpers for creating OAuth2 apps, performing authorization flows, token exchanges,
|
// It includes helpers for creating OAuth2 apps, performing authorization flows, token exchanges,
|
||||||
// PKCE challenge generation and verification, and testing error scenarios.
|
// PKCE challenge generation and verification, and testing error scenarios.
|
||||||
package identityprovidertest
|
package oauth2providertest
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
@ -1,4 +1,4 @@
|
|||||||
package identityprovidertest_test
|
package oauth2providertest_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
@ -6,7 +6,7 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||||
"github.com/coder/coder/v2/coderd/identityprovider/identityprovidertest"
|
"github.com/coder/coder/v2/coderd/oauth2provider/oauth2providertest"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestOAuth2AuthorizationServerMetadata(t *testing.T) {
|
func TestOAuth2AuthorizationServerMetadata(t *testing.T) {
|
||||||
@ -18,7 +18,7 @@ func TestOAuth2AuthorizationServerMetadata(t *testing.T) {
|
|||||||
_ = coderdtest.CreateFirstUser(t, client)
|
_ = coderdtest.CreateFirstUser(t, client)
|
||||||
|
|
||||||
// Fetch OAuth2 metadata
|
// Fetch OAuth2 metadata
|
||||||
metadata := identityprovidertest.FetchOAuth2Metadata(t, client.URL.String())
|
metadata := oauth2providertest.FetchOAuth2Metadata(t, client.URL.String())
|
||||||
|
|
||||||
// Verify required metadata fields
|
// Verify required metadata fields
|
||||||
require.Contains(t, metadata, "issuer", "missing issuer in metadata")
|
require.Contains(t, metadata, "issuer", "missing issuer in metadata")
|
||||||
@ -60,39 +60,39 @@ func TestOAuth2PKCEFlow(t *testing.T) {
|
|||||||
_ = coderdtest.CreateFirstUser(t, client)
|
_ = coderdtest.CreateFirstUser(t, client)
|
||||||
|
|
||||||
// Create OAuth2 app
|
// Create OAuth2 app
|
||||||
app, clientSecret := identityprovidertest.CreateTestOAuth2App(t, client)
|
app, clientSecret := oauth2providertest.CreateTestOAuth2App(t, client)
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
identityprovidertest.CleanupOAuth2App(t, client, app.ID)
|
oauth2providertest.CleanupOAuth2App(t, client, app.ID)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Generate PKCE parameters
|
// Generate PKCE parameters
|
||||||
codeVerifier, codeChallenge := identityprovidertest.GeneratePKCE(t)
|
codeVerifier, codeChallenge := oauth2providertest.GeneratePKCE(t)
|
||||||
state := identityprovidertest.GenerateState(t)
|
state := oauth2providertest.GenerateState(t)
|
||||||
|
|
||||||
// Perform authorization
|
// Perform authorization
|
||||||
authParams := identityprovidertest.AuthorizeParams{
|
authParams := oauth2providertest.AuthorizeParams{
|
||||||
ClientID: app.ID.String(),
|
ClientID: app.ID.String(),
|
||||||
ResponseType: "code",
|
ResponseType: "code",
|
||||||
RedirectURI: identityprovidertest.TestRedirectURI,
|
RedirectURI: oauth2providertest.TestRedirectURI,
|
||||||
State: state,
|
State: state,
|
||||||
CodeChallenge: codeChallenge,
|
CodeChallenge: codeChallenge,
|
||||||
CodeChallengeMethod: "S256",
|
CodeChallengeMethod: "S256",
|
||||||
}
|
}
|
||||||
|
|
||||||
code := identityprovidertest.AuthorizeOAuth2App(t, client, client.URL.String(), authParams)
|
code := oauth2providertest.AuthorizeOAuth2App(t, client, client.URL.String(), authParams)
|
||||||
require.NotEmpty(t, code, "should receive authorization code")
|
require.NotEmpty(t, code, "should receive authorization code")
|
||||||
|
|
||||||
// Exchange code for token with PKCE
|
// Exchange code for token with PKCE
|
||||||
tokenParams := identityprovidertest.TokenExchangeParams{
|
tokenParams := oauth2providertest.TokenExchangeParams{
|
||||||
GrantType: "authorization_code",
|
GrantType: "authorization_code",
|
||||||
Code: code,
|
Code: code,
|
||||||
ClientID: app.ID.String(),
|
ClientID: app.ID.String(),
|
||||||
ClientSecret: clientSecret,
|
ClientSecret: clientSecret,
|
||||||
CodeVerifier: codeVerifier,
|
CodeVerifier: codeVerifier,
|
||||||
RedirectURI: identityprovidertest.TestRedirectURI,
|
RedirectURI: oauth2providertest.TestRedirectURI,
|
||||||
}
|
}
|
||||||
|
|
||||||
token := identityprovidertest.ExchangeCodeForToken(t, client.URL.String(), tokenParams)
|
token := oauth2providertest.ExchangeCodeForToken(t, client.URL.String(), tokenParams)
|
||||||
require.NotEmpty(t, token.AccessToken, "should receive access token")
|
require.NotEmpty(t, token.AccessToken, "should receive access token")
|
||||||
require.NotEmpty(t, token.RefreshToken, "should receive refresh token")
|
require.NotEmpty(t, token.RefreshToken, "should receive refresh token")
|
||||||
require.Equal(t, "Bearer", token.TokenType, "token type should be Bearer")
|
require.Equal(t, "Bearer", token.TokenType, "token type should be Bearer")
|
||||||
@ -107,40 +107,40 @@ func TestOAuth2InvalidPKCE(t *testing.T) {
|
|||||||
_ = coderdtest.CreateFirstUser(t, client)
|
_ = coderdtest.CreateFirstUser(t, client)
|
||||||
|
|
||||||
// Create OAuth2 app
|
// Create OAuth2 app
|
||||||
app, clientSecret := identityprovidertest.CreateTestOAuth2App(t, client)
|
app, clientSecret := oauth2providertest.CreateTestOAuth2App(t, client)
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
identityprovidertest.CleanupOAuth2App(t, client, app.ID)
|
oauth2providertest.CleanupOAuth2App(t, client, app.ID)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Generate PKCE parameters
|
// Generate PKCE parameters
|
||||||
_, codeChallenge := identityprovidertest.GeneratePKCE(t)
|
_, codeChallenge := oauth2providertest.GeneratePKCE(t)
|
||||||
state := identityprovidertest.GenerateState(t)
|
state := oauth2providertest.GenerateState(t)
|
||||||
|
|
||||||
// Perform authorization
|
// Perform authorization
|
||||||
authParams := identityprovidertest.AuthorizeParams{
|
authParams := oauth2providertest.AuthorizeParams{
|
||||||
ClientID: app.ID.String(),
|
ClientID: app.ID.String(),
|
||||||
ResponseType: "code",
|
ResponseType: "code",
|
||||||
RedirectURI: identityprovidertest.TestRedirectURI,
|
RedirectURI: oauth2providertest.TestRedirectURI,
|
||||||
State: state,
|
State: state,
|
||||||
CodeChallenge: codeChallenge,
|
CodeChallenge: codeChallenge,
|
||||||
CodeChallengeMethod: "S256",
|
CodeChallengeMethod: "S256",
|
||||||
}
|
}
|
||||||
|
|
||||||
code := identityprovidertest.AuthorizeOAuth2App(t, client, client.URL.String(), authParams)
|
code := oauth2providertest.AuthorizeOAuth2App(t, client, client.URL.String(), authParams)
|
||||||
require.NotEmpty(t, code, "should receive authorization code")
|
require.NotEmpty(t, code, "should receive authorization code")
|
||||||
|
|
||||||
// Attempt token exchange with wrong code verifier
|
// Attempt token exchange with wrong code verifier
|
||||||
tokenParams := identityprovidertest.TokenExchangeParams{
|
tokenParams := oauth2providertest.TokenExchangeParams{
|
||||||
GrantType: "authorization_code",
|
GrantType: "authorization_code",
|
||||||
Code: code,
|
Code: code,
|
||||||
ClientID: app.ID.String(),
|
ClientID: app.ID.String(),
|
||||||
ClientSecret: clientSecret,
|
ClientSecret: clientSecret,
|
||||||
CodeVerifier: identityprovidertest.InvalidCodeVerifier,
|
CodeVerifier: oauth2providertest.InvalidCodeVerifier,
|
||||||
RedirectURI: identityprovidertest.TestRedirectURI,
|
RedirectURI: oauth2providertest.TestRedirectURI,
|
||||||
}
|
}
|
||||||
|
|
||||||
identityprovidertest.PerformTokenExchangeExpectingError(
|
oauth2providertest.PerformTokenExchangeExpectingError(
|
||||||
t, client.URL.String(), tokenParams, identityprovidertest.OAuth2ErrorTypes.InvalidGrant,
|
t, client.URL.String(), tokenParams, oauth2providertest.OAuth2ErrorTypes.InvalidGrant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -153,34 +153,34 @@ func TestOAuth2WithoutPKCE(t *testing.T) {
|
|||||||
_ = coderdtest.CreateFirstUser(t, client)
|
_ = coderdtest.CreateFirstUser(t, client)
|
||||||
|
|
||||||
// Create OAuth2 app
|
// Create OAuth2 app
|
||||||
app, clientSecret := identityprovidertest.CreateTestOAuth2App(t, client)
|
app, clientSecret := oauth2providertest.CreateTestOAuth2App(t, client)
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
identityprovidertest.CleanupOAuth2App(t, client, app.ID)
|
oauth2providertest.CleanupOAuth2App(t, client, app.ID)
|
||||||
})
|
})
|
||||||
|
|
||||||
state := identityprovidertest.GenerateState(t)
|
state := oauth2providertest.GenerateState(t)
|
||||||
|
|
||||||
// Perform authorization without PKCE
|
// Perform authorization without PKCE
|
||||||
authParams := identityprovidertest.AuthorizeParams{
|
authParams := oauth2providertest.AuthorizeParams{
|
||||||
ClientID: app.ID.String(),
|
ClientID: app.ID.String(),
|
||||||
ResponseType: "code",
|
ResponseType: "code",
|
||||||
RedirectURI: identityprovidertest.TestRedirectURI,
|
RedirectURI: oauth2providertest.TestRedirectURI,
|
||||||
State: state,
|
State: state,
|
||||||
}
|
}
|
||||||
|
|
||||||
code := identityprovidertest.AuthorizeOAuth2App(t, client, client.URL.String(), authParams)
|
code := oauth2providertest.AuthorizeOAuth2App(t, client, client.URL.String(), authParams)
|
||||||
require.NotEmpty(t, code, "should receive authorization code")
|
require.NotEmpty(t, code, "should receive authorization code")
|
||||||
|
|
||||||
// Exchange code for token without PKCE
|
// Exchange code for token without PKCE
|
||||||
tokenParams := identityprovidertest.TokenExchangeParams{
|
tokenParams := oauth2providertest.TokenExchangeParams{
|
||||||
GrantType: "authorization_code",
|
GrantType: "authorization_code",
|
||||||
Code: code,
|
Code: code,
|
||||||
ClientID: app.ID.String(),
|
ClientID: app.ID.String(),
|
||||||
ClientSecret: clientSecret,
|
ClientSecret: clientSecret,
|
||||||
RedirectURI: identityprovidertest.TestRedirectURI,
|
RedirectURI: oauth2providertest.TestRedirectURI,
|
||||||
}
|
}
|
||||||
|
|
||||||
token := identityprovidertest.ExchangeCodeForToken(t, client.URL.String(), tokenParams)
|
token := oauth2providertest.ExchangeCodeForToken(t, client.URL.String(), tokenParams)
|
||||||
require.NotEmpty(t, token.AccessToken, "should receive access token")
|
require.NotEmpty(t, token.AccessToken, "should receive access token")
|
||||||
require.NotEmpty(t, token.RefreshToken, "should receive refresh token")
|
require.NotEmpty(t, token.RefreshToken, "should receive refresh token")
|
||||||
}
|
}
|
||||||
@ -194,36 +194,36 @@ func TestOAuth2ResourceParameter(t *testing.T) {
|
|||||||
_ = coderdtest.CreateFirstUser(t, client)
|
_ = coderdtest.CreateFirstUser(t, client)
|
||||||
|
|
||||||
// Create OAuth2 app
|
// Create OAuth2 app
|
||||||
app, clientSecret := identityprovidertest.CreateTestOAuth2App(t, client)
|
app, clientSecret := oauth2providertest.CreateTestOAuth2App(t, client)
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
identityprovidertest.CleanupOAuth2App(t, client, app.ID)
|
oauth2providertest.CleanupOAuth2App(t, client, app.ID)
|
||||||
})
|
})
|
||||||
|
|
||||||
state := identityprovidertest.GenerateState(t)
|
state := oauth2providertest.GenerateState(t)
|
||||||
|
|
||||||
// Perform authorization with resource parameter
|
// Perform authorization with resource parameter
|
||||||
authParams := identityprovidertest.AuthorizeParams{
|
authParams := oauth2providertest.AuthorizeParams{
|
||||||
ClientID: app.ID.String(),
|
ClientID: app.ID.String(),
|
||||||
ResponseType: "code",
|
ResponseType: "code",
|
||||||
RedirectURI: identityprovidertest.TestRedirectURI,
|
RedirectURI: oauth2providertest.TestRedirectURI,
|
||||||
State: state,
|
State: state,
|
||||||
Resource: identityprovidertest.TestResourceURI,
|
Resource: oauth2providertest.TestResourceURI,
|
||||||
}
|
}
|
||||||
|
|
||||||
code := identityprovidertest.AuthorizeOAuth2App(t, client, client.URL.String(), authParams)
|
code := oauth2providertest.AuthorizeOAuth2App(t, client, client.URL.String(), authParams)
|
||||||
require.NotEmpty(t, code, "should receive authorization code")
|
require.NotEmpty(t, code, "should receive authorization code")
|
||||||
|
|
||||||
// Exchange code for token with resource parameter
|
// Exchange code for token with resource parameter
|
||||||
tokenParams := identityprovidertest.TokenExchangeParams{
|
tokenParams := oauth2providertest.TokenExchangeParams{
|
||||||
GrantType: "authorization_code",
|
GrantType: "authorization_code",
|
||||||
Code: code,
|
Code: code,
|
||||||
ClientID: app.ID.String(),
|
ClientID: app.ID.String(),
|
||||||
ClientSecret: clientSecret,
|
ClientSecret: clientSecret,
|
||||||
RedirectURI: identityprovidertest.TestRedirectURI,
|
RedirectURI: oauth2providertest.TestRedirectURI,
|
||||||
Resource: identityprovidertest.TestResourceURI,
|
Resource: oauth2providertest.TestResourceURI,
|
||||||
}
|
}
|
||||||
|
|
||||||
token := identityprovidertest.ExchangeCodeForToken(t, client.URL.String(), tokenParams)
|
token := oauth2providertest.ExchangeCodeForToken(t, client.URL.String(), tokenParams)
|
||||||
require.NotEmpty(t, token.AccessToken, "should receive access token")
|
require.NotEmpty(t, token.AccessToken, "should receive access token")
|
||||||
require.NotEmpty(t, token.RefreshToken, "should receive refresh token")
|
require.NotEmpty(t, token.RefreshToken, "should receive refresh token")
|
||||||
}
|
}
|
||||||
@ -237,43 +237,43 @@ func TestOAuth2TokenRefresh(t *testing.T) {
|
|||||||
_ = coderdtest.CreateFirstUser(t, client)
|
_ = coderdtest.CreateFirstUser(t, client)
|
||||||
|
|
||||||
// Create OAuth2 app
|
// Create OAuth2 app
|
||||||
app, clientSecret := identityprovidertest.CreateTestOAuth2App(t, client)
|
app, clientSecret := oauth2providertest.CreateTestOAuth2App(t, client)
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
identityprovidertest.CleanupOAuth2App(t, client, app.ID)
|
oauth2providertest.CleanupOAuth2App(t, client, app.ID)
|
||||||
})
|
})
|
||||||
|
|
||||||
state := identityprovidertest.GenerateState(t)
|
state := oauth2providertest.GenerateState(t)
|
||||||
|
|
||||||
// Get initial token
|
// Get initial token
|
||||||
authParams := identityprovidertest.AuthorizeParams{
|
authParams := oauth2providertest.AuthorizeParams{
|
||||||
ClientID: app.ID.String(),
|
ClientID: app.ID.String(),
|
||||||
ResponseType: "code",
|
ResponseType: "code",
|
||||||
RedirectURI: identityprovidertest.TestRedirectURI,
|
RedirectURI: oauth2providertest.TestRedirectURI,
|
||||||
State: state,
|
State: state,
|
||||||
}
|
}
|
||||||
|
|
||||||
code := identityprovidertest.AuthorizeOAuth2App(t, client, client.URL.String(), authParams)
|
code := oauth2providertest.AuthorizeOAuth2App(t, client, client.URL.String(), authParams)
|
||||||
|
|
||||||
tokenParams := identityprovidertest.TokenExchangeParams{
|
tokenParams := oauth2providertest.TokenExchangeParams{
|
||||||
GrantType: "authorization_code",
|
GrantType: "authorization_code",
|
||||||
Code: code,
|
Code: code,
|
||||||
ClientID: app.ID.String(),
|
ClientID: app.ID.String(),
|
||||||
ClientSecret: clientSecret,
|
ClientSecret: clientSecret,
|
||||||
RedirectURI: identityprovidertest.TestRedirectURI,
|
RedirectURI: oauth2providertest.TestRedirectURI,
|
||||||
}
|
}
|
||||||
|
|
||||||
initialToken := identityprovidertest.ExchangeCodeForToken(t, client.URL.String(), tokenParams)
|
initialToken := oauth2providertest.ExchangeCodeForToken(t, client.URL.String(), tokenParams)
|
||||||
require.NotEmpty(t, initialToken.RefreshToken, "should receive refresh token")
|
require.NotEmpty(t, initialToken.RefreshToken, "should receive refresh token")
|
||||||
|
|
||||||
// Use refresh token to get new access token
|
// Use refresh token to get new access token
|
||||||
refreshParams := identityprovidertest.TokenExchangeParams{
|
refreshParams := oauth2providertest.TokenExchangeParams{
|
||||||
GrantType: "refresh_token",
|
GrantType: "refresh_token",
|
||||||
RefreshToken: initialToken.RefreshToken,
|
RefreshToken: initialToken.RefreshToken,
|
||||||
ClientID: app.ID.String(),
|
ClientID: app.ID.String(),
|
||||||
ClientSecret: clientSecret,
|
ClientSecret: clientSecret,
|
||||||
}
|
}
|
||||||
|
|
||||||
refreshedToken := identityprovidertest.ExchangeCodeForToken(t, client.URL.String(), refreshParams)
|
refreshedToken := oauth2providertest.ExchangeCodeForToken(t, client.URL.String(), refreshParams)
|
||||||
require.NotEmpty(t, refreshedToken.AccessToken, "should receive new access token")
|
require.NotEmpty(t, refreshedToken.AccessToken, "should receive new access token")
|
||||||
require.NotEqual(t, initialToken.AccessToken, refreshedToken.AccessToken, "new access token should be different")
|
require.NotEqual(t, initialToken.AccessToken, refreshedToken.AccessToken, "new access token should be different")
|
||||||
}
|
}
|
||||||
@ -289,53 +289,53 @@ func TestOAuth2ErrorResponses(t *testing.T) {
|
|||||||
t.Run("InvalidClient", func(t *testing.T) {
|
t.Run("InvalidClient", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
tokenParams := identityprovidertest.TokenExchangeParams{
|
tokenParams := oauth2providertest.TokenExchangeParams{
|
||||||
GrantType: "authorization_code",
|
GrantType: "authorization_code",
|
||||||
Code: "invalid-code",
|
Code: "invalid-code",
|
||||||
ClientID: "non-existent-client",
|
ClientID: "non-existent-client",
|
||||||
ClientSecret: "invalid-secret",
|
ClientSecret: "invalid-secret",
|
||||||
}
|
}
|
||||||
|
|
||||||
identityprovidertest.PerformTokenExchangeExpectingError(
|
oauth2providertest.PerformTokenExchangeExpectingError(
|
||||||
t, client.URL.String(), tokenParams, identityprovidertest.OAuth2ErrorTypes.InvalidClient,
|
t, client.URL.String(), tokenParams, oauth2providertest.OAuth2ErrorTypes.InvalidClient,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("InvalidGrantType", func(t *testing.T) {
|
t.Run("InvalidGrantType", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
app, clientSecret := identityprovidertest.CreateTestOAuth2App(t, client)
|
app, clientSecret := oauth2providertest.CreateTestOAuth2App(t, client)
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
identityprovidertest.CleanupOAuth2App(t, client, app.ID)
|
oauth2providertest.CleanupOAuth2App(t, client, app.ID)
|
||||||
})
|
})
|
||||||
|
|
||||||
tokenParams := identityprovidertest.TokenExchangeParams{
|
tokenParams := oauth2providertest.TokenExchangeParams{
|
||||||
GrantType: "invalid_grant_type",
|
GrantType: "invalid_grant_type",
|
||||||
ClientID: app.ID.String(),
|
ClientID: app.ID.String(),
|
||||||
ClientSecret: clientSecret,
|
ClientSecret: clientSecret,
|
||||||
}
|
}
|
||||||
|
|
||||||
identityprovidertest.PerformTokenExchangeExpectingError(
|
oauth2providertest.PerformTokenExchangeExpectingError(
|
||||||
t, client.URL.String(), tokenParams, identityprovidertest.OAuth2ErrorTypes.UnsupportedGrantType,
|
t, client.URL.String(), tokenParams, oauth2providertest.OAuth2ErrorTypes.UnsupportedGrantType,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("MissingCode", func(t *testing.T) {
|
t.Run("MissingCode", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
app, clientSecret := identityprovidertest.CreateTestOAuth2App(t, client)
|
app, clientSecret := oauth2providertest.CreateTestOAuth2App(t, client)
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
identityprovidertest.CleanupOAuth2App(t, client, app.ID)
|
oauth2providertest.CleanupOAuth2App(t, client, app.ID)
|
||||||
})
|
})
|
||||||
|
|
||||||
tokenParams := identityprovidertest.TokenExchangeParams{
|
tokenParams := oauth2providertest.TokenExchangeParams{
|
||||||
GrantType: "authorization_code",
|
GrantType: "authorization_code",
|
||||||
ClientID: app.ID.String(),
|
ClientID: app.ID.String(),
|
||||||
ClientSecret: clientSecret,
|
ClientSecret: clientSecret,
|
||||||
}
|
}
|
||||||
|
|
||||||
identityprovidertest.PerformTokenExchangeExpectingError(
|
oauth2providertest.PerformTokenExchangeExpectingError(
|
||||||
t, client.URL.String(), tokenParams, identityprovidertest.OAuth2ErrorTypes.InvalidRequest,
|
t, client.URL.String(), tokenParams, oauth2providertest.OAuth2ErrorTypes.InvalidRequest,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
@ -1,4 +1,4 @@
|
|||||||
package identityprovider
|
package oauth2provider
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
@ -1,4 +1,4 @@
|
|||||||
package identityprovider_test
|
package oauth2provider_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
@ -7,7 +7,7 @@ import (
|
|||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"github.com/coder/coder/v2/coderd/identityprovider"
|
"github.com/coder/coder/v2/coderd/oauth2provider"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestVerifyPKCE(t *testing.T) {
|
func TestVerifyPKCE(t *testing.T) {
|
||||||
@ -55,7 +55,7 @@ func TestVerifyPKCE(t *testing.T) {
|
|||||||
tt := tt
|
tt := tt
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
result := identityprovider.VerifyPKCE(tt.challenge, tt.verifier)
|
result := oauth2provider.VerifyPKCE(tt.challenge, tt.verifier)
|
||||||
require.Equal(t, tt.expectValid, result)
|
require.Equal(t, tt.expectValid, result)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -73,5 +73,5 @@ func TestPKCES256Generation(t *testing.T) {
|
|||||||
challenge := base64.RawURLEncoding.EncodeToString(h[:])
|
challenge := base64.RawURLEncoding.EncodeToString(h[:])
|
||||||
|
|
||||||
require.Equal(t, expectedChallenge, challenge)
|
require.Equal(t, expectedChallenge, challenge)
|
||||||
require.True(t, identityprovider.VerifyPKCE(challenge, verifier))
|
require.True(t, oauth2provider.VerifyPKCE(challenge, verifier))
|
||||||
}
|
}
|
584
coderd/oauth2provider/registration.go
Normal file
584
coderd/oauth2provider/registration.go
Normal file
@ -0,0 +1,584 @@
|
|||||||
|
package oauth2provider
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/sqlc-dev/pqtype"
|
||||||
|
"golang.org/x/xerrors"
|
||||||
|
|
||||||
|
"cdr.dev/slog"
|
||||||
|
|
||||||
|
"github.com/coder/coder/v2/coderd/audit"
|
||||||
|
"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/userpassword"
|
||||||
|
"github.com/coder/coder/v2/codersdk"
|
||||||
|
"github.com/coder/coder/v2/cryptorand"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Constants for OAuth2 secret generation (RFC 7591)
|
||||||
|
const (
|
||||||
|
secretLength = 40 // Length of the actual secret part
|
||||||
|
displaySecretLength = 6 // Length of visible part in UI (last 6 characters)
|
||||||
|
)
|
||||||
|
|
||||||
|
// CreateDynamicClientRegistration returns an http.HandlerFunc that handles POST /oauth2/register
|
||||||
|
func CreateDynamicClientRegistration(db database.Store, accessURL *url.URL, auditor *audit.Auditor, logger slog.Logger) http.HandlerFunc {
|
||||||
|
return func(rw http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
aReq, commitAudit := audit.InitRequest[database.OAuth2ProviderApp](rw, &audit.RequestParams{
|
||||||
|
Audit: *auditor,
|
||||||
|
Log: logger,
|
||||||
|
Request: r,
|
||||||
|
Action: database.AuditActionCreate,
|
||||||
|
})
|
||||||
|
defer commitAudit()
|
||||||
|
|
||||||
|
// Parse request
|
||||||
|
var req codersdk.OAuth2ClientRegistrationRequest
|
||||||
|
if !httpapi.Read(ctx, rw, r, &req) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate request
|
||||||
|
if err := req.Validate(); err != nil {
|
||||||
|
writeOAuth2RegistrationError(ctx, rw, http.StatusBadRequest,
|
||||||
|
"invalid_client_metadata", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply defaults
|
||||||
|
req = req.ApplyDefaults()
|
||||||
|
|
||||||
|
// Generate client credentials
|
||||||
|
clientID := uuid.New()
|
||||||
|
clientSecret, hashedSecret, err := generateClientCredentials()
|
||||||
|
if err != nil {
|
||||||
|
writeOAuth2RegistrationError(ctx, rw, http.StatusInternalServerError,
|
||||||
|
"server_error", "Failed to generate client credentials")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate registration access token for RFC 7592 management
|
||||||
|
registrationToken, hashedRegToken, err := generateRegistrationAccessToken()
|
||||||
|
if err != nil {
|
||||||
|
writeOAuth2RegistrationError(ctx, rw, http.StatusInternalServerError,
|
||||||
|
"server_error", "Failed to generate registration token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store in database - use system context since this is a public endpoint
|
||||||
|
now := dbtime.Now()
|
||||||
|
clientName := req.GenerateClientName()
|
||||||
|
//nolint:gocritic // Dynamic client registration is a public endpoint, system access required
|
||||||
|
app, err := db.InsertOAuth2ProviderApp(dbauthz.AsSystemRestricted(ctx), database.InsertOAuth2ProviderAppParams{
|
||||||
|
ID: clientID,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
Name: clientName,
|
||||||
|
Icon: req.LogoURI,
|
||||||
|
CallbackURL: req.RedirectURIs[0], // Primary redirect URI
|
||||||
|
RedirectUris: req.RedirectURIs,
|
||||||
|
ClientType: sql.NullString{String: req.DetermineClientType(), Valid: true},
|
||||||
|
DynamicallyRegistered: sql.NullBool{Bool: true, Valid: true},
|
||||||
|
ClientIDIssuedAt: sql.NullTime{Time: now, Valid: true},
|
||||||
|
ClientSecretExpiresAt: sql.NullTime{}, // No expiration for now
|
||||||
|
GrantTypes: req.GrantTypes,
|
||||||
|
ResponseTypes: req.ResponseTypes,
|
||||||
|
TokenEndpointAuthMethod: sql.NullString{String: req.TokenEndpointAuthMethod, Valid: true},
|
||||||
|
Scope: sql.NullString{String: req.Scope, Valid: true},
|
||||||
|
Contacts: req.Contacts,
|
||||||
|
ClientUri: sql.NullString{String: req.ClientURI, Valid: req.ClientURI != ""},
|
||||||
|
LogoUri: sql.NullString{String: req.LogoURI, Valid: req.LogoURI != ""},
|
||||||
|
TosUri: sql.NullString{String: req.TOSURI, Valid: req.TOSURI != ""},
|
||||||
|
PolicyUri: sql.NullString{String: req.PolicyURI, Valid: req.PolicyURI != ""},
|
||||||
|
JwksUri: sql.NullString{String: req.JWKSURI, Valid: req.JWKSURI != ""},
|
||||||
|
Jwks: pqtype.NullRawMessage{RawMessage: req.JWKS, Valid: len(req.JWKS) > 0},
|
||||||
|
SoftwareID: sql.NullString{String: req.SoftwareID, Valid: req.SoftwareID != ""},
|
||||||
|
SoftwareVersion: sql.NullString{String: req.SoftwareVersion, Valid: req.SoftwareVersion != ""},
|
||||||
|
RegistrationAccessToken: sql.NullString{String: hashedRegToken, Valid: true},
|
||||||
|
RegistrationClientUri: sql.NullString{String: fmt.Sprintf("%s/oauth2/clients/%s", accessURL.String(), clientID), Valid: true},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
logger.Error(ctx, "failed to store oauth2 client registration",
|
||||||
|
slog.Error(err),
|
||||||
|
slog.F("client_name", clientName),
|
||||||
|
slog.F("client_id", clientID.String()),
|
||||||
|
slog.F("redirect_uris", req.RedirectURIs))
|
||||||
|
writeOAuth2RegistrationError(ctx, rw, http.StatusInternalServerError,
|
||||||
|
"server_error", "Failed to store client registration")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create client secret - parse the formatted secret to get components
|
||||||
|
parsedSecret, err := parseFormattedSecret(clientSecret)
|
||||||
|
if err != nil {
|
||||||
|
writeOAuth2RegistrationError(ctx, rw, http.StatusInternalServerError,
|
||||||
|
"server_error", "Failed to parse generated secret")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
//nolint:gocritic // Dynamic client registration is a public endpoint, system access required
|
||||||
|
_, err = db.InsertOAuth2ProviderAppSecret(dbauthz.AsSystemRestricted(ctx), database.InsertOAuth2ProviderAppSecretParams{
|
||||||
|
ID: uuid.New(),
|
||||||
|
CreatedAt: now,
|
||||||
|
SecretPrefix: []byte(parsedSecret.prefix),
|
||||||
|
HashedSecret: []byte(hashedSecret),
|
||||||
|
DisplaySecret: createDisplaySecret(clientSecret),
|
||||||
|
AppID: clientID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeOAuth2RegistrationError(ctx, rw, http.StatusInternalServerError,
|
||||||
|
"server_error", "Failed to store client secret")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set audit log data
|
||||||
|
aReq.New = app
|
||||||
|
|
||||||
|
// Return response
|
||||||
|
response := codersdk.OAuth2ClientRegistrationResponse{
|
||||||
|
ClientID: app.ID.String(),
|
||||||
|
ClientSecret: clientSecret,
|
||||||
|
ClientIDIssuedAt: app.ClientIDIssuedAt.Time.Unix(),
|
||||||
|
ClientSecretExpiresAt: 0, // No expiration
|
||||||
|
RedirectURIs: app.RedirectUris,
|
||||||
|
ClientName: app.Name,
|
||||||
|
ClientURI: app.ClientUri.String,
|
||||||
|
LogoURI: app.LogoUri.String,
|
||||||
|
TOSURI: app.TosUri.String,
|
||||||
|
PolicyURI: app.PolicyUri.String,
|
||||||
|
JWKSURI: app.JwksUri.String,
|
||||||
|
JWKS: app.Jwks.RawMessage,
|
||||||
|
SoftwareID: app.SoftwareID.String,
|
||||||
|
SoftwareVersion: app.SoftwareVersion.String,
|
||||||
|
GrantTypes: app.GrantTypes,
|
||||||
|
ResponseTypes: app.ResponseTypes,
|
||||||
|
TokenEndpointAuthMethod: app.TokenEndpointAuthMethod.String,
|
||||||
|
Scope: app.Scope.String,
|
||||||
|
Contacts: app.Contacts,
|
||||||
|
RegistrationAccessToken: registrationToken,
|
||||||
|
RegistrationClientURI: app.RegistrationClientUri.String,
|
||||||
|
}
|
||||||
|
|
||||||
|
httpapi.Write(ctx, rw, http.StatusCreated, response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetClientConfiguration returns an http.HandlerFunc that handles GET /oauth2/clients/{client_id}
|
||||||
|
func GetClientConfiguration(db database.Store) http.HandlerFunc {
|
||||||
|
return func(rw http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
// Extract client ID from URL path
|
||||||
|
clientIDStr := chi.URLParam(r, "client_id")
|
||||||
|
clientID, err := uuid.Parse(clientIDStr)
|
||||||
|
if err != nil {
|
||||||
|
writeOAuth2RegistrationError(ctx, rw, http.StatusBadRequest,
|
||||||
|
"invalid_client_metadata", "Invalid client ID format")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get app by client ID
|
||||||
|
//nolint:gocritic // RFC 7592 endpoints need system access to retrieve dynamically registered clients
|
||||||
|
app, err := db.GetOAuth2ProviderAppByClientID(dbauthz.AsSystemRestricted(ctx), clientID)
|
||||||
|
if err != nil {
|
||||||
|
if xerrors.Is(err, sql.ErrNoRows) {
|
||||||
|
writeOAuth2RegistrationError(ctx, rw, http.StatusUnauthorized,
|
||||||
|
"invalid_token", "Client not found")
|
||||||
|
} else {
|
||||||
|
writeOAuth2RegistrationError(ctx, rw, http.StatusInternalServerError,
|
||||||
|
"server_error", "Failed to retrieve client")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if client was dynamically registered
|
||||||
|
if !app.DynamicallyRegistered.Bool {
|
||||||
|
writeOAuth2RegistrationError(ctx, rw, http.StatusUnauthorized,
|
||||||
|
"invalid_token", "Client was not dynamically registered")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return client configuration (without client_secret for security)
|
||||||
|
response := codersdk.OAuth2ClientConfiguration{
|
||||||
|
ClientID: app.ID.String(),
|
||||||
|
ClientIDIssuedAt: app.ClientIDIssuedAt.Time.Unix(),
|
||||||
|
ClientSecretExpiresAt: 0, // No expiration for now
|
||||||
|
RedirectURIs: app.RedirectUris,
|
||||||
|
ClientName: app.Name,
|
||||||
|
ClientURI: app.ClientUri.String,
|
||||||
|
LogoURI: app.LogoUri.String,
|
||||||
|
TOSURI: app.TosUri.String,
|
||||||
|
PolicyURI: app.PolicyUri.String,
|
||||||
|
JWKSURI: app.JwksUri.String,
|
||||||
|
JWKS: app.Jwks.RawMessage,
|
||||||
|
SoftwareID: app.SoftwareID.String,
|
||||||
|
SoftwareVersion: app.SoftwareVersion.String,
|
||||||
|
GrantTypes: app.GrantTypes,
|
||||||
|
ResponseTypes: app.ResponseTypes,
|
||||||
|
TokenEndpointAuthMethod: app.TokenEndpointAuthMethod.String,
|
||||||
|
Scope: app.Scope.String,
|
||||||
|
Contacts: app.Contacts,
|
||||||
|
RegistrationAccessToken: "", // RFC 7592: Not returned in GET responses for security
|
||||||
|
RegistrationClientURI: app.RegistrationClientUri.String,
|
||||||
|
}
|
||||||
|
|
||||||
|
httpapi.Write(ctx, rw, http.StatusOK, response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateClientConfiguration returns an http.HandlerFunc that handles PUT /oauth2/clients/{client_id}
|
||||||
|
func UpdateClientConfiguration(db database.Store, auditor *audit.Auditor, logger slog.Logger) http.HandlerFunc {
|
||||||
|
return func(rw http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
aReq, commitAudit := audit.InitRequest[database.OAuth2ProviderApp](rw, &audit.RequestParams{
|
||||||
|
Audit: *auditor,
|
||||||
|
Log: logger,
|
||||||
|
Request: r,
|
||||||
|
Action: database.AuditActionWrite,
|
||||||
|
})
|
||||||
|
defer commitAudit()
|
||||||
|
|
||||||
|
// Extract client ID from URL path
|
||||||
|
clientIDStr := chi.URLParam(r, "client_id")
|
||||||
|
clientID, err := uuid.Parse(clientIDStr)
|
||||||
|
if err != nil {
|
||||||
|
writeOAuth2RegistrationError(ctx, rw, http.StatusBadRequest,
|
||||||
|
"invalid_client_metadata", "Invalid client ID format")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse request
|
||||||
|
var req codersdk.OAuth2ClientRegistrationRequest
|
||||||
|
if !httpapi.Read(ctx, rw, r, &req) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate request
|
||||||
|
if err := req.Validate(); err != nil {
|
||||||
|
writeOAuth2RegistrationError(ctx, rw, http.StatusBadRequest,
|
||||||
|
"invalid_client_metadata", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply defaults
|
||||||
|
req = req.ApplyDefaults()
|
||||||
|
|
||||||
|
// Get existing app to verify it exists and is dynamically registered
|
||||||
|
//nolint:gocritic // RFC 7592 endpoints need system access to retrieve dynamically registered clients
|
||||||
|
existingApp, err := db.GetOAuth2ProviderAppByClientID(dbauthz.AsSystemRestricted(ctx), clientID)
|
||||||
|
if err == nil {
|
||||||
|
aReq.Old = existingApp
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if xerrors.Is(err, sql.ErrNoRows) {
|
||||||
|
writeOAuth2RegistrationError(ctx, rw, http.StatusUnauthorized,
|
||||||
|
"invalid_token", "Client not found")
|
||||||
|
} else {
|
||||||
|
writeOAuth2RegistrationError(ctx, rw, http.StatusInternalServerError,
|
||||||
|
"server_error", "Failed to retrieve client")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if client was dynamically registered
|
||||||
|
if !existingApp.DynamicallyRegistered.Bool {
|
||||||
|
writeOAuth2RegistrationError(ctx, rw, http.StatusForbidden,
|
||||||
|
"invalid_token", "Client was not dynamically registered")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update app in database
|
||||||
|
now := dbtime.Now()
|
||||||
|
//nolint:gocritic // RFC 7592 endpoints need system access to update dynamically registered clients
|
||||||
|
updatedApp, err := db.UpdateOAuth2ProviderAppByClientID(dbauthz.AsSystemRestricted(ctx), database.UpdateOAuth2ProviderAppByClientIDParams{
|
||||||
|
ID: clientID,
|
||||||
|
UpdatedAt: now,
|
||||||
|
Name: req.GenerateClientName(),
|
||||||
|
Icon: req.LogoURI,
|
||||||
|
CallbackURL: req.RedirectURIs[0], // Primary redirect URI
|
||||||
|
RedirectUris: req.RedirectURIs,
|
||||||
|
ClientType: sql.NullString{String: req.DetermineClientType(), Valid: true},
|
||||||
|
ClientSecretExpiresAt: sql.NullTime{}, // No expiration for now
|
||||||
|
GrantTypes: req.GrantTypes,
|
||||||
|
ResponseTypes: req.ResponseTypes,
|
||||||
|
TokenEndpointAuthMethod: sql.NullString{String: req.TokenEndpointAuthMethod, Valid: true},
|
||||||
|
Scope: sql.NullString{String: req.Scope, Valid: true},
|
||||||
|
Contacts: req.Contacts,
|
||||||
|
ClientUri: sql.NullString{String: req.ClientURI, Valid: req.ClientURI != ""},
|
||||||
|
LogoUri: sql.NullString{String: req.LogoURI, Valid: req.LogoURI != ""},
|
||||||
|
TosUri: sql.NullString{String: req.TOSURI, Valid: req.TOSURI != ""},
|
||||||
|
PolicyUri: sql.NullString{String: req.PolicyURI, Valid: req.PolicyURI != ""},
|
||||||
|
JwksUri: sql.NullString{String: req.JWKSURI, Valid: req.JWKSURI != ""},
|
||||||
|
Jwks: pqtype.NullRawMessage{RawMessage: req.JWKS, Valid: len(req.JWKS) > 0},
|
||||||
|
SoftwareID: sql.NullString{String: req.SoftwareID, Valid: req.SoftwareID != ""},
|
||||||
|
SoftwareVersion: sql.NullString{String: req.SoftwareVersion, Valid: req.SoftwareVersion != ""},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeOAuth2RegistrationError(ctx, rw, http.StatusInternalServerError,
|
||||||
|
"server_error", "Failed to update client")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set audit log data
|
||||||
|
aReq.New = updatedApp
|
||||||
|
|
||||||
|
// Return updated client configuration
|
||||||
|
response := codersdk.OAuth2ClientConfiguration{
|
||||||
|
ClientID: updatedApp.ID.String(),
|
||||||
|
ClientIDIssuedAt: updatedApp.ClientIDIssuedAt.Time.Unix(),
|
||||||
|
ClientSecretExpiresAt: 0, // No expiration for now
|
||||||
|
RedirectURIs: updatedApp.RedirectUris,
|
||||||
|
ClientName: updatedApp.Name,
|
||||||
|
ClientURI: updatedApp.ClientUri.String,
|
||||||
|
LogoURI: updatedApp.LogoUri.String,
|
||||||
|
TOSURI: updatedApp.TosUri.String,
|
||||||
|
PolicyURI: updatedApp.PolicyUri.String,
|
||||||
|
JWKSURI: updatedApp.JwksUri.String,
|
||||||
|
JWKS: updatedApp.Jwks.RawMessage,
|
||||||
|
SoftwareID: updatedApp.SoftwareID.String,
|
||||||
|
SoftwareVersion: updatedApp.SoftwareVersion.String,
|
||||||
|
GrantTypes: updatedApp.GrantTypes,
|
||||||
|
ResponseTypes: updatedApp.ResponseTypes,
|
||||||
|
TokenEndpointAuthMethod: updatedApp.TokenEndpointAuthMethod.String,
|
||||||
|
Scope: updatedApp.Scope.String,
|
||||||
|
Contacts: updatedApp.Contacts,
|
||||||
|
RegistrationAccessToken: updatedApp.RegistrationAccessToken.String,
|
||||||
|
RegistrationClientURI: updatedApp.RegistrationClientUri.String,
|
||||||
|
}
|
||||||
|
|
||||||
|
httpapi.Write(ctx, rw, http.StatusOK, response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteClientConfiguration returns an http.HandlerFunc that handles DELETE /oauth2/clients/{client_id}
|
||||||
|
func DeleteClientConfiguration(db database.Store, auditor *audit.Auditor, logger slog.Logger) http.HandlerFunc {
|
||||||
|
return func(rw http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
aReq, commitAudit := audit.InitRequest[database.OAuth2ProviderApp](rw, &audit.RequestParams{
|
||||||
|
Audit: *auditor,
|
||||||
|
Log: logger,
|
||||||
|
Request: r,
|
||||||
|
Action: database.AuditActionDelete,
|
||||||
|
})
|
||||||
|
defer commitAudit()
|
||||||
|
|
||||||
|
// Extract client ID from URL path
|
||||||
|
clientIDStr := chi.URLParam(r, "client_id")
|
||||||
|
clientID, err := uuid.Parse(clientIDStr)
|
||||||
|
if err != nil {
|
||||||
|
writeOAuth2RegistrationError(ctx, rw, http.StatusBadRequest,
|
||||||
|
"invalid_client_metadata", "Invalid client ID format")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get existing app to verify it exists and is dynamically registered
|
||||||
|
//nolint:gocritic // RFC 7592 endpoints need system access to retrieve dynamically registered clients
|
||||||
|
existingApp, err := db.GetOAuth2ProviderAppByClientID(dbauthz.AsSystemRestricted(ctx), clientID)
|
||||||
|
if err == nil {
|
||||||
|
aReq.Old = existingApp
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if xerrors.Is(err, sql.ErrNoRows) {
|
||||||
|
writeOAuth2RegistrationError(ctx, rw, http.StatusUnauthorized,
|
||||||
|
"invalid_token", "Client not found")
|
||||||
|
} else {
|
||||||
|
writeOAuth2RegistrationError(ctx, rw, http.StatusInternalServerError,
|
||||||
|
"server_error", "Failed to retrieve client")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if client was dynamically registered
|
||||||
|
if !existingApp.DynamicallyRegistered.Bool {
|
||||||
|
writeOAuth2RegistrationError(ctx, rw, http.StatusForbidden,
|
||||||
|
"invalid_token", "Client was not dynamically registered")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the client and all associated data (tokens, secrets, etc.)
|
||||||
|
//nolint:gocritic // RFC 7592 endpoints need system access to delete dynamically registered clients
|
||||||
|
err = db.DeleteOAuth2ProviderAppByClientID(dbauthz.AsSystemRestricted(ctx), clientID)
|
||||||
|
if err != nil {
|
||||||
|
writeOAuth2RegistrationError(ctx, rw, http.StatusInternalServerError,
|
||||||
|
"server_error", "Failed to delete client")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: audit data already set above with aReq.Old = existingApp
|
||||||
|
|
||||||
|
// Return 204 No Content as per RFC 7592
|
||||||
|
rw.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireRegistrationAccessToken returns middleware that validates the registration access token for RFC 7592 endpoints
|
||||||
|
func RequireRegistrationAccessToken(db database.Store) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
// Extract client ID from URL path
|
||||||
|
clientIDStr := chi.URLParam(r, "client_id")
|
||||||
|
clientID, err := uuid.Parse(clientIDStr)
|
||||||
|
if err != nil {
|
||||||
|
writeOAuth2RegistrationError(ctx, rw, http.StatusBadRequest,
|
||||||
|
"invalid_client_id", "Invalid client ID format")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract registration access token from Authorization header
|
||||||
|
authHeader := r.Header.Get("Authorization")
|
||||||
|
if authHeader == "" {
|
||||||
|
writeOAuth2RegistrationError(ctx, rw, http.StatusUnauthorized,
|
||||||
|
"invalid_token", "Missing Authorization header")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasPrefix(authHeader, "Bearer ") {
|
||||||
|
writeOAuth2RegistrationError(ctx, rw, http.StatusUnauthorized,
|
||||||
|
"invalid_token", "Authorization header must use Bearer scheme")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token := strings.TrimPrefix(authHeader, "Bearer ")
|
||||||
|
if token == "" {
|
||||||
|
writeOAuth2RegistrationError(ctx, rw, http.StatusUnauthorized,
|
||||||
|
"invalid_token", "Missing registration access token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the client and verify the registration access token
|
||||||
|
//nolint:gocritic // RFC 7592 endpoints need system access to validate dynamically registered clients
|
||||||
|
app, err := db.GetOAuth2ProviderAppByClientID(dbauthz.AsSystemRestricted(ctx), clientID)
|
||||||
|
if err != nil {
|
||||||
|
if xerrors.Is(err, sql.ErrNoRows) {
|
||||||
|
// Return 401 for authentication-related issues, not 404
|
||||||
|
writeOAuth2RegistrationError(ctx, rw, http.StatusUnauthorized,
|
||||||
|
"invalid_token", "Client not found")
|
||||||
|
} else {
|
||||||
|
writeOAuth2RegistrationError(ctx, rw, http.StatusInternalServerError,
|
||||||
|
"server_error", "Failed to retrieve client")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if client was dynamically registered
|
||||||
|
if !app.DynamicallyRegistered.Bool {
|
||||||
|
writeOAuth2RegistrationError(ctx, rw, http.StatusForbidden,
|
||||||
|
"invalid_token", "Client was not dynamically registered")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the registration access token
|
||||||
|
if !app.RegistrationAccessToken.Valid {
|
||||||
|
writeOAuth2RegistrationError(ctx, rw, http.StatusInternalServerError,
|
||||||
|
"server_error", "Client has no registration access token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare the provided token with the stored hash
|
||||||
|
valid, err := userpassword.Compare(app.RegistrationAccessToken.String, token)
|
||||||
|
if err != nil {
|
||||||
|
writeOAuth2RegistrationError(ctx, rw, http.StatusInternalServerError,
|
||||||
|
"server_error", "Failed to verify registration access token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !valid {
|
||||||
|
writeOAuth2RegistrationError(ctx, rw, http.StatusUnauthorized,
|
||||||
|
"invalid_token", "Invalid registration access token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token is valid, continue to the next handler
|
||||||
|
next.ServeHTTP(rw, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions for RFC 7591 Dynamic Client Registration
|
||||||
|
|
||||||
|
// generateClientCredentials generates a client secret for OAuth2 apps
|
||||||
|
func generateClientCredentials() (plaintext, hashed string, err error) {
|
||||||
|
// Use the same pattern as existing OAuth2 app secrets
|
||||||
|
secret, err := GenerateSecret()
|
||||||
|
if err != nil {
|
||||||
|
return "", "", xerrors.Errorf("generate secret: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return secret.Formatted, secret.Hashed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateRegistrationAccessToken generates a registration access token for RFC 7592
|
||||||
|
func generateRegistrationAccessToken() (plaintext, hashed string, err error) {
|
||||||
|
token, err := cryptorand.String(secretLength)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", xerrors.Errorf("generate registration token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash the token for storage
|
||||||
|
hashedToken, err := userpassword.Hash(token)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", xerrors.Errorf("hash registration token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return token, hashedToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeOAuth2RegistrationError writes RFC 7591 compliant error responses
|
||||||
|
func writeOAuth2RegistrationError(_ context.Context, rw http.ResponseWriter, status int, errorCode, description string) {
|
||||||
|
// RFC 7591 error response format
|
||||||
|
errorResponse := map[string]string{
|
||||||
|
"error": errorCode,
|
||||||
|
}
|
||||||
|
if description != "" {
|
||||||
|
errorResponse["error_description"] = description
|
||||||
|
}
|
||||||
|
|
||||||
|
rw.Header().Set("Content-Type", "application/json")
|
||||||
|
rw.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(rw).Encode(errorResponse)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsedSecret represents the components of a formatted OAuth2 secret
|
||||||
|
type parsedSecret struct {
|
||||||
|
prefix string
|
||||||
|
secret string
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseFormattedSecret parses a formatted secret like "coder_prefix_secret"
|
||||||
|
func parseFormattedSecret(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])
|
||||||
|
}
|
||||||
|
return parsedSecret{
|
||||||
|
prefix: parts[1],
|
||||||
|
secret: parts[2],
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createDisplaySecret creates a display version of the secret showing only the last few characters
|
||||||
|
func createDisplaySecret(secret string) string {
|
||||||
|
if len(secret) <= displaySecretLength {
|
||||||
|
return secret
|
||||||
|
}
|
||||||
|
|
||||||
|
visiblePart := secret[len(secret)-displaySecretLength:]
|
||||||
|
hiddenLength := len(secret) - displaySecretLength
|
||||||
|
return strings.Repeat("*", hiddenLength) + visiblePart
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
package identityprovider
|
package oauth2provider
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
@ -1,16 +1,13 @@
|
|||||||
package identityprovider
|
package oauth2provider
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"golang.org/x/xerrors"
|
|
||||||
|
|
||||||
"github.com/coder/coder/v2/coderd/userpassword"
|
"github.com/coder/coder/v2/coderd/userpassword"
|
||||||
"github.com/coder/coder/v2/cryptorand"
|
"github.com/coder/coder/v2/cryptorand"
|
||||||
)
|
)
|
||||||
|
|
||||||
type OAuth2ProviderAppSecret struct {
|
type AppSecret struct {
|
||||||
// Formatted contains the secret. This value is owned by the client, not the
|
// Formatted contains the secret. This value is owned by the client, not the
|
||||||
// server. It is formatted to include the prefix.
|
// server. It is formatted to include the prefix.
|
||||||
Formatted string
|
Formatted string
|
||||||
@ -26,11 +23,11 @@ type OAuth2ProviderAppSecret struct {
|
|||||||
|
|
||||||
// GenerateSecret generates a secret to be used as a client secret, refresh
|
// GenerateSecret generates a secret to be used as a client secret, refresh
|
||||||
// token, or authorization code.
|
// token, or authorization code.
|
||||||
func GenerateSecret() (OAuth2ProviderAppSecret, error) {
|
func GenerateSecret() (AppSecret, error) {
|
||||||
// 40 characters matches the length of GitHub's client secrets.
|
// 40 characters matches the length of GitHub's client secrets.
|
||||||
secret, err := cryptorand.String(40)
|
secret, err := cryptorand.String(40)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return OAuth2ProviderAppSecret{}, err
|
return AppSecret{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// This ID is prefixed to the secret so it can be used to look up the secret
|
// This ID is prefixed to the secret so it can be used to look up the secret
|
||||||
@ -38,40 +35,17 @@ func GenerateSecret() (OAuth2ProviderAppSecret, error) {
|
|||||||
// will not have the salt.
|
// will not have the salt.
|
||||||
prefix, err := cryptorand.String(10)
|
prefix, err := cryptorand.String(10)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return OAuth2ProviderAppSecret{}, err
|
return AppSecret{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
hashed, err := userpassword.Hash(secret)
|
hashed, err := userpassword.Hash(secret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return OAuth2ProviderAppSecret{}, err
|
return AppSecret{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return OAuth2ProviderAppSecret{
|
return AppSecret{
|
||||||
Formatted: fmt.Sprintf("coder_%s_%s", prefix, secret),
|
Formatted: fmt.Sprintf("coder_%s_%s", prefix, secret),
|
||||||
Prefix: prefix,
|
Prefix: prefix,
|
||||||
Hashed: hashed,
|
Hashed: hashed,
|
||||||
}, nil
|
}, 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,4 +1,4 @@
|
|||||||
package identityprovider
|
package oauth2provider
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@ -183,7 +183,7 @@ func Tokens(db database.Store, lifetimes codersdk.SessionLifetime) http.HandlerF
|
|||||||
|
|
||||||
func authorizationCodeGrant(ctx context.Context, db database.Store, app database.OAuth2ProviderApp, lifetimes codersdk.SessionLifetime, params tokenParams) (oauth2.Token, error) {
|
func authorizationCodeGrant(ctx context.Context, db database.Store, app database.OAuth2ProviderApp, lifetimes codersdk.SessionLifetime, params tokenParams) (oauth2.Token, error) {
|
||||||
// Validate the client secret.
|
// Validate the client secret.
|
||||||
secret, err := parseSecret(params.clientSecret)
|
secret, err := parseFormattedSecret(params.clientSecret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return oauth2.Token{}, errBadSecret
|
return oauth2.Token{}, errBadSecret
|
||||||
}
|
}
|
||||||
@ -204,7 +204,7 @@ func authorizationCodeGrant(ctx context.Context, db database.Store, app database
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate the authorization code.
|
// Validate the authorization code.
|
||||||
code, err := parseSecret(params.code)
|
code, err := parseFormattedSecret(params.code)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return oauth2.Token{}, errBadCode
|
return oauth2.Token{}, errBadCode
|
||||||
}
|
}
|
||||||
@ -335,7 +335,7 @@ func authorizationCodeGrant(ctx context.Context, db database.Store, app database
|
|||||||
|
|
||||||
func refreshTokenGrant(ctx context.Context, db database.Store, app database.OAuth2ProviderApp, lifetimes codersdk.SessionLifetime, params tokenParams) (oauth2.Token, error) {
|
func refreshTokenGrant(ctx context.Context, db database.Store, app database.OAuth2ProviderApp, lifetimes codersdk.SessionLifetime, params tokenParams) (oauth2.Token, error) {
|
||||||
// Validate the token.
|
// Validate the token.
|
||||||
token, err := parseSecret(params.refreshToken)
|
token, err := parseFormattedSecret(params.refreshToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return oauth2.Token{}, errBadToken
|
return oauth2.Token{}, errBadToken
|
||||||
}
|
}
|
Reference in New Issue
Block a user