Skip to content
Open
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
17 changes: 17 additions & 0 deletions api/oauth2_metadata/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,20 @@
FIRST_PARTY_CLIENT_IDS = frozenset({FLAGSMITH_CLI_CLIENT_ID})
FIRST_PARTY_SCOPES = frozenset({SCOPE_ADMIN_API})
THIRD_PARTY_SCOPES = frozenset({SCOPE_MCP})

SCOPE_GRANTS: dict[str, tuple[str, ...]] = {
SCOPE_MCP: (
"Manage feature flags, toggle states, and update values",
"Create and manage audience targeting segments",
"View and configure environments",
"View and update project settings",
"Create and review change requests",
"View organisation details, roles, and groups",
"Act with your own permissions, in every organisation you belong to",
),
SCOPE_ADMIN_API: (
"Act with your own permissions, in every organisation you belong to",
"Manage feature flags, segments, environments and projects you can access",
"Manage organisation settings, members and roles you can access",
),
}
Comment on lines +10 to +25

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.

Suggested change
SCOPE_GRANTS: dict[str, tuple[str, ...]] = {
SCOPE_MCP: (
"Manage feature flags, toggle states, and update values",
"Create and manage audience targeting segments",
"View and configure environments",
"View and update project settings",
"Create and review change requests",
"View organisation details, roles, and groups",
"Act with your own permissions, in every organisation you belong to",
),
SCOPE_ADMIN_API: (
"Act with your own permissions, in every organisation you belong to",
"Manage feature flags, segments, environments and projects you can access",
"Manage organisation settings, members and roles you can access",
),
}
SCOPE_GRANTS: dict[str, frozenset[str, ...]] = {
SCOPE_MCP: frozenset([
"Manage feature flags, toggle states, and update values",
"Create and manage audience targeting segments",
"View and configure environments",
"View and update project settings",
"Create and review change requests",
"View organisation details, roles, and groups",
"Act with your own permissions, in every organisation you belong to",
]),
SCOPE_ADMIN_API: frozenset([
"Act with your own permissions, in every organisation you belong to",
"Manage feature flags, segments, environments and projects you can access",
"Manage organisation settings, members and roles you can access",
]),
}

nit: The immutability of tuples does not justify using them as immutable collections. Please, if you agree, prefer to use tuples when they could be interchanged with their verbose brother, NamedTuple.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

It's hard to justify frozenset here considering we don't need O(1) membership checks here; and using a mutable type like list makes things geniunely worse in my opinion.

I'm sorry, I'd rather you come to terms with the fact that tuple[str, ...] is a legitimate type.

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.

I'm sorry, I'd rather you come to terms with the fact that tuple[str, ...] is a legitimate type.

I continue to disagree, and I'd be glad to argument, though this isn't worth it. I will try not to pick these from now on.

25 changes: 25 additions & 0 deletions api/oauth2_metadata/mappers.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
from collections.abc import Iterable
from typing import Any

from oauth2_provider.scopes import get_scopes_backend

from oauth2_metadata.constants import SCOPE_GRANTS
from oauth2_metadata.types import ScopeDescription

_RFC7591_ERROR_CODES: dict[str, str] = {
"redirect_uris": "invalid_redirect_uri",
"client_name": "invalid_client_metadata",
Expand All @@ -9,6 +15,25 @@
}


def map_scopes_to_descriptions(
scopes: Iterable[str],
) -> dict[str, ScopeDescription]:
"""Describe scopes for a consent screen.

The label is the scope registry's own one-liner; the grants spell it out.
A scope with nothing written about it is described by its label alone,
so an added scope is never presented as granting nothing.
"""
all_scopes: dict[str, str] = get_scopes_backend().get_all_scopes()
return {
scope: ScopeDescription(
label=all_scopes.get(scope, scope),
grants=list(SCOPE_GRANTS.get(scope, ())),
)
for scope in scopes
}


def map_drf_error_to_rfc7591_error_body(errors: dict[str, Any]) -> dict[str, str]:
"""Format DRF serializer errors per RFC 7591 section 3.2.2."""
first_field = next(iter(errors))
Expand Down
8 changes: 8 additions & 0 deletions api/oauth2_metadata/types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from typing import TypedDict


class ScopeDescription(TypedDict):
"""How a scope is presented to a user asked to consent to it."""

label: str
grants: list[str]
10 changes: 5 additions & 5 deletions api/oauth2_metadata/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
from django.views.decorators.http import require_GET
from oauth2_provider.exceptions import OAuthToolkitError
from oauth2_provider.models import get_application_model
from oauth2_provider.scopes import get_scopes_backend
from oauth2_provider.views.mixins import OAuthLibMixin
from rest_framework import status
from rest_framework import status as drf_status
Expand All @@ -18,7 +17,10 @@
from rest_framework.views import APIView

from oauth2_metadata.dataclasses import OAuthConfig
from oauth2_metadata.mappers import map_drf_error_to_rfc7591_error_body
from oauth2_metadata.mappers import (
map_drf_error_to_rfc7591_error_body,
map_scopes_to_descriptions,
)
from oauth2_metadata.metrics import flagsmith_oauth2_dcr_registrations_total
from oauth2_metadata.serializers import (
TOKEN_ENDPOINT_AUTH_METHODS,
Expand Down Expand Up @@ -84,15 +86,13 @@ def get(self, request: Request, *args: Any, **kwargs: Any) -> Response:
application = Application.objects.get(
client_id=credentials["client_id"],
)
all_scopes = get_scopes_backend().get_all_scopes()
scopes_dict: dict[str, str] = {s: all_scopes.get(s, s) for s in scopes}
return Response(
{
"application": {
"name": application.name,
"client_id": application.client_id,
},
"scopes": scopes_dict,
"scopes": map_scopes_to_descriptions(scopes),
"redirect_uri": credentials.get("redirect_uri", ""),
# skip_authorization is safe to reuse here: this custom view
# always shows the consent screen regardless of this flag.
Expand Down
17 changes: 14 additions & 3 deletions api/tests/unit/oauth2_metadata/test_authorize_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@
from rest_framework import status
from rest_framework.test import APIClient

from oauth2_metadata.constants import FLAGSMITH_CLI_CLIENT_ID
from oauth2_metadata.constants import (
FLAGSMITH_CLI_CLIENT_ID,
SCOPE_ADMIN_API,
SCOPE_GRANTS,
SCOPE_MCP,
)


def _pkce_pair() -> tuple[str, str]:
Expand Down Expand Up @@ -82,7 +87,10 @@ def test_get__valid_params__returns_application_info(
data = response.json()
assert data["application"]["name"] == "Test App"
assert data["application"]["client_id"] == oauth_application.client_id
assert "mcp" in data["scopes"]
assert data["scopes"]["mcp"] == {
"label": "MCP access",
"grants": list(SCOPE_GRANTS[SCOPE_MCP]),
}
Comment thread
emyller marked this conversation as resolved.
assert data["redirect_uri"] == "https://example.com/callback"
assert data["is_verified"] is False

Expand Down Expand Up @@ -330,5 +338,8 @@ def test_get__flagsmith_cli_requests_admin_api__returns_application_info(
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data["application"]["client_id"] == FLAGSMITH_CLI_CLIENT_ID
assert "admin-api" in data["scopes"]
assert data["scopes"]["admin-api"] == {
"label": "Admin API access",
"grants": list(SCOPE_GRANTS[SCOPE_ADMIN_API]),
}
assert data["is_verified"] is True
66 changes: 66 additions & 0 deletions api/tests/unit/oauth2_metadata/test_mappers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
from pytest_django.fixtures import SettingsWrapper

from oauth2_metadata.constants import SCOPE_ADMIN_API, SCOPE_GRANTS, SCOPE_MCP
from oauth2_metadata.mappers import map_scopes_to_descriptions


def test_map_scopes_to_descriptions__described_scopes__returns_labels_and_grants() -> (
None
):
# Given
scopes = [SCOPE_MCP, SCOPE_ADMIN_API]

# When
descriptions = map_scopes_to_descriptions(scopes)

# Then
assert descriptions == {
SCOPE_MCP: {
"label": "MCP access",
"grants": list(SCOPE_GRANTS[SCOPE_MCP]),
},
SCOPE_ADMIN_API: {
"label": "Admin API access",
"grants": list(SCOPE_GRANTS[SCOPE_ADMIN_API]),
},
}


def test_map_scopes_to_descriptions__scope_without_grants__returns_label_only(
settings: SettingsWrapper,
) -> None:
# Given
settings.OAUTH2_PROVIDER = {
**settings.OAUTH2_PROVIDER,
"SCOPES": {**settings.OAUTH2_PROVIDER["SCOPES"], "read": "Read access"},
}

# When
descriptions = map_scopes_to_descriptions(["read"])

# Then
assert descriptions == {"read": {"label": "Read access", "grants": []}}


def test_map_scopes_to_descriptions__unregistered_scope__falls_back_to_its_name() -> (
None
):
# Given
scopes = ["not-a-scope"]

# When
descriptions = map_scopes_to_descriptions(scopes)

# Then
assert descriptions == {"not-a-scope": {"label": "not-a-scope", "grants": []}}


def test_map_scopes_to_descriptions__no_scopes__returns_empty() -> None:
# Given
scopes: list[str] = []

# When
descriptions = map_scopes_to_descriptions(scopes)

# Then
assert descriptions == {}
4 changes: 3 additions & 1 deletion frontend/common/stores/account-store.js
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,9 @@ const controller = {
// never let analytics break the login flow
}
}
return controller.onLogin()
// Signing up through an identity provider is still a signup, so it
// gets the same onboarding entry decision as `register` makes.
return controller.onLogin(!!res.is_new_user && !API.getInvite())
})
.catch((e) => API.ajaxHandler(store, e))
},
Expand Down
7 changes: 6 additions & 1 deletion frontend/common/types/responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,11 @@ export type Subscription = {
}

export type OnboardingVariant = 'control' | 'single_page'
// What consenting to an OAuth scope grants, as the API describes it.
export type OAuthScopeDescription = {
label: string
grants: string[]
}
export type Organisation = {
id: number
name: string
Expand Down Expand Up @@ -1516,7 +1521,7 @@ export type Res = {
}
validateOAuthAuthorize: {
application: { name: string; client_id: string }
scopes: Record<string, string>
scopes: Record<string, OAuthScopeDescription>
redirect_uri: string
is_verified: boolean
}
Expand Down
41 changes: 41 additions & 0 deletions frontend/common/utils/__tests__/pendingAuthorisation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { isPendingAuthorisation } from 'common/utils/pendingAuthorisation'

describe('isPendingAuthorisation', () => {
it('matches a consent request with its query', () => {
expect(
isPendingAuthorisation(
'/oauth/authorize?client_id=flagsmith-cli&scope=admin-api&state=abc',
),
).toBe(true)
})

it('matches the trailing-slash form the CLI is sent to', () => {
expect(isPendingAuthorisation('/oauth/authorize/?client_id=x')).toBe(true)
})

it('matches the bare path', () => {
expect(isPendingAuthorisation('/oauth/authorize')).toBe(true)
})

it('does not match an identity provider callback', () => {
expect(isPendingAuthorisation('/oauth/google?code=abc')).toBe(false)
})

it('does not match another page', () => {
expect(isPendingAuthorisation('/project/1/environment/abc/features')).toBe(
false,
)
})

it('does not match an absolute url wearing the path', () => {
expect(isPendingAuthorisation('https://evil.example/oauth/authorize')).toBe(
false,
)
})

it('is false when there is no redirect', () => {
expect(isPendingAuthorisation(undefined)).toBe(false)
expect(isPendingAuthorisation(null)).toBe(false)
expect(isPendingAuthorisation('')).toBe(false)
})
})
18 changes: 18 additions & 0 deletions frontend/common/utils/pendingAuthorisation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// The dashboard hosts the OAuth consent screen (it is the authorization
// endpoint in our RFC 8414 metadata), so a CLI or MCP client that opened it
// while logged out is now blocked on a loopback callback that only this browser
// can answer - and it gives up after a few minutes.
export const AUTHORISE_PATH = '/oauth/authorize'

/**
* Whether a stored post-login redirect is a consent request waiting to be
* answered. Compares the whole path so an absolute URL never matches: the
* redirect is read from a cookie, and this decides where we send the browser.
*/
export const isPendingAuthorisation = (
redirect?: string | null,
): redirect is string => {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (!redirect) return false
const path = redirect.split('?')[0]
return path.replace(/\/+$/, '') === AUTHORISE_PATH
}
12 changes: 12 additions & 0 deletions frontend/web/components/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
getStoredOnboardingVariant,
persistOnboardingEntry,
} from 'common/utils/onboardingEntry'
import { AUTHORISE_PATH } from 'common/utils/pendingAuthorisation'
import { Provider } from 'react-redux'
import { getStore } from 'common/store'
import ConfigProvider from 'common/providers/ConfigProvider'
Expand Down Expand Up @@ -140,6 +141,17 @@ const App = class extends Component {
return
}

// The consent screen is a destination, not a stop on the way to one, so
// never redirect away from it. Creating an organisation re-fires this while
// the organisation list is still refreshing, and the branch below would
// then yank the user off a request they were about to authorise.
if (this.props.location.pathname.startsWith(AUTHORISE_PATH)) {
return
}

// A signup with a consent request waiting keeps its redirect cookie
// through the branch below: onboarding provisions the organisation the
// client needs, then answers the request itself.
if (!AccountStore.getOrganisation() && !invite) {
// New users with no organisation go through the single-page onboarding
// flow when it's enabled - it creates the organisation itself, so it
Expand Down
12 changes: 12 additions & 0 deletions frontend/web/components/pages/CreateOrganisationPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import Button from 'components/base/forms/Button'
import API from 'project/api'
import AppActions from 'common/dispatcher/app-actions'
import { getStoredOnboardingTargetingKey } from 'common/utils/onboardingEntry'
import { isPendingAuthorisation } from 'common/utils/pendingAuthorisation'
import Utils from 'common/utils/utils'
// @ts-ignore
import Project from 'common/project'
Expand Down Expand Up @@ -54,6 +55,17 @@ const CreateOrganisationPage: React.FC = () => {
})
}

// A client waiting on the consent screen sent this user here to sign up,
// and the organisation it needed now exists. Answer it before going on:
// it returns the browser to onboarding once the user has authorised.
// Replaced, not pushed - going Back to a spent request only errors.
const pending = API.getRedirect()
if (isPendingAuthorisation(pending)) {
API.setRedirect('')
history.replace(pending)
return
}

history.push('/getting-started')
}
AccountStore.on('change', onChangeAccountStore)
Expand Down
Loading
Loading