Repository navigation
Support confidential clients with client_secret_post #3082
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
Merged
Merged
Changes from all commits
Commits
Show all changes
28 commits
Select commit
Hold shift + click to select a range
b4e4d11
Support confidential clients with client_secret_post
jeremy 37f11e0
Reject non-string client secrets and mark credential responses no-store
jeremy 9b757e3
Require client_id with client_secret_post and declare secret expiry
jeremy 55314a8
Confine client credentials to the request body
jeremy e8cf390
Authenticate the client before validating the refresh grant
jeremy ee4fa81
Answer failed client_secret_post authentication with 400, not 401
jeremy 631fd64
Authenticate confidential clients before checking the code's verifier…
jeremy 1dc8358
Pin that confidential clients sit behind the availability switches
jeremy 4aa879c
Authenticate the named client before resolving the grant, and never c…
jeremy 9f62e06
Accept client_secret_basic at the token endpoint
jeremy 7f853cd
Authenticate clients at revocation and revoke only their own tokens
jeremy fde9061
Refuse a revocation whose client secret authenticates no client
jeremy 11132df
Refuse a client secret that names a public client at revocation
jeremy 9e107d8
Name the Basic scheme only to a client that tried the Authorization h…
jeremy cbb2dd9
Revoke personal access tokens at the revocation endpoint again
jeremy 6fec38d
Answer a grant issued to another client exactly as a dead one
jeremy 9888bee
Throttle revocation like the token endpoint
jeremy 1688454
Answer invalid_client with 400 unless Basic authentication failed
jeremy 1e12953
Keep every authorization server response out of caches and Referers
jeremy 2f3f4d3
Fail a bare Basic scheme as client authentication, not ignore it
jeremy 274c566
Read the grant's refresh token before the bare-Basic attempts, not af…
jeremy c60db71
Merge oauth-refresh-tokens: its migrations can be run again after sto…
jeremy dae1248
Merge oauth-refresh-tokens
jeremy 3443a91
Merge oauth-refresh-tokens into oauth-confidential-clients
jeremy 151024e
Merge oauth-refresh-tokens into oauth-confidential-clients
jeremy a63d86e
Merge oauth-refresh-tokens into oauth-confidential-clients
jeremy 7427c08
Redact OAuth client statements from logged SQL, now that a client has…
jeremy 5f9e19b
Merge oauth-refresh-tokens into oauth-confidential-clients
jeremy File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
134 changes: 134 additions & 0 deletions
134
app/controllers/concerns/oauth/client_authentication.rb
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,134 @@ | ||
| # Client credentials arrive one of two ways (RFC 6749 §2.3.1): | ||
| # | ||
| # - client_secret_basic: an Authorization: Basic header carrying the | ||
| # form-urlencoded client_id and client_secret. | ||
| # - client_secret_post: client_id and client_secret in the request body. A | ||
| # secret in the query string never authenticates, since secrets there leak | ||
| # into proxy and access logs. | ||
| # | ||
| # A confidential client may use either, whichever it registered: the | ||
| # registered token_endpoint_auth_method is advisory. Using both at once is | ||
| # refused, since a client MUST NOT use more than one method per request (§2.3). | ||
| module Oauth::ClientAuthentication | ||
| # The scheme alone, credentials or not: a bare "Basic" is a malformed attempt, | ||
| # not an absent one. | ||
| BASIC_SCHEME = /\ABasic(?: |\z)/i | ||
|
|
||
| private | ||
| # RFC 6749 §5.2 names more than one authentication method invalid_request. | ||
| # That answer turns on the request's shape alone, so it says nothing about | ||
| # which clients exist. A malformed Basic header, or one naming a different | ||
| # client than the body, fails like any other bad credential. | ||
| def reject_ambiguous_client_credentials | ||
| if client_secret_basic? | ||
| if request.request_parameters["client_secret"].present? | ||
| oauth_error "invalid_request", "Client credentials must use only one authentication method" | ||
| elsif basic_client_credentials.nil? || conflicting_client_id? | ||
| client_authentication_failed | ||
| end | ||
| end | ||
| end | ||
|
|
||
| # The client the request authenticates as, or nil. A confidential client | ||
| # authenticates with its secret. A public client identifies by client_id | ||
| # alone, but never alongside a secret: it holds no password, so Basic or a | ||
| # body client_secret naming one is a failed attempt, not identification. | ||
| def authenticated_client | ||
| if client = requesting_client | ||
| client if client.confidential? ? client_secret_authenticates?(client) : !client_secret_basic? && oauth_client_secret.blank? | ||
| end | ||
| end | ||
|
|
||
| # A request attempts client authentication when it uses Basic, names a | ||
| # confidential client, or carries a client secret. This turns on what the | ||
| # request presents, so refusing a failed attempt reveals nothing about | ||
| # which clients exist. | ||
| def attempts_client_authentication? | ||
| client_secret_basic? || requesting_client&.confidential? || oauth_client_secret.present? | ||
| end | ||
|
|
||
| def requesting_client | ||
| if oauth_client_id | ||
| @requesting_client ||= Oauth::Client.find_by(client_id: oauth_client_id) | ||
| end | ||
| end | ||
|
|
||
| def client_secret_authenticates?(client) | ||
| (client_secret_basic? || request.request_parameters["client_id"] == client.client_id) && | ||
| client.authenticate_secret(oauth_client_secret) | ||
| end | ||
|
|
||
| # A failed Basic authentication is a 401 challenging Basic (RFC 6749 §5.2): | ||
| # the endpoint evaluated the Authorization header, so the challenge applies | ||
| # (RFC 9110 §15.5.2). Every other failure is a 400 with no challenge: a | ||
| # body credential, a public client presenting a secret, or a secret that | ||
| # authenticates no client. A bare 401 would break RFC 9110, a challenge for | ||
| # a scheme the client never tried applies to nothing, oauth4webapi and | ||
| # openid-client surface a challenged 401 as a WWWAuthenticateChallengeError | ||
| # rather than invalid_client, and a Basic challenge on a form POST can pop | ||
| # a browser's password dialog. Either way the answer turns on the request's | ||
| # own header alone, so an unknown client and a wrong secret look the same. | ||
| # RFC 7617 defines no error parameter for Basic, only realm. | ||
| def client_authentication_failed | ||
| if client_secret_basic? | ||
| response.headers["WWW-Authenticate"] = %(Basic realm="#{oauth_issuer}") | ||
| oauth_error "invalid_client", "Client authentication failed", status: :unauthorized | ||
| else | ||
| oauth_error "invalid_client", "Client authentication failed" | ||
| end | ||
| end | ||
|
|
||
| # Whether the request used the Basic scheme, well-formed or not. | ||
| def client_secret_basic? | ||
| request.authorization.to_s.match?(BASIC_SCHEME) | ||
| end | ||
|
|
||
| def conflicting_client_id? | ||
| params[:client_id].present? && params[:client_id] != basic_client_credentials.first | ||
| end | ||
|
|
||
| # A Basic header wins over the body. Only a scalar String names a client. | ||
| def oauth_client_id | ||
| if client_secret_basic? | ||
| basic_client_credentials&.first | ||
| elsif params[:client_id].is_a?(String) | ||
| params[:client_id] | ||
| end | ||
| end | ||
|
|
||
| def oauth_client_secret | ||
| if client_secret_basic? | ||
| basic_client_credentials&.last | ||
| else | ||
| request.request_parameters["client_secret"] | ||
| end | ||
| end | ||
|
|
||
| # [client_id, client_secret] from Authorization: Basic, or nil when it's | ||
| # malformed. Each half is form-urlencoded before the pair is Basic-encoded | ||
| # (RFC 6749 Appendix B), so the split happens on the first raw colon and | ||
| # each half decodes after it: %3A is a literal colon and + a space. | ||
| def basic_client_credentials | ||
| if defined?(@basic_client_credentials) | ||
| @basic_client_credentials | ||
| else | ||
| @basic_client_credentials = decode_basic_client_credentials | ||
| end | ||
| end | ||
|
|
||
| def decode_basic_client_credentials | ||
| decoded = Base64.strict_decode64(request.authorization.to_s.sub(BASIC_SCHEME, "").strip) | ||
| client_id, client_secret = decoded.split(":", 2).map { |part| URI.decode_www_form_component(part) } | ||
|
|
||
| if client_id.present? && client_secret.is_a?(String) && client_id.valid_encoding? && client_secret.valid_encoding? | ||
| [ client_id, client_secret ] | ||
| end | ||
| rescue ArgumentError | ||
| nil | ||
| end | ||
|
|
||
| # The pilot exemption keys on the client that authenticates, header or body. | ||
| def piloting_client_id | ||
| oauth_client_id | ||
| end | ||
| end | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,22 +1,54 @@ | ||
| # Token revocation (RFC 7009). The server validates the client's credentials | ||
| # and that the token was issued to it (§2.1), so a client revokes only its own | ||
| # tokens: a confidential client authenticates, and a public client names its | ||
| # client_id. Anything else that fails to identify a client, and any token that | ||
| # is unknown or belongs to another client, gets the same 200 as a successful | ||
| # revocation (§2.2), so the endpoint is no oracle for which tokens or clients | ||
| # exist. Only failed authentication is refused: a known confidential client | ||
| # with a missing or wrong secret, a Basic header that doesn't authenticate, | ||
| # or a client secret that authenticates no client. | ||
| # | ||
| # A personal access token was issued to no client, so possession of it is the | ||
| # credential: whoever presents one revokes it, client or not. Client | ||
| # credentials presented beside it must still authenticate, by the same rules. | ||
| class Oauth::RevocationsController < Oauth::BaseController | ||
| include Oauth::ClientAuthentication | ||
|
|
||
| allow_unauthenticated_access | ||
| skip_forgery_protection | ||
|
|
||
| before_action :set_access_token | ||
| # Client authentication happens here too, so secrets can be guessed here: | ||
| # throttled like the token endpoint (RFC 6749 §2.3.1, RFC 7009 §5). | ||
| rate_limit to: 20, within: 1.minute, only: :create, with: :oauth_rate_limit_exceeded | ||
|
|
||
| before_action :reject_ambiguous_client_credentials | ||
| before_action :require_token | ||
|
jeremy marked this conversation as resolved.
|
||
|
|
||
| def create | ||
| @access_token&.destroy | ||
| if attempts_client_authentication? && !authenticated_client | ||
| client_authentication_failed | ||
| else | ||
| revocable_token&.destroy | ||
|
|
||
| head :ok # Don't behave as oracle, per RFC 7009 | ||
| head :ok | ||
| end | ||
| end | ||
|
|
||
| private | ||
| def set_access_token | ||
| if (token = params[:token]).present? | ||
| @access_token = Identity::AccessToken.find_by(token: token) || | ||
| Identity::AccessToken.find_by(refresh_token: token) | ||
| else | ||
| def require_token | ||
| unless params[:token].is_a?(String) && params[:token].present? | ||
| oauth_error "invalid_request", "token is required" | ||
| end | ||
| end | ||
|
|
||
| def revocable_token | ||
| Identity::AccessToken.personal.find_by(token: params[:token]) || client_token | ||
| end | ||
|
|
||
| def client_token | ||
| if client = authenticated_client | ||
| tokens = Identity::AccessToken.oauth.where(oauth_client: client) | ||
| tokens.find_by(token: params[:token]) || tokens.find_by(refresh_token: params[:token]) | ||
| end | ||
| end | ||
| end | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.