Repository navigation
Minimal modern OAuth 2.1 stack #2296
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
Open
Changes from all commits
Commits
Show all changes
28 commits
Select commit
Hold shift + click to select a range
0521fe1
OAuth 2.1 support
jeremy 7b43efc
OAuth discovery and dynamic client registration
jeremy 13ae0cb
Connected Apps UI for OAuth grant management
jeremy 62797f0
Accept form-encoded requests at the OAuth protocol endpoints
jeremy 130bd79
Redeem each authorization code once, for the client it was issued to
jeremy 14bce34
Let consent redirect past CSP, name the issuer, and grant canonical s…
jeremy d36b5c2
Describe revocation and response modes honestly, bound client names, …
jeremy 266e948
Ship the OAuth authorization server dark behind availability switches
jeremy 8c8eb93
Keep registration dark when a request names a pilot client
jeremy 3e6cc4a
Reject non-string token request parameters as malformed
jeremy 86d5744
Vary the port only for http loopback redirects
jeremy ca19332
Point a client's access_tokens at oauth_client_id, and drop the stray…
jeremy 0c91659
Refuse personal access token management to OAuth grants
jeremy 8d7750d
Refuse a PKCE verifier outside 43 to 128 unreserved characters
jeremy 3d75b99
Keep OAuth grants out of every credential and consent surface
jeremy 6971938
Keep OAuth grants out of email changes too
jeremy 910e88a
Keep credentials that outlive a grant away from OAuth apps
jeremy f7304f2
Filter authorization codes, PKCE verifiers and DPoP proofs from logs
jeremy 0a2b7e1
Accept an authorization request without state, and bound one that has it
jeremy 5075bac
Reach Connected Apps without an account, as personal access tokens are
jeremy 76024c1
Bound what open registration stores: ten redirect URIs of 2000 charac…
jeremy f64ab63
Issue a code only for an S256-shaped code_challenge
jeremy 07108bb
Make the OAuth migrations safe to run again after stopping partway
jeremy e0c0cf0
Answer 404 from a dark endpoint however many times it's asked
jeremy 77618dc
Merge main into oauth
jeremy 1938037
Bring main's tests up to this branch: Ruby 4 and the access token finder
jeremy a035b2f
Merge main into oauth
jeremy 0083f54
Filter the code parameter by its whole name, so code_challenge stays …
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
There are no files selected for viewing
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
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,34 @@ | ||
| class My::ConnectedAppsController < ApplicationController | ||
| # Grants belong to the identity, not to an account, so revoking them must not | ||
| # depend on still having one: an identity whose only membership has ended | ||
| # keeps its grants, and could regain their reach by joining again. | ||
| skip_before_action :require_account | ||
| disallow_oauth_grants | ||
|
jeremy marked this conversation as resolved.
|
||
|
|
||
| before_action :set_connected_apps, only: :index | ||
| before_action :set_oauth_client, only: :destroy | ||
|
|
||
| def index | ||
| end | ||
|
|
||
| def destroy | ||
|
jeremy marked this conversation as resolved.
|
||
| @tokens.destroy_all | ||
|
|
||
| redirect_to my_connected_apps_path, notice: "#{@client.name} has been disconnected" | ||
| end | ||
|
|
||
| private | ||
| def set_connected_apps | ||
| tokens = oauth_tokens.includes(:oauth_client).order(:created_at) | ||
| @connected_apps = tokens.group_by(&:oauth_client).sort_by { |client, _| client.name.downcase } | ||
| end | ||
|
|
||
| def set_oauth_client | ||
| @tokens = oauth_tokens.where(oauth_client_id: params.require(:id)) | ||
| @client = @tokens.first&.oauth_client or raise ActiveRecord::RecordNotFound | ||
| end | ||
|
|
||
| def oauth_tokens | ||
| Current.identity.access_tokens.oauth | ||
| 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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,170 @@ | ||
| class Oauth::AuthorizationsController < Oauth::BaseController | ||
| FORM_ACTION_ORIGIN = %r{\Ahttps?://[a-z0-9.-]+(:\d+)?\z}i | ||
| FORM_ACTION_SCHEME = /\A[a-z][a-z0-9+.-]*\z/i | ||
| STATE_MAXIMUM_LENGTH = 2048 | ||
| # An S256 challenge is the unpadded base64url SHA-256 of the verifier: 43 characters (RFC 7636 §4.2). | ||
| S256_CODE_CHALLENGE = /\A[A-Za-z0-9_-]{43}\z/ | ||
| RETURN_URL_MAXIMUM_LENGTH = 2000 | ||
|
|
||
| # Browsers apply form-action to every hop of a form submission, redirects | ||
| # included, so the app-wide form-action 'self' would block the consent POST's | ||
| # redirect to the client. Allow just this request's validated redirect target. | ||
| content_security_policy if: -> { request.content_security_policy } do |policy| | ||
| policy.form_action :self, -> { validated_redirect_form_action_source } | ||
| end | ||
|
|
||
| # Gated ahead of sign-in so a dark server never asks anyone to sign in for | ||
| # it. A denial is never gated: it only tells the client no. | ||
| before_action :require_issuance_enabled, unless: :denial? | ||
| before_action :save_oauth_return_url | ||
| before_action :require_authentication | ||
| disallow_oauth_grants | ||
|
|
||
| before_action :set_client | ||
| before_action :validate_redirect_uri | ||
| before_action :validate_response_type | ||
| before_action :validate_pkce | ||
| before_action :validate_scope | ||
| before_action :validate_state | ||
|
|
||
| def new | ||
| @scope = Oauth.canonical_scope(params[:scope].presence || "read") | ||
| @redirect_uri = params[:redirect_uri] | ||
| @state = params[:state] | ||
| @code_challenge = params[:code_challenge] | ||
| end | ||
|
|
||
| def create | ||
| if denial? | ||
| redirect_to error_redirect_uri("access_denied", "User denied the request"), allow_other_host: true | ||
| else | ||
| code = Oauth::AuthorizationCode.generate \ | ||
| client_id: @client.client_id, | ||
| identity_id: Current.identity.id, | ||
| code_challenge: params[:code_challenge], | ||
| redirect_uri: params[:redirect_uri], | ||
| scope: Oauth.canonical_scope(params[:scope].presence || "read") | ||
|
jeremy marked this conversation as resolved.
|
||
|
|
||
| redirect_to success_redirect_uri(code), allow_other_host: true | ||
| end | ||
| end | ||
|
|
||
| private | ||
| def denial? | ||
| request.post? && params[:error] == "access_denied" | ||
| end | ||
|
|
||
| # Runs before authentication, so a person already signed in is told apart | ||
| # by their session cookie. The URL goes into the cookie-stored session, | ||
| # which overflows (a 500) past 4KB once encrypted, so a request too long to | ||
| # keep isn't kept: the person signs in and starts again from the app. | ||
| def save_oauth_return_url | ||
| if request.get? && !find_session_by_cookie && request.url.bytesize <= RETURN_URL_MAXIMUM_LENGTH | ||
| session[:return_to_after_authenticating] = request.url | ||
| end | ||
| end | ||
|
|
||
| def set_client | ||
| @client = Oauth::Client.find_by(client_id: params[:client_id]) | ||
| oauth_error("invalid_request", "Unknown client") unless @client | ||
| end | ||
|
|
||
| def validate_redirect_uri | ||
| unless performed? || @client.allows_redirect?(params[:redirect_uri]) | ||
| redirect_with_error "invalid_request", "Invalid redirect_uri" | ||
| end | ||
| end | ||
|
|
||
| def validate_response_type | ||
| unless performed? || params[:response_type] == "code" | ||
| redirect_with_error "unsupported_response_type", "Only 'code' response_type is supported" | ||
| end | ||
| end | ||
|
|
||
| def validate_pkce | ||
| unless performed? || params[:code_challenge].present? | ||
| redirect_with_error "invalid_request", "code_challenge is required" | ||
|
jeremy marked this conversation as resolved.
|
||
| end | ||
|
|
||
| unless performed? || params[:code_challenge].is_a?(String) && params[:code_challenge].match?(S256_CODE_CHALLENGE) | ||
| redirect_with_error "invalid_request", "code_challenge must be an S256 challenge" | ||
| end | ||
|
|
||
| unless performed? || params[:code_challenge_method] == "S256" | ||
| redirect_with_error "invalid_request", "code_challenge_method must be S256" | ||
| end | ||
| end | ||
|
|
||
| def validate_scope | ||
| unless performed? || @client.allows_scope?(params[:scope].presence || "read") | ||
| redirect_with_error "invalid_scope", "Requested scope is not allowed" | ||
| end | ||
| end | ||
|
|
||
| # state is optional: PKCE is the CSRF defence (OAuth 2.1 §7.5.1), so a | ||
| # client that doesn't send it isn't refused. One that does gets it back | ||
| # exactly, and it must be a single string of bounded length, since it is | ||
| # carried through the consent form and into the redirect. | ||
| def validate_state | ||
| unless performed? || valid_state? | ||
| redirect_with_error "invalid_request", "state must be a string of at most #{STATE_MAXIMUM_LENGTH} characters" | ||
| end | ||
| end | ||
|
|
||
| def valid_state? | ||
| params[:state].nil? || (params[:state].is_a?(String) && params[:state].length <= STATE_MAXIMUM_LENGTH) | ||
| end | ||
|
|
||
| def redirect_with_error(error, description) | ||
| if params[:redirect_uri].present? && @client&.allows_redirect?(params[:redirect_uri]) | ||
| redirect_to error_redirect_uri(error, description), allow_other_host: true | ||
| else | ||
| @error = error | ||
| @error_description = description | ||
| render :error, status: :bad_request | ||
| end | ||
| end | ||
|
|
||
| def success_redirect_uri(code) | ||
| build_redirect_uri params[:redirect_uri], | ||
| code: code, | ||
| state: params[:state].presence, | ||
| iss: oauth_issuer | ||
| end | ||
|
|
||
| def error_redirect_uri(error, description) | ||
| build_redirect_uri params[:redirect_uri], | ||
| error: error, | ||
| error_description: description, | ||
| state: params[:state].presence, | ||
| iss: oauth_issuer | ||
| end | ||
|
|
||
| # A CSP source naming the validated redirect origin, port included, since a | ||
| # loopback client may present any port (RFC 8252 §7.3). CSP host-sources | ||
| # can't express IPv6 literals, and a registered native redirect has no | ||
| # host, so those get their bare scheme. Nothing outside these shapes is | ||
| # written into the header. | ||
| def validated_redirect_form_action_source | ||
| if @client&.allows_redirect?(params[:redirect_uri]) | ||
| uri = URI.parse(params[:redirect_uri]) | ||
| origin = "#{uri.scheme}://#{uri.host}#{":#{uri.port}" unless uri.port == uri.default_port}" | ||
|
|
||
| if origin.match?(FORM_ACTION_ORIGIN) | ||
| origin | ||
| elsif uri.scheme.to_s.match?(FORM_ACTION_SCHEME) | ||
| "#{uri.scheme}:" | ||
| end | ||
| end | ||
| rescue URI::InvalidURIError | ||
| nil | ||
| end | ||
|
|
||
| def build_redirect_uri(base, **query_params) | ||
| uri = URI.parse(base) | ||
| query = URI.decode_www_form(uri.query || "") | ||
| query_params.compact.each { |k, v| query << [ k.to_s, v ] } | ||
| uri.query = URI.encode_www_form(query) | ||
| uri.to_s | ||
| 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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| class Oauth::BaseController < ApplicationController | ||
| disallow_account_scope | ||
|
|
||
| private | ||
| # Discovery follows acceptance (see Oauth::Availability): a dark server | ||
| # answers as if it had no authorization server at all. | ||
| def require_acceptance_enabled | ||
| head :not_found unless Oauth::Availability.acceptance_enabled? | ||
| end | ||
|
|
||
| # Minting endpoints are 404 while dark and 503 while issuance is paused. | ||
| # Revocation, Connected Apps and denials never call this, so people can | ||
| # always shed access. The pilot exemption reads the claimed client_id, | ||
| # before the client authenticates: this only decides whether the endpoint | ||
| # answers at all. | ||
| def require_issuance_enabled | ||
| if !Oauth::Availability.acceptance_enabled?(piloting_client_id) | ||
| head :not_found | ||
| elsif !Oauth::Availability.issuance_enabled?(piloting_client_id) | ||
| head :service_unavailable | ||
| end | ||
| end | ||
|
|
||
| # The client a request acts for, if it names one. | ||
| def piloting_client_id | ||
| params[:client_id] | ||
| end | ||
|
|
||
| # The metadata issuer and the iss authorization response parameter must be | ||
| # identical (RFC 9207 §2). script_name: nil keeps an account prefix out of it. | ||
| def oauth_issuer | ||
| root_url(script_name: nil) | ||
| end | ||
|
|
||
| def oauth_error(error, description = nil, status: :bad_request) | ||
| render json: { error: error, error_description: description }.compact, status: status | ||
| end | ||
|
|
||
| def oauth_rate_limit_exceeded | ||
| oauth_error "slow_down", "Too many requests", status: :too_many_requests | ||
| end | ||
| end |
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.