Repository navigation
Design the OAuth consent screen and Connected apps (Decision 8) #3212
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
41 commits
Select commit
Hold shift + click to select a range
ca5c1ba
Design the consent screen and Connected apps: who's asking, what it m…
jeremy 56bd25c
Name every remote host a connected app can send its access to
jeremy 0cc0901
Offer "Not you?" on the consent screen, and call last activity what i…
jeremy cc77884
Tell people about an unknown client on the error page, and switch onl…
jeremy cee4bed
Merge oauth-token-digests: refuse non-scalar OAuth parameters (#3236)
jeremy afc2ecd
Merge oauth-token-digests: main, with the crass 1.0.8 bump
jeremy 091f3bb
Merge oauth-token-digests: filter the code parameter by its whole name
jeremy 3d3289d
Merge oauth-token-digests: redact retired refresh tokens from logged SQL
jeremy c2efad2
Say which accounts a read grant's people come from, and read hosts in…
jeremy faa911d
Merge oauth-token-digests: its migrations can be run again after stop…
jeremy c1a0497
Merge oauth-token-digests
jeremy 32fcebd
Say what a self-registered app claims to be, and that we haven't chec…
jeremy baeaa30
Merge oauth-token-digests into oauth-consent-design
jeremy 77ed599
Merge oauth-dcr-allowlist into oauth-consent-design, so they land on …
jeremy 7f1e11f
Merge oauth-dcr-allowlist into oauth-consent-design
jeremy bd8fc43
Bring the train's tests up to the consent design, and carry an audien…
jeremy 54b3de7
Keep the metadata-document client's line on the redesigned consent sc…
jeremy 4bcaf9c
Merge oauth-dcr-allowlist into oauth-consent-design
jeremy 022cf7f
Merge oauth-dcr-allowlist into oauth-consent-design
jeremy 6c68825
Merge oauth-dcr-allowlist into oauth-consent-design
jeremy 09f4f4d
Merge oauth-dcr-allowlist into oauth-consent-design
jeremy 16db553
Merge oauth-dcr-allowlist into oauth-consent-design
jeremy 94a984f
Merge oauth-dcr-allowlist into oauth-consent-design
jeremy f34e990
Merge oauth-dcr-allowlist into oauth-consent-design
jeremy b601ef6
Say where a metadata document app's name comes from and which account…
jeremy 064220e
Merge oauth-dcr-allowlist into oauth-consent-design
jeremy dd225a7
Pair each permission with the accounts an app holds it in, and count …
jeremy df4ee4f
Merge oauth-dcr-allowlist into oauth-consent-design
jeremy 5a01195
Render Connected apps when a grant's bound account is gone
jeremy 51e8cd8
Merge oauth-dcr-allowlist into oauth-consent-design
jeremy 07a7519
Load Connected apps' accounts with their grants, and call a nameless …
jeremy 8611525
Merge oauth-dcr-allowlist: the review fixes from below
jeremy 33dba08
Refuse a switch that has no browser session to end, and isolate accou…
jeremy 2a80f79
Merge oauth-dcr-allowlist: review fixes from below
jeremy 28b9958
Merge oauth-dcr-allowlist: review fixes from below
jeremy 3765a67
Answer a consent error as JSON when JSON is asked for, and group http…
jeremy ea4cedb
Merge the staff gate into the consent design, which now lands after it
jeremy fe43cf0
Merge the staff gate's replay lock into the consent design
jeremy b2904ec
Show reach only in accounts the person is still active in, and let No…
jeremy a0bb85a
Merge the staff gate's replay-lock test into the consent design
jeremy 8e0a182
Let long emails and account names wrap on consent, and read active ac…
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,125 @@ | ||
| /* Authorizing an app (oauth/authorizations) and the apps you've connected (my/connected_apps) */ | ||
|
|
||
| @layer components { | ||
| .oauth-consent { | ||
| display: flex; | ||
| flex-direction: column; | ||
| gap: var(--block-space); | ||
| } | ||
|
|
||
| .oauth-client__name { | ||
| overflow-wrap: anywhere; | ||
| } | ||
|
|
||
| .oauth-client__self-name { | ||
| overflow-wrap: anywhere; | ||
| } | ||
|
|
||
| /* A long email still leaves room for "Not you?": the row wraps, and the address breaks */ | ||
| .oauth-consent__identity { | ||
| flex-wrap: wrap; | ||
|
|
||
| > span { | ||
| min-inline-size: 0; | ||
| overflow-wrap: anywhere; | ||
| } | ||
| } | ||
|
|
||
| .oauth-client__warning { | ||
| background-color: var(--color-highlight); | ||
| border-radius: 0.5em; | ||
| padding: var(--block-space-half) var(--inline-space); | ||
| } | ||
|
|
||
| .oauth-access { | ||
| border: 0; | ||
| margin: 0; | ||
| padding: 0; | ||
| } | ||
|
|
||
| .oauth-access__legend { | ||
| margin-block-end: var(--block-space-half); | ||
| padding: 0; | ||
| } | ||
|
|
||
| .oauth-access__choice { | ||
| align-items: start; | ||
|
|
||
| /* A long account name breaks inside its card rather than widening it */ | ||
| > span { | ||
| min-inline-size: 0; | ||
| overflow-wrap: anywhere; | ||
| } | ||
|
|
||
| border: var(--border); | ||
| border-radius: 0.5em; | ||
| cursor: pointer; | ||
| display: flex; | ||
| gap: var(--inline-space); | ||
| padding: var(--block-space-half) var(--inline-space); | ||
|
|
||
| &:has(:checked) { | ||
| border-color: var(--color-ink); | ||
| } | ||
|
|
||
| &:has(:focus-visible) { | ||
| outline: 2px solid var(--color-ink); | ||
| outline-offset: 2px; | ||
| } | ||
| } | ||
|
|
||
| .oauth-access__radio { | ||
| accent-color: var(--color-ink); | ||
| flex-shrink: 0; | ||
| margin-block-start: 0.2em; | ||
| } | ||
|
|
||
| .oauth-consent__actions { | ||
| flex-wrap: wrap; | ||
| } | ||
|
|
||
| .connected-apps { | ||
| display: flex; | ||
| flex-direction: column; | ||
| gap: var(--block-space); | ||
| } | ||
|
|
||
| .connected-apps__group { | ||
| display: flex; | ||
| flex-direction: column; | ||
| } | ||
|
|
||
| .connected-apps__empty { | ||
| border: 2px dashed var(--color-ink-lighter); | ||
| border-radius: 0.5em; | ||
| padding: var(--block-space) var(--inline-space-double); | ||
| } | ||
|
|
||
| .connected-app { | ||
| align-items: start; | ||
| border-block-end: var(--border); | ||
| display: flex; | ||
| gap: var(--inline-space); | ||
| justify-content: space-between; | ||
| padding-block: var(--block-space-half); | ||
|
|
||
| &:last-child { | ||
| border-block-end: 0; | ||
| } | ||
|
|
||
| /* Narrow screens: the disconnect button drops under the details */ | ||
| @media (max-width: 480px) { | ||
| flex-direction: column; | ||
| } | ||
| } | ||
|
|
||
| .connected-app__disconnect { | ||
| flex-shrink: 0; | ||
| } | ||
|
|
||
| .connected-app__details { | ||
| gap: 0.15em; | ||
| min-inline-size: 0; | ||
| overflow-wrap: anywhere; | ||
| } | ||
| } |
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
50 changes: 50 additions & 0 deletions
50
app/controllers/oauth/authorizations/switches_controller.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,50 @@ | ||
| # "Not you?" on the consent screen. Signs the person out, then brings whoever | ||
| # signs in next back to the same authorization request. The request is rebuilt | ||
| # from its parameters once they have passed every check the consent screen's | ||
| # did, never taken as a URL, so the only place this can return anyone to is | ||
| # consent for that request. The account choice isn't carried: it was the | ||
| # signed-out identity's, so the account check that guards granting doesn't | ||
| # apply here. The resource is carried, so an audienced request comes back as | ||
| # one, and its consent asks the new identity to pick an account. | ||
| class Oauth::Authorizations::SwitchesController < Oauth::AuthorizationsController | ||
| AUTHORIZATION_PARAMS = %i[ client_id redirect_uri response_type scope state code_challenge code_challenge_method resource ] | ||
|
|
||
| # Account eligibility belongs to the identity being signed out, so neither | ||
| # account check applies; the request-level audience check still does. | ||
| skip_before_action :set_account, :set_accounts | ||
| before_action :require_browser_session | ||
|
|
||
|
|
||
| # A request too long to keep in the session can't be resumed, so the person | ||
| # stays signed in rather than signing out to nowhere. | ||
| def create | ||
| return_to = new_oauth_authorization_url(script_name: nil, **authorization_params) | ||
|
|
||
| if return_to.bytesize <= RETURN_URL_MAXIMUM_LENGTH | ||
| terminate_session | ||
|
jeremy marked this conversation as resolved.
|
||
| reset_session | ||
| session[:return_to_after_authenticating] = return_to | ||
|
|
||
| redirect_to_login_url | ||
| else | ||
| render_error "invalid_request", "This request is too long to pick up again after switching. Sign out, sign in as the right person, then connect from the app again." | ||
| end | ||
| end | ||
|
|
||
| private | ||
| # Switching signs a browser out. A request authenticated by a bearer token | ||
| # has no session to end, so it is refused rather than half-handled. | ||
| def require_browser_session | ||
| head :forbidden unless Current.session | ||
| end | ||
|
|
||
| # Switching identity is a POST but authorizes nothing, so a metadata | ||
| # document client is only read here, never registered. | ||
| def persist_metadata_document? | ||
| false | ||
| end | ||
|
|
||
| def authorization_params | ||
| params.permit(*AUTHORIZATION_PARAMS).to_h.symbolize_keys | ||
| 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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,118 @@ | ||
| module OauthHelper | ||
| # Bidirectional controls can make a name read as something else, or push the | ||
| # host beside it out of place. A self-registered name is the app's own claim, | ||
| # so they go before it is shown anywhere. | ||
| BIDI_CONTROLS = /[--]/ | ||
|
|
||
| # Grouped by what the app is (Oauth::Client#connection_kind), in this order; | ||
| # a group with nothing in it isn't shown. | ||
| CONNECTED_APP_GROUPS = { | ||
| web: "Apps and assistants on the web", | ||
| local: "Apps on your devices", | ||
| fizzy: "Fizzy apps" | ||
| } | ||
|
|
||
| ACCESS_LEVELS = { | ||
| "read" => { | ||
| label: "Read only", | ||
| description: "See your boards, cards, comments and the people in your %{accounts}. It can't change anything." | ||
| }, | ||
| "read write" => { | ||
| label: "Read and write", | ||
| description: "See everything you can, and act as you: create, edit, move, close and delete cards, comments and boards." | ||
| } | ||
| } | ||
|
|
||
| # A name of nothing but direction controls is no name, so it reads as one. | ||
| def oauth_client_self_name(client) | ||
| client.name.to_s.gsub(BIDI_CONTROLS, "").presence || "Unnamed app" | ||
| end | ||
|
|
||
| # Who the person is dealing with, as far as we can vouch for it. A trusted or | ||
| # operator-registered app is known by its name. A self-registered app's name | ||
| # is only its claim, so it goes by where its access goes: the callback's | ||
| # host, port included when it isn't the default, or this device. | ||
| def oauth_client_display_identity(client, redirect_uri) | ||
| if client.dynamically_registered? | ||
|
jeremy marked this conversation as resolved.
|
||
| oauth_redirect_host(redirect_uri) || "an app on your device" | ||
| else | ||
| oauth_client_self_name(client) | ||
| end | ||
| end | ||
|
|
||
| # Lowercase, as a browser reads it. nil for a callback that stays on this | ||
| # device: loopback, or an app's own URL scheme. | ||
| def oauth_redirect_host(redirect_uri) | ||
| uri = URI.parse(redirect_uri.to_s) | ||
|
|
||
| if uri.host.present? && !Oauth.loopback_host?(uri.host) && uri.scheme.to_s.downcase.in?(%w[ http https ]) | ||
| uri.port == uri.default_port ? uri.host.downcase : "#{uri.host.downcase}:#{uri.port}" | ||
| end | ||
| rescue URI::InvalidURIError | ||
| nil | ||
| end | ||
|
|
||
| # Every place outside this device a client's access can go, as consent would | ||
| # name each one. A client may register several callbacks, and its grants may | ||
| # have come through any of them, so none speaks for the rest. | ||
| def oauth_client_remote_hosts(client) | ||
| client.redirect_uris.filter_map { |uri| oauth_redirect_host(uri) }.uniq | ||
| end | ||
|
|
||
| # The access levels a client may be granted, weakest first, each with what | ||
| # it lets the app do in plain words. A grant reaches every account the | ||
| # person belongs to, unless it's bound to the one picked at consent. | ||
| def oauth_access_levels(client, one_account: false) | ||
| levels = client.scopes.include?("write") ? ACCESS_LEVELS.keys : %w[ read ] | ||
|
|
||
| levels.map do |scope| | ||
| level = ACCESS_LEVELS.fetch(scope) | ||
| [ scope, level.merge(description: format(level[:description], accounts: one_account ? "account" : "accounts")) ] | ||
| end | ||
| end | ||
|
|
||
| def oauth_strongest_permission(tokens) | ||
| if tokens.any?(&:write?) | ||
| "write" | ||
| elsif tokens.any? | ||
| "read" | ||
| end | ||
| end | ||
|
|
||
| def oauth_access_label(permission) | ||
| ACCESS_LEVELS.fetch(Oauth.canonical_scope(permission))[:label] | ||
| end | ||
|
|
||
| # What an app's grants together let it do, as [ permission, accounts ] | ||
| # pairs, accounts nil for every account the person belongs to. An unbound | ||
| # grant reaches every account; a bound one adds only its own, and only | ||
| # where it grants more, so no pair claims more than some grant gives. | ||
| def oauth_connected_app_reach(tokens) | ||
| # A grant bound to an account since deleted, cancelled, or left reaches | ||
| # nothing: refreshing it would be refused, so it isn't shown as reach. | ||
| unbound, bound = tokens.partition { |token| !token.account_bound? } | ||
| bound = bound.select { |token| token.account && oauth_active_account_ids.include?(token.account_id) } | ||
| everywhere = oauth_strongest_permission(unbound) | ||
|
|
||
| reach = bound.group_by(&:account).filter_map do |account, account_tokens| | ||
|
jeremy marked this conversation as resolved.
|
||
| permission = oauth_strongest_permission(account_tokens) | ||
| [ permission, account ] if everywhere.nil? || (permission == "write" && everywhere == "read") | ||
| end | ||
|
|
||
| [ (everywhere && [ everywhere, nil ]), *reach.group_by(&:first).map { |permission, pairs| [ permission, pairs.map(&:last) ] } ].compact | ||
| end | ||
|
|
||
| # The accounts the signed-in person is still active in, read once per render | ||
| # however many apps the page lists. | ||
| def oauth_active_account_ids | ||
| @oauth_active_account_ids ||= Current.identity.users_with_active_accounts.pluck(:account_id).to_set | ||
| end | ||
|
|
||
| def connected_app_groups(connected_apps) | ||
| by_kind = connected_apps.group_by { |client, _| client.connection_kind } | ||
|
|
||
| CONNECTED_APP_GROUPS.filter_map do |kind, heading| | ||
| [ kind, heading, by_kind[kind] ] if by_kind[kind].present? | ||
| end | ||
| 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.