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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 14 additions & 9 deletions coderd/oauth2provider/metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,20 @@ func GetAuthorizationServerMetadata(db database.Store, accessURL *url.URL) http.
}

metadata := codersdk.OAuth2AuthorizationServerMetadata{
Issuer: accessURL.String(),
AuthorizationEndpoint: accessURL.JoinPath("/oauth2/authorize").String(),
TokenEndpoint: accessURL.JoinPath("/oauth2/tokens").String(),
RevocationEndpoint: accessURL.JoinPath("/oauth2/revoke").String(), // RFC 7009
ResponseTypesSupported: []codersdk.OAuth2ProviderResponseType{codersdk.OAuth2ProviderResponseTypeCode},
GrantTypesSupported: []codersdk.OAuth2ProviderGrantType{codersdk.OAuth2ProviderGrantTypeAuthorizationCode, codersdk.OAuth2ProviderGrantTypeRefreshToken},
CodeChallengeMethodsSupported: []codersdk.OAuth2PKCECodeChallengeMethod{codersdk.OAuth2PKCECodeChallengeMethodS256},
ScopesSupported: rbac.ExternalScopeNames(),
TokenEndpointAuthMethodsSupported: []codersdk.OAuth2TokenEndpointAuthMethod{codersdk.OAuth2TokenEndpointAuthMethodClientSecretBasic, codersdk.OAuth2TokenEndpointAuthMethodClientSecretPost},
Issuer: accessURL.String(),
AuthorizationEndpoint: accessURL.JoinPath("/oauth2/authorize").String(),
TokenEndpoint: accessURL.JoinPath("/oauth2/tokens").String(),
RevocationEndpoint: accessURL.JoinPath("/oauth2/revoke").String(), // RFC 7009
ResponseTypesSupported: []codersdk.OAuth2ProviderResponseType{codersdk.OAuth2ProviderResponseTypeCode},
GrantTypesSupported: []codersdk.OAuth2ProviderGrantType{codersdk.OAuth2ProviderGrantTypeAuthorizationCode, codersdk.OAuth2ProviderGrantTypeRefreshToken},
CodeChallengeMethodsSupported: []codersdk.OAuth2PKCECodeChallengeMethod{codersdk.OAuth2PKCECodeChallengeMethodS256},
ScopesSupported: rbac.ExternalScopeNames(),
// Derived from the same list Valid() uses, so discovery cannot

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit [CRF-11] The 5-line comment above TokenEndpointAuthMethodsSupported duplicates the godoc on codersdk.AllOAuth2TokenEndpointAuthMethods and pads the rest. (Gon P2)

From Gon: "codersdk/oauth2.go:273-275 already documents the shared-source invariant on the called function: 'Both Valid() and the discovery metadata are derived from it, so what registration accepts and what /.well-known advertises cannot drift apart.' The first two lines here restate that. The remaining three lines carry one fact ('not gated on dcrEnabled because existing public clients still exchange tokens') in five lines of prose."

Trim to the why-not-what unique to this site:

// Not gated on dcrEnabled: existing public clients still need to
// exchange tokens when new registrations are turned off.

Related: CRF-3 asks you to revise the same block's rationale because the "existing ones exchanging tokens" claim is not currently true for the tree; if you take that fix the trim rolls in for free.

🤖

// advertise a method registration rejects, or hide one it accepts.
// Not gated on dcrEnabled: disabling registration stops new public
// clients being created but does not stop existing ones exchanging
// tokens, so the method remains supported.
TokenEndpointAuthMethodsSupported: codersdk.AllOAuth2TokenEndpointAuthMethods(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 [CRF-3] Discovery advertises "none" in token_endpoint_auth_methods_supported, but the token endpoint at tokens.go:96-101 still appends client_secret is required and cannot be empty for every authorization_code grant with an empty secret and never inspects client_type. (Knov P2, Kurapika P3, Meruem P3, Melody P3, Mafuuu Note, Mafu-san Note, Pariston Note, Kite Note, Luffy Note)

From Kurapika: "A conforming client that trusts .well-known/oauth-authorization-server, registers with token_endpoint_auth_method=none, gets no secret in the response (correct per RFC 7591 §3.2.1), and then attempts the token exchange will hit client_secret is required at parameter parsing, before authorizationCodeGrant even runs."

From Knov on the in-file comment's rationale ("disabling registration stops new public clients being created but does not stop existing ones exchanging tokens, so the method remains supported"): "No existing public client can exchange tokens today, so that rationale is aspirational, not a description of the tree."

The follow-up PR named in the description will make this coherent, but reviewing this PR standalone, discovery is telling a conforming client something the server will not do. DCR is off by default (GetOAuth2DCREnabled), but discovery is served unconditionally.

Options:

  • Hold "none" out of AllOAuth2TokenEndpointAuthMethods() until the token endpoint accepts it. Registration then rejects "none" (consistent with the token endpoint's current behavior) and this becomes a coherent no-op until the follow-up.
  • Introduce a separate AdvertisedTokenEndpointAuthMethods() filtered by what the token endpoint executes.
  • Land the discovery advertisement in the same PR as the token-endpoint change so the pair agrees at merge.

Also: revise the comment at metadata.go:39-42. As written, it justifies the choice with a state the tree does not currently produce.

🤖

}
if dcrEnabled {
metadata.RegistrationEndpoint = accessURL.JoinPath("/oauth2/register").String() // RFC 7591
Expand Down
3 changes: 3 additions & 0 deletions coderd/oauth2provider/metadata_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ func TestOAuth2AuthorizationServerMetadata(t *testing.T) {
require.Contains(t, metadata.GrantTypesSupported, codersdk.OAuth2ProviderGrantTypeAuthorizationCode)
require.Contains(t, metadata.GrantTypesSupported, codersdk.OAuth2ProviderGrantTypeRefreshToken)
require.Contains(t, metadata.CodeChallengeMethodsSupported, codersdk.OAuth2PKCECodeChallengeMethodS256)
// Public (secretless, PKCE-only) clients must be advertised so they can
// discover that Coder will accept a "none" auth method.
require.Contains(t, metadata.TokenEndpointAuthMethodsSupported, codersdk.OAuth2TokenEndpointAuthMethodNone)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note [CRF-8] require.Contains(..., None) does not pin the drift-prevention invariant the metadata comment claims. (Bisky Note)

From Bisky: "The metadata.go comment says TokenEndpointAuthMethodsSupported is derived from AllOAuth2TokenEndpointAuthMethods() 'so discovery cannot advertise a method registration rejects, or hide one it accepts.' The test asserts only Contains(..., None). Hardcode the advertised list to []{None} and drop client_secret_basic and client_secret_post: registration still accepts them via Valid(), discovery hides them, this test still passes."

require.ElementsMatch(t, codersdk.AllOAuth2TokenEndpointAuthMethods(), metadata.TokenEndpointAuthMethodsSupported) costs a line and pins the actual invariant.

🤖

// Supported scopes are published from the curated catalog
require.Equal(t, rbac.ExternalScopeNames(), metadata.ScopesSupported)
}
Expand Down
20 changes: 20 additions & 0 deletions coderd/oauth2provider/oauth2providertest/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
package oauth2providertest

import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
Expand Down Expand Up @@ -77,6 +78,25 @@ func CreateTestOAuth2App(t *testing.T, client *codersdk.Client) (*codersdk.OAuth
return &app, secret.ClientSecretFull
}

// RegisterPublicClient registers a public (secretless, PKCE-only) OAuth2 client
// via RFC 7591 dynamic registration, the only way to create one. This is the
// public counterpart to CreateTestOAuth2App. The caller must call EnableDCR
// first, and needs owner-level permissions to do so.
func RegisterPublicClient(ctx context.Context, t *testing.T, client *codersdk.Client, name, redirectURI string) codersdk.OAuth2ClientRegistrationResponse {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note [CRF-10] RegisterPublicClient is added to a public test-support package but has no in-tree caller, and its signature diverges from the surrounding helpers on the ctx parameter. (Netero Note, Chopper Note, Gon Note, Luffy Note, Mafu-san Note, Razor Note, Hisoka Nit, Zoro Note+Nit)

From Netero: "grep -rn RegisterPublicClient --include='*.go' returns only the definition. The PR description names a follow-up PR in the stack as the intended caller (token-endpoint PKCE-only exchange). Netero flags unused exports without evaluating stack intent; if the follow-up is dropped, this stays as a small dead export in a test-support package."

From Zoro on the signature: "Every other helper defined in this file that needs a context creates it inline from t (CreateTestOAuth2App line 61, EnableDCR line 107, AuthorizeOAuth2App via doAuthorizeRequest line 148, ExchangeCodeForToken line 229, FetchOAuth2Metadata line 347, PerformTokenExchangeExpectingError line 296). Taking ctx from the caller is not wrong on its own; it is only inconsistent with the pattern the file has already committed to."

From Mafu-san on the risk: "Adding a test helper without exercising it means a bug in the helper (wrong RedirectURIs field name, wrong request shape, wrong assertion) rides through this PR uncaught; a caller in the next PR would surface it late and blame the wrong change."

Options: (a) fold the helper introduction into the PR that first uses it, or (b) add one minimal exercise here (register through EnableDCR, assert resp.ClientSecret == ""), and align the signature to func(t *testing.T, ...)-first with ctx created inline like the neighbors.

🤖

t.Helper()

resp, err := client.PostOAuth2ClientRegistration(ctx, codersdk.OAuth2ClientRegistrationRequest{
RedirectURIs: []string{redirectURI},
ClientName: fmt.Sprintf("%s-%s", name, testutil.MustRandString(t, 10)),
TokenEndpointAuthMethod: codersdk.OAuth2TokenEndpointAuthMethodNone,
})
require.NoError(t, err, "failed to register public OAuth2 client")
// A public client is issued no secret. Asserting it here means every caller
// inherits the check rather than restating it.
require.Empty(t, resp.ClientSecret, "public client must not be issued a secret")
return resp
}

// EnableDCR turns on dynamic client registration for the deployment.
// DCR defaults to disabled, so any test that registers a client via
// POST /oauth2/register must call this first. The caller-provided client
Expand Down
140 changes: 81 additions & 59 deletions coderd/oauth2provider/registration.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"context"
"database/sql"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
Expand Down Expand Up @@ -72,13 +71,22 @@ func CreateDynamicClientRegistration(db database.Store, accessURL *url.URL, audi
// Apply defaults
req = req.ApplyDefaults()

// Generate client credentials
clientType := req.DetermineClientType()
isPublic := clientType == codersdk.OAuth2ClientTypePublic

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit [CRF-13] // Generate client credentials. narrates the mechanism of the two lines that follow it. (Gon Nit)

From Gon: "The payload of the block is the second sentence ('Public clients authenticate with PKCE alone and never receive a secret'). Drop the leading section-header sentence and keep the rationale:

// Public clients skip secret generation and authenticate with PKCE
// alone (RFC 7591 §2, OAuth 2.1 §2.1).
```"

> 🤖

// Generate client credentials. Public clients authenticate with PKCE
// alone and never receive a secret (RFC 7591 §2, OAuth 2.1 §2.1).
clientID := uuid.New()
clientSecret, hashedSecret, err := generateClientCredentials()
if err != nil {
writeOAuth2RegistrationError(ctx, rw, http.StatusInternalServerError,
"server_error", "Failed to generate client credentials")
return
var clientSecret string
var hashedSecret []byte
if !isPublic {
var err error
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
Expand All @@ -92,35 +100,72 @@ func CreateDynamicClientRegistration(db database.Store, accessURL *url.URL, audi
// Store in database - use system context since this is a public endpoint
now := dbtime.Now()
clientName := req.GenerateClientName()
//nolint:gocritic // OAuth2 system context — dynamic registration is a public endpoint
app, err := db.InsertOAuth2ProviderApp(dbauthz.AsSystemOAuth2(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: string(req.DetermineClientType()),
DynamicallyRegistered: sql.NullBool{Bool: true, Valid: true},
ClientIDIssuedAt: sql.NullTime{Time: now, Valid: true},
ClientSecretExpiresAt: sql.NullTime{}, // No expiration for now
GrantTypes: slice.ToStrings(req.GrantTypes),
ResponseTypes: slice.ToStrings(req.ResponseTypes),
TokenEndpointAuthMethod: sql.NullString{String: 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: hashedRegToken,
RegistrationClientUri: sql.NullString{String: fmt.Sprintf("%s/oauth2/clients/%s", accessURL.String(), clientID), Valid: true},
})
// The app and its secret are written in one transaction. A partial
// write would commit an app that can never authenticate, and which
// still holds a registration access token.
var app database.OAuth2ProviderApp
err = db.InTx(func(tx database.Store) error {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note [CRF-15] now := dbtime.Now() and uuid.New() for the secret ID are captured outside the InTx closure. (Pariston Note)

From Pariston: "Not currently a bug: InTx(fn, nil) runs at the driver's default isolation and does not retry on serialization failure, so the captured values are used exactly once. Worth knowing because if anyone later passes a TxOptions with retry semantics, or wraps this in a retry loop, the same secret ID would be reused across attempts and the second attempt would fail on uniqueness. Moving the uuid.New() and dbtime.Now() calls inside the closure would remove that footgun for the cost of two lines."

🤖

var err error
//nolint:gocritic // OAuth2 system context, dynamic registration is a public endpoint

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit [CRF-7] The em-dash → comma cleanup is applied to the two //nolint:gocritic comments this change touched but not to the ten sibling instances in the same file and package. (Kite Nit)

From Kite: "Six identical // OAuth2 system context — RFC 7592 client configuration endpoint comments remain in registration.go at lines 227, 313, 338, 421, 445, 497, and four more in tokens.go at lines 261, 289, 451, 488. scripts/check_emdash.sh only scans added lines in the diff, so these siblings sit under the linter's radar but violate the same rule the change is enforcing on the lines it touched."

One-character change each. Doing them now stops the class re-appearing in a future diff against those lines.

🤖

app, err = tx.InsertOAuth2ProviderApp(dbauthz.AsSystemOAuth2(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: string(clientType),
DynamicallyRegistered: sql.NullBool{Bool: true, Valid: true},
ClientIDIssuedAt: sql.NullTime{Time: now, Valid: true},
ClientSecretExpiresAt: sql.NullTime{}, // No expiration for now
GrantTypes: slice.ToStrings(req.GrantTypes),
ResponseTypes: slice.ToStrings(req.ResponseTypes),
TokenEndpointAuthMethod: sql.NullString{String: 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: hashedRegToken,
// JoinPath, not Sprintf: an access URL configured with a
// trailing slash would otherwise mint "//oauth2/clients/{id}"
// and hand it to the client as its management endpoint.
RegistrationClientUri: sql.NullString{String: accessURL.JoinPath("/oauth2/clients", clientID.String()).String(), Valid: true},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 [CRF-5] The fmt.SprintfaccessURL.JoinPath fix has no regression test. (Mafu-san P3, Chopper Nit, Meruem Nit, Ryosuke Note)

From Mafu-san: "None of the three new tests uses a trailing-slash accessURL... Revert accessURL.JoinPath(\"/oauth2/clients\", clientID.String()) to the old fmt.Sprintf and every test still passes. The fix is real (verified against the code) but the regression guard is not."

The PR description names this fix explicitly ("a latent bug where an access URL configured with a trailing slash would mint //oauth2/clients/{id} as the client's management endpoint"), so it is worth pinning. Add one subtest row that sets accessURL to https://example.com/ (trailing slash) and asserts the response's RegistrationClientURI contains exactly one slash between authority and /oauth2/clients/. Same pattern would fail against path.Join as well.

🤖

})
if err != nil {
return xerrors.Errorf("insert oauth2 provider app: %w", err)
}

if isPublic {
return nil
}

// Create client secret - parse the formatted secret to get components

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 [CRF-6] // Create client secret - parse the formatted secret to get components mislocates the operation. (Gon P2)

From Gon: "'Create client secret' is wrong at this location. The secret was already generated on line ~82 by generateClientCredentials(). The block that follows parses that string to pull the prefix out, then inserts a DB row for it. It creates a row, not a secret. 'parse the formatted secret to get components' is what the function name ParseFormattedSecret says."

A future maintainer reading this hunts for a secret-minting call that is not there. Delete or replace with one line naming what the parse is for (extracting Prefix for InsertOAuth2ProviderAppSecretParams.SecretPrefix).

🤖

parsedSecret, err := ParseFormattedSecret(clientSecret)
if err != nil {
return xerrors.Errorf("parse generated secret: %w", err)
}

//nolint:gocritic // OAuth2 system context, dynamic registration is a public endpoint
_, err = tx.InsertOAuth2ProviderAppSecret(dbauthz.AsSystemOAuth2(ctx), database.InsertOAuth2ProviderAppSecretParams{
ID: uuid.New(),
CreatedAt: now,
SecretPrefix: []byte(parsedSecret.Prefix),
HashedSecret: hashedSecret,
DisplaySecret: createDisplaySecret(clientSecret),
AppID: clientID,
})
if err != nil {
return xerrors.Errorf("insert oauth2 provider app secret: %w", err)
}
return nil
}, nil)
if err != nil {
logger.Error(ctx, "failed to store oauth2 client registration",
slog.Error(err),
Expand All @@ -132,29 +177,6 @@ func CreateDynamicClientRegistration(db database.Store, accessURL *url.URL, audi
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 // OAuth2 system context — dynamic registration is a public endpoint
_, err = db.InsertOAuth2ProviderAppSecret(dbauthz.AsSystemOAuth2(ctx), database.InsertOAuth2ProviderAppSecretParams{
ID: uuid.New(),
CreatedAt: now,
SecretPrefix: []byte(parsedSecret.Prefix),
HashedSecret: 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

Expand Down
Loading
Loading