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:
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)
|
||||
}
|
||||
}
|
165
coderd/oauth2provider/authorize.go
Normal file
165
coderd/oauth2provider/authorize.go
Normal file
@ -0,0 +1,165 @@
|
||||
package oauth2provider
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
"github.com/coder/coder/v2/coderd/httpmw"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
type authorizeParams struct {
|
||||
clientID string
|
||||
redirectURL *url.URL
|
||||
responseType codersdk.OAuth2ProviderResponseType
|
||||
scope []string
|
||||
state string
|
||||
resource string // RFC 8707 resource indicator
|
||||
codeChallenge string // PKCE code challenge
|
||||
codeChallengeMethod string // PKCE challenge method
|
||||
}
|
||||
|
||||
func extractAuthorizeParams(r *http.Request, callbackURL *url.URL) (authorizeParams, []codersdk.ValidationError, error) {
|
||||
p := httpapi.NewQueryParamParser()
|
||||
vals := r.URL.Query()
|
||||
|
||||
p.RequiredNotEmpty("state", "response_type", "client_id")
|
||||
|
||||
params := authorizeParams{
|
||||
clientID: p.String(vals, "", "client_id"),
|
||||
redirectURL: p.RedirectURL(vals, callbackURL, "redirect_uri"),
|
||||
responseType: httpapi.ParseCustom(p, vals, "", "response_type", httpapi.ParseEnum[codersdk.OAuth2ProviderResponseType]),
|
||||
scope: p.Strings(vals, []string{}, "scope"),
|
||||
state: p.String(vals, "", "state"),
|
||||
resource: p.String(vals, "", "resource"),
|
||||
codeChallenge: p.String(vals, "", "code_challenge"),
|
||||
codeChallengeMethod: p.String(vals, "", "code_challenge_method"),
|
||||
}
|
||||
// Validate resource indicator syntax (RFC 8707): must be absolute URI without fragment
|
||||
if err := validateResourceParameter(params.resource); err != nil {
|
||||
p.Errors = append(p.Errors, codersdk.ValidationError{
|
||||
Field: "resource",
|
||||
Detail: "must be an absolute URI without fragment",
|
||||
})
|
||||
}
|
||||
|
||||
p.ErrorExcessParams(vals)
|
||||
if len(p.Errors) > 0 {
|
||||
// Create a readable error message with validation details
|
||||
var errorDetails []string
|
||||
for _, err := range p.Errors {
|
||||
errorDetails = append(errorDetails, err.Error())
|
||||
}
|
||||
errorMsg := "Invalid query params: " + strings.Join(errorDetails, ", ")
|
||||
return authorizeParams{}, p.Errors, xerrors.Errorf(errorMsg)
|
||||
}
|
||||
return params, nil, nil
|
||||
}
|
||||
|
||||
// ShowAuthorizePage handles GET /oauth2/authorize requests to display the HTML authorization page.
|
||||
// It uses authorizeMW which intercepts GET requests to show the authorization form.
|
||||
func ShowAuthorizePage(db database.Store, accessURL *url.URL) http.HandlerFunc {
|
||||
handler := authorizeMW(accessURL)(ProcessAuthorize(db, accessURL))
|
||||
return handler.ServeHTTP
|
||||
}
|
||||
|
||||
// ProcessAuthorize handles POST /oauth2/authorize requests to process the user's authorization decision
|
||||
// and generate an authorization code. GET requests are handled by authorizeMW.
|
||||
func ProcessAuthorize(db database.Store, accessURL *url.URL) http.HandlerFunc {
|
||||
handler := func(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
apiKey := httpmw.APIKey(r)
|
||||
app := httpmw.OAuth2ProviderApp(r)
|
||||
|
||||
callbackURL, err := url.Parse(app.CallbackURL)
|
||||
if err != nil {
|
||||
httpapi.WriteOAuth2Error(r.Context(), rw, http.StatusInternalServerError, "server_error", "Failed to validate query parameters")
|
||||
return
|
||||
}
|
||||
|
||||
params, _, err := extractAuthorizeParams(r, callbackURL)
|
||||
if err != nil {
|
||||
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Validate PKCE for public clients (MCP requirement)
|
||||
if params.codeChallenge != "" {
|
||||
// If code_challenge is provided but method is not, default to S256
|
||||
if params.codeChallengeMethod == "" {
|
||||
params.codeChallengeMethod = "S256"
|
||||
}
|
||||
if params.codeChallengeMethod != "S256" {
|
||||
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, "invalid_request", "Invalid code_challenge_method: only S256 is supported")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Ignoring scope for now, but should look into implementing.
|
||||
code, err := GenerateSecret()
|
||||
if err != nil {
|
||||
httpapi.WriteOAuth2Error(r.Context(), rw, http.StatusInternalServerError, "server_error", "Failed to generate OAuth2 app authorization code")
|
||||
return
|
||||
}
|
||||
err = db.InTx(func(tx database.Store) error {
|
||||
// Delete any previous codes.
|
||||
err = tx.DeleteOAuth2ProviderAppCodesByAppAndUserID(ctx, database.DeleteOAuth2ProviderAppCodesByAppAndUserIDParams{
|
||||
AppID: app.ID,
|
||||
UserID: apiKey.UserID,
|
||||
})
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return xerrors.Errorf("delete oauth2 app codes: %w", err)
|
||||
}
|
||||
|
||||
// Insert the new code.
|
||||
_, err = tx.InsertOAuth2ProviderAppCode(ctx, database.InsertOAuth2ProviderAppCodeParams{
|
||||
ID: uuid.New(),
|
||||
CreatedAt: dbtime.Now(),
|
||||
// TODO: Configurable expiration? Ten minutes matches GitHub.
|
||||
// This timeout is only for the code that will be exchanged for the
|
||||
// access token, not the access token itself. It does not need to be
|
||||
// long-lived because normally it will be exchanged immediately after it
|
||||
// is received. If the application does wait before exchanging the
|
||||
// token (for example suppose they ask the user to confirm and the user
|
||||
// has left) then they can just retry immediately and get a new code.
|
||||
ExpiresAt: dbtime.Now().Add(time.Duration(10) * time.Minute),
|
||||
SecretPrefix: []byte(code.Prefix),
|
||||
HashedSecret: []byte(code.Hashed),
|
||||
AppID: app.ID,
|
||||
UserID: apiKey.UserID,
|
||||
ResourceUri: sql.NullString{String: params.resource, Valid: params.resource != ""},
|
||||
CodeChallenge: sql.NullString{String: params.codeChallenge, Valid: params.codeChallenge != ""},
|
||||
CodeChallengeMethod: sql.NullString{String: params.codeChallengeMethod, Valid: params.codeChallengeMethod != ""},
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("insert oauth2 authorization code: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}, nil)
|
||||
if err != nil {
|
||||
httpapi.WriteOAuth2Error(ctx, rw, http.StatusInternalServerError, "server_error", "Failed to generate OAuth2 authorization code")
|
||||
return
|
||||
}
|
||||
|
||||
newQuery := params.redirectURL.Query()
|
||||
newQuery.Add("code", code.Formatted)
|
||||
newQuery.Add("state", params.state)
|
||||
params.redirectURL.RawQuery = newQuery.Encode()
|
||||
|
||||
http.Redirect(rw, r, params.redirectURL.String(), http.StatusTemporaryRedirect)
|
||||
}
|
||||
|
||||
// Always wrap with its custom mw.
|
||||
return authorizeMW(accessURL)(http.HandlerFunc(handler)).ServeHTTP
|
||||
}
|
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)
|
||||
}
|
||||
}
|
83
coderd/oauth2provider/middleware.go
Normal file
83
coderd/oauth2provider/middleware.go
Normal file
@ -0,0 +1,83 @@
|
||||
package oauth2provider
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/httpmw"
|
||||
"github.com/coder/coder/v2/site"
|
||||
)
|
||||
|
||||
// authorizeMW serves to remove some code from the primary authorize handler.
|
||||
// It decides when to show the html allow page, and when to just continue.
|
||||
func authorizeMW(accessURL *url.URL) func(next http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||
app := httpmw.OAuth2ProviderApp(r)
|
||||
ua := httpmw.UserAuthorization(r.Context())
|
||||
|
||||
// If this is a POST request, it means the user clicked the "Allow" button
|
||||
// on the consent form. Process the authorization.
|
||||
if r.Method == http.MethodPost {
|
||||
next.ServeHTTP(rw, r)
|
||||
return
|
||||
}
|
||||
|
||||
// For GET requests, show the authorization consent page
|
||||
// TODO: For now only browser-based auth flow is officially supported but
|
||||
// in a future PR we should support a cURL-based flow where we output text
|
||||
// instead of HTML.
|
||||
|
||||
callbackURL, err := url.Parse(app.CallbackURL)
|
||||
if err != nil {
|
||||
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
|
||||
Status: http.StatusInternalServerError,
|
||||
HideStatus: false,
|
||||
Title: "Internal Server Error",
|
||||
Description: err.Error(),
|
||||
RetryEnabled: false,
|
||||
DashboardURL: accessURL.String(),
|
||||
Warnings: nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Extract the form parameters for two reasons:
|
||||
// 1. We need the redirect URI to build the cancel URI.
|
||||
// 2. Since validation will run once the user clicks "allow", it is
|
||||
// better to validate now to avoid wasting the user's time clicking a
|
||||
// button that will just error anyway.
|
||||
params, validationErrs, err := extractAuthorizeParams(r, callbackURL)
|
||||
if err != nil {
|
||||
errStr := make([]string, len(validationErrs))
|
||||
for i, err := range validationErrs {
|
||||
errStr[i] = err.Detail
|
||||
}
|
||||
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
|
||||
Status: http.StatusBadRequest,
|
||||
HideStatus: false,
|
||||
Title: "Invalid Query Parameters",
|
||||
Description: "One or more query parameters are missing or invalid.",
|
||||
RetryEnabled: false,
|
||||
DashboardURL: accessURL.String(),
|
||||
Warnings: errStr,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
cancel := params.redirectURL
|
||||
cancelQuery := params.redirectURL.Query()
|
||||
cancelQuery.Add("error", "access_denied")
|
||||
cancel.RawQuery = cancelQuery.Encode()
|
||||
|
||||
// Render the consent page with the current URL (no need to add redirected parameter)
|
||||
site.RenderOAuthAllowPage(rw, r, site.RenderOAuthAllowData{
|
||||
AppIcon: app.Icon,
|
||||
AppName: app.Name,
|
||||
CancelURI: cancel.String(),
|
||||
RedirectURI: r.URL.String(),
|
||||
Username: ua.FriendlyName,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
41
coderd/oauth2provider/oauth2providertest/fixtures.go
Normal file
41
coderd/oauth2provider/oauth2providertest/fixtures.go
Normal file
@ -0,0 +1,41 @@
|
||||
package oauth2providertest
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
)
|
||||
|
||||
// Test constants for OAuth2 testing
|
||||
const (
|
||||
// TestRedirectURI is the standard test redirect URI
|
||||
TestRedirectURI = "http://localhost:9876/callback"
|
||||
|
||||
// TestResourceURI is used for testing resource parameter
|
||||
TestResourceURI = "https://api.example.com"
|
||||
|
||||
// Invalid PKCE verifier for negative testing
|
||||
InvalidCodeVerifier = "wrong-verifier"
|
||||
)
|
||||
|
||||
// OAuth2ErrorTypes contains standard OAuth2 error codes
|
||||
var OAuth2ErrorTypes = struct {
|
||||
InvalidRequest string
|
||||
InvalidClient string
|
||||
InvalidGrant string
|
||||
UnauthorizedClient string
|
||||
UnsupportedGrantType string
|
||||
InvalidScope string
|
||||
}{
|
||||
InvalidRequest: "invalid_request",
|
||||
InvalidClient: "invalid_client",
|
||||
InvalidGrant: "invalid_grant",
|
||||
UnauthorizedClient: "unauthorized_client",
|
||||
UnsupportedGrantType: "unsupported_grant_type",
|
||||
InvalidScope: "invalid_scope",
|
||||
}
|
||||
|
||||
// GenerateCodeChallenge creates an S256 code challenge from a verifier
|
||||
func GenerateCodeChallenge(verifier string) string {
|
||||
h := sha256.Sum256([]byte(verifier))
|
||||
return base64.RawURLEncoding.EncodeToString(h[:])
|
||||
}
|
328
coderd/oauth2provider/oauth2providertest/helpers.go
Normal file
328
coderd/oauth2provider/oauth2providertest/helpers.go
Normal file
@ -0,0 +1,328 @@
|
||||
// Package oauth2providertest provides comprehensive testing utilities for OAuth2 identity provider functionality.
|
||||
// It includes helpers for creating OAuth2 apps, performing authorization flows, token exchanges,
|
||||
// PKCE challenge generation and verification, and testing error scenarios.
|
||||
package oauth2providertest
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
// AuthorizeParams contains parameters for OAuth2 authorization
|
||||
type AuthorizeParams struct {
|
||||
ClientID string
|
||||
ResponseType string
|
||||
RedirectURI string
|
||||
State string
|
||||
CodeChallenge string
|
||||
CodeChallengeMethod string
|
||||
Resource string
|
||||
Scope string
|
||||
}
|
||||
|
||||
// TokenExchangeParams contains parameters for token exchange
|
||||
type TokenExchangeParams struct {
|
||||
GrantType string
|
||||
Code string
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
CodeVerifier string
|
||||
RedirectURI string
|
||||
RefreshToken string
|
||||
Resource string
|
||||
}
|
||||
|
||||
// OAuth2Error represents an OAuth2 error response
|
||||
type OAuth2Error struct {
|
||||
Error string `json:"error"`
|
||||
ErrorDescription string `json:"error_description,omitempty"`
|
||||
}
|
||||
|
||||
// CreateTestOAuth2App creates an OAuth2 app for testing and returns the app and client secret
|
||||
func CreateTestOAuth2App(t *testing.T, client *codersdk.Client) (*codersdk.OAuth2ProviderApp, string) {
|
||||
t.Helper()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
// Create unique app name with random suffix
|
||||
appName := fmt.Sprintf("test-oauth2-app-%s", testutil.MustRandString(t, 10))
|
||||
|
||||
req := codersdk.PostOAuth2ProviderAppRequest{
|
||||
Name: appName,
|
||||
CallbackURL: TestRedirectURI,
|
||||
}
|
||||
|
||||
app, err := client.PostOAuth2ProviderApp(ctx, req)
|
||||
require.NoError(t, err, "failed to create OAuth2 app")
|
||||
|
||||
// Create client secret
|
||||
secret, err := client.PostOAuth2ProviderAppSecret(ctx, app.ID)
|
||||
require.NoError(t, err, "failed to create OAuth2 app secret")
|
||||
|
||||
return &app, secret.ClientSecretFull
|
||||
}
|
||||
|
||||
// GeneratePKCE generates a random PKCE code verifier and challenge
|
||||
func GeneratePKCE(t *testing.T) (verifier, challenge string) {
|
||||
t.Helper()
|
||||
|
||||
// Generate 32 random bytes for verifier
|
||||
bytes := make([]byte, 32)
|
||||
_, err := rand.Read(bytes)
|
||||
require.NoError(t, err, "failed to generate random bytes")
|
||||
|
||||
// Create code verifier (base64url encoding without padding)
|
||||
verifier = base64.RawURLEncoding.EncodeToString(bytes)
|
||||
|
||||
// Create code challenge using S256 method
|
||||
challenge = GenerateCodeChallenge(verifier)
|
||||
|
||||
return verifier, challenge
|
||||
}
|
||||
|
||||
// GenerateState generates a random state parameter
|
||||
func GenerateState(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
bytes := make([]byte, 16)
|
||||
_, err := rand.Read(bytes)
|
||||
require.NoError(t, err, "failed to generate random bytes")
|
||||
|
||||
return base64.RawURLEncoding.EncodeToString(bytes)
|
||||
}
|
||||
|
||||
// AuthorizeOAuth2App performs the OAuth2 authorization flow and returns the authorization code
|
||||
func AuthorizeOAuth2App(t *testing.T, client *codersdk.Client, baseURL string, params AuthorizeParams) string {
|
||||
t.Helper()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
// Build authorization URL
|
||||
authURL, err := url.Parse(baseURL + "/oauth2/authorize")
|
||||
require.NoError(t, err, "failed to parse authorization URL")
|
||||
|
||||
query := url.Values{}
|
||||
query.Set("client_id", params.ClientID)
|
||||
query.Set("response_type", params.ResponseType)
|
||||
query.Set("redirect_uri", params.RedirectURI)
|
||||
query.Set("state", params.State)
|
||||
|
||||
if params.CodeChallenge != "" {
|
||||
query.Set("code_challenge", params.CodeChallenge)
|
||||
query.Set("code_challenge_method", params.CodeChallengeMethod)
|
||||
}
|
||||
if params.Resource != "" {
|
||||
query.Set("resource", params.Resource)
|
||||
}
|
||||
if params.Scope != "" {
|
||||
query.Set("scope", params.Scope)
|
||||
}
|
||||
|
||||
authURL.RawQuery = query.Encode()
|
||||
|
||||
// Create POST request to authorize endpoint (simulating user clicking "Allow")
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", authURL.String(), nil)
|
||||
require.NoError(t, err, "failed to create authorization request")
|
||||
|
||||
// Add session token
|
||||
req.Header.Set("Coder-Session-Token", client.SessionToken())
|
||||
|
||||
// Perform request
|
||||
httpClient := &http.Client{
|
||||
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
||||
// Don't follow redirects, we want to capture the redirect URL
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
require.NoError(t, err, "failed to perform authorization request")
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Should get a redirect response (either 302 Found or 307 Temporary Redirect)
|
||||
require.True(t, resp.StatusCode == http.StatusFound || resp.StatusCode == http.StatusTemporaryRedirect,
|
||||
"expected redirect response, got %d", resp.StatusCode)
|
||||
|
||||
// Extract redirect URL
|
||||
location := resp.Header.Get("Location")
|
||||
require.NotEmpty(t, location, "missing Location header in redirect response")
|
||||
|
||||
// Parse redirect URL to extract authorization code
|
||||
redirectURL, err := url.Parse(location)
|
||||
require.NoError(t, err, "failed to parse redirect URL")
|
||||
|
||||
code := redirectURL.Query().Get("code")
|
||||
require.NotEmpty(t, code, "missing authorization code in redirect URL")
|
||||
|
||||
// Verify state parameter
|
||||
returnedState := redirectURL.Query().Get("state")
|
||||
require.Equal(t, params.State, returnedState, "state parameter mismatch")
|
||||
|
||||
return code
|
||||
}
|
||||
|
||||
// ExchangeCodeForToken exchanges an authorization code for tokens
|
||||
func ExchangeCodeForToken(t *testing.T, baseURL string, params TokenExchangeParams) *oauth2.Token {
|
||||
t.Helper()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
// Prepare form data
|
||||
data := url.Values{}
|
||||
data.Set("grant_type", params.GrantType)
|
||||
|
||||
if params.Code != "" {
|
||||
data.Set("code", params.Code)
|
||||
}
|
||||
if params.ClientID != "" {
|
||||
data.Set("client_id", params.ClientID)
|
||||
}
|
||||
if params.ClientSecret != "" {
|
||||
data.Set("client_secret", params.ClientSecret)
|
||||
}
|
||||
if params.CodeVerifier != "" {
|
||||
data.Set("code_verifier", params.CodeVerifier)
|
||||
}
|
||||
if params.RedirectURI != "" {
|
||||
data.Set("redirect_uri", params.RedirectURI)
|
||||
}
|
||||
if params.RefreshToken != "" {
|
||||
data.Set("refresh_token", params.RefreshToken)
|
||||
}
|
||||
if params.Resource != "" {
|
||||
data.Set("resource", params.Resource)
|
||||
}
|
||||
|
||||
// Create request
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", baseURL+"/oauth2/tokens", strings.NewReader(data.Encode()))
|
||||
require.NoError(t, err, "failed to create token request")
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
// Perform request
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err, "failed to perform token request")
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Parse response
|
||||
var tokenResp oauth2.Token
|
||||
err = json.NewDecoder(resp.Body).Decode(&tokenResp)
|
||||
require.NoError(t, err, "failed to decode token response")
|
||||
|
||||
require.NotEmpty(t, tokenResp.AccessToken, "missing access token")
|
||||
require.Equal(t, "Bearer", tokenResp.TokenType, "unexpected token type")
|
||||
|
||||
return &tokenResp
|
||||
}
|
||||
|
||||
// RequireOAuth2Error checks that the HTTP response contains an expected OAuth2 error
|
||||
func RequireOAuth2Error(t *testing.T, resp *http.Response, expectedError string) {
|
||||
t.Helper()
|
||||
|
||||
var errorResp OAuth2Error
|
||||
err := json.NewDecoder(resp.Body).Decode(&errorResp)
|
||||
require.NoError(t, err, "failed to decode error response")
|
||||
|
||||
require.Equal(t, expectedError, errorResp.Error, "unexpected OAuth2 error code")
|
||||
require.NotEmpty(t, errorResp.ErrorDescription, "missing error description")
|
||||
}
|
||||
|
||||
// PerformTokenExchangeExpectingError performs a token exchange expecting an OAuth2 error
|
||||
func PerformTokenExchangeExpectingError(t *testing.T, baseURL string, params TokenExchangeParams, expectedError string) {
|
||||
t.Helper()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
// Prepare form data
|
||||
data := url.Values{}
|
||||
data.Set("grant_type", params.GrantType)
|
||||
|
||||
if params.Code != "" {
|
||||
data.Set("code", params.Code)
|
||||
}
|
||||
if params.ClientID != "" {
|
||||
data.Set("client_id", params.ClientID)
|
||||
}
|
||||
if params.ClientSecret != "" {
|
||||
data.Set("client_secret", params.ClientSecret)
|
||||
}
|
||||
if params.CodeVerifier != "" {
|
||||
data.Set("code_verifier", params.CodeVerifier)
|
||||
}
|
||||
if params.RedirectURI != "" {
|
||||
data.Set("redirect_uri", params.RedirectURI)
|
||||
}
|
||||
if params.RefreshToken != "" {
|
||||
data.Set("refresh_token", params.RefreshToken)
|
||||
}
|
||||
if params.Resource != "" {
|
||||
data.Set("resource", params.Resource)
|
||||
}
|
||||
|
||||
// Create request
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", baseURL+"/oauth2/tokens", strings.NewReader(data.Encode()))
|
||||
require.NoError(t, err, "failed to create token request")
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
// Perform request
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err, "failed to perform token request")
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Should be a 4xx error
|
||||
require.True(t, resp.StatusCode >= 400 && resp.StatusCode < 500, "expected 4xx status code, got %d", resp.StatusCode)
|
||||
|
||||
// Check OAuth2 error
|
||||
RequireOAuth2Error(t, resp, expectedError)
|
||||
}
|
||||
|
||||
// FetchOAuth2Metadata fetches and returns OAuth2 authorization server metadata
|
||||
func FetchOAuth2Metadata(t *testing.T, baseURL string) map[string]any {
|
||||
t.Helper()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", baseURL+"/.well-known/oauth-authorization-server", nil)
|
||||
require.NoError(t, err, "failed to create metadata request")
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err, "failed to fetch metadata")
|
||||
defer resp.Body.Close()
|
||||
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode, "unexpected metadata response status")
|
||||
|
||||
var metadata map[string]any
|
||||
err = json.NewDecoder(resp.Body).Decode(&metadata)
|
||||
require.NoError(t, err, "failed to decode metadata response")
|
||||
|
||||
return metadata
|
||||
}
|
||||
|
||||
// CleanupOAuth2App deletes an OAuth2 app (helper for test cleanup)
|
||||
func CleanupOAuth2App(t *testing.T, client *codersdk.Client, appID uuid.UUID) {
|
||||
t.Helper()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
err := client.DeleteOAuth2ProviderApp(ctx, appID)
|
||||
if err != nil {
|
||||
t.Logf("Warning: failed to cleanup OAuth2 app %s: %v", appID, err)
|
||||
}
|
||||
}
|
341
coderd/oauth2provider/oauth2providertest/oauth2_test.go
Normal file
341
coderd/oauth2provider/oauth2providertest/oauth2_test.go
Normal file
@ -0,0 +1,341 @@
|
||||
package oauth2providertest_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/coderd/oauth2provider/oauth2providertest"
|
||||
)
|
||||
|
||||
func TestOAuth2AuthorizationServerMetadata(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
IncludeProvisionerDaemon: false,
|
||||
})
|
||||
_ = coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
// Fetch OAuth2 metadata
|
||||
metadata := oauth2providertest.FetchOAuth2Metadata(t, client.URL.String())
|
||||
|
||||
// Verify required metadata fields
|
||||
require.Contains(t, metadata, "issuer", "missing issuer in metadata")
|
||||
require.Contains(t, metadata, "authorization_endpoint", "missing authorization_endpoint in metadata")
|
||||
require.Contains(t, metadata, "token_endpoint", "missing token_endpoint in metadata")
|
||||
|
||||
// Verify response types
|
||||
responseTypes, ok := metadata["response_types_supported"].([]any)
|
||||
require.True(t, ok, "response_types_supported should be an array")
|
||||
require.Contains(t, responseTypes, "code", "should support authorization code flow")
|
||||
|
||||
// Verify grant types
|
||||
grantTypes, ok := metadata["grant_types_supported"].([]any)
|
||||
require.True(t, ok, "grant_types_supported should be an array")
|
||||
require.Contains(t, grantTypes, "authorization_code", "should support authorization_code grant")
|
||||
require.Contains(t, grantTypes, "refresh_token", "should support refresh_token grant")
|
||||
|
||||
// Verify PKCE support
|
||||
challengeMethods, ok := metadata["code_challenge_methods_supported"].([]any)
|
||||
require.True(t, ok, "code_challenge_methods_supported should be an array")
|
||||
require.Contains(t, challengeMethods, "S256", "should support S256 PKCE method")
|
||||
|
||||
// Verify endpoints are proper URLs
|
||||
authEndpoint, ok := metadata["authorization_endpoint"].(string)
|
||||
require.True(t, ok, "authorization_endpoint should be a string")
|
||||
require.Contains(t, authEndpoint, "/oauth2/authorize", "authorization endpoint should be /oauth2/authorize")
|
||||
|
||||
tokenEndpoint, ok := metadata["token_endpoint"].(string)
|
||||
require.True(t, ok, "token_endpoint should be a string")
|
||||
require.Contains(t, tokenEndpoint, "/oauth2/tokens", "token endpoint should be /oauth2/tokens")
|
||||
}
|
||||
|
||||
func TestOAuth2PKCEFlow(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
IncludeProvisionerDaemon: false,
|
||||
})
|
||||
_ = coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
// Create OAuth2 app
|
||||
app, clientSecret := oauth2providertest.CreateTestOAuth2App(t, client)
|
||||
t.Cleanup(func() {
|
||||
oauth2providertest.CleanupOAuth2App(t, client, app.ID)
|
||||
})
|
||||
|
||||
// Generate PKCE parameters
|
||||
codeVerifier, codeChallenge := oauth2providertest.GeneratePKCE(t)
|
||||
state := oauth2providertest.GenerateState(t)
|
||||
|
||||
// Perform authorization
|
||||
authParams := oauth2providertest.AuthorizeParams{
|
||||
ClientID: app.ID.String(),
|
||||
ResponseType: "code",
|
||||
RedirectURI: oauth2providertest.TestRedirectURI,
|
||||
State: state,
|
||||
CodeChallenge: codeChallenge,
|
||||
CodeChallengeMethod: "S256",
|
||||
}
|
||||
|
||||
code := oauth2providertest.AuthorizeOAuth2App(t, client, client.URL.String(), authParams)
|
||||
require.NotEmpty(t, code, "should receive authorization code")
|
||||
|
||||
// Exchange code for token with PKCE
|
||||
tokenParams := oauth2providertest.TokenExchangeParams{
|
||||
GrantType: "authorization_code",
|
||||
Code: code,
|
||||
ClientID: app.ID.String(),
|
||||
ClientSecret: clientSecret,
|
||||
CodeVerifier: codeVerifier,
|
||||
RedirectURI: oauth2providertest.TestRedirectURI,
|
||||
}
|
||||
|
||||
token := oauth2providertest.ExchangeCodeForToken(t, client.URL.String(), tokenParams)
|
||||
require.NotEmpty(t, token.AccessToken, "should receive access token")
|
||||
require.NotEmpty(t, token.RefreshToken, "should receive refresh token")
|
||||
require.Equal(t, "Bearer", token.TokenType, "token type should be Bearer")
|
||||
}
|
||||
|
||||
func TestOAuth2InvalidPKCE(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
IncludeProvisionerDaemon: false,
|
||||
})
|
||||
_ = coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
// Create OAuth2 app
|
||||
app, clientSecret := oauth2providertest.CreateTestOAuth2App(t, client)
|
||||
t.Cleanup(func() {
|
||||
oauth2providertest.CleanupOAuth2App(t, client, app.ID)
|
||||
})
|
||||
|
||||
// Generate PKCE parameters
|
||||
_, codeChallenge := oauth2providertest.GeneratePKCE(t)
|
||||
state := oauth2providertest.GenerateState(t)
|
||||
|
||||
// Perform authorization
|
||||
authParams := oauth2providertest.AuthorizeParams{
|
||||
ClientID: app.ID.String(),
|
||||
ResponseType: "code",
|
||||
RedirectURI: oauth2providertest.TestRedirectURI,
|
||||
State: state,
|
||||
CodeChallenge: codeChallenge,
|
||||
CodeChallengeMethod: "S256",
|
||||
}
|
||||
|
||||
code := oauth2providertest.AuthorizeOAuth2App(t, client, client.URL.String(), authParams)
|
||||
require.NotEmpty(t, code, "should receive authorization code")
|
||||
|
||||
// Attempt token exchange with wrong code verifier
|
||||
tokenParams := oauth2providertest.TokenExchangeParams{
|
||||
GrantType: "authorization_code",
|
||||
Code: code,
|
||||
ClientID: app.ID.String(),
|
||||
ClientSecret: clientSecret,
|
||||
CodeVerifier: oauth2providertest.InvalidCodeVerifier,
|
||||
RedirectURI: oauth2providertest.TestRedirectURI,
|
||||
}
|
||||
|
||||
oauth2providertest.PerformTokenExchangeExpectingError(
|
||||
t, client.URL.String(), tokenParams, oauth2providertest.OAuth2ErrorTypes.InvalidGrant,
|
||||
)
|
||||
}
|
||||
|
||||
func TestOAuth2WithoutPKCE(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
IncludeProvisionerDaemon: false,
|
||||
})
|
||||
_ = coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
// Create OAuth2 app
|
||||
app, clientSecret := oauth2providertest.CreateTestOAuth2App(t, client)
|
||||
t.Cleanup(func() {
|
||||
oauth2providertest.CleanupOAuth2App(t, client, app.ID)
|
||||
})
|
||||
|
||||
state := oauth2providertest.GenerateState(t)
|
||||
|
||||
// Perform authorization without PKCE
|
||||
authParams := oauth2providertest.AuthorizeParams{
|
||||
ClientID: app.ID.String(),
|
||||
ResponseType: "code",
|
||||
RedirectURI: oauth2providertest.TestRedirectURI,
|
||||
State: state,
|
||||
}
|
||||
|
||||
code := oauth2providertest.AuthorizeOAuth2App(t, client, client.URL.String(), authParams)
|
||||
require.NotEmpty(t, code, "should receive authorization code")
|
||||
|
||||
// Exchange code for token without PKCE
|
||||
tokenParams := oauth2providertest.TokenExchangeParams{
|
||||
GrantType: "authorization_code",
|
||||
Code: code,
|
||||
ClientID: app.ID.String(),
|
||||
ClientSecret: clientSecret,
|
||||
RedirectURI: oauth2providertest.TestRedirectURI,
|
||||
}
|
||||
|
||||
token := oauth2providertest.ExchangeCodeForToken(t, client.URL.String(), tokenParams)
|
||||
require.NotEmpty(t, token.AccessToken, "should receive access token")
|
||||
require.NotEmpty(t, token.RefreshToken, "should receive refresh token")
|
||||
}
|
||||
|
||||
func TestOAuth2ResourceParameter(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
IncludeProvisionerDaemon: false,
|
||||
})
|
||||
_ = coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
// Create OAuth2 app
|
||||
app, clientSecret := oauth2providertest.CreateTestOAuth2App(t, client)
|
||||
t.Cleanup(func() {
|
||||
oauth2providertest.CleanupOAuth2App(t, client, app.ID)
|
||||
})
|
||||
|
||||
state := oauth2providertest.GenerateState(t)
|
||||
|
||||
// Perform authorization with resource parameter
|
||||
authParams := oauth2providertest.AuthorizeParams{
|
||||
ClientID: app.ID.String(),
|
||||
ResponseType: "code",
|
||||
RedirectURI: oauth2providertest.TestRedirectURI,
|
||||
State: state,
|
||||
Resource: oauth2providertest.TestResourceURI,
|
||||
}
|
||||
|
||||
code := oauth2providertest.AuthorizeOAuth2App(t, client, client.URL.String(), authParams)
|
||||
require.NotEmpty(t, code, "should receive authorization code")
|
||||
|
||||
// Exchange code for token with resource parameter
|
||||
tokenParams := oauth2providertest.TokenExchangeParams{
|
||||
GrantType: "authorization_code",
|
||||
Code: code,
|
||||
ClientID: app.ID.String(),
|
||||
ClientSecret: clientSecret,
|
||||
RedirectURI: oauth2providertest.TestRedirectURI,
|
||||
Resource: oauth2providertest.TestResourceURI,
|
||||
}
|
||||
|
||||
token := oauth2providertest.ExchangeCodeForToken(t, client.URL.String(), tokenParams)
|
||||
require.NotEmpty(t, token.AccessToken, "should receive access token")
|
||||
require.NotEmpty(t, token.RefreshToken, "should receive refresh token")
|
||||
}
|
||||
|
||||
func TestOAuth2TokenRefresh(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
IncludeProvisionerDaemon: false,
|
||||
})
|
||||
_ = coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
// Create OAuth2 app
|
||||
app, clientSecret := oauth2providertest.CreateTestOAuth2App(t, client)
|
||||
t.Cleanup(func() {
|
||||
oauth2providertest.CleanupOAuth2App(t, client, app.ID)
|
||||
})
|
||||
|
||||
state := oauth2providertest.GenerateState(t)
|
||||
|
||||
// Get initial token
|
||||
authParams := oauth2providertest.AuthorizeParams{
|
||||
ClientID: app.ID.String(),
|
||||
ResponseType: "code",
|
||||
RedirectURI: oauth2providertest.TestRedirectURI,
|
||||
State: state,
|
||||
}
|
||||
|
||||
code := oauth2providertest.AuthorizeOAuth2App(t, client, client.URL.String(), authParams)
|
||||
|
||||
tokenParams := oauth2providertest.TokenExchangeParams{
|
||||
GrantType: "authorization_code",
|
||||
Code: code,
|
||||
ClientID: app.ID.String(),
|
||||
ClientSecret: clientSecret,
|
||||
RedirectURI: oauth2providertest.TestRedirectURI,
|
||||
}
|
||||
|
||||
initialToken := oauth2providertest.ExchangeCodeForToken(t, client.URL.String(), tokenParams)
|
||||
require.NotEmpty(t, initialToken.RefreshToken, "should receive refresh token")
|
||||
|
||||
// Use refresh token to get new access token
|
||||
refreshParams := oauth2providertest.TokenExchangeParams{
|
||||
GrantType: "refresh_token",
|
||||
RefreshToken: initialToken.RefreshToken,
|
||||
ClientID: app.ID.String(),
|
||||
ClientSecret: clientSecret,
|
||||
}
|
||||
|
||||
refreshedToken := oauth2providertest.ExchangeCodeForToken(t, client.URL.String(), refreshParams)
|
||||
require.NotEmpty(t, refreshedToken.AccessToken, "should receive new access token")
|
||||
require.NotEqual(t, initialToken.AccessToken, refreshedToken.AccessToken, "new access token should be different")
|
||||
}
|
||||
|
||||
func TestOAuth2ErrorResponses(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
IncludeProvisionerDaemon: false,
|
||||
})
|
||||
_ = coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
t.Run("InvalidClient", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tokenParams := oauth2providertest.TokenExchangeParams{
|
||||
GrantType: "authorization_code",
|
||||
Code: "invalid-code",
|
||||
ClientID: "non-existent-client",
|
||||
ClientSecret: "invalid-secret",
|
||||
}
|
||||
|
||||
oauth2providertest.PerformTokenExchangeExpectingError(
|
||||
t, client.URL.String(), tokenParams, oauth2providertest.OAuth2ErrorTypes.InvalidClient,
|
||||
)
|
||||
})
|
||||
|
||||
t.Run("InvalidGrantType", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, clientSecret := oauth2providertest.CreateTestOAuth2App(t, client)
|
||||
t.Cleanup(func() {
|
||||
oauth2providertest.CleanupOAuth2App(t, client, app.ID)
|
||||
})
|
||||
|
||||
tokenParams := oauth2providertest.TokenExchangeParams{
|
||||
GrantType: "invalid_grant_type",
|
||||
ClientID: app.ID.String(),
|
||||
ClientSecret: clientSecret,
|
||||
}
|
||||
|
||||
oauth2providertest.PerformTokenExchangeExpectingError(
|
||||
t, client.URL.String(), tokenParams, oauth2providertest.OAuth2ErrorTypes.UnsupportedGrantType,
|
||||
)
|
||||
})
|
||||
|
||||
t.Run("MissingCode", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
app, clientSecret := oauth2providertest.CreateTestOAuth2App(t, client)
|
||||
t.Cleanup(func() {
|
||||
oauth2providertest.CleanupOAuth2App(t, client, app.ID)
|
||||
})
|
||||
|
||||
tokenParams := oauth2providertest.TokenExchangeParams{
|
||||
GrantType: "authorization_code",
|
||||
ClientID: app.ID.String(),
|
||||
ClientSecret: clientSecret,
|
||||
}
|
||||
|
||||
oauth2providertest.PerformTokenExchangeExpectingError(
|
||||
t, client.URL.String(), tokenParams, oauth2providertest.OAuth2ErrorTypes.InvalidRequest,
|
||||
)
|
||||
})
|
||||
}
|
20
coderd/oauth2provider/pkce.go
Normal file
20
coderd/oauth2provider/pkce.go
Normal file
@ -0,0 +1,20 @@
|
||||
package oauth2provider
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
)
|
||||
|
||||
// VerifyPKCE verifies that the code_verifier matches the code_challenge
|
||||
// using the S256 method as specified in RFC 7636.
|
||||
func VerifyPKCE(challenge, verifier string) bool {
|
||||
if challenge == "" || verifier == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// S256: BASE64URL-ENCODE(SHA256(ASCII(code_verifier))) == code_challenge
|
||||
h := sha256.Sum256([]byte(verifier))
|
||||
computed := base64.RawURLEncoding.EncodeToString(h[:])
|
||||
return subtle.ConstantTimeCompare([]byte(challenge), []byte(computed)) == 1
|
||||
}
|
77
coderd/oauth2provider/pkce_test.go
Normal file
77
coderd/oauth2provider/pkce_test.go
Normal file
@ -0,0 +1,77 @@
|
||||
package oauth2provider_test
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/oauth2provider"
|
||||
)
|
||||
|
||||
func TestVerifyPKCE(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
verifier string
|
||||
challenge string
|
||||
expectValid bool
|
||||
}{
|
||||
{
|
||||
name: "ValidPKCE",
|
||||
verifier: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
|
||||
challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
|
||||
expectValid: true,
|
||||
},
|
||||
{
|
||||
name: "InvalidPKCE",
|
||||
verifier: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
|
||||
challenge: "wrong_challenge",
|
||||
expectValid: false,
|
||||
},
|
||||
{
|
||||
name: "EmptyChallenge",
|
||||
verifier: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
|
||||
challenge: "",
|
||||
expectValid: false,
|
||||
},
|
||||
{
|
||||
name: "EmptyVerifier",
|
||||
verifier: "",
|
||||
challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
|
||||
expectValid: false,
|
||||
},
|
||||
{
|
||||
name: "BothEmpty",
|
||||
verifier: "",
|
||||
challenge: "",
|
||||
expectValid: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
result := oauth2provider.VerifyPKCE(tt.challenge, tt.verifier)
|
||||
require.Equal(t, tt.expectValid, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPKCES256Generation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Test that we can generate a valid S256 challenge from a verifier
|
||||
verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
|
||||
expectedChallenge := "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
|
||||
|
||||
// Generate challenge using S256 method
|
||||
h := sha256.Sum256([]byte(verifier))
|
||||
challenge := base64.RawURLEncoding.EncodeToString(h[:])
|
||||
|
||||
require.Equal(t, expectedChallenge, challenge)
|
||||
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
|
||||
}
|
44
coderd/oauth2provider/revoke.go
Normal file
44
coderd/oauth2provider/revoke.go
Normal file
@ -0,0 +1,44 @@
|
||||
package oauth2provider
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
"github.com/coder/coder/v2/coderd/httpmw"
|
||||
)
|
||||
|
||||
func RevokeApp(db database.Store) http.HandlerFunc {
|
||||
return func(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
apiKey := httpmw.APIKey(r)
|
||||
app := httpmw.OAuth2ProviderApp(r)
|
||||
|
||||
err := db.InTx(func(tx database.Store) error {
|
||||
err := tx.DeleteOAuth2ProviderAppCodesByAppAndUserID(ctx, database.DeleteOAuth2ProviderAppCodesByAppAndUserIDParams{
|
||||
AppID: app.ID,
|
||||
UserID: apiKey.UserID,
|
||||
})
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
|
||||
err = tx.DeleteOAuth2ProviderAppTokensByAppAndUserID(ctx, database.DeleteOAuth2ProviderAppTokensByAppAndUserIDParams{
|
||||
AppID: app.ID,
|
||||
UserID: apiKey.UserID,
|
||||
})
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}, nil)
|
||||
if err != nil {
|
||||
httpapi.InternalServerError(rw, err)
|
||||
return
|
||||
}
|
||||
rw.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
51
coderd/oauth2provider/secrets.go
Normal file
51
coderd/oauth2provider/secrets.go
Normal file
@ -0,0 +1,51 @@
|
||||
package oauth2provider
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/userpassword"
|
||||
"github.com/coder/coder/v2/cryptorand"
|
||||
)
|
||||
|
||||
type AppSecret struct {
|
||||
// Formatted contains the secret. This value is owned by the client, not the
|
||||
// server. It is formatted to include the prefix.
|
||||
Formatted string
|
||||
// Prefix is the ID of this secret owned by the server. When a client uses a
|
||||
// secret, this is the matching string to do a lookup on the hashed value. We
|
||||
// cannot use the hashed value directly because the server does not store the
|
||||
// salt.
|
||||
Prefix string
|
||||
// Hashed is the server stored hash(secret,salt,...). Used for verifying a
|
||||
// secret.
|
||||
Hashed string
|
||||
}
|
||||
|
||||
// GenerateSecret generates a secret to be used as a client secret, refresh
|
||||
// token, or authorization code.
|
||||
func GenerateSecret() (AppSecret, error) {
|
||||
// 40 characters matches the length of GitHub's client secrets.
|
||||
secret, err := cryptorand.String(40)
|
||||
if err != nil {
|
||||
return AppSecret{}, err
|
||||
}
|
||||
|
||||
// This ID is prefixed to the secret so it can be used to look up the secret
|
||||
// when the user provides it, since we cannot just re-hash it to match as we
|
||||
// will not have the salt.
|
||||
prefix, err := cryptorand.String(10)
|
||||
if err != nil {
|
||||
return AppSecret{}, err
|
||||
}
|
||||
|
||||
hashed, err := userpassword.Hash(secret)
|
||||
if err != nil {
|
||||
return AppSecret{}, err
|
||||
}
|
||||
|
||||
return AppSecret{
|
||||
Formatted: fmt.Sprintf("coder_%s_%s", prefix, secret),
|
||||
Prefix: prefix,
|
||||
Hashed: hashed,
|
||||
}, nil
|
||||
}
|
466
coderd/oauth2provider/tokens.go
Normal file
466
coderd/oauth2provider/tokens.go
Normal file
@ -0,0 +1,466 @@
|
||||
package oauth2provider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/apikey"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
"github.com/coder/coder/v2/coderd/httpmw"
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
"github.com/coder/coder/v2/coderd/userpassword"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
var (
|
||||
// errBadSecret means the user provided a bad secret.
|
||||
errBadSecret = xerrors.New("Invalid client secret")
|
||||
// errBadCode means the user provided a bad code.
|
||||
errBadCode = xerrors.New("Invalid code")
|
||||
// errBadToken means the user provided a bad token.
|
||||
errBadToken = xerrors.New("Invalid token")
|
||||
// errInvalidPKCE means the PKCE verification failed.
|
||||
errInvalidPKCE = xerrors.New("invalid code_verifier")
|
||||
// errInvalidResource means the resource parameter validation failed.
|
||||
errInvalidResource = xerrors.New("invalid resource parameter")
|
||||
)
|
||||
|
||||
type tokenParams struct {
|
||||
clientID string
|
||||
clientSecret string
|
||||
code string
|
||||
grantType codersdk.OAuth2ProviderGrantType
|
||||
redirectURL *url.URL
|
||||
refreshToken string
|
||||
codeVerifier string // PKCE verifier
|
||||
resource string // RFC 8707 resource for token binding
|
||||
}
|
||||
|
||||
func extractTokenParams(r *http.Request, callbackURL *url.URL) (tokenParams, []codersdk.ValidationError, error) {
|
||||
p := httpapi.NewQueryParamParser()
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
return tokenParams{}, nil, xerrors.Errorf("parse form: %w", err)
|
||||
}
|
||||
|
||||
vals := r.Form
|
||||
p.RequiredNotEmpty("grant_type")
|
||||
grantType := httpapi.ParseCustom(p, vals, "", "grant_type", httpapi.ParseEnum[codersdk.OAuth2ProviderGrantType])
|
||||
switch grantType {
|
||||
case codersdk.OAuth2ProviderGrantTypeRefreshToken:
|
||||
p.RequiredNotEmpty("refresh_token")
|
||||
case codersdk.OAuth2ProviderGrantTypeAuthorizationCode:
|
||||
p.RequiredNotEmpty("client_secret", "client_id", "code")
|
||||
}
|
||||
|
||||
params := tokenParams{
|
||||
clientID: p.String(vals, "", "client_id"),
|
||||
clientSecret: p.String(vals, "", "client_secret"),
|
||||
code: p.String(vals, "", "code"),
|
||||
grantType: grantType,
|
||||
redirectURL: p.RedirectURL(vals, callbackURL, "redirect_uri"),
|
||||
refreshToken: p.String(vals, "", "refresh_token"),
|
||||
codeVerifier: p.String(vals, "", "code_verifier"),
|
||||
resource: p.String(vals, "", "resource"),
|
||||
}
|
||||
// Validate resource parameter syntax (RFC 8707): must be absolute URI without fragment
|
||||
if err := validateResourceParameter(params.resource); err != nil {
|
||||
p.Errors = append(p.Errors, codersdk.ValidationError{
|
||||
Field: "resource",
|
||||
Detail: "must be an absolute URI without fragment",
|
||||
})
|
||||
}
|
||||
|
||||
p.ErrorExcessParams(vals)
|
||||
if len(p.Errors) > 0 {
|
||||
return tokenParams{}, p.Errors, xerrors.Errorf("invalid query params: %w", p.Errors)
|
||||
}
|
||||
return params, nil, nil
|
||||
}
|
||||
|
||||
// Tokens
|
||||
// TODO: the sessions lifetime config passed is for coder api tokens.
|
||||
// Should there be a separate config for oauth2 tokens? They are related,
|
||||
// but they are not the same.
|
||||
func Tokens(db database.Store, lifetimes codersdk.SessionLifetime) http.HandlerFunc {
|
||||
return func(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
app := httpmw.OAuth2ProviderApp(r)
|
||||
|
||||
callbackURL, err := url.Parse(app.CallbackURL)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to validate form values.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
params, validationErrs, err := extractTokenParams(r, callbackURL)
|
||||
if err != nil {
|
||||
// Check for specific validation errors in priority order
|
||||
if slices.ContainsFunc(validationErrs, func(validationError codersdk.ValidationError) bool {
|
||||
return validationError.Field == "grant_type"
|
||||
}) {
|
||||
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, "unsupported_grant_type", "The grant type is missing or unsupported")
|
||||
return
|
||||
}
|
||||
|
||||
// Check for missing required parameters for authorization_code grant
|
||||
for _, field := range []string{"code", "client_id", "client_secret"} {
|
||||
if slices.ContainsFunc(validationErrs, func(validationError codersdk.ValidationError) bool {
|
||||
return validationError.Field == field
|
||||
}) {
|
||||
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, "invalid_request", fmt.Sprintf("Missing required parameter: %s", field))
|
||||
return
|
||||
}
|
||||
}
|
||||
// Generic invalid request for other validation errors
|
||||
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, "invalid_request", "The request is missing required parameters or is otherwise malformed")
|
||||
return
|
||||
}
|
||||
|
||||
var token oauth2.Token
|
||||
//nolint:gocritic,revive // More cases will be added later.
|
||||
switch params.grantType {
|
||||
// TODO: Client creds, device code.
|
||||
case codersdk.OAuth2ProviderGrantTypeRefreshToken:
|
||||
token, err = refreshTokenGrant(ctx, db, app, lifetimes, params)
|
||||
case codersdk.OAuth2ProviderGrantTypeAuthorizationCode:
|
||||
token, err = authorizationCodeGrant(ctx, db, app, lifetimes, params)
|
||||
default:
|
||||
// This should handle truly invalid grant types
|
||||
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, "unsupported_grant_type", fmt.Sprintf("The grant type %q is not supported", params.grantType))
|
||||
return
|
||||
}
|
||||
|
||||
if errors.Is(err, errBadSecret) {
|
||||
httpapi.WriteOAuth2Error(ctx, rw, http.StatusUnauthorized, "invalid_client", "The client credentials are invalid")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, errBadCode) {
|
||||
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, "invalid_grant", "The authorization code is invalid or expired")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, errInvalidPKCE) {
|
||||
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, "invalid_grant", "The PKCE code verifier is invalid")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, errInvalidResource) {
|
||||
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, "invalid_target", "The resource parameter is invalid")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, errBadToken) {
|
||||
httpapi.WriteOAuth2Error(ctx, rw, http.StatusBadRequest, "invalid_grant", "The refresh token is invalid or expired")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to exchange token",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Some client libraries allow this to be "application/x-www-form-urlencoded". We can implement that upon
|
||||
// request. The same libraries should also accept JSON. If implemented, choose based on "Accept" header.
|
||||
httpapi.Write(ctx, rw, http.StatusOK, token)
|
||||
}
|
||||
}
|
||||
|
||||
func authorizationCodeGrant(ctx context.Context, db database.Store, app database.OAuth2ProviderApp, lifetimes codersdk.SessionLifetime, params tokenParams) (oauth2.Token, error) {
|
||||
// Validate the client secret.
|
||||
secret, err := parseFormattedSecret(params.clientSecret)
|
||||
if err != nil {
|
||||
return oauth2.Token{}, errBadSecret
|
||||
}
|
||||
//nolint:gocritic // Users cannot read secrets so we must use the system.
|
||||
dbSecret, err := db.GetOAuth2ProviderAppSecretByPrefix(dbauthz.AsSystemRestricted(ctx), []byte(secret.prefix))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return oauth2.Token{}, errBadSecret
|
||||
}
|
||||
if err != nil {
|
||||
return oauth2.Token{}, err
|
||||
}
|
||||
equal, err := userpassword.Compare(string(dbSecret.HashedSecret), secret.secret)
|
||||
if err != nil {
|
||||
return oauth2.Token{}, xerrors.Errorf("unable to compare secret: %w", err)
|
||||
}
|
||||
if !equal {
|
||||
return oauth2.Token{}, errBadSecret
|
||||
}
|
||||
|
||||
// Validate the authorization code.
|
||||
code, err := parseFormattedSecret(params.code)
|
||||
if err != nil {
|
||||
return oauth2.Token{}, errBadCode
|
||||
}
|
||||
//nolint:gocritic // There is no user yet so we must use the system.
|
||||
dbCode, err := db.GetOAuth2ProviderAppCodeByPrefix(dbauthz.AsSystemRestricted(ctx), []byte(code.prefix))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return oauth2.Token{}, errBadCode
|
||||
}
|
||||
if err != nil {
|
||||
return oauth2.Token{}, err
|
||||
}
|
||||
equal, err = userpassword.Compare(string(dbCode.HashedSecret), code.secret)
|
||||
if err != nil {
|
||||
return oauth2.Token{}, xerrors.Errorf("unable to compare code: %w", err)
|
||||
}
|
||||
if !equal {
|
||||
return oauth2.Token{}, errBadCode
|
||||
}
|
||||
|
||||
// Ensure the code has not expired.
|
||||
if dbCode.ExpiresAt.Before(dbtime.Now()) {
|
||||
return oauth2.Token{}, errBadCode
|
||||
}
|
||||
|
||||
// Verify PKCE challenge if present
|
||||
if dbCode.CodeChallenge.Valid && dbCode.CodeChallenge.String != "" {
|
||||
if params.codeVerifier == "" {
|
||||
return oauth2.Token{}, errInvalidPKCE
|
||||
}
|
||||
if !VerifyPKCE(dbCode.CodeChallenge.String, params.codeVerifier) {
|
||||
return oauth2.Token{}, errInvalidPKCE
|
||||
}
|
||||
}
|
||||
|
||||
// Verify resource parameter consistency (RFC 8707)
|
||||
if dbCode.ResourceUri.Valid && dbCode.ResourceUri.String != "" {
|
||||
// Resource was specified during authorization - it must match in token request
|
||||
if params.resource == "" {
|
||||
return oauth2.Token{}, errInvalidResource
|
||||
}
|
||||
if params.resource != dbCode.ResourceUri.String {
|
||||
return oauth2.Token{}, errInvalidResource
|
||||
}
|
||||
} else if params.resource != "" {
|
||||
// Resource was not specified during authorization but is now provided
|
||||
return oauth2.Token{}, errInvalidResource
|
||||
}
|
||||
|
||||
// Generate a refresh token.
|
||||
refreshToken, err := GenerateSecret()
|
||||
if err != nil {
|
||||
return oauth2.Token{}, err
|
||||
}
|
||||
|
||||
// Generate the API key we will swap for the code.
|
||||
// TODO: We are ignoring scopes for now.
|
||||
tokenName := fmt.Sprintf("%s_%s_oauth_session_token", dbCode.UserID, app.ID)
|
||||
key, sessionToken, err := apikey.Generate(apikey.CreateParams{
|
||||
UserID: dbCode.UserID,
|
||||
LoginType: database.LoginTypeOAuth2ProviderApp,
|
||||
DefaultLifetime: lifetimes.DefaultDuration.Value(),
|
||||
// For now, we allow only one token per app and user at a time.
|
||||
TokenName: tokenName,
|
||||
})
|
||||
if err != nil {
|
||||
return oauth2.Token{}, err
|
||||
}
|
||||
|
||||
// Grab the user roles so we can perform the exchange as the user.
|
||||
actor, _, err := httpmw.UserRBACSubject(ctx, db, dbCode.UserID, rbac.ScopeAll)
|
||||
if err != nil {
|
||||
return oauth2.Token{}, xerrors.Errorf("fetch user actor: %w", err)
|
||||
}
|
||||
|
||||
// Do the actual token exchange in the database.
|
||||
err = db.InTx(func(tx database.Store) error {
|
||||
ctx := dbauthz.As(ctx, actor)
|
||||
err = tx.DeleteOAuth2ProviderAppCodeByID(ctx, dbCode.ID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("delete oauth2 app code: %w", err)
|
||||
}
|
||||
|
||||
// Delete the previous key, if any.
|
||||
prevKey, err := tx.GetAPIKeyByName(ctx, database.GetAPIKeyByNameParams{
|
||||
UserID: dbCode.UserID,
|
||||
TokenName: tokenName,
|
||||
})
|
||||
if err == nil {
|
||||
err = tx.DeleteAPIKeyByID(ctx, prevKey.ID)
|
||||
}
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return xerrors.Errorf("delete api key by name: %w", err)
|
||||
}
|
||||
|
||||
newKey, err := tx.InsertAPIKey(ctx, key)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("insert oauth2 access token: %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.InsertOAuth2ProviderAppToken(ctx, database.InsertOAuth2ProviderAppTokenParams{
|
||||
ID: uuid.New(),
|
||||
CreatedAt: dbtime.Now(),
|
||||
ExpiresAt: key.ExpiresAt,
|
||||
HashPrefix: []byte(refreshToken.Prefix),
|
||||
RefreshHash: []byte(refreshToken.Hashed),
|
||||
AppSecretID: dbSecret.ID,
|
||||
APIKeyID: newKey.ID,
|
||||
UserID: dbCode.UserID,
|
||||
Audience: dbCode.ResourceUri,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("insert oauth2 refresh token: %w", err)
|
||||
}
|
||||
return nil
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return oauth2.Token{}, err
|
||||
}
|
||||
|
||||
return oauth2.Token{
|
||||
AccessToken: sessionToken,
|
||||
TokenType: "Bearer",
|
||||
RefreshToken: refreshToken.Formatted,
|
||||
Expiry: key.ExpiresAt,
|
||||
ExpiresIn: int64(time.Until(key.ExpiresAt).Seconds()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func refreshTokenGrant(ctx context.Context, db database.Store, app database.OAuth2ProviderApp, lifetimes codersdk.SessionLifetime, params tokenParams) (oauth2.Token, error) {
|
||||
// Validate the token.
|
||||
token, err := parseFormattedSecret(params.refreshToken)
|
||||
if err != nil {
|
||||
return oauth2.Token{}, errBadToken
|
||||
}
|
||||
//nolint:gocritic // There is no user yet so we must use the system.
|
||||
dbToken, err := db.GetOAuth2ProviderAppTokenByPrefix(dbauthz.AsSystemRestricted(ctx), []byte(token.prefix))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return oauth2.Token{}, errBadToken
|
||||
}
|
||||
if err != nil {
|
||||
return oauth2.Token{}, err
|
||||
}
|
||||
equal, err := userpassword.Compare(string(dbToken.RefreshHash), token.secret)
|
||||
if err != nil {
|
||||
return oauth2.Token{}, xerrors.Errorf("unable to compare token: %w", err)
|
||||
}
|
||||
if !equal {
|
||||
return oauth2.Token{}, errBadToken
|
||||
}
|
||||
|
||||
// Ensure the token has not expired.
|
||||
if dbToken.ExpiresAt.Before(dbtime.Now()) {
|
||||
return oauth2.Token{}, errBadToken
|
||||
}
|
||||
|
||||
// Verify resource parameter consistency for refresh tokens (RFC 8707)
|
||||
if params.resource != "" {
|
||||
// If resource is provided in refresh request, it must match the original token's audience
|
||||
if !dbToken.Audience.Valid || dbToken.Audience.String != params.resource {
|
||||
return oauth2.Token{}, errInvalidResource
|
||||
}
|
||||
}
|
||||
|
||||
// Grab the user roles so we can perform the refresh as the user.
|
||||
//nolint:gocritic // There is no user yet so we must use the system.
|
||||
prevKey, err := db.GetAPIKeyByID(dbauthz.AsSystemRestricted(ctx), dbToken.APIKeyID)
|
||||
if err != nil {
|
||||
return oauth2.Token{}, err
|
||||
}
|
||||
|
||||
actor, _, err := httpmw.UserRBACSubject(ctx, db, prevKey.UserID, rbac.ScopeAll)
|
||||
if err != nil {
|
||||
return oauth2.Token{}, xerrors.Errorf("fetch user actor: %w", err)
|
||||
}
|
||||
|
||||
// Generate a new refresh token.
|
||||
refreshToken, err := GenerateSecret()
|
||||
if err != nil {
|
||||
return oauth2.Token{}, err
|
||||
}
|
||||
|
||||
// Generate the new API key.
|
||||
// TODO: We are ignoring scopes for now.
|
||||
tokenName := fmt.Sprintf("%s_%s_oauth_session_token", prevKey.UserID, app.ID)
|
||||
key, sessionToken, err := apikey.Generate(apikey.CreateParams{
|
||||
UserID: prevKey.UserID,
|
||||
LoginType: database.LoginTypeOAuth2ProviderApp,
|
||||
DefaultLifetime: lifetimes.DefaultDuration.Value(),
|
||||
// For now, we allow only one token per app and user at a time.
|
||||
TokenName: tokenName,
|
||||
})
|
||||
if err != nil {
|
||||
return oauth2.Token{}, err
|
||||
}
|
||||
|
||||
// Replace the token.
|
||||
err = db.InTx(func(tx database.Store) error {
|
||||
ctx := dbauthz.As(ctx, actor)
|
||||
err = tx.DeleteAPIKeyByID(ctx, prevKey.ID) // This cascades to the token.
|
||||
if err != nil {
|
||||
return xerrors.Errorf("delete oauth2 app token: %w", err)
|
||||
}
|
||||
|
||||
newKey, err := tx.InsertAPIKey(ctx, key)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("insert oauth2 access token: %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.InsertOAuth2ProviderAppToken(ctx, database.InsertOAuth2ProviderAppTokenParams{
|
||||
ID: uuid.New(),
|
||||
CreatedAt: dbtime.Now(),
|
||||
ExpiresAt: key.ExpiresAt,
|
||||
HashPrefix: []byte(refreshToken.Prefix),
|
||||
RefreshHash: []byte(refreshToken.Hashed),
|
||||
AppSecretID: dbToken.AppSecretID,
|
||||
APIKeyID: newKey.ID,
|
||||
UserID: dbToken.UserID,
|
||||
Audience: dbToken.Audience,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("insert oauth2 refresh token: %w", err)
|
||||
}
|
||||
return nil
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return oauth2.Token{}, err
|
||||
}
|
||||
|
||||
return oauth2.Token{
|
||||
AccessToken: sessionToken,
|
||||
TokenType: "Bearer",
|
||||
RefreshToken: refreshToken.Formatted,
|
||||
Expiry: key.ExpiresAt,
|
||||
ExpiresIn: int64(time.Until(key.ExpiresAt).Seconds()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// validateResourceParameter validates that a resource parameter conforms to RFC 8707:
|
||||
// must be an absolute URI without fragment component.
|
||||
func validateResourceParameter(resource string) error {
|
||||
if resource == "" {
|
||||
return nil // Resource parameter is optional
|
||||
}
|
||||
|
||||
u, err := url.Parse(resource)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("invalid URI syntax: %w", err)
|
||||
}
|
||||
|
||||
if u.Scheme == "" {
|
||||
return xerrors.New("must be an absolute URI with scheme")
|
||||
}
|
||||
|
||||
if u.Fragment != "" {
|
||||
return xerrors.New("must not contain fragment component")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
Reference in New Issue
Block a user