-
Notifications
You must be signed in to change notification settings - Fork 1.4k
feat: register public clients without a secret #28046
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: oauth2-public-clients-vocabulary
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
| // 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(), | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3 [CRF-3] Discovery advertises From Kurapika: "A conforming client that trusts 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 ( Options:
Also: revise the comment at
|
||
| } | ||
| if dcrEnabled { | ||
| metadata.RegistrationEndpoint = accessURL.JoinPath("/oauth2/register").String() // RFC 7591 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Note [CRF-8] From Bisky: "The metadata.go comment says
|
||
| // Supported scopes are published from the curated catalog | ||
| require.Equal(t, rbac.ExternalScopeNames(), metadata.ScopesSupported) | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,6 +4,7 @@ | |
| package oauth2providertest | ||
|
|
||
| import ( | ||
| "context" | ||
| "crypto/rand" | ||
| "encoding/base64" | ||
| "encoding/json" | ||
|
|
@@ -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 { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Note [CRF-10] From Netero: " From Zoro on the signature: "Every other helper defined in this file that needs a context creates it inline from From Mafu-san on the risk: "Adding a test helper without exercising it means a bug in the helper (wrong Options: (a) fold the helper introduction into the PR that first uses it, or (b) add one minimal exercise here (register through
|
||
| 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 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,7 +4,6 @@ import ( | |
| "context" | ||
| "database/sql" | ||
| "encoding/json" | ||
| "fmt" | ||
| "net/http" | ||
| "net/url" | ||
| "strings" | ||
|
|
@@ -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 | ||
|
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit [CRF-13] 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 | ||
|
|
@@ -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 { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Note [CRF-15] From Pariston: "Not currently a bug:
|
||
| var err error | ||
| //nolint:gocritic // OAuth2 system context, dynamic registration is a public endpoint | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 From Kite: "Six identical 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}, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3 [CRF-5] The From Mafu-san: "None of the three new tests uses a trailing-slash The PR description names this fix explicitly ("a latent bug where an access URL configured with a trailing slash would mint
|
||
| }) | ||
| 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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3 [CRF-6] From Gon: "'Create client secret' is wrong at this location. The secret was already generated on line ~82 by 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
|
||
| 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), | ||
|
|
@@ -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 | ||
|
|
||
|
|
||
There was a problem hiding this comment.
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
TokenEndpointAuthMethodsSupportedduplicates the godoc oncodersdk.AllOAuth2TokenEndpointAuthMethodsand pads the rest. (Gon P2)From Gon: "
codersdk/oauth2.go:273-275already 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:
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.