feat: accept redirect_uris on the OAuth2 app admin API and deprecate callback_url (#29502)

- Add `redirect_uris` to admin OAuth2 app responses and create/update requests.
- Deprecate `callback_url` while preserving compatibility; it maps to the first redirect URI.
- Reject empty `redirect_uris` lists instead of silently ignoring them.
- Update fixtures, tests, and docs.

Part of PLAT-619. Builds on #29440; admin UI support follows in #29603.
This commit is contained in:
Bobby Ho
2026-09-22 19:12:27 +00:00
committed by GitHub
parent 674166077c
commit 9fd0131d04
29 changed files with 1193 additions and 190 deletions
+26 -4
View File
@@ -16809,7 +16809,7 @@ const docTemplate = `{
"description": "HTML error page. The failure names the redirect URI or the client, so RFC 6749 4.1.2.1 withholds the callback"
},
"500": {
"description": "HTML error page. The app's registered callback URL is not usable"
"description": "HTML error page. One of the app's registered redirect URIs is not usable"
}
},
"security": [
@@ -16897,7 +16897,7 @@ const docTemplate = `{
}
},
"500": {
"description": "The app's registered callback URL is not usable",
"description": "One of the app's registered redirect URIs is not usable",
"schema": {
"$ref": "#/definitions/codersdk.OAuth2Error"
}
@@ -25603,6 +25603,7 @@ const docTemplate = `{
"type": "object",
"properties": {
"callback_url": {
"description": "Deprecated: equal to the first entry of redirect_uris. Read\nredirect_uris instead.",
"type": "string"
},
"client_type": {
@@ -25631,6 +25632,13 @@ const docTemplate = `{
"name": {
"type": "string"
},
"redirect_uris": {
"description": "RedirectURIs are the app's registered redirect URIs, primary first.",
"type": "array",
"items": {
"type": "string"
}
},
"scope": {
"description": "Scope is the space-separated list of scopes this app's tokens may be\ngranted. Empty means unrestricted. A non-empty value with no names is a\nconfigured allowlist that grants nothing.",
"type": "string"
@@ -26604,11 +26612,11 @@ const docTemplate = `{
"codersdk.PostOAuth2ProviderAppRequest": {
"type": "object",
"required": [
"callback_url",
"name"
],
"properties": {
"callback_url": {
"description": "Deprecated: send redirect_uris instead. If both are sent, callback_url\nmust equal the first entry of redirect_uris.",
"type": "string"
},
"icon": {
@@ -26617,6 +26625,13 @@ const docTemplate = `{
"name": {
"type": "string"
},
"redirect_uris": {
"description": "RedirectURIs is the ordered list of URIs the app may redirect to. The\nfirst entry is the primary. Required, unless the deprecated\ncallback_url is sent instead.",
"type": "array",
"items": {
"type": "string"
}
},
"scope": {
"description": "Scope is the space-separated list of scopes this app's tokens may be\ngranted. Leave empty, or omit, for unrestricted.",
"type": "string"
@@ -27450,11 +27465,11 @@ const docTemplate = `{
"codersdk.PutOAuth2ProviderAppRequest": {
"type": "object",
"required": [
"callback_url",
"name"
],
"properties": {
"callback_url": {
"description": "Deprecated: send redirect_uris instead. If both are sent, callback_url\nmust equal the first entry of redirect_uris.",
"type": "string"
},
"icon": {
@@ -27463,6 +27478,13 @@ const docTemplate = `{
"name": {
"type": "string"
},
"redirect_uris": {
"description": "RedirectURIs is the ordered list of URIs the app may redirect to. The\nfirst entry is the primary. Omit both this and callback_url to keep the\nstored redirect URIs. Other fields are replaced. Sending an empty list\nis an error, not a way to keep the stored list.",
"type": "array",
"items": {
"type": "string"
}
},
"scope": {
"description": "Scope replaces the app's current allowlist. Omit to leave the existing\nallowlist untouched. Set to an empty string to clear it, making the app\nunrestricted.",
"type": "string"
+28 -4
View File
@@ -14955,7 +14955,7 @@
"description": "HTML error page. The failure names the redirect URI or the client, so RFC 6749 4.1.2.1 withholds the callback"
},
"500": {
"description": "HTML error page. The app's registered callback URL is not usable"
"description": "HTML error page. One of the app's registered redirect URIs is not usable"
}
},
"security": [
@@ -15035,7 +15035,7 @@
}
},
"500": {
"description": "The app's registered callback URL is not usable",
"description": "One of the app's registered redirect URIs is not usable",
"schema": {
"$ref": "#/definitions/codersdk.OAuth2Error"
}
@@ -23415,6 +23415,7 @@
"type": "object",
"properties": {
"callback_url": {
"description": "Deprecated: equal to the first entry of redirect_uris. Read\nredirect_uris instead.",
"type": "string"
},
"client_type": {
@@ -23443,6 +23444,13 @@
"name": {
"type": "string"
},
"redirect_uris": {
"description": "RedirectURIs are the app's registered redirect URIs, primary first.",
"type": "array",
"items": {
"type": "string"
}
},
"scope": {
"description": "Scope is the space-separated list of scopes this app's tokens may be\ngranted. Empty means unrestricted. A non-empty value with no names is a\nconfigured allowlist that grants nothing.",
"type": "string"
@@ -24384,9 +24392,10 @@
},
"codersdk.PostOAuth2ProviderAppRequest": {
"type": "object",
"required": ["callback_url", "name"],
"required": ["name"],
"properties": {
"callback_url": {
"description": "Deprecated: send redirect_uris instead. If both are sent, callback_url\nmust equal the first entry of redirect_uris.",
"type": "string"
},
"icon": {
@@ -24395,6 +24404,13 @@
"name": {
"type": "string"
},
"redirect_uris": {
"description": "RedirectURIs is the ordered list of URIs the app may redirect to. The\nfirst entry is the primary. Required, unless the deprecated\ncallback_url is sent instead.",
"type": "array",
"items": {
"type": "string"
}
},
"scope": {
"description": "Scope is the space-separated list of scopes this app's tokens may be\ngranted. Leave empty, or omit, for unrestricted.",
"type": "string"
@@ -25187,9 +25203,10 @@
},
"codersdk.PutOAuth2ProviderAppRequest": {
"type": "object",
"required": ["callback_url", "name"],
"required": ["name"],
"properties": {
"callback_url": {
"description": "Deprecated: send redirect_uris instead. If both are sent, callback_url\nmust equal the first entry of redirect_uris.",
"type": "string"
},
"icon": {
@@ -25198,6 +25215,13 @@
"name": {
"type": "string"
},
"redirect_uris": {
"description": "RedirectURIs is the ordered list of URIs the app may redirect to. The\nfirst entry is the primary. Omit both this and callback_url to keep the\nstored redirect URIs. Other fields are replaced. Sending an empty list\nis an error, not a way to keep the stored list.",
"type": "array",
"items": {
"type": "string"
}
},
"scope": {
"description": "Scope replaces the app's current allowlist. Omit to leave the existing\nallowlist untouched. Set to an empty string to clear it, making the app\nunrestricted.",
"type": "string"
+8 -6
View File
@@ -478,13 +478,15 @@ func TemplateVersionParameterOptionFromPreview(option *previewtypes.ParameterOpt
}
func OAuth2ProviderApp(accessURL *url.URL, dbApp database.OAuth2ProviderApp) codersdk.OAuth2ProviderApp {
uris := dbApp.RegisteredRedirectURIs()
return codersdk.OAuth2ProviderApp{
ID: dbApp.ID,
Name: dbApp.Name,
CallbackURL: dbApp.RegisteredRedirectURIs()[0],
Icon: dbApp.Icon,
Scope: rbac.CanonicalScopeList(dbApp.Scope.String),
ClientType: codersdk.OAuth2ClientType(dbApp.ClientType),
ID: dbApp.ID,
Name: dbApp.Name,
RedirectURIs: uris,
CallbackURL: uris[0],
Icon: dbApp.Icon,
Scope: rbac.CanonicalScopeList(dbApp.Scope.String),
ClientType: codersdk.OAuth2ClientType(dbApp.ClientType),
Endpoints: codersdk.OAuth2AppEndpoints{
Authorization: accessURL.ResolveReference(&url.URL{
Path: "/oauth2/authorize",
+7
View File
@@ -4327,6 +4327,13 @@ func (q *querier) GetOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) (d
return q.db.GetOAuth2ProviderAppByID(ctx, id)
}
func (q *querier) GetOAuth2ProviderAppByIDForUpdate(ctx context.Context, id uuid.UUID) (database.OAuth2ProviderApp, error) {
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceOauth2App); err != nil {
return database.OAuth2ProviderApp{}, err
}
return q.db.GetOAuth2ProviderAppByIDForUpdate(ctx, id)
}
func (q *querier) GetOAuth2ProviderAppCodeByID(ctx context.Context, id uuid.UUID) (database.OAuth2ProviderAppCode, error) {
return fetch(q.log, q.auth, q.db.GetOAuth2ProviderAppCodeByID)(ctx, id)
}
+4
View File
@@ -6004,6 +6004,10 @@ func (s *MethodTestSuite) TestOAuth2ProviderApps() {
app := dbgen.OAuth2ProviderApp(s.T(), db, database.OAuth2ProviderApp{})
check.Args(app.ID).Asserts(rbac.ResourceOauth2App, policy.ActionRead).Returns(app)
}))
s.Run("GetOAuth2ProviderAppByIDForUpdate", s.Subtest(func(db database.Store, check *expects) {
app := dbgen.OAuth2ProviderApp(s.T(), db, database.OAuth2ProviderApp{})
check.Args(app.ID).Asserts(rbac.ResourceOauth2App, policy.ActionRead).Returns(app)
}))
s.Run("GetOAuth2ProviderAppsByUserID", s.Subtest(func(db database.Store, check *expects) {
dbtestutil.DisableForeignKeysAndTriggers(s.T(), db)
user := dbgen.User(s.T(), db, database.User{})
+8
View File
@@ -2520,6 +2520,14 @@ func (m queryMetricsStore) GetOAuth2ProviderAppByID(ctx context.Context, id uuid
return r0, r1
}
func (m queryMetricsStore) GetOAuth2ProviderAppByIDForUpdate(ctx context.Context, id uuid.UUID) (database.OAuth2ProviderApp, error) {
start := time.Now()
r0, r1 := m.s.GetOAuth2ProviderAppByIDForUpdate(ctx, id)
m.queryLatencies.WithLabelValues("GetOAuth2ProviderAppByIDForUpdate").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetOAuth2ProviderAppByIDForUpdate").Inc()
return r0, r1
}
func (m queryMetricsStore) GetOAuth2ProviderAppCodeByID(ctx context.Context, id uuid.UUID) (database.OAuth2ProviderAppCode, error) {
start := time.Now()
r0, r1 := m.s.GetOAuth2ProviderAppCodeByID(ctx, id)
+15
View File
@@ -4724,6 +4724,21 @@ func (mr *MockStoreMockRecorder) GetOAuth2ProviderAppByID(ctx, id any) *gomock.C
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOAuth2ProviderAppByID", reflect.TypeOf((*MockStore)(nil).GetOAuth2ProviderAppByID), ctx, id)
}
// GetOAuth2ProviderAppByIDForUpdate mocks base method.
func (m *MockStore) GetOAuth2ProviderAppByIDForUpdate(ctx context.Context, id uuid.UUID) (database.OAuth2ProviderApp, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetOAuth2ProviderAppByIDForUpdate", ctx, id)
ret0, _ := ret[0].(database.OAuth2ProviderApp)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetOAuth2ProviderAppByIDForUpdate indicates an expected call of GetOAuth2ProviderAppByIDForUpdate.
func (mr *MockStoreMockRecorder) GetOAuth2ProviderAppByIDForUpdate(ctx, id any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOAuth2ProviderAppByIDForUpdate", reflect.TypeOf((*MockStore)(nil).GetOAuth2ProviderAppByIDForUpdate), ctx, id)
}
// GetOAuth2ProviderAppCodeByID mocks base method.
func (m *MockStore) GetOAuth2ProviderAppCodeByID(ctx context.Context, id uuid.UUID) (database.OAuth2ProviderAppCode, error) {
m.ctrl.T.Helper()
+1
View File
@@ -712,6 +712,7 @@ type sqlcQuerier interface {
// RFC 7591/7592 Dynamic Client Registration queries
GetOAuth2ProviderAppByClientID(ctx context.Context, id uuid.UUID) (OAuth2ProviderApp, error)
GetOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) (OAuth2ProviderApp, error)
GetOAuth2ProviderAppByIDForUpdate(ctx context.Context, id uuid.UUID) (OAuth2ProviderApp, error)
GetOAuth2ProviderAppCodeByID(ctx context.Context, id uuid.UUID) (OAuth2ProviderAppCode, error)
GetOAuth2ProviderAppCodeByPrefix(ctx context.Context, secretPrefix []byte) (OAuth2ProviderAppCode, error)
GetOAuth2ProviderAppSecretByID(ctx context.Context, id uuid.UUID) (OAuth2ProviderAppSecret, error)
+38
View File
@@ -20426,6 +20426,44 @@ func (q *sqlQuerier) GetOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID)
return i, err
}
const getOAuth2ProviderAppByIDForUpdate = `-- name: GetOAuth2ProviderAppByIDForUpdate :one
SELECT id, created_at, updated_at, name, icon, callback_url, redirect_uris, client_type, dynamically_registered, client_id_issued_at, client_secret_expires_at, grant_types, response_types, token_endpoint_auth_method, scope, contacts, client_uri, logo_uri, tos_uri, policy_uri, jwks_uri, jwks, software_id, software_version, registration_access_token, registration_client_uri FROM oauth2_provider_apps WHERE id = $1 FOR UPDATE
`
func (q *sqlQuerier) GetOAuth2ProviderAppByIDForUpdate(ctx context.Context, id uuid.UUID) (OAuth2ProviderApp, error) {
row := q.db.QueryRowContext(ctx, getOAuth2ProviderAppByIDForUpdate, id)
var i OAuth2ProviderApp
err := row.Scan(
&i.ID,
&i.CreatedAt,
&i.UpdatedAt,
&i.Name,
&i.Icon,
&i.CallbackURL,
pq.Array(&i.RedirectUris),
&i.ClientType,
&i.DynamicallyRegistered,
&i.ClientIDIssuedAt,
&i.ClientSecretExpiresAt,
pq.Array(&i.GrantTypes),
pq.Array(&i.ResponseTypes),
&i.TokenEndpointAuthMethod,
&i.Scope,
pq.Array(&i.Contacts),
&i.ClientUri,
&i.LogoUri,
&i.TosUri,
&i.PolicyUri,
&i.JwksUri,
&i.Jwks,
&i.SoftwareID,
&i.SoftwareVersion,
&i.RegistrationAccessToken,
&i.RegistrationClientUri,
)
return i, err
}
const getOAuth2ProviderAppCodeByID = `-- name: GetOAuth2ProviderAppCodeByID :one
SELECT id, created_at, expires_at, secret_prefix, hashed_secret, user_id, app_id, resource_uri, code_challenge, code_challenge_method, state_hash, redirect_uri, scope FROM oauth2_provider_app_codes WHERE id = $1
`
+3
View File
@@ -4,6 +4,9 @@ SELECT * FROM oauth2_provider_apps ORDER BY (name, id) ASC;
-- name: GetOAuth2ProviderAppByID :one
SELECT * FROM oauth2_provider_apps WHERE id = $1;
-- name: GetOAuth2ProviderAppByIDForUpdate :one
SELECT * FROM oauth2_provider_apps WHERE id = $1 FOR UPDATE;
-- name: InsertOAuth2ProviderApp :one
INSERT INTO oauth2_provider_apps (
id,
+2 -2
View File
@@ -128,7 +128,7 @@ func (api *API) deleteOAuth2ProviderAppSecret() http.HandlerFunc {
// @Success 200 "Returns HTML authorization page"
// @Success 302 "Redirects to the app's registered callback carrying an OAuth2 error (RFC 6749 4.1.2.1)"
// @Failure 400 "HTML error page. The failure names the redirect URI or the client, so RFC 6749 4.1.2.1 withholds the callback"
// @Failure 500 "HTML error page. The app's registered callback URL is not usable"
// @Failure 500 "HTML error page. One of the app's registered redirect URIs is not usable"
// @Router /oauth2/authorize [get]
func (api *API) getOAuth2ProviderAppAuthorize() http.HandlerFunc {
return oauth2provider.ShowAuthorizePage(api.AccessURL, api.Logger)
@@ -149,7 +149,7 @@ func (api *API) getOAuth2ProviderAppAuthorize() http.HandlerFunc {
// @Param resource query string false "RFC 8707 resource indicator: an absolute URI without a fragment"
// @Success 302 "Redirects to the app's registered callback carrying either an authorization code or an OAuth2 error (RFC 6749 4.1.2.1)"
// @Failure 400 {object} codersdk.OAuth2Error "The failure names the redirect URI or the client, so RFC 6749 4.1.2.1 withholds the callback"
// @Failure 500 {object} codersdk.OAuth2Error "The app's registered callback URL is not usable"
// @Failure 500 {object} codersdk.OAuth2Error "One of the app's registered redirect URIs is not usable"
// @Router /oauth2/authorize [post]
func (api *API) postOAuth2ProviderAppAuthorize() http.HandlerFunc {
return oauth2provider.ProcessAuthorize(api.Database, api.Logger)
+4 -4
View File
@@ -321,7 +321,7 @@ func TestOAuth2ProviderTokenExchange(t *testing.T) {
app: apps.Default,
preAuth: func(valid *oauth2.Config) {
newURL := must(url.Parse(valid.RedirectURL))
newURL.Scheme = "https"
newURL.Scheme = "http"
valid.RedirectURL = newURL.String()
},
authError: "Invalid query params:",
@@ -591,7 +591,7 @@ func TestOAuth2ProviderTokenExchangeCodeBelongsToDifferentApp(t *testing.T) {
owner := coderdtest.CreateFirstUser(t, ownerClient)
ctx := testutil.Context(t, testutil.WaitLong)
const sharedCallback = "http://localhost1:8080/foo/bar"
const sharedCallback = "https://localhost1:8080/foo/bar"
createApp := func(name string) (codersdk.OAuth2ProviderApp, codersdk.OAuth2ProviderAppSecretFull) {
//nolint:gocritic // OAauth2 app management requires owner permission.
app, err := ownerClient.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{
@@ -1601,8 +1601,8 @@ func generateApps(ctx context.Context, t *testing.T, client *codersdk.Client, su
}
return provisionedApps{
Default: create("app-a", "http://localhost1:8080/foo/bar"),
NoPort: create("app-b", "http://localhost2"),
Default: create("app-a", "https://localhost1:8080/foo/bar"),
NoPort: create("app-b", "https://localhost2"),
Subdomain: create("app-z", "http://30.localhost:3000"),
Extra: []codersdk.OAuth2ProviderApp{
create("app-x", "http://20.localhost:3000"),
+126 -66
View File
@@ -10,6 +10,7 @@ import (
"github.com/google/uuid"
"github.com/sqlc-dev/pqtype"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/coderd/audit"
@@ -22,39 +23,61 @@ import (
"github.com/coder/coder/v2/codersdk"
)
// errRedirectURIValidation ends the update transaction when the resolved list
// fails validation. The handler reports the collected validation errors instead.
var errRedirectURIValidation = xerrors.New("redirect URI validation failed")
// validateRedirectURIFieldsAgree returns an error when a request sets both
// callback_url and redirect_uris but they name a different first URI. The two
// fields mean the same thing, so a mismatch means the client edited one and
// forgot the other. Rejecting it avoids guessing which edit was intended.
func validateRedirectURIFieldsAgree(callbackURL string, redirectURIs []string) []codersdk.ValidationError {
if callbackURL == "" || len(redirectURIs) == 0 || redirectURIs[0] == callbackURL {
return nil
}
return []codersdk.ValidationError{{
Field: "callback_url",
Detail: "callback URL must equal the first redirect URI when both are sent",
}}
}
// resolveRedirectURIs returns the redirect URIs an app should have after a
// create or update request. The first entry is the primary.
//
// If the request has redirectURIs, that list is used. If it also has
// callbackURL, callbackURL is moved to the front of the list.
// If the request has only callbackURL, an update replaces the first stored
// URI with callbackURL and keeps the rest. A create uses callbackURL alone.
// If the request has neither, the stored list is kept.
// stored is nil on a create.
//
// Only the list-only and callback-only shapes have callers today.
// Sending redirectURIs replaces the whole stored list. Sending only
// callbackURL replaces the first stored URI and keeps the rest. Sending
// neither keeps the stored list. stored is nil on a create.
func resolveRedirectURIs(callbackURL string, redirectURIs, stored []string) []string {
list := slice.Unique(redirectURIs)
if len(list) == 0 && len(stored) > 0 {
if callbackURL == "" {
return stored
}
list = stored[1:]
if redirectURIs != nil {
return slice.Unique(redirectURIs)
}
if callbackURL != "" {
list = slices.DeleteFunc(slices.Clone(list), func(s string) bool { return s == callbackURL })
list = append([]string{callbackURL}, list...)
if callbackURL == "" {
return stored
}
return list
if len(stored) == 0 {
return []string{callbackURL}
}
rest := slices.DeleteFunc(slices.Clone(stored[1:]), func(s string) bool { return s == callbackURL })
return append([]string{callbackURL}, rest...)
}
// emptyRedirectURIsDetail returns the error detail for an empty resolved list.
func emptyRedirectURIsDetail(fromCallback string) string {
if fromCallback == "" {
return "at least one redirect URI is required"
}
return "redirect_uris was sent as an empty list, which overrides callback_url; send at least one redirect URI, or omit redirect_uris to use callback_url"
}
// validateAppRedirectURIFields checks the list an admin request resolved to
// and reports each failure against the request field that caused it. Every
// entry passes ValidateRedirectURIShape; entries of a public app also
// pass ValidateRedirectURI.
// entry passes both ValidateRedirectURIShape and ValidateRedirectURI, so an
// admin cannot store a redirect URI that dynamic client registration would
// refuse for the same client type.
//
// Stored URIs are checked again on every update. An app that predates the
// caps and no longer passes must be deleted and created again.
// Stored URIs are checked again on every update, so an app that predates the
// caps, or that stored a cleartext http URI while this path did not check
// the scheme, cannot be saved until a request sends a list that passes.
func validateAppRedirectURIFields(uris []string, clientType codersdk.OAuth2ClientType, fromCallback string) []codersdk.ValidationError {
// A failure on the request's callback_url is reported against that field
// and without a list index, since the caller never sent a list.
@@ -70,7 +93,7 @@ func validateAppRedirectURIFields(uris []string, clientType codersdk.OAuth2Clien
if len(uris) == 0 {
return []codersdk.ValidationError{{
Field: "redirect_uris",
Detail: "at least one redirect URI is required",
Detail: emptyRedirectURIsDetail(fromCallback),
}}
}
if len(uris) > codersdk.OAuth2RedirectURIsMaxCount {
@@ -86,9 +109,8 @@ func validateAppRedirectURIFields(uris []string, clientType codersdk.OAuth2Clien
if err := codersdk.ValidateRedirectURIShape(uri); err != nil {
return invalid(i, uri, err.Error())
}
if clientType != codersdk.OAuth2ClientTypePublic {
continue
}
// The shape check runs first because it names the more specific
// reason for a malformed URI.
if err := codersdk.ValidateRedirectURI(uri, clientType); err != nil {
return invalid(i, uri, err.Error())
}
@@ -189,8 +211,12 @@ func CreateApp(db database.Store, accessURL *url.URL, auditor *audit.Auditor, lo
if !httpapi.Read(ctx, rw, r, &req) {
return
}
redirectURIs := resolveRedirectURIs(req.CallbackURL, nil, nil)
if errs := validateAppRedirectURIFields(redirectURIs, codersdk.OAuth2ClientTypeConfidential, req.CallbackURL); errs != nil {
redirectURIs := resolveRedirectURIs(req.CallbackURL, req.RedirectURIs, nil)
errs := validateRedirectURIFieldsAgree(req.CallbackURL, req.RedirectURIs)
if errs == nil {
errs = validateAppRedirectURIFields(redirectURIs, codersdk.OAuth2ClientTypeConfidential, req.CallbackURL)
}
if errs != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Validation failed.",
Validations: errs,
@@ -259,58 +285,92 @@ func UpdateApp(db database.Store, accessURL *url.URL, auditor *audit.Auditor, lo
if !httpapi.Read(ctx, rw, r, &req) {
return
}
clientType := codersdk.OAuth2ClientTypeConfidential
if app.IsPublic() {
clientType = codersdk.OAuth2ClientTypePublic
}
redirectURIs := resolveRedirectURIs(req.CallbackURL, nil, app.RegisteredRedirectURIs())
if errs := validateAppRedirectURIFields(redirectURIs, clientType, req.CallbackURL); errs != nil {
if errs := validateRedirectURIFieldsAgree(req.CallbackURL, req.RedirectURIs); errs != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Validation failed.",
Validations: errs,
})
return
}
scope := app.Scope // Keep existing value
if req.Scope != nil {
if writeInvalidScopeError(ctx, rw, *req.Scope) {
if req.Scope != nil && writeInvalidScopeError(ctx, rw, *req.Scope) {
return
}
var (
updated database.OAuth2ProviderApp
validationErrs []codersdk.ValidationError
)
err := db.InTx(func(tx database.Store) error {
// The middleware read is a snapshot. When the request omits the
// URI fields, the stored list is written back, so it must come
// from a locked read or a concurrent change to it would be undone.
current, err := tx.GetOAuth2ProviderAppByIDForUpdate(ctx, app.ID)
if err != nil {
return xerrors.Errorf("get OAuth2 app for update: %w", err)
}
aReq.Old = current
clientType := codersdk.OAuth2ClientTypeConfidential
if current.IsPublic() {
clientType = codersdk.OAuth2ClientTypePublic
}
redirectURIs := resolveRedirectURIs(req.CallbackURL, req.RedirectURIs, current.RegisteredRedirectURIs())
validationErrs = validateAppRedirectURIFields(redirectURIs, clientType, req.CallbackURL)
if validationErrs != nil {
return errRedirectURIValidation
}
scope := current.Scope // Keep existing value
if req.Scope != nil {
scope = scopeAllowlist(*req.Scope)
}
updated, err = tx.UpdateOAuth2ProviderAppByID(ctx, database.UpdateOAuth2ProviderAppByIDParams{
ID: current.ID,
UpdatedAt: dbtime.Now(),
Name: req.Name,
Icon: req.Icon,
CallbackURL: redirectURIs[0],
RedirectUris: redirectURIs,
ClientType: current.ClientType, // Keep existing value
DynamicallyRegistered: current.DynamicallyRegistered, // Keep existing value
ClientSecretExpiresAt: current.ClientSecretExpiresAt, // Keep existing value
GrantTypes: current.GrantTypes, // Keep existing value
ResponseTypes: current.ResponseTypes, // Keep existing value
TokenEndpointAuthMethod: current.TokenEndpointAuthMethod, // Keep existing value
Scope: scope,
Contacts: current.Contacts, // Keep existing value
ClientUri: current.ClientUri, // Keep existing value
LogoUri: current.LogoUri, // Keep existing value
TosUri: current.TosUri, // Keep existing value
PolicyUri: current.PolicyUri, // Keep existing value
JwksUri: current.JwksUri, // Keep existing value
Jwks: current.Jwks, // Keep existing value
SoftwareID: current.SoftwareID, // Keep existing value
SoftwareVersion: current.SoftwareVersion, // Keep existing value
})
if err != nil {
return xerrors.Errorf("update OAuth2 app: %w", err)
}
return nil
}, nil)
if validationErrs != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Validation failed.",
Validations: validationErrs,
})
return
}
if err != nil {
if httpapi.Is404Error(err) {
httpapi.ResourceNotFound(rw)
return
}
scope = scopeAllowlist(*req.Scope)
}
app, err := db.UpdateOAuth2ProviderAppByID(ctx, database.UpdateOAuth2ProviderAppByIDParams{
ID: app.ID,
UpdatedAt: dbtime.Now(),
Name: req.Name,
Icon: req.Icon,
CallbackURL: redirectURIs[0],
RedirectUris: redirectURIs,
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: scope,
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))
aReq.New = updated
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.OAuth2ProviderApp(accessURL, updated))
}
}
+55 -13
View File
@@ -34,16 +34,11 @@ func TestResolveRedirectURIs(t *testing.T) {
want: []string{c},
},
{
name: "ListWithNewCallback",
callbackURL: c,
name: "ListWithMatchingCallback",
callbackURL: a,
redirectURIs: []string{a, b},
want: []string{c, a, b},
},
{
name: "ListWithCallbackFromList",
callbackURL: b,
redirectURIs: []string{a, b},
want: []string{b, a},
stored: []string{c},
want: []string{a, b},
},
{
name: "CallbackOnlyCreate",
@@ -75,7 +70,20 @@ func TestResolveRedirectURIs(t *testing.T) {
},
{
name: "NeitherOnCreate",
want: []string{},
want: nil,
},
{
name: "ExplicitEmptyListIgnoresStored",
redirectURIs: []string{},
stored: []string{a, b},
want: []string{},
},
{
name: "ExplicitEmptyListIgnoresCallback",
callbackURL: c,
redirectURIs: []string{},
stored: []string{a, b},
want: []string{},
},
}
@@ -84,10 +92,44 @@ func TestResolveRedirectURIs(t *testing.T) {
t.Parallel()
got := resolveRedirectURIs(tc.callbackURL, tc.redirectURIs, tc.stored)
require.Len(t, got, len(tc.want))
if len(tc.want) > 0 {
require.Equal(t, tc.want, got)
require.Equal(t, tc.want, got)
})
}
}
func TestValidateRedirectURIFieldsAgree(t *testing.T) {
t.Parallel()
const (
a = "https://a.example.com/callback"
b = "https://b.example.com/callback"
)
tests := []struct {
name string
callbackURL string
redirectURIs []string
wantErr bool
}{
{name: "ListOnly", redirectURIs: []string{a, b}},
{name: "CallbackOnly", callbackURL: a},
{name: "CallbackWithEmptyList", callbackURL: a, redirectURIs: []string{}},
{name: "CallbackMatchesFirst", callbackURL: a, redirectURIs: []string{a, b}},
{name: "CallbackMatchesLater", callbackURL: b, redirectURIs: []string{a, b}, wantErr: true},
{name: "CallbackNotInList", callbackURL: "https://c.example.com/callback", redirectURIs: []string{a, b}, wantErr: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
errs := validateRedirectURIFieldsAgree(tc.callbackURL, tc.redirectURIs)
if !tc.wantErr {
require.Nil(t, errs)
return
}
require.Len(t, errs, 1)
require.Equal(t, "callback_url", errs[0].Field)
})
}
}
+21 -18
View File
@@ -256,11 +256,11 @@ type authorizeFailure struct {
// the answer stays on this server, because the failure names the redirect
// URI or the client identifier.
redirect authorizeResponse
// corruptCallback is set when the app's registered callback does not parse
// or uses a scheme registration rejects. That is bad server state rather
// than a client mistake, so it answers 500, and it is decided before any
// parameter is read.
corruptCallback error
// corruptRedirectURI is set when one of the app's registered redirect URIs
// does not parse or uses a scheme registration rejects. That is bad server
// state rather than a client mistake, so it answers 500, and it is decided
// before any parameter is read.
corruptRedirectURI error
// code is the OAuth2 error to answer with. Read it through errorCode, which
// supplies the invalid_request default.
code codersdk.OAuth2ErrorCode
@@ -292,7 +292,7 @@ const (
func (f authorizeFailure) kind() failureKind {
switch {
case f.corruptCallback != nil:
case f.corruptRedirectURI != nil:
return failureCorruptRegistration
case f.redirect.canRedirect():
return failureDeliverToClient
@@ -310,7 +310,7 @@ func extractAuthorizeParams(r *http.Request, logger slog.Logger, app database.OA
response, err := newAuthorizeResponse(p, vals, app)
if err != nil {
return authorizeParams{}, &authorizeFailure{corruptCallback: err}
return authorizeParams{}, &authorizeFailure{corruptRedirectURI: err}
}
params := authorizeParams{
@@ -481,7 +481,9 @@ func newAuthorizeResponse(p *httpapi.QueryParamParser, vals url.Values, app data
}
for _, u := range append([]*url.URL{primary}, alternates...) {
if err := codersdk.ValidateRedirectURIScheme(u); err != nil {
return authorizeResponse{}, err
// The scheme error names no URI, so the log would otherwise
// carry the whole list with no way to tell which entry failed.
return authorizeResponse{}, xerrors.Errorf("registered redirect URI %q: %w", u.Redacted(), err)
}
}
@@ -584,11 +586,12 @@ func redirectAuthorizeError(rw http.ResponseWriter, r *http.Request, logger slog
http.Redirect(rw, r, response.errorURL(code, description).String(), http.StatusFound)
}
// logCorruptCallback reports a registered callback URL this server should never
// have stored: unparsable, or using a scheme registration rejects. The response
// only says the callback is bad, so operators need the log to identify the app.
func logCorruptCallback(ctx context.Context, logger slog.Logger, app database.OAuth2ProviderApp, err error) {
logger.Error(ctx, "oauth2 app has an unusable registered callback URL",
// logCorruptRedirectURI reports a registered redirect URI this server should
// never have stored: unparsable, or using a scheme registration rejects. The
// response only says one entry is bad, so operators need the log to identify
// the app and which URI failed.
func logCorruptRedirectURI(ctx context.Context, logger slog.Logger, app database.OAuth2ProviderApp, err error) {
logger.Error(ctx, "oauth2 app has an unusable registered redirect URI",
slog.Error(err),
slog.F("app_id", app.ID.String()),
slog.F("redirect_uris", app.RegisteredRedirectURIs()))
@@ -620,9 +623,9 @@ func ShowAuthorizePage(accessURL *url.URL, logger slog.Logger) http.HandlerFunc
if failure != nil {
switch failure.kind() {
case failureCorruptRegistration:
logCorruptCallback(r.Context(), logger, app, failure.corruptCallback)
errorPage(http.StatusInternalServerError, "Invalid Callback URL",
"The application's registered callback URL is not usable.", nil)
logCorruptRedirectURI(r.Context(), logger, app, failure.corruptRedirectURI)
errorPage(http.StatusInternalServerError, "Invalid Redirect URI",
"One of the application's registered redirect URIs is not usable.", nil)
case failureDeliverToClient:
// §4.1.2.1: once the callback has been matched against the app's
@@ -716,10 +719,10 @@ func ProcessAuthorize(db database.Store, logger slog.Logger) http.HandlerFunc {
if failure != nil {
switch failure.kind() {
case failureCorruptRegistration:
logCorruptCallback(ctx, logger, app, failure.corruptCallback)
logCorruptRedirectURI(ctx, logger, app, failure.corruptRedirectURI)
httpapi.WriteOAuth2Error(ctx, rw, http.StatusInternalServerError,
codersdk.OAuth2ErrorCodeServerError,
"The application's registered callback URL is not usable")
"One of the application's registered redirect URIs is not usable")
case failureDeliverToClient:
redirectAuthorizeError(rw, r, logger, failure.redirect,
@@ -643,13 +643,13 @@ func TestFailureKind(t *testing.T) {
t.Parallel()
deliverable := authorizeResponse{callback: &url.URL{Scheme: "https", Host: "app.example.com"}}
unusable := xerrors.New("registered callback is not usable")
unusable := xerrors.New("registered redirect URI is not usable")
require.Equal(t, failureAnswerHere, authorizeFailure{}.kind())
require.Equal(t, failureDeliverToClient, authorizeFailure{redirect: deliverable}.kind())
require.Equal(t, failureCorruptRegistration, authorizeFailure{corruptCallback: unusable}.kind())
require.Equal(t, failureCorruptRegistration, authorizeFailure{corruptRedirectURI: unusable}.kind())
require.Equal(t, failureCorruptRegistration,
authorizeFailure{corruptCallback: unusable, redirect: deliverable}.kind(),
authorizeFailure{corruptRedirectURI: unusable, redirect: deliverable}.kind(),
"the registration is what a Location header would be trusting, so its failure outranks a usable callback")
}
+7 -7
View File
@@ -394,8 +394,8 @@ func TestOAuth2AuthorizeScopeNegotiation(t *testing.T) {
require.Empty(t, getResp.Header.Get("Location"),
"GET: a dangerous scheme must never reach a Location header")
getBody := readBody(t, getResp)
require.Contains(t, getBody, "Invalid Callback URL",
"GET: the failure must name the callback URL, not the scope")
require.Contains(t, getBody, "Invalid Redirect URI",
"GET: the failure must name the redirect URI, not the scope")
require.NotContains(t, getBody, "javascript:",
"GET: the scheme must not reach the page as a link either")
@@ -406,8 +406,8 @@ func TestOAuth2AuthorizeScopeNegotiation(t *testing.T) {
"POST: a dangerous scheme must never reach a Location header")
postBody := readBody(t, postResp)
require.Contains(t, postBody, string(codersdk.OAuth2ErrorCodeServerError))
require.Contains(t, postBody, "callback URL is not usable",
"POST: the failure must name the callback, not just the error class")
require.Contains(t, postBody, "registered redirect URIs is not usable",
"POST: the failure must name the redirect URI, not just the error class")
})
// The other half of the same class: a stored callback that does not even
@@ -428,8 +428,8 @@ func TestOAuth2AuthorizeScopeNegotiation(t *testing.T) {
defer getResp.Body.Close()
require.Equal(t, http.StatusInternalServerError, getResp.StatusCode)
getBody := readBody(t, getResp)
require.Contains(t, getBody, "Invalid Callback URL",
"GET: the failure must name the callback URL")
require.Contains(t, getBody, "Invalid Redirect URI",
"GET: the failure must name the redirect URI")
require.NotContains(t, getBody, unparsable,
"GET: the Go parse error carries the stored URL, which must not reach the page")
@@ -438,7 +438,7 @@ func TestOAuth2AuthorizeScopeNegotiation(t *testing.T) {
require.Equal(t, http.StatusInternalServerError, postResp.StatusCode)
postBody := readBody(t, postResp)
require.Contains(t, postBody, string(codersdk.OAuth2ErrorCodeServerError))
require.Contains(t, postBody, "callback URL is not usable",
require.Contains(t, postBody, "registered redirect URIs is not usable",
"POST: nothing was validated, so the description must not blame the query")
})
@@ -62,8 +62,8 @@ func CreateTestOAuth2App(t *testing.T, client *codersdk.Client) (*codersdk.OAuth
appName := fmt.Sprintf("test-oauth2-app-%s", testutil.MustRandString(t, 10))
req := codersdk.PostOAuth2ProviderAppRequest{
Name: appName,
CallbackURL: TestRedirectURI,
Name: appName,
RedirectURIs: []string{TestRedirectURI},
}
app, err := client.PostOAuth2ProviderApp(ctx, req)
+541 -10
View File
@@ -7,6 +7,7 @@ import (
"net/http"
"slices"
"strings"
"sync/atomic"
"testing"
"time"
@@ -281,6 +282,7 @@ func TestOAuth2ProviderAppValidation(t *testing.T) {
}
require.NoError(t, err)
require.Equal(t, test.callbackURL, updated.CallbackURL)
require.Equal(t, []string{test.callbackURL}, updated.RedirectURIs)
require.Equal(t, codersdk.OAuth2ClientTypePublic, updated.ClientType)
// The edit replaces the registered URI rather than adding to it.
@@ -531,7 +533,7 @@ func TestOAuth2ProviderAppOperations(t *testing.T) {
// Should be able to keep the same name when updating.
req := codersdk.PutOAuth2ProviderAppRequest{
Name: expectedApps.Default.Name,
CallbackURL: "http://coder.com",
CallbackURL: "https://coder.com",
Icon: "test",
}
//nolint:gocritic // OAuth2 app management requires owner permission.
@@ -543,6 +545,7 @@ func TestOAuth2ProviderAppOperations(t *testing.T) {
require.Equal(t, expectedApps.Default.ID, newApp.ID)
// The callback is stored as the app's only redirect URI.
require.Equal(t, []string{req.CallbackURL}, newApp.RedirectURIs)
stored, err := db.GetOAuth2ProviderAppByID(ctx, newApp.ID)
require.NoError(t, err)
require.Equal(t, []string{req.CallbackURL}, stored.RedirectUris)
@@ -551,7 +554,7 @@ func TestOAuth2ProviderAppOperations(t *testing.T) {
// Should be able to update name.
req = codersdk.PutOAuth2ProviderAppRequest{
Name: "new-foo",
CallbackURL: "http://coder.com",
CallbackURL: "https://coder.com",
Icon: "test",
}
//nolint:gocritic // OAuth2 app management requires owner permission.
@@ -605,7 +608,7 @@ func TestOAuth2ProviderAppOperations(t *testing.T) {
//nolint:gocritic // OAuth2 app management requires owner permission.
app, err := client.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{
Name: "scope-test-unrestricted",
CallbackURL: "http://coder.com",
CallbackURL: "https://coder.com",
})
require.NoError(t, err)
require.Empty(t, app.Scope)
@@ -615,7 +618,7 @@ func TestOAuth2ProviderAppOperations(t *testing.T) {
//nolint:gocritic // OAuth2 app management requires owner permission.
app, err = client.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{
Name: "scope-test-scoped",
CallbackURL: "http://coder.com",
CallbackURL: "https://coder.com",
Scope: "all workspace:read all",
})
require.NoError(t, err)
@@ -684,7 +687,7 @@ func TestOAuth2ProviderAppOperations(t *testing.T) {
//nolint:gocritic // OAuth2 app management requires owner permission.
admin, err := client.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{
Name: "scope-origin-admin",
CallbackURL: "http://coder.com",
CallbackURL: "https://coder.com",
Scope: "all workspace:read all",
})
require.NoError(t, err)
@@ -732,6 +735,51 @@ func requireRedirectURIsValidationError(t *testing.T, err error, detail string)
}, "expected redirect_uris validation error, got: %+v", apiErr.Validations)
}
// competingRedirectURIWriteStore removes the second redirect URI of an app
// right after the middleware reads it, once armed, and returns the row as it
// was before the removal. This stands in for another admin whose update
// commits while a request is in flight.
type competingRedirectURIWriteStore struct {
database.Store
armed atomic.Bool
}
func (s *competingRedirectURIWriteStore) GetOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) (database.OAuth2ProviderApp, error) {
app, err := s.Store.GetOAuth2ProviderAppByID(ctx, id)
if err != nil || !s.armed.CompareAndSwap(true, false) {
return app, err
}
_, err = s.UpdateOAuth2ProviderAppByID(ctx, database.UpdateOAuth2ProviderAppByIDParams{
ID: app.ID,
UpdatedAt: app.UpdatedAt,
Name: app.Name,
Icon: app.Icon,
CallbackURL: app.RedirectUris[0],
RedirectUris: app.RedirectUris[:1],
ClientType: app.ClientType,
DynamicallyRegistered: app.DynamicallyRegistered,
ClientSecretExpiresAt: app.ClientSecretExpiresAt,
GrantTypes: app.GrantTypes,
ResponseTypes: app.ResponseTypes,
TokenEndpointAuthMethod: app.TokenEndpointAuthMethod,
Scope: app.Scope,
Contacts: app.Contacts,
ClientUri: app.ClientUri,
LogoUri: app.LogoUri,
TosUri: app.TosUri,
PolicyUri: app.PolicyUri,
JwksUri: app.JwksUri,
Jwks: app.Jwks,
SoftwareID: app.SoftwareID,
SoftwareVersion: app.SoftwareVersion,
})
if err != nil {
return database.OAuth2ProviderApp{}, err
}
return app, nil
}
type provisionedApps struct {
Default codersdk.OAuth2ProviderApp
NoPort codersdk.OAuth2ProviderApp
@@ -745,19 +793,20 @@ func generateApps(ctx context.Context, t *testing.T, client *codersdk.Client, su
name = fmt.Sprintf("%s-%s", name, suffix)
//nolint:gocritic // OAuth2 app management requires owner permission.
app, err := client.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{
Name: name,
CallbackURL: callback,
Icon: "",
Name: name,
RedirectURIs: []string{callback},
Icon: "",
})
require.NoError(t, err)
require.Equal(t, name, app.Name)
require.Equal(t, callback, app.CallbackURL)
require.Equal(t, []string{callback}, app.RedirectURIs)
return app
}
return provisionedApps{
Default: create("app-a", "http://localhost1:8080/foo/bar"),
NoPort: create("app-b", "http://localhost2"),
Default: create("app-a", "https://localhost1:8080/foo/bar"),
NoPort: create("app-b", "https://localhost2"),
Subdomain: create("app-z", "http://30.localhost:3000"),
Extra: []codersdk.OAuth2ProviderApp{
create("app-x", "http://20.localhost:3000"),
@@ -1019,4 +1068,486 @@ func TestOAuth2ProviderAppRedirectURIs(t *testing.T) {
require.NoError(t, err)
require.Equal(t, []string{first, second}, config.RedirectURIs)
})
// A stored list with a duplicate, which DCR accepted before the caps
// existed, is returned deduplicated by both the admin API and the client
// configuration endpoint.
t.Run("StoredDuplicateReadsAgree", func(t *testing.T) {
t.Parallel()
db, pubsub := dbtestutil.NewDB(t)
client := coderdtest.New(t, &coderdtest.Options{Database: db, Pubsub: pubsub})
_ = coderdtest.CreateFirstUser(t, client)
oauth2providertest.EnableDCR(t, client)
ctx := testutil.Context(t, testutil.WaitLong)
registered, err := client.PostOAuth2ClientRegistration(ctx, codersdk.OAuth2ClientRegistrationRequest{
ClientName: testutil.GetRandomName(t),
RedirectURIs: []string{first, second},
})
require.NoError(t, err)
appID, err := uuid.Parse(registered.ClientID)
require.NoError(t, err)
// Every write path deduplicates, so the duplicate goes in directly.
stored, err := db.GetOAuth2ProviderAppByID(ctx, appID)
require.NoError(t, err)
_, err = db.UpdateOAuth2ProviderAppByID(ctx, database.UpdateOAuth2ProviderAppByIDParams{
ID: stored.ID,
UpdatedAt: stored.UpdatedAt,
Name: stored.Name,
Icon: stored.Icon,
CallbackURL: stored.CallbackURL,
RedirectUris: []string{first, second, first},
ClientType: stored.ClientType,
DynamicallyRegistered: stored.DynamicallyRegistered,
ClientSecretExpiresAt: stored.ClientSecretExpiresAt,
GrantTypes: stored.GrantTypes,
ResponseTypes: stored.ResponseTypes,
TokenEndpointAuthMethod: stored.TokenEndpointAuthMethod,
Scope: stored.Scope,
Contacts: stored.Contacts,
ClientUri: stored.ClientUri,
LogoUri: stored.LogoUri,
TosUri: stored.TosUri,
PolicyUri: stored.PolicyUri,
JwksUri: stored.JwksUri,
Jwks: stored.Jwks,
SoftwareID: stored.SoftwareID,
SoftwareVersion: stored.SoftwareVersion,
})
require.NoError(t, err)
config, err := client.GetOAuth2ClientConfiguration(ctx, registered.ClientID, registered.RegistrationAccessToken)
require.NoError(t, err)
require.Equal(t, []string{first, second}, config.RedirectURIs)
//nolint:gocritic // OAuth2 app management requires owner permission.
app, err := client.OAuth2ProviderApp(ctx, appID)
require.NoError(t, err)
require.Equal(t, config.RedirectURIs, app.RedirectURIs)
})
// Creating and updating an app with the redirect_uris field stores and
// returns the list as given, with callback_url equal to the first entry.
t.Run("ExplicitListOnCreateAndUpdate", func(t *testing.T) {
t.Parallel()
db, pubsub := dbtestutil.NewDB(t)
client := coderdtest.New(t, &coderdtest.Options{Database: db, Pubsub: pubsub})
_ = coderdtest.CreateFirstUser(t, client)
ctx := testutil.Context(t, testutil.WaitLong)
//nolint:gocritic // OAuth2 app management requires owner permission.
app, err := client.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{
Name: "explicit-list",
RedirectURIs: []string{first, second},
})
require.NoError(t, err)
require.Equal(t, first, app.CallbackURL)
require.Equal(t, []string{first, second}, app.RedirectURIs)
//nolint:gocritic // OAuth2 app management requires owner permission.
updated, err := client.PutOAuth2ProviderApp(ctx, app.ID, codersdk.PutOAuth2ProviderAppRequest{
Name: "explicit-list",
RedirectURIs: []string{third, second},
})
require.NoError(t, err)
require.Equal(t, third, updated.CallbackURL)
require.Equal(t, []string{third, second}, updated.RedirectURIs)
stored, err := db.GetOAuth2ProviderAppByID(ctx, app.ID)
require.NoError(t, err)
require.Equal(t, []string{third, second}, stored.RedirectUris)
})
// Omitting both fields on an update keeps the stored list unchanged.
t.Run("NeitherFieldKeepsStoredList", func(t *testing.T) {
t.Parallel()
db, pubsub := dbtestutil.NewDB(t)
client := coderdtest.New(t, &coderdtest.Options{Database: db, Pubsub: pubsub})
_ = coderdtest.CreateFirstUser(t, client)
ctx := testutil.Context(t, testutil.WaitLong)
//nolint:gocritic // OAuth2 app management requires owner permission.
app, err := client.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{
Name: "keep-list",
RedirectURIs: []string{first, second},
})
require.NoError(t, err)
//nolint:gocritic // OAuth2 app management requires owner permission.
updated, err := client.PutOAuth2ProviderApp(ctx, app.ID, codersdk.PutOAuth2ProviderAppRequest{
Name: "renamed",
})
require.NoError(t, err)
require.Equal(t, []string{first, second}, updated.RedirectURIs)
stored, err := db.GetOAuth2ProviderAppByID(ctx, app.ID)
require.NoError(t, err)
require.Equal(t, []string{first, second}, stored.RedirectUris)
})
// Sending both fields is a 400 unless callback_url is the first redirect
// URI, so an edit to redirect_uris is never undone by a stale callback_url.
t.Run("BothFieldsMustAgree", func(t *testing.T) {
t.Parallel()
db, pubsub := dbtestutil.NewDB(t)
client := coderdtest.New(t, &coderdtest.Options{Database: db, Pubsub: pubsub})
_ = coderdtest.CreateFirstUser(t, client)
ctx := testutil.Context(t, testutil.WaitLong)
//nolint:gocritic // OAuth2 app management requires owner permission.
_, err := client.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{
Name: "both-fields-create",
CallbackURL: second,
RedirectURIs: []string{first, second},
})
requireCallbackURLValidationError(t, err)
//nolint:gocritic // OAuth2 app management requires owner permission.
app, err := client.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{
Name: "both-fields",
RedirectURIs: []string{first, second},
})
require.NoError(t, err)
// Echoing the GET body back unchanged still works.
//nolint:gocritic // OAuth2 app management requires owner permission.
updated, err := client.PutOAuth2ProviderApp(ctx, app.ID, codersdk.PutOAuth2ProviderAppRequest{
Name: app.Name,
Icon: app.Icon,
CallbackURL: app.CallbackURL,
RedirectURIs: app.RedirectURIs,
})
require.NoError(t, err)
require.Equal(t, []string{first, second}, updated.RedirectURIs)
// Removing the primary from the list while echoing the old callback_url.
//nolint:gocritic // OAuth2 app management requires owner permission.
_, err = client.PutOAuth2ProviderApp(ctx, app.ID, codersdk.PutOAuth2ProviderAppRequest{
Name: app.Name,
CallbackURL: first,
RedirectURIs: []string{second},
})
requireCallbackURLValidationError(t, err)
// Reordering the list while echoing the old callback_url.
//nolint:gocritic // OAuth2 app management requires owner permission.
_, err = client.PutOAuth2ProviderApp(ctx, app.ID, codersdk.PutOAuth2ProviderAppRequest{
Name: app.Name,
CallbackURL: first,
RedirectURIs: []string{second, first},
})
requireCallbackURLValidationError(t, err)
// A callback_url that is not in the list at all.
//nolint:gocritic // OAuth2 app management requires owner permission.
_, err = client.PutOAuth2ProviderApp(ctx, app.ID, codersdk.PutOAuth2ProviderAppRequest{
Name: app.Name,
CallbackURL: third,
RedirectURIs: []string{first, second},
})
requireCallbackURLValidationError(t, err)
stored, err := db.GetOAuth2ProviderAppByID(ctx, app.ID)
require.NoError(t, err)
require.Equal(t, []string{first, second}, stored.RedirectUris)
})
// A rename-only update writes the stored redirect URIs back, so a change
// to them that lands between the middleware read and the update must not
// be undone.
t.Run("RenameKeepsConcurrentRedirectURIChange", func(t *testing.T) {
t.Parallel()
db, pubsub := dbtestutil.NewDB(t)
store := &competingRedirectURIWriteStore{Store: db}
client := coderdtest.New(t, &coderdtest.Options{Database: store, Pubsub: pubsub})
_ = coderdtest.CreateFirstUser(t, client)
ctx := testutil.Context(t, testutil.WaitLong)
//nolint:gocritic // OAuth2 app management requires owner permission.
app, err := client.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{
Name: "concurrent",
RedirectURIs: []string{first, second},
})
require.NoError(t, err)
store.armed.Store(true)
//nolint:gocritic // OAuth2 app management requires owner permission.
updated, err := client.PutOAuth2ProviderApp(ctx, app.ID, codersdk.PutOAuth2ProviderAppRequest{
Name: "renamed",
})
require.NoError(t, err)
require.False(t, store.armed.Load(), "the competing write did not run")
require.Equal(t, "renamed", updated.Name)
require.Equal(t, []string{first}, updated.RedirectURIs)
stored, err := db.GetOAuth2ProviderAppByID(ctx, app.ID)
require.NoError(t, err)
require.Equal(t, []string{first}, stored.RedirectUris)
})
// The cap on redirect_uris is reachable from a single admin request.
t.Run("ListCap", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
ctx := testutil.Context(t, testutil.WaitLong)
// Entries are distinct because duplicates are dropped before the count.
uris := make([]string, 0, codersdk.OAuth2RedirectURIsMaxCount+1)
for i := range codersdk.OAuth2RedirectURIsMaxCount + 1 {
uris = append(uris, fmt.Sprintf("https://example.com/callback/%d", i))
}
//nolint:gocritic // OAuth2 app management requires owner permission.
app, err := client.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{
Name: "at-cap",
RedirectURIs: uris[:codersdk.OAuth2RedirectURIsMaxCount],
})
require.NoError(t, err)
require.Len(t, app.RedirectURIs, codersdk.OAuth2RedirectURIsMaxCount)
var sdkErr *codersdk.Error
//nolint:gocritic // OAuth2 app management requires owner permission.
_, err = client.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{
Name: "over-cap",
RedirectURIs: uris,
})
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
require.Len(t, sdkErr.Validations, 1)
require.Equal(t, "redirect_uris", sdkErr.Validations[0].Field)
require.Equal(t, "at most 32 redirect URIs are allowed", sdkErr.Validations[0].Detail)
//nolint:gocritic // OAuth2 app management requires owner permission.
_, err = client.PutOAuth2ProviderApp(ctx, app.ID, codersdk.PutOAuth2ProviderAppRequest{
Name: "at-cap",
RedirectURIs: uris,
})
require.ErrorAs(t, err, &sdkErr)
require.Len(t, sdkErr.Validations, 1)
require.Equal(t, "redirect_uris", sdkErr.Validations[0].Field)
require.Equal(t, "at most 32 redirect URIs are allowed", sdkErr.Validations[0].Detail)
})
// The admin path applies the same transport rule as dynamic client
// registration, so an admin cannot store a cleartext target that a client
// could not register for itself.
t.Run("CleartextHTTPIsRefused", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
ctx := testutil.Context(t, testutil.WaitLong)
var sdkErr *codersdk.Error
//nolint:gocritic // OAuth2 app management requires owner permission.
_, err := client.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{
Name: "cleartext-create",
RedirectURIs: []string{"http://plaintext.example.com/callback"},
})
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
require.Len(t, sdkErr.Validations, 1)
require.Equal(t, "redirect_uris", sdkErr.Validations[0].Field)
require.Equal(t, "redirect URI at index 0 must use https scheme for non-localhost URLs", sdkErr.Validations[0].Detail)
// The index names the offending entry rather than the first one.
//nolint:gocritic // OAuth2 app management requires owner permission.
_, err = client.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{
Name: "cleartext-create-second",
RedirectURIs: []string{first, "http://plaintext.example.com/callback"},
})
sdkErr = nil
require.ErrorAs(t, err, &sdkErr)
require.Len(t, sdkErr.Validations, 1)
require.Equal(t, "redirect URI at index 1 must use https scheme for non-localhost URLs", sdkErr.Validations[0].Detail)
// The deprecated field is reported against its own name.
//nolint:gocritic // OAuth2 app management requires owner permission.
_, err = client.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{
Name: "cleartext-create-callback",
CallbackURL: "http://plaintext.example.com/callback",
})
sdkErr = nil
require.ErrorAs(t, err, &sdkErr)
require.Len(t, sdkErr.Validations, 1)
require.Equal(t, "callback_url", sdkErr.Validations[0].Field)
require.Equal(t, "callback URL must use https scheme for non-localhost URLs", sdkErr.Validations[0].Detail)
// Loopback and localhost subdomains stay usable for local development,
// and https to any host is unaffected.
//nolint:gocritic // OAuth2 app management requires owner permission.
app, err := client.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{
Name: "cleartext-allowed-forms",
RedirectURIs: []string{
"http://localhost:3000/callback",
"http://127.0.0.1:3000/callback",
"http://app.localhost/callback",
"https://plaintext.example.com/callback",
},
})
require.NoError(t, err)
require.Len(t, app.RedirectURIs, 4)
// An update is checked the same way.
//nolint:gocritic // OAuth2 app management requires owner permission.
_, err = client.PutOAuth2ProviderApp(ctx, app.ID, codersdk.PutOAuth2ProviderAppRequest{
Name: "cleartext-allowed-forms",
RedirectURIs: []string{"http://plaintext.example.com/callback"},
})
sdkErr = nil
require.ErrorAs(t, err, &sdkErr)
require.Len(t, sdkErr.Validations, 1)
require.Equal(t, "redirect_uris", sdkErr.Validations[0].Field)
require.Equal(t, "redirect URI at index 0 must use https scheme for non-localhost URLs", sdkErr.Validations[0].Detail)
})
t.Run("LegacyCleartextHTTPCallback", func(t *testing.T) {
t.Parallel()
db, pubsub := dbtestutil.NewDB(t)
client := coderdtest.New(t, &coderdtest.Options{Database: db, Pubsub: pubsub})
_ = coderdtest.CreateFirstUser(t, client)
ctx := testutil.Context(t, testutil.WaitLong)
const legacy = "http://intranet.example.com/callback"
app := dbgen.OAuth2ProviderApp(t, db, database.OAuth2ProviderApp{
Name: "legacy-cleartext",
CallbackURL: legacy,
RedirectUris: []string{legacy},
})
query := authorizeQuery(t, app.ID.String(), "")
query.Set("redirect_uri", legacy)
resp := sendAuthorizeRequest(ctx, t, client, http.MethodGet, query)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode, readBody(t, resp))
//nolint:gocritic // OAuth2 app management requires owner permission.
_, err := client.PutOAuth2ProviderApp(ctx, app.ID, codersdk.PutOAuth2ProviderAppRequest{
Name: "renamed",
})
requireRedirectURIsValidationError(t, err, "redirect URI at index 0 must use https scheme for non-localhost URLs")
stored, err := db.GetOAuth2ProviderAppByID(ctx, app.ID)
require.NoError(t, err)
require.Equal(t, "legacy-cleartext", stored.Name)
require.Equal(t, legacy, stored.CallbackURL)
require.Equal(t, []string{legacy}, stored.RedirectUris)
//nolint:gocritic // OAuth2 app management requires owner permission.
updated, err := client.PutOAuth2ProviderApp(ctx, app.ID, codersdk.PutOAuth2ProviderAppRequest{
Name: "renamed",
RedirectURIs: []string{first, second},
})
require.NoError(t, err)
require.Equal(t, "renamed", updated.Name)
require.Equal(t, first, updated.CallbackURL)
require.Equal(t, []string{first, second}, updated.RedirectURIs)
stored, err = db.GetOAuth2ProviderAppByID(ctx, app.ID)
require.NoError(t, err)
require.Equal(t, first, stored.CallbackURL)
require.Equal(t, []string{first, second}, stored.RedirectUris)
})
// An update sending redirect_uris as an empty list is refused rather than
// silently keeping the stored list, with or without callback_url.
t.Run("EmptyListIsRefused", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
ctx := testutil.Context(t, testutil.WaitLong)
//nolint:gocritic // OAuth2 app management requires owner permission.
app, err := client.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{
Name: "empty-list-update",
RedirectURIs: []string{first},
})
require.NoError(t, err)
//nolint:gocritic // OAuth2 app management requires owner permission.
_, err = client.PutOAuth2ProviderApp(ctx, app.ID, codersdk.PutOAuth2ProviderAppRequest{
Name: "empty-list-update",
RedirectURIs: []string{},
})
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
require.Len(t, sdkErr.Validations, 1)
require.Equal(t, "redirect_uris", sdkErr.Validations[0].Field)
require.Equal(t, "at least one redirect URI is required", sdkErr.Validations[0].Detail)
// With callback_url in the same request, the message says the empty
// list is what discarded it.
//nolint:gocritic // OAuth2 app management requires owner permission.
_, err = client.PutOAuth2ProviderApp(ctx, app.ID, codersdk.PutOAuth2ProviderAppRequest{
Name: "empty-list-update",
CallbackURL: second,
RedirectURIs: []string{},
})
sdkErr = nil
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
require.Len(t, sdkErr.Validations, 1)
require.Equal(t, "redirect_uris", sdkErr.Validations[0].Field)
require.Equal(t, "redirect_uris was sent as an empty list, which overrides callback_url; send at least one redirect URI, or omit redirect_uris to use callback_url", sdkErr.Validations[0].Detail)
})
// A create that sends neither URI field is refused.
t.Run("OmittedFieldsOnCreateRefused", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
ctx := testutil.Context(t, testutil.WaitLong)
var sdkErr *codersdk.Error
//nolint:gocritic // OAuth2 app management requires owner permission.
_, err := client.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{
Name: "omitted-fields-create",
})
require.ErrorAs(t, err, &sdkErr)
require.Len(t, sdkErr.Validations, 1)
require.Equal(t, "redirect_uris", sdkErr.Validations[0].Field)
require.Equal(t, "at least one redirect URI is required", sdkErr.Validations[0].Detail)
})
// A malformed entry sent through redirect_uris is attributed to that
// field with its index; the same value sent through callback_url is
// attributed to callback_url instead.
t.Run("AttributionByOriginField", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
ctx := testutil.Context(t, testutil.WaitLong)
//nolint:gocritic // OAuth2 app management requires owner permission.
_, err := client.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{
Name: "bad-list-entry",
RedirectURIs: []string{first, "javascript:alert(1)"},
})
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Len(t, sdkErr.Validations, 1)
require.Equal(t, "redirect_uris", sdkErr.Validations[0].Field)
require.Equal(t, "redirect URI at index 1 uses the dangerous scheme javascript", sdkErr.Validations[0].Detail)
//nolint:gocritic // OAuth2 app management requires owner permission.
_, err = client.PostOAuth2ProviderApp(ctx, codersdk.PostOAuth2ProviderAppRequest{
Name: "bad-callback",
CallbackURL: "javascript:alert(1)",
})
requireCallbackURLValidationError(t, err)
require.ErrorContains(t, err, "callback URL uses the dangerous scheme javascript")
})
}
+1 -1
View File
@@ -257,7 +257,7 @@ func GetClientConfiguration(db database.Store) http.HandlerFunc {
ClientID: app.ID.String(),
ClientIDIssuedAt: app.ClientIDIssuedAt.Time.Unix(),
ClientSecretExpiresAt: 0, // No expiration for now
RedirectURIs: app.RedirectUris,
RedirectURIs: app.RegisteredRedirectURIs(),
ClientName: app.Name,
ClientURI: app.ClientUri.String,
LogoURI: app.LogoUri.String,
+25 -8
View File
@@ -15,10 +15,14 @@ import (
)
type OAuth2ProviderApp struct {
ID uuid.UUID `json:"id" format:"uuid"`
Name string `json:"name"`
CallbackURL string `json:"callback_url"`
Icon string `json:"icon"`
ID uuid.UUID `json:"id" format:"uuid"`
Name string `json:"name"`
// RedirectURIs are the app's registered redirect URIs, primary first.
RedirectURIs []string `json:"redirect_uris"`
// Deprecated: equal to the first entry of redirect_uris. Read
// redirect_uris instead.
CallbackURL string `json:"callback_url"`
Icon string `json:"icon"`
// Scope is the space-separated list of scopes this app's tokens may be
// granted. Empty means unrestricted. A non-empty value with no names is a
// configured allowlist that grants nothing.
@@ -83,8 +87,14 @@ func (c *Client) OAuth2ProviderApp(ctx context.Context, id uuid.UUID) (OAuth2Pro
}
type PostOAuth2ProviderAppRequest struct {
Name string `json:"name" validate:"required,oauth2_app_name"`
CallbackURL string `json:"callback_url" validate:"required"`
Name string `json:"name" validate:"required,oauth2_app_name"`
// RedirectURIs is the ordered list of URIs the app may redirect to. The
// first entry is the primary. Required, unless the deprecated
// callback_url is sent instead.
RedirectURIs []string `json:"redirect_uris,omitzero"`
// Deprecated: send redirect_uris instead. If both are sent, callback_url
// must equal the first entry of redirect_uris.
CallbackURL string `json:"callback_url,omitempty" validate:"omitempty"`
Icon string `json:"icon" validate:"omitempty"`
// Scope is the space-separated list of scopes this app's tokens may be
// granted. Leave empty, or omit, for unrestricted.
@@ -107,8 +117,15 @@ func (c *Client) PostOAuth2ProviderApp(ctx context.Context, app PostOAuth2Provid
}
type PutOAuth2ProviderAppRequest struct {
Name string `json:"name" validate:"required,oauth2_app_name"`
CallbackURL string `json:"callback_url" validate:"required"`
Name string `json:"name" validate:"required,oauth2_app_name"`
// RedirectURIs is the ordered list of URIs the app may redirect to. The
// first entry is the primary. Omit both this and callback_url to keep the
// stored redirect URIs. Other fields are replaced. Sending an empty list
// is an error, not a way to keep the stored list.
RedirectURIs []string `json:"redirect_uris,omitzero"`
// Deprecated: send redirect_uris instead. If both are sent, callback_url
// must equal the first entry of redirect_uris.
CallbackURL string `json:"callback_url,omitempty" validate:"omitempty"`
Icon string `json:"icon" validate:"omitempty"`
// Scope replaces the app's current allowlist. Omit to leave the existing
// allowlist untouched. Set to an empty string to clear it, making the app
+42 -9
View File
@@ -74,12 +74,35 @@ curl -X POST \
-H "Content-Type: application/json" \
-d '{
"name": "My Application",
"callback_url": "https://myapp.example.com/callback",
"redirect_uris": [
"https://myapp.example.com/callback",
"http://localhost:8080/callback"
],
"icon": "https://myapp.example.com/icon.png"
}' \
"$CODER_URL/api/v2/oauth2-provider/apps"
```
`callback_url` is still accepted and still returned, but it is deprecated: it is equal to the first entry in `redirect_uris`. New scripts should send and read `redirect_uris` instead.
Update an application with `PUT`. Fetch it first and edit the fields you want to change, then send the result back:
```sh
curl -X PUT \
-H "Authorization: Bearer $CODER_SESSION_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "My Application",
"redirect_uris": ["https://myapp.example.com/callback"],
"icon": "https://myapp.example.com/icon.png"
}' \
"$CODER_URL/api/v2/oauth2-provider/apps/$APP_ID"
```
`name` is required on every `PUT`, and `icon` is cleared if you leave it out.
`redirect_uris` replaces the stored list when present and keeps it when omitted.
`scope` is kept when omitted; refer to [Scopes](#scopes) for how to change it.
Add an optional `scope` field to restrict which scopes the application's clients may request.
Refer to [Scopes](#scopes) for how the allowlist is applied and how to change it later.
@@ -134,9 +157,11 @@ Disabling only blocks *new* self-registrations. Applications that already
registered while it was enabled keep authorizing and exchanging tokens
normally; disabling does not revoke or otherwise affect them.
A registration may list several `redirect_uris`.
An application may list several `redirect_uris`, whether it registered itself or an admin created it.
A request may present any of them, and the code it receives can only be exchanged with that same URI.
The first entry is the primary callback: it is what the web UI shows for the application, and what a request that omits `redirect_uri` is sent to.
An admin can edit the list through the management API, and a self-registered client can update its own list with its registration access token.
The admin `PUT` also validates the stored name, so a self-registered client whose name has leading or trailing whitespace can only be updated with its registration access token.
## Integration Patterns
@@ -465,11 +490,14 @@ Refer to the note under [Client Authentication Methods](#client-authentication-m
### "Invalid Callback URL" on the consent page
If you see this error when authorizing, the application's registered callback
URL is not usable: either it does not parse as a URL, or it uses a blocked
scheme (`javascript:`, `data:`, `file:`, or `ftp:`). The same cause answers
`server_error` on `POST /oauth2/authorize`. Update the application's callback
URL (see [Callback URL schemes](#callback-url-schemes)).
If you see this error when authorizing, one of the application's registered
redirect URIs is not usable: either it does not parse as a URL, or it uses a
blocked scheme (`javascript:`, `data:`, `file:`, or `ftp:`). The same cause
answers `server_error` on `POST /oauth2/authorize`. Use
`GET /api/v2/oauth2-provider/apps/{app}` to see every registered redirect
URI, then update the application with a corrected `redirect_uris` list as
shown under [Management API](#method-2-management-api). Refer to
[Callback URL schemes](#callback-url-schemes) for which values are accepted.
The server log records the application ID and the stored value. The response
does not, so a bad URL is never echoed back to a browser.
@@ -679,6 +707,10 @@ The following schemes are blocked for security reasons: `javascript:`, `data:`,
Public clients (`token_endpoint_auth_method: none`) additionally cannot register `mailto:`, `tel:`, or `sms:` redirect URIs, since those schemes hand off to another app rather than returning an authorization code to the client. Confidential clients are not subject to this restriction.
A cleartext `http://` redirect URI is accepted only for a local host. A confidential client may use `localhost`, `127.0.0.1`, `::1`, or a `.localhost` subdomain such as `http://app.localhost/callback`. A public client is limited to `localhost`, `127.0.0.1`, and `::1`. Every other host must use `https://`, so that an authorization code is never delivered in the clear. The management API and Dynamic Client Registration apply the same rule, so an administrator cannot store a target that a client could not register for itself.
These rules apply to every entry in `redirect_uris`, not only the first one.
## Security Considerations
- **Use HTTPS**: Always use HTTPS in production to protect tokens in transit
@@ -706,8 +738,9 @@ The current implementation has these limitations:
- No device authorization grant support (RFC 8628)
- Implicit grant (`response_type=token`) is not supported; OAuth 2.1 deprecated this flow due to token leakage risks, and a request for it redirects to the registered callback with `unsupported_response_type`
- Limited to opaque access tokens (no JWT support)
- An application may register at most 32 redirect URIs of at most 2048 bytes each. An application that stored a longer list before this limit existed keeps working, but it cannot be saved again until the list fits. To fix it, delete the application and create or register it again. Editing **Callback URL** replaces the primary redirect URI and preserves the other registered URIs. The application page and admin application API display only the primary.
- A redirect URI with a private-use scheme must name a path or an authority, as in `com.example.app:/callback` or `com.example.app://auth/callback`. The bare form `com.example.app:callback` is rejected. Dynamic Client Registration accepted it in earlier versions, so a client that registered one must re-register with one of the other two forms.
- An application may register at most 32 redirect URIs of at most 2048 bytes each. An application that stored a longer list before this limit existed keeps working, but it cannot be saved again until the list fits. To fix it, send a `PUT` with a `redirect_uris` list that fits, as shown under [Management API](#method-2-management-api). The web UI cannot edit the list: editing **Callback URL** replaces the primary redirect URI and preserves the other registered URIs, and the application page displays only the primary.
- A cleartext `http://` redirect URI to a host that is not local is rejected. Earlier versions accepted one through the management API for a confidential application, although Dynamic Client Registration always refused it. An application that stored one keeps working, but it cannot be saved again until its list uses `https://` or a local host, as described under [Callback URL schemes](#callback-url-schemes).
- A redirect URI with a private-use scheme must name a path or an authority, as in `com.example.app:/callback` or `com.example.app://auth/callback`. The bare form `com.example.app:callback` is rejected. Dynamic Client Registration accepted it in earlier versions. A client that registered one can re-register with one of the other two forms, or an administrator can correct it with the same `PUT`.
The `redirect_uris` list is now the source of truth for an application's callbacks, and its first entry is the primary:
+22 -3
View File
@@ -1528,6 +1528,9 @@ curl -X GET http://coder-server:8080/api/v2/oauth2-provider/apps \
"icon": "string",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"name": "string",
"redirect_uris": [
"string"
],
"scope": "string"
}
]
@@ -1546,7 +1549,7 @@ Status Code **200**
| Name | Type | Required | Restrictions | Description |
|---------------------------|----------------------------------------------------------------------|----------|--------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `[array item]` | array | false | | |
| `» callback_url` | string | false | | |
| `» callback_url` | string | false | | Deprecated: equal to the first entry of redirect_uris. Read redirect_uris instead. |
| `» client_type` | [codersdk.OAuth2ClientType](schemas.md#codersdkoauth2clienttype) | false | | Client type is "confidential" or "public". |
| `» endpoints` | [codersdk.OAuth2AppEndpoints](schemas.md#codersdkoauth2appendpoints) | false | | Endpoints are included in the app response for easier discovery. The OAuth2 spec does not have a defined place to find these (for comparison, OIDC has a '/.well-known/openid-configuration' endpoint). |
| `»» authorization` | string | false | | |
@@ -1556,6 +1559,7 @@ Status Code **200**
| `» icon` | string | false | | |
| `» id` | string(uuid) | false | | |
| `» name` | string | false | | |
| `» redirect_uris` | array | false | | Redirect uris are the app's registered redirect URIs, primary first. |
| `» scope` | string | false | | Scope is the space-separated list of scopes this app's tokens may be granted. Empty means unrestricted. A non-empty value with no names is a configured allowlist that grants nothing. |
#### Enumerated Values
@@ -1587,6 +1591,9 @@ curl -X POST http://coder-server:8080/api/v2/oauth2-provider/apps \
"callback_url": "string",
"icon": "string",
"name": "string",
"redirect_uris": [
"string"
],
"scope": "string"
}
```
@@ -1614,6 +1621,9 @@ curl -X POST http://coder-server:8080/api/v2/oauth2-provider/apps \
"icon": "string",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"name": "string",
"redirect_uris": [
"string"
],
"scope": "string"
}
```
@@ -1662,6 +1672,9 @@ curl -X GET http://coder-server:8080/api/v2/oauth2-provider/apps/{app} \
"icon": "string",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"name": "string",
"redirect_uris": [
"string"
],
"scope": "string"
}
```
@@ -1695,6 +1708,9 @@ curl -X PUT http://coder-server:8080/api/v2/oauth2-provider/apps/{app} \
"callback_url": "string",
"icon": "string",
"name": "string",
"redirect_uris": [
"string"
],
"scope": "string"
}
```
@@ -1723,6 +1739,9 @@ curl -X PUT http://coder-server:8080/api/v2/oauth2-provider/apps/{app} \
"icon": "string",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"name": "string",
"redirect_uris": [
"string"
],
"scope": "string"
}
```
@@ -5014,7 +5033,7 @@ curl -X GET http://coder-server:8080/oauth2/authorize?client_id=string&response_
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | Returns HTML authorization page | |
| 302 | [Found](https://tools.ietf.org/html/rfc7231#section-6.4.3) | Redirects to the app's registered callback carrying an OAuth2 error (RFC 6749 4.1.2.1) | |
| 400 | [Bad Request](https://tools.ietf.org/html/rfc7231#section-6.5.1) | HTML error page. The failure names the redirect URI or the client, so RFC 6749 4.1.2.1 withholds the callback | |
| 500 | [Internal Server Error](https://tools.ietf.org/html/rfc7231#section-6.6.1) | HTML error page. The app's registered callback URL is not usable | |
| 500 | [Internal Server Error](https://tools.ietf.org/html/rfc7231#section-6.6.1) | HTML error page. One of the app's registered redirect URIs is not usable | |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
@@ -5069,7 +5088,7 @@ curl -X POST http://coder-server:8080/oauth2/authorize?client_id=string&response
|--------|----------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------|
| 302 | [Found](https://tools.ietf.org/html/rfc7231#section-6.4.3) | Redirects to the app's registered callback carrying either an authorization code or an OAuth2 error (RFC 6749 4.1.2.1) | |
| 400 | [Bad Request](https://tools.ietf.org/html/rfc7231#section-6.5.1) | The failure names the redirect URI or the client, so RFC 6749 4.1.2.1 withholds the callback | [codersdk.OAuth2Error](schemas.md#codersdkoauth2error) |
| 500 | [Internal Server Error](https://tools.ietf.org/html/rfc7231#section-6.6.1) | The app's registered callback URL is not usable | [codersdk.OAuth2Error](schemas.md#codersdkoauth2error) |
| 500 | [Internal Server Error](https://tools.ietf.org/html/rfc7231#section-6.6.1) | One of the app's registered redirect URIs is not usable | [codersdk.OAuth2Error](schemas.md#codersdkoauth2error) |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
+33 -21
View File
@@ -11014,21 +11014,25 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith
"icon": "string",
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"name": "string",
"redirect_uris": [
"string"
],
"scope": "string"
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
|----------------|------------------------------------------------------------|----------|--------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `callback_url` | string | false | | |
| `client_type` | [codersdk.OAuth2ClientType](#codersdkoauth2clienttype) | false | | Client type is "confidential" or "public". |
| `endpoints` | [codersdk.OAuth2AppEndpoints](#codersdkoauth2appendpoints) | false | | Endpoints are included in the app response for easier discovery. The OAuth2 spec does not have a defined place to find these (for comparison, OIDC has a '/.well-known/openid-configuration' endpoint). |
| `icon` | string | false | | |
| `id` | string | false | | |
| `name` | string | false | | |
| `scope` | string | false | | Scope is the space-separated list of scopes this app's tokens may be granted. Empty means unrestricted. A non-empty value with no names is a configured allowlist that grants nothing. |
| Name | Type | Required | Restrictions | Description |
|-----------------|------------------------------------------------------------|----------|--------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `callback_url` | string | false | | Deprecated: equal to the first entry of redirect_uris. Read redirect_uris instead. |
| `client_type` | [codersdk.OAuth2ClientType](#codersdkoauth2clienttype) | false | | Client type is "confidential" or "public". |
| `endpoints` | [codersdk.OAuth2AppEndpoints](#codersdkoauth2appendpoints) | false | | Endpoints are included in the app response for easier discovery. The OAuth2 spec does not have a defined place to find these (for comparison, OIDC has a '/.well-known/openid-configuration' endpoint). |
| `icon` | string | false | | |
| `id` | string | false | | |
| `name` | string | false | | |
| `redirect_uris` | array of string | false | | Redirect uris are the app's registered redirect URIs, primary first. |
| `scope` | string | false | | Scope is the space-separated list of scopes this app's tokens may be granted. Empty means unrestricted. A non-empty value with no names is a configured allowlist that grants nothing. |
## codersdk.OAuth2ProviderAppSecret
@@ -12205,18 +12209,22 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith
"callback_url": "string",
"icon": "string",
"name": "string",
"redirect_uris": [
"string"
],
"scope": "string"
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
|----------------|--------|----------|--------------|-----------------------------------------------------------------------------------------------------------------------|
| `callback_url` | string | true | | |
| `icon` | string | false | | |
| `name` | string | true | | |
| `scope` | string | false | | Scope is the space-separated list of scopes this app's tokens may be granted. Leave empty, or omit, for unrestricted. |
| Name | Type | Required | Restrictions | Description |
|-----------------|-----------------|----------|--------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `callback_url` | string | false | | Deprecated: send redirect_uris instead. If both are sent, callback_url must equal the first entry of redirect_uris. |
| `icon` | string | false | | |
| `name` | string | true | | |
| `redirect_uris` | array of string | false | | Redirect uris is the ordered list of URIs the app may redirect to. The first entry is the primary. Required, unless the deprecated callback_url is sent instead. |
| `scope` | string | false | | Scope is the space-separated list of scopes this app's tokens may be granted. Leave empty, or omit, for unrestricted. |
## codersdk.PostWorkspaceUsageRequest
@@ -13094,18 +13102,22 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith
"callback_url": "string",
"icon": "string",
"name": "string",
"redirect_uris": [
"string"
],
"scope": "string"
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
|----------------|--------|----------|--------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `callback_url` | string | true | | |
| `icon` | string | false | | |
| `name` | string | true | | |
| `scope` | string | false | | Scope replaces the app's current allowlist. Omit to leave the existing allowlist untouched. Set to an empty string to clear it, making the app unrestricted. |
| Name | Type | Required | Restrictions | Description |
|-----------------|-----------------|----------|--------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `callback_url` | string | false | | Deprecated: send redirect_uris instead. If both are sent, callback_url must equal the first entry of redirect_uris. |
| `icon` | string | false | | |
| `name` | string | true | | |
| `redirect_uris` | array of string | false | | Redirect uris is the ordered list of URIs the app may redirect to. The first entry is the primary. Omit both this and callback_url to keep the stored redirect URIs. Other fields are replaced. Sending an empty list is an error, not a way to keep the stored list. |
| `scope` | string | false | | Scope replaces the app's current allowlist. Omit to leave the existing allowlist untouched. Set to an empty string to clear it, making the app unrestricted. |
## codersdk.RBACAction
+31 -2
View File
@@ -6722,6 +6722,14 @@ export interface OAuth2ProtectedResourceMetadata {
export interface OAuth2ProviderApp {
readonly id: string;
readonly name: string;
/**
* RedirectURIs are the app's registered redirect URIs, primary first.
*/
readonly redirect_uris: readonly string[];
/**
* @deprecated equal to the first entry of redirect_uris. Read
* redirect_uris instead.
*/
readonly callback_url: string;
readonly icon: string;
/**
@@ -7506,7 +7514,17 @@ export interface Permission {
// From codersdk/oauth2.go
export interface PostOAuth2ProviderAppRequest {
readonly name: string;
readonly callback_url: string;
/**
* RedirectURIs is the ordered list of URIs the app may redirect to. The
* first entry is the primary. Required, unless the deprecated
* callback_url is sent instead.
*/
readonly redirect_uris?: readonly string[];
/**
* @deprecated send redirect_uris instead. If both are sent, callback_url
* must equal the first entry of redirect_uris.
*/
readonly callback_url?: string;
readonly icon: string;
/**
* Scope is the space-separated list of scopes this app's tokens may be
@@ -8030,7 +8048,18 @@ export interface PutExtendWorkspaceRequest {
// From codersdk/oauth2.go
export interface PutOAuth2ProviderAppRequest {
readonly name: string;
readonly callback_url: string;
/**
* RedirectURIs is the ordered list of URIs the app may redirect to. The
* first entry is the primary. Omit both this and callback_url to keep the
* stored redirect URIs. Other fields are replaced. Sending an empty list
* is an error, not a way to keep the stored list.
*/
readonly redirect_uris?: readonly string[];
/**
* @deprecated send redirect_uris instead. If both are sent, callback_url
* must equal the first entry of redirect_uris.
*/
readonly callback_url?: string;
readonly icon: string;
/**
* Scope replaces the app's current allowlist. Omit to leave the existing
@@ -171,6 +171,7 @@ export const DynamicallyRegisteredValues: Story = {
...mockApp,
name: "VS Code Coder Extension",
callback_url: "vscode://coder.coder-remote/oauth/callback",
redirect_uris: ["vscode://coder.coder-remote/oauth/callback"],
},
},
{
@@ -66,6 +66,7 @@ describe("OAuth2AppForm", () => {
...MockOAuth2ProviderApps[0],
name: "VS Code Coder Extension",
callback_url: "vscode://coder.coder-remote/oauth/callback",
redirect_uris: ["vscode://coder.coder-remote/oauth/callback"],
};
render(
@@ -205,6 +206,7 @@ describe("OAuth2AppForm", () => {
{ callback: "http://localhost:3000/callback", valid: true },
{ callback: "http://127.0.0.1:3000/callback", valid: true },
{ callback: "http://[::1]:3000/callback", valid: true },
{ callback: "http://app.localhost/callback", valid: false },
{ callback: "https://example.com/callback", valid: true },
{ callback: "vscode://coder.coder-remote/oauth/callback", valid: true },
{ callback: "com.example.app:/oauth2redirect", valid: true },
@@ -245,6 +247,119 @@ describe("OAuth2AppForm", () => {
},
);
it.each([
{ callback: "http://example.com/callback", valid: false },
{ callback: "http://10.0.0.5:8080/callback", valid: false },
{ callback: "http://localhost:3000/callback", valid: true },
{ callback: "http://127.0.0.1:3000/callback", valid: true },
{ callback: "http://[::1]:3000/callback", valid: true },
{ callback: "http://app.localhost/callback", valid: true },
{ callback: "https://example.com/callback", valid: true },
{ callback: "vscode://coder.coder-remote/oauth/callback", valid: true },
])(
"validates confidential client callback $callback",
async ({ callback, valid }) => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(
<OAuth2AppForm
onSubmit={onSubmit}
isUpdating={false}
disabled={false}
/>,
);
await user.type(screen.getByLabelText(/^name/i), "confidential-app");
await user.type(
screen.getByLabelText(/callback url/i),
callback.replaceAll("[", "[["),
);
await user.click(
screen.getByRole("button", { name: /create application/i }),
);
await act(async () => {});
if (valid) {
await waitFor(() =>
expect(onSubmit).toHaveBeenCalledWith({
name: "confidential-app",
callback_url: callback,
icon: "",
scope: "",
}),
);
} else {
expect(onSubmit).not.toHaveBeenCalled();
}
},
);
// Confidential apps could store a non-local http callback before the form
// checked for it. The stored value fails validation on load, so the error
// must show without the field being touched.
it("shows the error for a stored callback that fails validation", async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(
<OAuth2AppForm
app={{
...MockOAuth2ProviderApps[0],
callback_url: "http://intranet.example.com/callback",
}}
onSubmit={onSubmit}
isUpdating={false}
disabled={false}
/>,
);
expect(
await screen.findByText("Please enter a valid callback URL."),
).toBeInTheDocument();
await user.type(screen.getByLabelText(/^name/i), " updated");
expect(
screen.getByRole("button", { name: /update application/i }),
).toBeDisabled();
await user.clear(screen.getByLabelText(/callback url/i));
await user.type(
screen.getByLabelText(/callback url/i),
"https://intranet.example.com/callback",
);
await waitFor(() =>
expect(
screen.queryByText("Please enter a valid callback URL."),
).not.toBeInTheDocument(),
);
await user.click(
screen.getByRole("button", { name: /update application/i }),
);
await waitFor(() =>
expect(onSubmit).toHaveBeenCalledWith({
name: `${MockOAuth2ProviderApps[0].name} updated`,
callback_url: "https://intranet.example.com/callback",
icon: MockOAuth2ProviderApps[0].icon,
}),
);
});
it("keeps the callback error hidden when the stored callback is valid", async () => {
const user = userEvent.setup();
render(
<OAuth2AppForm
app={MockOAuth2ProviderApps[0]}
onSubmit={vi.fn()}
isUpdating={false}
disabled={false}
/>,
);
await user.type(screen.getByLabelText(/^name/i), " updated");
expect(
screen.queryByText("Please enter a valid callback URL."),
).not.toBeInTheDocument();
expect(
screen.getByRole("button", { name: /update application/i }),
).toBeEnabled();
});
it("submits the selected scopes as a space separated list", async () => {
const onSubmit = vi.fn();
const user = userEvent.setup();
@@ -51,6 +51,14 @@ const SCOPE_LABEL = "Allowed scopes";
// oxlint-disable-next-line eslint/no-script-url -- This blocklist rejects the scheme; it is never used as a navigation target.
const DANGEROUS_CALLBACK_SCHEMES = ["javascript:", "data:", "file:", "ftp:"];
const LOOPBACK_HOSTS = ["localhost", "127.0.0.1", "[::1]"];
// A public client is held to RFC 8252 loopback. A confidential client may also
// use a .localhost subdomain, which is how the server draws the line.
const allowsCleartextHTTP = (hostname: string, isPublicClient: boolean) =>
LOOPBACK_HOSTS.includes(hostname) ||
(!isPublicClient && hostname.endsWith(".localhost"));
const isValidCallbackURL = (
value: string | undefined,
isPublicClient: boolean,
@@ -77,12 +85,12 @@ const isValidCallbackURL = (
) {
return false;
}
if (
url.protocol === "http:" &&
!["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)
) {
return false;
}
}
if (
url.protocol === "http:" &&
!allowsCleartextHTTP(url.hostname, isPublicClient)
) {
return false;
}
if (
(url.protocol === "http:" || url.protocol === "https:") &&
@@ -128,6 +136,12 @@ export const OAuth2AppForm: FC<OAuth2AppFormProps> = ({
onIconChange,
}) => {
const didSubmit = useRef(false);
const isPublicClient = app?.client_type === "public";
// A stored callback that no longer passes validation disables Update on
// load. Show its error right away instead of waiting for the field to be
// touched, so the admin can see what to correct.
const storedCallbackInvalid =
app !== undefined && !isValidCallbackURL(app.callback_url, isPublicClient);
const form = useFormik<OAuth2AppFormValues>({
initialValues: {
name: app?.name ?? defaultValues?.name ?? "",
@@ -136,7 +150,8 @@ export const OAuth2AppForm: FC<OAuth2AppFormProps> = ({
scope:
app?.scope.split(" ").filter(Boolean) ?? defaultValues?.scope ?? [],
},
validationSchema: validationSchema(app?.client_type === "public"),
initialTouched: storedCallbackInvalid ? { callback_url: true } : undefined,
validationSchema: validationSchema(isPublicClient),
validateOnMount: true,
onSubmit: async ({ scope: selectedScopes, ...values }) => {
didSubmit.current = true;
+2
View File
@@ -4974,6 +4974,7 @@ export const MockOAuth2ProviderApps: TypesGen.OAuth2ProviderApp[] = [
{
id: "1",
name: "foo",
redirect_uris: ["http://127.0.0.1:3001"],
callback_url: "http://127.0.0.1:3001",
icon: "/icon/github.svg",
scope: "",
@@ -4990,6 +4991,7 @@ export const MockOAuth2ProviderApps: TypesGen.OAuth2ProviderApp[] = [
export const MockOAuth2ProviderAppPublic: TypesGen.OAuth2ProviderApp = {
id: "2",
name: "bar (public)",
redirect_uris: ["http://127.0.0.1:3002"],
callback_url: "http://127.0.0.1:3002",
icon: "/icon/github.svg",
scope: "",