mirror of
https://github.com/BillyOutlast/posthog.git
synced 2026-02-04 03:01:23 +01:00
feat: oauth authorization flow (#33667)
Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Michael Matloka <michael@matloka.com>
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
@@ -108,6 +108,7 @@ import {
|
||||
NotebookListItemType,
|
||||
NotebookNodeResource,
|
||||
NotebookType,
|
||||
type OAuthApplicationPublicMetadata,
|
||||
OrganizationFeatureFlags,
|
||||
OrganizationFeatureFlagsCopyBody,
|
||||
OrganizationMemberScopedApiKeysResponse,
|
||||
@@ -1306,6 +1307,10 @@ export class ApiRequest {
|
||||
return this.messagingTemplates().addPathComponent(templateId)
|
||||
}
|
||||
|
||||
public oauthApplicationPublicMetadata(clientId: string): ApiRequest {
|
||||
return this.addPathComponent('oauth_application').addPathComponent('metadata').addPathComponent(clientId)
|
||||
}
|
||||
|
||||
public hogFlows(): ApiRequest {
|
||||
return this.environments().current().addPathComponent('hog_flows')
|
||||
}
|
||||
@@ -3453,6 +3458,11 @@ const api = {
|
||||
return await new ApiRequest().messagingTemplate(templateId).update({ data })
|
||||
},
|
||||
},
|
||||
oauthApplication: {
|
||||
async getPublicMetadata(clientId: string): Promise<OAuthApplicationPublicMetadata> {
|
||||
return await new ApiRequest().oauthApplicationPublicMetadata(clientId).get()
|
||||
},
|
||||
},
|
||||
hogFlows: {
|
||||
async getHogFlows(): Promise<PaginatedResponse<HogFlow>> {
|
||||
return await new ApiRequest().hogFlows().get()
|
||||
|
||||
174
frontend/src/lib/scopes.tsx
Normal file
174
frontend/src/lib/scopes.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import type { APIScopeAction, APIScopeObject } from '~/types'
|
||||
|
||||
export const MAX_API_KEYS_PER_USER = 10 // Same as in posthog/api/personal_api_key.py
|
||||
|
||||
export type APIScope = {
|
||||
key: APIScopeObject
|
||||
info?: string | JSX.Element
|
||||
disabledActions?: ('read' | 'write')[]
|
||||
disabledWhenProjectScoped?: boolean
|
||||
description?: string
|
||||
warnings?: Partial<Record<'read' | 'write', string | JSX.Element>>
|
||||
objectPlural: string
|
||||
}
|
||||
|
||||
export const API_SCOPES: APIScope[] = [
|
||||
{ key: 'action', objectPlural: 'actions' },
|
||||
{ key: 'activity_log', objectPlural: 'activity logs' },
|
||||
{ key: 'annotation', objectPlural: 'annotations' },
|
||||
{ key: 'batch_export', objectPlural: 'batch exports' },
|
||||
{ key: 'cohort', objectPlural: 'cohorts' },
|
||||
{ key: 'dashboard', objectPlural: 'dashboards' },
|
||||
{ key: 'dashboard_template', objectPlural: 'dashboard templates' },
|
||||
{ key: 'early_access_feature', objectPlural: 'early access features' },
|
||||
{ key: 'event_definition', objectPlural: 'event definitions' },
|
||||
{ key: 'error_tracking', objectPlural: 'error tracking' },
|
||||
{ key: 'experiment', objectPlural: 'experiments' },
|
||||
{ key: 'export', objectPlural: 'exports' },
|
||||
{ key: 'feature_flag', objectPlural: 'feature flags' },
|
||||
{ key: 'group', objectPlural: 'groups' },
|
||||
{ key: 'hog_function', objectPlural: 'hog functions' },
|
||||
{ key: 'insight', objectPlural: 'insights' },
|
||||
{ key: 'notebook', objectPlural: 'notebooks' },
|
||||
{ key: 'organization', disabledWhenProjectScoped: true, objectPlural: 'organizations' },
|
||||
{
|
||||
key: 'organization_member',
|
||||
objectPlural: 'organization members',
|
||||
disabledWhenProjectScoped: true,
|
||||
warnings: {
|
||||
write: (
|
||||
<>
|
||||
This scope can be used to invite users to your organization,
|
||||
<br />
|
||||
effectively <strong>allowing access to other scopes via the added user</strong>.
|
||||
</>
|
||||
),
|
||||
},
|
||||
},
|
||||
{ key: 'person', objectPlural: 'persons' },
|
||||
{ key: 'plugin', objectPlural: 'plugins' },
|
||||
{
|
||||
key: 'project',
|
||||
objectPlural: 'projects',
|
||||
warnings: {
|
||||
write: 'This scope can be used to create or modify projects, including settings about how data is ingested.',
|
||||
},
|
||||
},
|
||||
{ key: 'property_definition', objectPlural: 'property definitions' },
|
||||
{ key: 'query', disabledActions: ['write'], objectPlural: 'queries' },
|
||||
{ key: 'session_recording', objectPlural: 'session recordings' },
|
||||
{ key: 'session_recording_playlist', objectPlural: 'session recording playlists' },
|
||||
{ key: 'sharing_configuration', objectPlural: 'sharing configurations' },
|
||||
{ key: 'subscription', objectPlural: 'subscriptions' },
|
||||
{ key: 'survey', objectPlural: 'surveys' },
|
||||
{
|
||||
key: 'user',
|
||||
objectPlural: 'users',
|
||||
disabledActions: ['write'],
|
||||
warnings: {
|
||||
read: (
|
||||
<>
|
||||
This scope allows you to retrieve your own user object.
|
||||
<br />
|
||||
Note that the user object <strong>lists all organizations and projects you're in</strong>.
|
||||
</>
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'webhook',
|
||||
info: 'Webhook configuration is currently only enabled for the Zapier integration.',
|
||||
objectPlural: 'webhooks',
|
||||
},
|
||||
{ key: 'warehouse_view', objectPlural: 'warehouse views' },
|
||||
{ key: 'warehouse_table', objectPlural: 'warehouse tables' },
|
||||
]
|
||||
|
||||
export const API_KEY_SCOPE_PRESETS: { value: string; label: string; scopes: string[]; isCloudOnly?: boolean }[] = [
|
||||
{ value: 'local_evaluation', label: 'Local feature flag evaluation', scopes: ['feature_flag:read'] },
|
||||
{
|
||||
value: 'zapier',
|
||||
label: 'Zapier integration',
|
||||
scopes: ['action:read', 'query:read', 'project:read', 'organization:read', 'user:read', 'webhook:write'],
|
||||
},
|
||||
{ value: 'analytics', label: 'Performing analytics queries', scopes: ['query:read'] },
|
||||
{
|
||||
value: 'project_management',
|
||||
label: 'Project & user management',
|
||||
scopes: ['project:write', 'organization:read', 'organization_member:write'],
|
||||
},
|
||||
{
|
||||
value: 'mcp_server',
|
||||
label: 'MCP Server',
|
||||
scopes: API_SCOPES.map(({ key }) =>
|
||||
['feature_flag', 'insight'].includes(key) ? `${key}:write` : `${key}:read`
|
||||
),
|
||||
},
|
||||
{ value: 'all_access', label: 'All access', scopes: ['*'] },
|
||||
]
|
||||
|
||||
export const APIScopeActionLabels: Record<APIScopeAction, string> = {
|
||||
read: 'Read',
|
||||
write: 'Write',
|
||||
}
|
||||
|
||||
export const DEFAULT_OAUTH_SCOPES = ['openid', 'email', 'profile']
|
||||
|
||||
export const getScopeDescription = (scope: string): string => {
|
||||
if (scope === '*') {
|
||||
return 'Read and write access to all PostHog data'
|
||||
}
|
||||
|
||||
if (scope === 'openid') {
|
||||
return 'View your User ID'
|
||||
}
|
||||
|
||||
if (scope === 'email') {
|
||||
return 'View your email address'
|
||||
}
|
||||
|
||||
if (scope === 'profile') {
|
||||
return 'View basic user account information'
|
||||
}
|
||||
|
||||
const [object, action] = scope.split(':')
|
||||
|
||||
if (!object || !action) {
|
||||
return scope
|
||||
}
|
||||
|
||||
const scopeObject = API_SCOPES.find((s) => s.key === object)
|
||||
const actionWord = action === 'write' ? 'Write' : 'Read'
|
||||
|
||||
return `${actionWord} access to ${scopeObject?.objectPlural ?? scope}`
|
||||
}
|
||||
|
||||
export const getMinimumEquivalentScopes = (scopes: string[]): string[] => {
|
||||
if (scopes.includes('*')) {
|
||||
return ['*']
|
||||
}
|
||||
|
||||
const highestScopes: Record<string, string> = {}
|
||||
|
||||
for (const scope of scopes) {
|
||||
if (['openid', 'email', 'profile'].includes(scope)) {
|
||||
highestScopes[scope] = 'default'
|
||||
continue
|
||||
}
|
||||
|
||||
const [object, action] = scope.split(':')
|
||||
if (!object || !action) {
|
||||
continue
|
||||
}
|
||||
if (!highestScopes[object] || (action === 'write' && highestScopes[object] === 'read')) {
|
||||
highestScopes[object] = action
|
||||
}
|
||||
}
|
||||
|
||||
return Object.entries(highestScopes).map(([object, action]) => {
|
||||
if (action === 'default') {
|
||||
return object
|
||||
}
|
||||
return `${object}:${action}`
|
||||
})
|
||||
}
|
||||
@@ -12,6 +12,7 @@ const pathsWithoutProjectId = [
|
||||
'signup',
|
||||
'create-organization',
|
||||
'account',
|
||||
'oauth',
|
||||
]
|
||||
|
||||
function isPathWithoutProjectId(path: string): boolean {
|
||||
|
||||
@@ -84,6 +84,7 @@ export const appScenes: Record<Scene | string, () => any> = {
|
||||
import('scenes/web-analytics/SessionAttributionExplorer/SessionAttributionExplorerScene'),
|
||||
[Scene.Wizard]: () => import('./wizard/Wizard'),
|
||||
[Scene.StartupProgram]: () => import('./startups/StartupProgram'),
|
||||
[Scene.OAuthAuthorize]: () => import('./oauth/OAuthAuthorize'),
|
||||
[Scene.HogFunction]: () => import('./hog-functions/HogFunctionScene'),
|
||||
[Scene.DataPipelines]: () => import('./data-pipelines/DataPipelinesScene'),
|
||||
[Scene.DataPipelinesNew]: () => import('./data-pipelines/DataPipelinesNewScene'),
|
||||
|
||||
89
frontend/src/scenes/oauth/OAuthAuthorize.stories.tsx
Normal file
89
frontend/src/scenes/oauth/OAuthAuthorize.stories.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { Meta, StoryFn } from '@storybook/react'
|
||||
import { router } from 'kea-router'
|
||||
import { useEffect } from 'react'
|
||||
import { App } from 'scenes/App'
|
||||
import { urls } from 'scenes/urls'
|
||||
|
||||
import { mswDecorator } from '~/mocks/browser'
|
||||
|
||||
const meta: Meta = {
|
||||
title: 'Scenes-App/OAuth/Authorize',
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
viewMode: 'story',
|
||||
mockDate: '2023-02-01',
|
||||
testOptions: {
|
||||
waitForSelector: '.max-w-2xl',
|
||||
},
|
||||
},
|
||||
decorators: [
|
||||
mswDecorator({
|
||||
get: {
|
||||
'/api/oauth_application/metadata/test-client-id/': {
|
||||
id: '123',
|
||||
client_id: 'test-client-id',
|
||||
name: 'Test OAuth Application',
|
||||
description: 'This is a test OAuth application for development',
|
||||
created_at: '2023-01-01T00:00:00Z',
|
||||
updated_at: '2023-01-01T00:00:00Z',
|
||||
},
|
||||
'/api/projects/': {
|
||||
results: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Default Project',
|
||||
organization: {
|
||||
id: '1',
|
||||
name: 'Default Organization',
|
||||
slug: 'default-org',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Analytics Project',
|
||||
organization: {
|
||||
id: '1',
|
||||
name: 'Default Organization',
|
||||
slug: 'default-org',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
post: {
|
||||
'/oauth/authorize/': {
|
||||
redirect_to: 'https://example.com/callback?code=test-auth-code&state=test-state',
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
}
|
||||
|
||||
export default meta
|
||||
|
||||
export const DefaultScopes: StoryFn = () => {
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams({
|
||||
client_id: 'test-client-id',
|
||||
redirect_uri: 'https://example.com/callback',
|
||||
response_type: 'code',
|
||||
state: 'test-state',
|
||||
})
|
||||
router.actions.push(`${urls.oauthAuthorize()}?${params.toString()}`)
|
||||
}, [])
|
||||
return <App />
|
||||
}
|
||||
|
||||
export const WithScopes: StoryFn = () => {
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams({
|
||||
client_id: 'test-client-id',
|
||||
redirect_uri: 'https://app.example.com/oauth/callback',
|
||||
response_type: 'code',
|
||||
state: 'test-state',
|
||||
scope: 'experiment:read experiment:write query:read feature_flag:write',
|
||||
})
|
||||
router.actions.push(`${urls.oauthAuthorize()}?${params.toString()}`)
|
||||
}, [])
|
||||
return <App />
|
||||
}
|
||||
130
frontend/src/scenes/oauth/OAuthAuthorize.tsx
Normal file
130
frontend/src/scenes/oauth/OAuthAuthorize.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { IconCheck, IconWarning, IconX } from '@posthog/icons'
|
||||
import { useActions, useValues } from 'kea'
|
||||
import { Form } from 'kea-forms'
|
||||
import { LemonButton } from 'lib/lemon-ui/LemonButton'
|
||||
import { Spinner } from 'lib/lemon-ui/Spinner'
|
||||
import ScopeAccessSelector from 'scenes/settings/user/scopes/ScopeAccessSelector'
|
||||
|
||||
import { SceneExport } from '../sceneTypes'
|
||||
import { oauthAuthorizeLogic } from './oauthAuthorizeLogic'
|
||||
|
||||
export const OAuthAuthorizeError = ({ title, description }: { title: string; description: string }): JSX.Element => {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4 py-12">
|
||||
<IconWarning className="text-muted-alt text-4xl" />
|
||||
<div className="text-xl font-semibold">{title}</div>
|
||||
<div className="text-sm text-muted">{description}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const OAuthAuthorize = (): JSX.Element => {
|
||||
const {
|
||||
scopeDescriptions,
|
||||
oauthApplication,
|
||||
oauthApplicationLoading,
|
||||
allOrganizations,
|
||||
allTeams,
|
||||
oauthAuthorization,
|
||||
isOauthAuthorizationSubmitting,
|
||||
redirectDomain,
|
||||
} = useValues(oauthAuthorizeLogic)
|
||||
const { cancel, submitOauthAuthorization } = useActions(oauthAuthorizeLogic)
|
||||
|
||||
if (oauthApplicationLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full py-12">
|
||||
<Spinner />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!oauthApplication) {
|
||||
return (
|
||||
<OAuthAuthorizeError
|
||||
title="No application found"
|
||||
description="The application requesting access to your data does not exist."
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full">
|
||||
<div className="max-w-2xl mx-auto py-12 px-6">
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-2xl font-semibold">
|
||||
Authorize <strong>{oauthApplication.name}</strong>
|
||||
</h2>
|
||||
<p className="text-muted mt-2">{oauthApplication.name} is requesting access to your data.</p>
|
||||
</div>
|
||||
|
||||
<Form logic={oauthAuthorizeLogic} formKey="oauthAuthorization">
|
||||
<div className="flex flex-col gap-6 bg-bg-light border border-border rounded p-6 shadow">
|
||||
<ScopeAccessSelector
|
||||
accessType={oauthAuthorization.access_type}
|
||||
organizations={allOrganizations}
|
||||
teams={allTeams ?? undefined}
|
||||
/>
|
||||
<div>
|
||||
<div className="text-sm font-semibold uppercase text-muted mb-2">Requested Permissions</div>
|
||||
<ul className="space-y-2">
|
||||
{scopeDescriptions.map((scopeDescription, idx) => (
|
||||
<li key={idx} className="flex items-center space-x-2 text-large">
|
||||
<IconCheck color="var(--success)" />
|
||||
<span className="font-medium">{scopeDescription}</span>
|
||||
</li>
|
||||
))}
|
||||
<li className="flex items-center space-x-2 text-large">
|
||||
<IconX color="var(--danger)" />
|
||||
<span className="font-medium">Replace your dashboards with hedgehog memes</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{redirectDomain && (
|
||||
<div className="text-xs text-muted">
|
||||
<p>
|
||||
Once you authorize, you will be redirected to <strong>{redirectDomain}</strong>
|
||||
</p>
|
||||
<p>
|
||||
The developer of {oauthApplication.name}'s privacy policy and terms of service apply
|
||||
to this application
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end space-x-2 pt-4">
|
||||
<LemonButton
|
||||
type="tertiary"
|
||||
status="alt"
|
||||
htmlType="button"
|
||||
loading={isOauthAuthorizationSubmitting}
|
||||
disabledReason={isOauthAuthorizationSubmitting ? 'Processing...' : undefined}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
cancel()
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</LemonButton>
|
||||
<LemonButton
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={isOauthAuthorizationSubmitting}
|
||||
disabledReason={isOauthAuthorizationSubmitting ? 'Authorizing...' : undefined}
|
||||
onClick={() => submitOauthAuthorization()}
|
||||
>
|
||||
Authorize {oauthApplication?.name}
|
||||
</LemonButton>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const scene: SceneExport = {
|
||||
component: OAuthAuthorize,
|
||||
logic: oauthAuthorizeLogic,
|
||||
}
|
||||
164
frontend/src/scenes/oauth/oauthAuthorizeLogic.ts
Normal file
164
frontend/src/scenes/oauth/oauthAuthorizeLogic.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { actions, connect, kea, listeners, path, reducers, selectors } from 'kea'
|
||||
import { forms } from 'kea-forms'
|
||||
import { loaders } from 'kea-loaders'
|
||||
import { router, urlToAction } from 'kea-router'
|
||||
import api from 'lib/api'
|
||||
import { lemonToast } from 'lib/lemon-ui/LemonToast/LemonToast'
|
||||
import { DEFAULT_OAUTH_SCOPES, getMinimumEquivalentScopes, getScopeDescription } from 'lib/scopes'
|
||||
import { userLogic } from 'scenes/userLogic'
|
||||
|
||||
import type { OAuthApplicationPublicMetadata, OrganizationBasicType, TeamBasicType, UserType } from '~/types'
|
||||
|
||||
import type { oauthAuthorizeLogicType } from './oauthAuthorizeLogicType'
|
||||
|
||||
export type OAuthAuthorizationFormValues = {
|
||||
scoped_organizations: number[]
|
||||
scoped_teams: number[]
|
||||
access_type: 'all' | 'organizations' | 'teams'
|
||||
}
|
||||
|
||||
const oauthAuthorize = async (values: OAuthAuthorizationFormValues & { allow: boolean }): Promise<void> => {
|
||||
try {
|
||||
const response = await api.create('/oauth/authorize/', {
|
||||
client_id: router.values.searchParams['client_id'],
|
||||
redirect_uri: router.values.searchParams['redirect_uri'],
|
||||
response_type: router.values.searchParams['response_type'],
|
||||
state: router.values.searchParams['state'],
|
||||
scope: router.values.searchParams['scope'],
|
||||
code_challenge: router.values.searchParams['code_challenge'],
|
||||
code_challenge_method: router.values.searchParams['code_challenge_method'],
|
||||
nonce: router.values.searchParams['nonce'],
|
||||
claims: router.values.searchParams['claims'],
|
||||
scoped_organizations: values.access_type === 'organizations' ? values.scoped_organizations : [],
|
||||
scoped_teams: values.access_type === 'teams' ? values.scoped_teams : [],
|
||||
access_level:
|
||||
values.access_type === 'all' ? 'all' : values.access_type === 'organizations' ? 'organization' : 'team',
|
||||
allow: values.allow,
|
||||
})
|
||||
|
||||
if (response.redirect_to) {
|
||||
location.href = response.redirect_to
|
||||
}
|
||||
} catch (error: any) {
|
||||
lemonToast.error('Something went wrong while authorizing the application')
|
||||
throw error
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
export const oauthAuthorizeLogic = kea<oauthAuthorizeLogicType>([
|
||||
path(['oauth', 'authorize']),
|
||||
connect(() => ({
|
||||
values: [userLogic, ['user']],
|
||||
})),
|
||||
actions({
|
||||
setScopes: (scopes: string[]) => ({ scopes }),
|
||||
cancel: () => ({}),
|
||||
}),
|
||||
loaders({
|
||||
allTeams: [
|
||||
null as TeamBasicType[] | null,
|
||||
{
|
||||
loadAllTeams: async () => {
|
||||
return await api.loadPaginatedResults('api/projects')
|
||||
},
|
||||
},
|
||||
],
|
||||
oauthApplication: [
|
||||
null as OAuthApplicationPublicMetadata | null,
|
||||
{
|
||||
loadOAuthApplication: async () => {
|
||||
return await api.oauthApplication.getPublicMetadata(
|
||||
router.values.searchParams['client_id'] as string
|
||||
)
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
listeners(({ values }) => ({
|
||||
cancel: async () => {
|
||||
await oauthAuthorize({
|
||||
scoped_organizations: values.oauthAuthorization.scoped_organizations,
|
||||
scoped_teams: values.oauthAuthorization.scoped_teams,
|
||||
access_type: values.oauthAuthorization.access_type,
|
||||
allow: false,
|
||||
})
|
||||
},
|
||||
})),
|
||||
reducers({
|
||||
scopes: [
|
||||
[] as string[],
|
||||
{
|
||||
setScopes: (_, { scopes }) => scopes,
|
||||
},
|
||||
],
|
||||
}),
|
||||
forms(() => ({
|
||||
oauthAuthorization: {
|
||||
defaults: {
|
||||
scoped_organizations: [],
|
||||
scoped_teams: [],
|
||||
access_type: 'all',
|
||||
} as OAuthAuthorizationFormValues,
|
||||
errors: ({ access_type, scoped_organizations, scoped_teams }: OAuthAuthorizationFormValues) => ({
|
||||
access_type: !access_type ? ('Select access mode' as any) : undefined,
|
||||
scoped_organizations:
|
||||
access_type === 'organizations' && !scoped_organizations?.length
|
||||
? ('Select at least one organization' as any)
|
||||
: undefined,
|
||||
scoped_teams:
|
||||
access_type === 'teams' && !scoped_teams?.length
|
||||
? ('Select at least one project' as any)
|
||||
: undefined,
|
||||
}),
|
||||
submit: async (values: OAuthAuthorizationFormValues) => {
|
||||
await oauthAuthorize({
|
||||
...values,
|
||||
allow: true,
|
||||
})
|
||||
},
|
||||
},
|
||||
})),
|
||||
selectors(() => ({
|
||||
allOrganizations: [
|
||||
(s) => [s.user],
|
||||
(user: UserType): OrganizationBasicType[] => {
|
||||
return user?.organizations ?? []
|
||||
},
|
||||
],
|
||||
scopeDescriptions: [
|
||||
(s) => [s.scopes],
|
||||
(scopes: string[]): string[] => {
|
||||
const minimumEquivalentScopes = getMinimumEquivalentScopes(scopes)
|
||||
|
||||
return minimumEquivalentScopes.map(getScopeDescription)
|
||||
},
|
||||
],
|
||||
redirectDomain: [
|
||||
(s) => [s.oauthApplication],
|
||||
(): string => {
|
||||
const redirectUri = router.values.searchParams['redirect_uri'] as string
|
||||
if (!redirectUri) {
|
||||
return ''
|
||||
}
|
||||
try {
|
||||
const url = new URL(redirectUri)
|
||||
return url.hostname
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
},
|
||||
],
|
||||
})),
|
||||
urlToAction(({ actions }) => ({
|
||||
'/oauth/authorize': (_, searchParams) => {
|
||||
const requestedScopes = searchParams['scope']?.split(' ')?.filter((scope: string) => scope.length) ?? []
|
||||
const scopes = requestedScopes.length === 0 ? DEFAULT_OAUTH_SCOPES : requestedScopes
|
||||
|
||||
actions.setScopes(scopes)
|
||||
actions.loadOAuthApplication()
|
||||
actions.loadAllTeams()
|
||||
},
|
||||
})),
|
||||
])
|
||||
@@ -62,6 +62,7 @@ const pathPrefixesOnboardingNotRequiredFor = [
|
||||
urls.debugHog(),
|
||||
urls.debugQuery(),
|
||||
urls.activity(),
|
||||
urls.oauthAuthorize(),
|
||||
]
|
||||
|
||||
export const sceneLogic = kea<sceneLogicType>([
|
||||
|
||||
@@ -98,6 +98,7 @@ export enum Scene {
|
||||
MessagingCampaign = 'MessagingCampaign',
|
||||
Wizard = 'Wizard',
|
||||
StartupProgram = 'StartupProgram',
|
||||
OAuthAuthorize = 'OAuthAuthorize',
|
||||
HogFunction = 'HogFunction',
|
||||
DataPipelines = 'DataPipelines',
|
||||
DataPipelinesNew = 'DataPipelinesNew',
|
||||
|
||||
@@ -457,6 +457,13 @@ export const sceneConfigurations: Record<Scene | string, SceneConfig> = {
|
||||
organizationBased: true,
|
||||
layout: 'app-container',
|
||||
},
|
||||
[Scene.OAuthAuthorize]: {
|
||||
name: 'Authorize',
|
||||
layout: 'plain',
|
||||
projectBased: false,
|
||||
organizationBased: false,
|
||||
allowUnauthenticated: true,
|
||||
},
|
||||
[Scene.HogFunction]: {
|
||||
projectBased: true,
|
||||
name: 'Hog function',
|
||||
@@ -691,6 +698,7 @@ export const routes: Record<string, [Scene | string, string]> = {
|
||||
[urls.wizard()]: [Scene.Wizard, 'wizard'],
|
||||
[urls.startups()]: [Scene.StartupProgram, 'startupProgram'],
|
||||
[urls.startups(':referrer')]: [Scene.StartupProgram, 'startupProgramWithReferrer'],
|
||||
[urls.oauthAuthorize()]: [Scene.OAuthAuthorize, 'oauthAuthorize'],
|
||||
[urls.dataPipelines(':kind')]: [Scene.DataPipelines, 'dataPipelines'],
|
||||
[urls.dataPipelinesNew(':kind')]: [Scene.DataPipelinesNew, 'dataPipelinesNew'],
|
||||
[urls.dataWarehouseSourceNew()]: [Scene.DataWarehouseSourceNew, 'dataWarehouseSourceNew'],
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
LemonBanner,
|
||||
LemonDialog,
|
||||
LemonInput,
|
||||
LemonInputSelect,
|
||||
LemonLabel,
|
||||
LemonMenu,
|
||||
LemonModal,
|
||||
@@ -21,11 +20,13 @@ import { Form } from 'kea-forms'
|
||||
import { IconErrorOutline } from 'lib/lemon-ui/icons'
|
||||
import { LemonButton } from 'lib/lemon-ui/LemonButton'
|
||||
import { LemonField } from 'lib/lemon-ui/LemonField'
|
||||
import { API_KEY_SCOPE_PRESETS, API_SCOPES, MAX_API_KEYS_PER_USER } from 'lib/scopes'
|
||||
import { capitalizeFirstLetter, humanFriendlyDetailedTime } from 'lib/utils'
|
||||
import { Fragment, useEffect } from 'react'
|
||||
import { preflightLogic } from 'scenes/PreflightCheck/preflightLogic'
|
||||
|
||||
import { API_KEY_SCOPE_PRESETS, APIScopes, MAX_API_KEYS_PER_USER, personalAPIKeysLogic } from './personalAPIKeysLogic'
|
||||
import { personalAPIKeysLogic } from './personalAPIKeysLogic'
|
||||
import ScopeAccessSelector from './scopes/ScopeAccessSelector'
|
||||
|
||||
function EditKeyModal(): JSX.Element {
|
||||
const {
|
||||
@@ -36,7 +37,6 @@ function EditKeyModal(): JSX.Element {
|
||||
allAccessSelected,
|
||||
editingKey,
|
||||
allTeams,
|
||||
allTeamsLoading,
|
||||
allOrganizations,
|
||||
} = useValues(personalAPIKeysLogic)
|
||||
const { setEditingKeyId, setScopeRadioValue, submitEditingKey, resetScopes } = useActions(personalAPIKeysLogic)
|
||||
@@ -74,127 +74,11 @@ function EditKeyModal(): JSX.Element {
|
||||
<LemonField name="label" label="Label">
|
||||
<LemonInput placeholder='For example "Reports bot" or "Zapier"' maxLength={40} />
|
||||
</LemonField>
|
||||
|
||||
<LemonField name="access_type" className="mt-4 mb-2">
|
||||
{({ value, onChange }) => (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<LemonLabel>Organization & project access</LemonLabel>
|
||||
<LemonSegmentedButton
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
options={[
|
||||
{ label: 'All access', value: 'all' },
|
||||
{
|
||||
label: 'Organizations',
|
||||
value: 'organizations',
|
||||
},
|
||||
{
|
||||
label: 'Projects',
|
||||
value: 'teams',
|
||||
},
|
||||
]}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</LemonField>
|
||||
|
||||
{editingKey.access_type === 'all' ? (
|
||||
<p className="mb-0">
|
||||
This API key will allow access to all organizations and projects you're in.
|
||||
</p>
|
||||
) : editingKey.access_type === 'organizations' ? (
|
||||
<>
|
||||
<p className="mb-2">
|
||||
This API key will only allow access to selected organizations and all project within
|
||||
them.
|
||||
</p>
|
||||
|
||||
<LemonField name="scoped_organizations">
|
||||
<LemonInputSelect
|
||||
mode="multiple"
|
||||
data-attr="organizations"
|
||||
options={
|
||||
allOrganizations.map((org) => ({
|
||||
key: `${org.id}`,
|
||||
label: org.name,
|
||||
labelComponent: (
|
||||
<Tooltip
|
||||
title={
|
||||
<div>
|
||||
<div className="font-semibold">{org.name}</div>
|
||||
<div className="text-xs whitespace-nowrap">
|
||||
ID: {org.id}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<span className="flex-1 font-semibold">{org.name}</span>
|
||||
</Tooltip>
|
||||
),
|
||||
})) ?? []
|
||||
}
|
||||
placeholder="Select organizations..."
|
||||
/>
|
||||
</LemonField>
|
||||
</>
|
||||
) : editingKey.access_type === 'teams' ? (
|
||||
<>
|
||||
<p className="mb-2">This API key will only allow access to selected projects.</p>
|
||||
<LemonField name="scoped_teams">
|
||||
{({ value, onChange }) => (
|
||||
<LemonInputSelect
|
||||
mode="multiple"
|
||||
data-attr="teams"
|
||||
value={value.map((x: number) => String(x))}
|
||||
onChange={(val: string[]) => onChange(val.map((x) => parseInt(x)))}
|
||||
options={
|
||||
allTeams?.map((team) => ({
|
||||
key: `${team.id}`,
|
||||
label: team.name,
|
||||
labelComponent: (
|
||||
<Tooltip
|
||||
title={
|
||||
<div>
|
||||
<div className="font-semibold">{team.name}</div>
|
||||
<div className="text-xs whitespace-nowrap">
|
||||
Token: {team.api_token}
|
||||
</div>
|
||||
<div className="text-xs whitespace-nowrap">
|
||||
Organization ID: {team.organization}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{allOrganizations.length > 1 ? (
|
||||
<span>
|
||||
<span>
|
||||
{
|
||||
allOrganizations.find(
|
||||
(org) => org.id === team.organization
|
||||
)?.name
|
||||
}
|
||||
</span>
|
||||
<span className="text-secondary mx-1">/</span>
|
||||
<span className="flex-1 font-semibold">
|
||||
{team.name}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span>{team.name}</span>
|
||||
)}
|
||||
</Tooltip>
|
||||
),
|
||||
})) ?? []
|
||||
}
|
||||
loading={allTeamsLoading}
|
||||
placeholder="Select projects..."
|
||||
/>
|
||||
)}
|
||||
</LemonField>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<ScopeAccessSelector
|
||||
accessType={editingKey.access_type}
|
||||
organizations={allOrganizations}
|
||||
teams={allTeams ?? undefined}
|
||||
/>
|
||||
<div className="flex items-center justify-between mt-4 mb-2">
|
||||
<LemonLabel>Scopes</LemonLabel>
|
||||
<LemonField name="preset">
|
||||
@@ -239,7 +123,7 @@ function EditKeyModal(): JSX.Element {
|
||||
</LemonBanner>
|
||||
) : (
|
||||
<div>
|
||||
{APIScopes.map(
|
||||
{API_SCOPES.map(
|
||||
({ key, disabledActions, warnings, disabledWhenProjectScoped, info }) => {
|
||||
const disabledDueToProjectScope =
|
||||
disabledWhenProjectScoped && editingKey.access_type === 'teams'
|
||||
|
||||
@@ -9,109 +9,11 @@ import { lemonToast } from 'lib/lemon-ui/LemonToast/LemonToast'
|
||||
import { urls } from 'scenes/urls'
|
||||
import { userLogic } from 'scenes/userLogic'
|
||||
|
||||
import { APIScopeObject, OrganizationBasicType, PersonalAPIKeyType, TeamBasicType } from '~/types'
|
||||
import { API_KEY_SCOPE_PRESETS } from '~/lib/scopes'
|
||||
import { OrganizationBasicType, PersonalAPIKeyType, TeamBasicType } from '~/types'
|
||||
|
||||
import type { personalAPIKeysLogicType } from './personalAPIKeysLogicType'
|
||||
|
||||
export const MAX_API_KEYS_PER_USER = 10 // Same as in posthog/api/personal_api_key.py
|
||||
|
||||
export type APIScope = {
|
||||
key: APIScopeObject
|
||||
info?: string | JSX.Element
|
||||
disabledActions?: ('read' | 'write')[]
|
||||
disabledWhenProjectScoped?: boolean
|
||||
description?: string
|
||||
warnings?: Partial<Record<'read' | 'write', string | JSX.Element>>
|
||||
}
|
||||
|
||||
export const APIScopes: APIScope[] = [
|
||||
{ key: 'action' },
|
||||
{ key: 'activity_log' },
|
||||
{ key: 'annotation' },
|
||||
{ key: 'batch_export' },
|
||||
{ key: 'cohort' },
|
||||
{ key: 'dashboard' },
|
||||
{ key: 'dashboard_template' },
|
||||
{ key: 'early_access_feature' },
|
||||
{ key: 'event_definition' },
|
||||
{ key: 'error_tracking' },
|
||||
{ key: 'experiment' },
|
||||
{ key: 'export' },
|
||||
{ key: 'feature_flag' },
|
||||
{ key: 'group' },
|
||||
{ key: 'hog_function' },
|
||||
{ key: 'insight' },
|
||||
{ key: 'notebook' },
|
||||
{ key: 'organization', disabledWhenProjectScoped: true },
|
||||
{
|
||||
key: 'organization_member',
|
||||
disabledWhenProjectScoped: true,
|
||||
warnings: {
|
||||
write: (
|
||||
<>
|
||||
This scope can be used to invite users to your organization,
|
||||
<br />
|
||||
effectively <strong>allowing access to other scopes via the added user</strong>.
|
||||
</>
|
||||
),
|
||||
},
|
||||
},
|
||||
{ key: 'person' },
|
||||
{ key: 'plugin' },
|
||||
{
|
||||
key: 'project',
|
||||
warnings: {
|
||||
write: 'This scope can be used to create or modify projects, including settings about how data is ingested.',
|
||||
},
|
||||
},
|
||||
{ key: 'property_definition' },
|
||||
{ key: 'query', disabledActions: ['write'] },
|
||||
{ key: 'session_recording' },
|
||||
{ key: 'session_recording_playlist' },
|
||||
{ key: 'sharing_configuration' },
|
||||
{ key: 'subscription' },
|
||||
{ key: 'survey' },
|
||||
{
|
||||
key: 'user',
|
||||
disabledActions: ['write'],
|
||||
warnings: {
|
||||
read: (
|
||||
<>
|
||||
This scope allows you to retrieve your own user object.
|
||||
<br />
|
||||
Note that the user object <strong>lists all organizations and projects you're in</strong>.
|
||||
</>
|
||||
),
|
||||
},
|
||||
},
|
||||
{ key: 'webhook', info: 'Webhook configuration is currently only enabled for the Zapier integration.' },
|
||||
{ key: 'warehouse_view' },
|
||||
{ key: 'warehouse_table' },
|
||||
]
|
||||
|
||||
export const API_KEY_SCOPE_PRESETS: { value: string; label: string; scopes: string[]; isCloudOnly?: boolean }[] = [
|
||||
{ value: 'local_evaluation', label: 'Local feature flag evaluation', scopes: ['feature_flag:read'] },
|
||||
{
|
||||
value: 'zapier',
|
||||
label: 'Zapier integration',
|
||||
scopes: ['action:read', 'query:read', 'project:read', 'organization:read', 'user:read', 'webhook:write'],
|
||||
},
|
||||
{ value: 'analytics', label: 'Performing analytics queries', scopes: ['query:read'] },
|
||||
{
|
||||
value: 'project_management',
|
||||
label: 'Project & user management',
|
||||
scopes: ['project:write', 'organization:read', 'organization_member:write'],
|
||||
},
|
||||
{
|
||||
value: 'mcp_server',
|
||||
label: 'MCP Server',
|
||||
scopes: APIScopes.map(({ key }) =>
|
||||
['feature_flag', 'insight'].includes(key) ? `${key}:write` : `${key}:read`
|
||||
),
|
||||
},
|
||||
{ value: 'all_access', label: 'All access', scopes: ['*'] },
|
||||
]
|
||||
|
||||
export type EditingKeyFormValues = Pick<
|
||||
PersonalAPIKeyType,
|
||||
'label' | 'scopes' | 'scoped_organizations' | 'scoped_teams'
|
||||
|
||||
133
frontend/src/scenes/settings/user/scopes/ScopeAccessSelector.tsx
Normal file
133
frontend/src/scenes/settings/user/scopes/ScopeAccessSelector.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { LemonInputSelect, LemonLabel, Tooltip } from '@posthog/lemon-ui'
|
||||
import { LemonField } from 'lib/lemon-ui/LemonField'
|
||||
import { LemonSegmentedButton } from 'lib/lemon-ui/LemonSegmentedButton'
|
||||
|
||||
import type { OrganizationBasicType, TeamBasicType } from '~/types'
|
||||
|
||||
type Props = {
|
||||
organizations: Pick<OrganizationBasicType, 'id' | 'name'>[]
|
||||
teams?: Pick<TeamBasicType, 'id' | 'name' | 'organization' | 'api_token'>[]
|
||||
accessType?: 'all' | 'organizations' | 'teams'
|
||||
}
|
||||
|
||||
const ScopeAccessSelector = ({ accessType, organizations, teams }: Props): JSX.Element => {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<LemonField name="access_type" className="mt-4 mb-2">
|
||||
{({ value, onChange }) => (
|
||||
<div className="flex flex-col gap-2 md:flex-row items-start md:items-center justify-between">
|
||||
<LemonLabel>Organization & project access</LemonLabel>
|
||||
<LemonSegmentedButton
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
options={[
|
||||
{ label: 'All access', value: 'all' },
|
||||
{
|
||||
label: 'Organizations',
|
||||
value: 'organizations',
|
||||
},
|
||||
{
|
||||
label: 'Projects',
|
||||
value: 'teams',
|
||||
},
|
||||
]}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</LemonField>
|
||||
|
||||
{accessType === 'all' ? (
|
||||
<p className="mb-0">This will allow access to all organizations and projects you're in.</p>
|
||||
) : accessType === 'organizations' ? (
|
||||
<>
|
||||
<p className="mb-2">
|
||||
This will only allow access to selected organizations and all projects within them.
|
||||
</p>
|
||||
|
||||
<LemonField name="scoped_organizations">
|
||||
<LemonInputSelect
|
||||
mode="multiple"
|
||||
data-attr="organizations"
|
||||
options={
|
||||
organizations.map((org) => ({
|
||||
key: `${org.id}`,
|
||||
label: org.name,
|
||||
labelComponent: (
|
||||
<Tooltip
|
||||
title={
|
||||
<div>
|
||||
<div className="font-semibold">{org.name}</div>
|
||||
<div className="text-xs whitespace-nowrap">ID: {org.id}</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<span className="flex-1 font-semibold">{org.name}</span>
|
||||
</Tooltip>
|
||||
),
|
||||
})) ?? []
|
||||
}
|
||||
loading={organizations === undefined}
|
||||
placeholder="Select organizations..."
|
||||
/>
|
||||
</LemonField>
|
||||
</>
|
||||
) : accessType === 'teams' ? (
|
||||
<>
|
||||
<p className="mb-2">This will only allow access to selected projects.</p>
|
||||
<LemonField name="scoped_teams">
|
||||
{({ value, onChange }) => (
|
||||
<LemonInputSelect
|
||||
mode="multiple"
|
||||
data-attr="teams"
|
||||
value={value.map((x: number) => String(x))}
|
||||
onChange={(val: string[]) => onChange(val.map((x) => parseInt(x)))}
|
||||
options={
|
||||
teams?.map((team) => ({
|
||||
key: `${team.id}`,
|
||||
label: team.name,
|
||||
labelComponent: (
|
||||
<Tooltip
|
||||
title={
|
||||
<div>
|
||||
<div className="font-semibold">{team.name}</div>
|
||||
<div className="text-xs whitespace-nowrap">
|
||||
Token: {team.api_token}
|
||||
</div>
|
||||
<div className="text-xs whitespace-nowrap">
|
||||
Organization ID: {team.organization}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{organizations.length > 1 ? (
|
||||
<span>
|
||||
<span>
|
||||
{
|
||||
organizations.find(
|
||||
(org) => org.id === team.organization
|
||||
)?.name
|
||||
}
|
||||
</span>
|
||||
<span className="text-secondary mx-1">/</span>
|
||||
<span className="flex-1 font-semibold">{team.name}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span>{team.name}</span>
|
||||
)}
|
||||
</Tooltip>
|
||||
),
|
||||
})) ?? []
|
||||
}
|
||||
loading={teams === undefined}
|
||||
placeholder="Select projects..."
|
||||
/>
|
||||
)}
|
||||
</LemonField>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ScopeAccessSelector
|
||||
@@ -170,6 +170,7 @@ export const urls = {
|
||||
sessionAttributionExplorer: (): string => '/web/session-attribution-explorer',
|
||||
wizard: (): string => `/wizard`,
|
||||
startups: (referrer?: string): string => `/startups${referrer ? `/${referrer}` : ''}`,
|
||||
oauthAuthorize: (): string => '/oauth/authorize',
|
||||
dataPipelines: (kind?: string): string => `/data-pipelines/${kind ?? ''}`,
|
||||
dataPipelinesNew: (kind?: string): string => `/data-pipelines/new/${kind ?? ''}`,
|
||||
dataWarehouseSource: (id: string, tab?: string): string => `/data-warehouse/sources/${id}/${tab ?? 'schemas'}`,
|
||||
|
||||
@@ -4289,6 +4289,20 @@ export type APIScopeObject =
|
||||
| 'warehouse_view'
|
||||
| 'warehouse_table'
|
||||
|
||||
export type APIScopeAction = 'read' | 'write'
|
||||
|
||||
export type APIScope = {
|
||||
key: APIScopeObject
|
||||
objectPlural: string
|
||||
info?: string | JSX.Element
|
||||
disabledActions?: APIScopeAction[]
|
||||
disabledWhenProjectScoped?: boolean
|
||||
description?: string
|
||||
warnings?: Partial<Record<APIScopeAction, string | JSX.Element>>
|
||||
}
|
||||
|
||||
export type APIScopePreset = { value: string; label: string; scopes: string[]; isCloudOnly?: boolean }
|
||||
|
||||
export enum AccessControlLevel {
|
||||
None = 'none',
|
||||
Member = 'member',
|
||||
@@ -5529,6 +5543,10 @@ export interface ProjectTreeRef {
|
||||
ref: string | null
|
||||
}
|
||||
|
||||
export type OAuthApplicationPublicMetadata = {
|
||||
name: string
|
||||
client_id: string
|
||||
}
|
||||
export interface EmailSenderDomainStatus {
|
||||
status: 'pending' | 'success'
|
||||
dnsRecords: (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from rest_framework import decorators, exceptions, viewsets
|
||||
from rest_framework_extensions.routers import NestedRegistryItem
|
||||
|
||||
from .oauth_application import OAuthApplicationPublicMetadataViewSet
|
||||
import products.data_warehouse.backend.api.fix_hogql as fix_hogql
|
||||
import products.early_access_features.backend.api as early_access_feature
|
||||
from products.user_interviews.backend.api import UserInterviewViewSet
|
||||
@@ -102,7 +103,7 @@ router.register(r"plugin_config", plugin.LegacyPluginConfigViewSet, "legacy_plug
|
||||
|
||||
router.register(r"feature_flag", feature_flag.LegacyFeatureFlagViewSet) # Used for library side feature flag evaluation
|
||||
router.register(r"llm_proxy", LLMProxyViewSet, "llm_proxy")
|
||||
|
||||
router.register(r"oauth_application/metadata", OAuthApplicationPublicMetadataViewSet, "oauth_application_metadata")
|
||||
# Nested endpoints shared
|
||||
projects_router = router.register(r"projects", project.RootProjectViewSet, "projects")
|
||||
projects_router.register(r"environments", team.TeamViewSet, "project_environments", ["project_id"])
|
||||
|
||||
408
posthog/api/oauth.py
Normal file
408
posthog/api/oauth.py
Normal file
@@ -0,0 +1,408 @@
|
||||
from datetime import timedelta
|
||||
import json
|
||||
import uuid
|
||||
from oauth2_provider.views import TokenView, RevokeTokenView, IntrospectTokenView
|
||||
from posthog.models import OAuthApplication, OAuthAccessToken, User, Team
|
||||
from oauth2_provider.settings import oauth2_settings
|
||||
from oauth2_provider.http import OAuth2ResponseRedirect
|
||||
from oauth2_provider.exceptions import OAuthToolkitError
|
||||
from django.utils import timezone
|
||||
|
||||
from rest_framework import serializers, status
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.response import Response
|
||||
from oauth2_provider.views.mixins import OAuthLibMixin
|
||||
from oauth2_provider.views import ConnectDiscoveryInfoView, JwksInfoView, UserInfoView
|
||||
from oauth2_provider.oauth2_validators import OAuth2Validator
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.authentication import SessionAuthentication
|
||||
import structlog
|
||||
from django.utils.decorators import method_decorator
|
||||
from typing import TypedDict, cast
|
||||
|
||||
from posthog.models.oauth import OAuthApplicationAccessLevel, OAuthGrant, OAuthRefreshToken
|
||||
from posthog.user_permissions import UserPermissions
|
||||
from posthog.utils import render_template
|
||||
from posthog.views import login_required
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class OAuthAuthorizationContext(TypedDict):
|
||||
user: User
|
||||
|
||||
|
||||
class OAuthAuthorizationSerializer(serializers.Serializer):
|
||||
client_id = serializers.CharField()
|
||||
redirect_uri = serializers.CharField(required=False, allow_null=True, default=None)
|
||||
response_type = serializers.CharField(required=False)
|
||||
state = serializers.CharField(required=False, allow_null=True, default=None)
|
||||
code_challenge = serializers.CharField(required=False, allow_null=True, default=None)
|
||||
code_challenge_method = serializers.CharField(required=False, allow_null=True, default=None)
|
||||
nonce = serializers.CharField(required=False, allow_null=True, default=None)
|
||||
claims = serializers.CharField(required=False, allow_null=True, default=None)
|
||||
scope = serializers.CharField()
|
||||
allow = serializers.BooleanField()
|
||||
prompt = serializers.CharField(required=False, allow_null=True, default=None)
|
||||
approval_prompt = serializers.CharField(required=False, allow_null=True, default=None)
|
||||
access_level = serializers.ChoiceField(choices=[level.value for level in OAuthApplicationAccessLevel])
|
||||
scoped_organizations = serializers.ListField(
|
||||
child=serializers.CharField(), required=False, allow_null=True, default=[]
|
||||
)
|
||||
scoped_teams = serializers.ListField(child=serializers.IntegerField(), required=False, allow_null=True, default=[])
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
context = kwargs.get("context", {})
|
||||
if "user" not in context:
|
||||
raise ValueError("OAuthAuthorizationSerializer requires 'user' in context")
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def validate_scoped_organizations(self, scoped_organization_ids: list[str]) -> list[str]:
|
||||
access_level = self.initial_data.get("access_level")
|
||||
requesting_user: User = self.context["user"]
|
||||
user_permissions = UserPermissions(requesting_user)
|
||||
org_memberships = user_permissions.organization_memberships
|
||||
|
||||
if access_level == OAuthApplicationAccessLevel.ORGANIZATION.value:
|
||||
if not scoped_organization_ids or len(scoped_organization_ids) == 0:
|
||||
raise serializers.ValidationError("scoped_organizations is required when access_level is organization")
|
||||
try:
|
||||
organization_uuids = [uuid.UUID(org_id) for org_id in scoped_organization_ids]
|
||||
for org_uuid in organization_uuids:
|
||||
if org_uuid not in org_memberships or not org_memberships[org_uuid].level:
|
||||
raise serializers.ValidationError(
|
||||
f"You must be a member of organization '{org_uuid}' to scope access to it."
|
||||
)
|
||||
except ValueError:
|
||||
raise serializers.ValidationError("Invalid organization UUID provided in scoped_organizations.")
|
||||
return scoped_organization_ids
|
||||
elif scoped_organization_ids and len(scoped_organization_ids) > 0:
|
||||
raise serializers.ValidationError(
|
||||
f"scoped_organizations is not allowed when access_level is {access_level}"
|
||||
)
|
||||
return []
|
||||
|
||||
def validate_scoped_teams(self, scoped_team_ids: list[int]) -> list[int]:
|
||||
access_level = self.initial_data.get("access_level")
|
||||
requesting_user: User = self.context["user"]
|
||||
user_permissions = UserPermissions(requesting_user)
|
||||
|
||||
if access_level == OAuthApplicationAccessLevel.TEAM.value:
|
||||
if not scoped_team_ids or len(scoped_team_ids) == 0:
|
||||
raise serializers.ValidationError("scoped_teams is required when access_level is team")
|
||||
|
||||
teams = Team.objects.filter(pk__in=scoped_team_ids)
|
||||
if len(teams) != len(scoped_team_ids):
|
||||
raise serializers.ValidationError("One or more specified teams in scoped_teams do not exist.")
|
||||
|
||||
for team in teams:
|
||||
if user_permissions.team(team).effective_membership_level is None:
|
||||
raise serializers.ValidationError(
|
||||
f"You must be a member of team '{team.id}' ({team.name}) to scope access to it."
|
||||
)
|
||||
return scoped_team_ids
|
||||
elif scoped_team_ids and len(scoped_team_ids) > 0:
|
||||
raise serializers.ValidationError(f"scoped_teams is not allowed when access_level is {access_level}")
|
||||
return []
|
||||
|
||||
|
||||
class OAuthValidator(OAuth2Validator):
|
||||
def get_additional_claims(self, request):
|
||||
return {
|
||||
"given_name": request.user.first_name,
|
||||
"family_name": request.user.last_name,
|
||||
"email": request.user.email,
|
||||
"email_verified": request.user.is_email_verified or False,
|
||||
"sub": str(request.user.uuid),
|
||||
}
|
||||
|
||||
def _create_access_token(self, expires, request, token, source_refresh_token=None):
|
||||
id_token = token.get("id_token", None)
|
||||
if id_token:
|
||||
id_token = self._load_id_token(id_token)
|
||||
|
||||
scoped_teams, scoped_organizations = self._get_scoped_teams_and_organizations(
|
||||
request, access_token=None, grant=None, refresh_token=source_refresh_token
|
||||
)
|
||||
|
||||
return OAuthAccessToken.objects.create(
|
||||
user=request.user,
|
||||
scope=token.get("scope", None),
|
||||
expires=expires,
|
||||
token=token.get("access_token", None),
|
||||
id_token=id_token,
|
||||
application=request.client,
|
||||
source_refresh_token=source_refresh_token,
|
||||
scoped_teams=scoped_teams,
|
||||
scoped_organizations=scoped_organizations,
|
||||
)
|
||||
|
||||
def _create_authorization_code(self, request, code, expires=None):
|
||||
scoped_teams, scoped_organizations = self._get_scoped_teams_and_organizations(
|
||||
request, access_token=None, grant=None, refresh_token=None
|
||||
)
|
||||
|
||||
if not expires:
|
||||
expires = timezone.now() + timedelta(seconds=cast(int, oauth2_settings.AUTHORIZATION_CODE_EXPIRE_SECONDS))
|
||||
return OAuthGrant.objects.create(
|
||||
application=request.client,
|
||||
user=request.user,
|
||||
code=code.get("code", None),
|
||||
expires=expires,
|
||||
redirect_uri=request.redirect_uri,
|
||||
scope=" ".join(request.scopes),
|
||||
code_challenge=request.code_challenge or "",
|
||||
code_challenge_method=request.code_challenge_method or "",
|
||||
nonce=request.nonce or "",
|
||||
claims=json.dumps(request.claims or {}),
|
||||
scoped_teams=scoped_teams,
|
||||
scoped_organizations=scoped_organizations,
|
||||
)
|
||||
|
||||
def _create_refresh_token(self, request, refresh_token_code, access_token, previous_refresh_token):
|
||||
if previous_refresh_token:
|
||||
token_family = previous_refresh_token.token_family
|
||||
else:
|
||||
token_family = uuid.uuid4()
|
||||
|
||||
scoped_teams, scoped_organizations = self._get_scoped_teams_and_organizations(
|
||||
request, access_token=None, grant=None, refresh_token=previous_refresh_token
|
||||
)
|
||||
|
||||
return OAuthRefreshToken.objects.create(
|
||||
user=request.user,
|
||||
token=refresh_token_code,
|
||||
application=request.client,
|
||||
access_token=access_token,
|
||||
token_family=token_family,
|
||||
scoped_teams=scoped_teams,
|
||||
scoped_organizations=scoped_organizations,
|
||||
)
|
||||
|
||||
def _get_scoped_teams_and_organizations(
|
||||
self,
|
||||
request,
|
||||
access_token: OAuthAccessToken | None,
|
||||
grant: OAuthGrant | None = None,
|
||||
refresh_token: OAuthRefreshToken | None = None,
|
||||
):
|
||||
scoped_teams = None
|
||||
scoped_organizations = None
|
||||
|
||||
if hasattr(request, "scoped_teams") and hasattr(request, "scoped_organizations"):
|
||||
scoped_teams = request.scoped_teams
|
||||
scoped_organizations = request.scoped_organizations
|
||||
elif access_token:
|
||||
scoped_teams = access_token.scoped_teams
|
||||
scoped_organizations = access_token.scoped_organizations
|
||||
elif refresh_token:
|
||||
scoped_teams = refresh_token.scoped_teams
|
||||
scoped_organizations = refresh_token.scoped_organizations
|
||||
elif grant:
|
||||
scoped_teams = grant.scoped_teams
|
||||
scoped_organizations = grant.scoped_organizations
|
||||
|
||||
if request.decoded_body:
|
||||
try:
|
||||
code = dict(request.decoded_body).get("code", None)
|
||||
if code:
|
||||
code_grant = OAuthGrant.objects.get(code=code)
|
||||
scoped_teams = code_grant.scoped_teams
|
||||
scoped_organizations = code_grant.scoped_organizations
|
||||
except OAuthGrant.DoesNotExist:
|
||||
pass
|
||||
|
||||
if scoped_teams is None or scoped_organizations is None:
|
||||
raise OAuthToolkitError("Unable to find scoped_teams or scoped_organizations")
|
||||
|
||||
return scoped_teams, scoped_organizations
|
||||
|
||||
|
||||
class OAuthAuthorizationView(OAuthLibMixin, APIView):
|
||||
"""
|
||||
This view handles incoming requests to /authorize.
|
||||
|
||||
A GET request to /authorize validates the request and decides if it should:
|
||||
a) Redirect to the redirect_uri with error parameters
|
||||
b) Show an error state (e.g. when no redirect_uri is available)
|
||||
c) Show an authorize page
|
||||
|
||||
A POST request is made to /authorize with allow=True if the user authorizes the request and allow=False otherwise.
|
||||
This returns a redirect_uri in it's response body to redirect the user to. In a successful flow, this will include a code
|
||||
parameter. In a failed flow, this will include error paramaters.
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
authentication_classes = [SessionAuthentication]
|
||||
|
||||
server_class = oauth2_settings.OAUTH2_SERVER_CLASS
|
||||
validator_class = oauth2_settings.OAUTH2_VALIDATOR_CLASS
|
||||
|
||||
def get_permissions(self):
|
||||
if self.request.method == "POST":
|
||||
return [IsAuthenticated()]
|
||||
return []
|
||||
|
||||
@method_decorator(login_required)
|
||||
def get(self, request, *args, **kwargs):
|
||||
try:
|
||||
scopes, credentials = self.validate_authorization_request(request)
|
||||
except OAuthToolkitError as error:
|
||||
return self.error_response(error, application=None, state=request.query_params.get("state"))
|
||||
|
||||
# Handle login prompt
|
||||
if request.query_params.get("prompt") == "login":
|
||||
return Response({"error": "login_required"}, status=status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
# Get application and scope details
|
||||
try:
|
||||
application = OAuthApplication.objects.get(client_id=credentials["client_id"])
|
||||
except OAuthApplication.DoesNotExist:
|
||||
return Response({"error": "Invalid client_id"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Check for auto-approval
|
||||
if request.query_params.get("approval_prompt", oauth2_settings.REQUEST_APPROVAL_PROMPT) == "auto":
|
||||
try:
|
||||
tokens = OAuthAccessToken.objects.filter(
|
||||
user=request.user, application=application, expires__gt=timezone.now()
|
||||
).all()
|
||||
|
||||
for token in tokens:
|
||||
if token.allow_scopes(scopes):
|
||||
uri, headers, body, status_code = self.create_authorization_response(
|
||||
request=request, scopes=" ".join(scopes), credentials=credentials, allow=True
|
||||
)
|
||||
return Response({"redirect_uri": uri})
|
||||
except OAuthToolkitError as error:
|
||||
return self.error_response(error, application, state=request.query_params.get("state"))
|
||||
|
||||
return render_template("index.html", request)
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
serializer = OAuthAuthorizationSerializer(data=request.data, context={"user": request.user})
|
||||
|
||||
if not serializer.is_valid():
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
application = OAuthApplication.objects.get(client_id=serializer.validated_data["client_id"])
|
||||
except OAuthApplication.DoesNotExist:
|
||||
return Response({"error": "Invalid client_id"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
credentials = {
|
||||
"client_id": serializer.validated_data["client_id"],
|
||||
"redirect_uri": serializer.validated_data["redirect_uri"],
|
||||
"response_type": serializer.validated_data.get("response_type"),
|
||||
"state": serializer.validated_data.get("state"),
|
||||
"scoped_organizations": serializer.validated_data.get("scoped_organizations"),
|
||||
"scoped_teams": serializer.validated_data.get("scoped_teams"),
|
||||
}
|
||||
|
||||
# Add optional fields if present
|
||||
for field in ["code_challenge", "code_challenge_method", "nonce", "claims"]:
|
||||
if serializer.validated_data.get(field):
|
||||
credentials[field] = serializer.validated_data[field]
|
||||
|
||||
try:
|
||||
uri, headers, body, status_code = self.create_authorization_response(
|
||||
request=request,
|
||||
scopes=serializer.validated_data.get("scope", ""),
|
||||
credentials=credentials,
|
||||
allow=serializer.validated_data["allow"],
|
||||
)
|
||||
|
||||
except OAuthToolkitError as error:
|
||||
return self.error_response(
|
||||
error, application, no_redirect=True, state=serializer.validated_data.get("state")
|
||||
)
|
||||
|
||||
logger.debug("Success url for the request: %s", uri)
|
||||
|
||||
redirect = self.redirect(uri, application)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"redirect_to": redirect.url,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
def redirect(self, redirect_to, application: OAuthApplication | None):
|
||||
if application is None:
|
||||
# The application can be None in case of an error during app validation
|
||||
# In such cases, fall back to default ALLOWED_REDIRECT_URI_SCHEMES
|
||||
allowed_schemes = oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES
|
||||
else:
|
||||
allowed_schemes = application.get_allowed_schemes()
|
||||
|
||||
return OAuth2ResponseRedirect(redirect_to, allowed_schemes)
|
||||
|
||||
def error_response(self, error, application, no_redirect=False, **kwargs):
|
||||
"""
|
||||
Handle errors either by redirecting to redirect_uri with a json in the body containing
|
||||
error details or providing an error response
|
||||
"""
|
||||
redirect, error_response = super().error_response(error, **kwargs)
|
||||
|
||||
if redirect:
|
||||
if no_redirect:
|
||||
return Response(
|
||||
{
|
||||
"redirect_to": error_response["url"],
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
return self.redirect(error_response["url"], application)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"error": error_response["error"].error,
|
||||
"error_description": error_response["error"].description,
|
||||
},
|
||||
status=error_response["error"].status_code,
|
||||
)
|
||||
|
||||
|
||||
class OAuthTokenView(TokenView):
|
||||
"""
|
||||
OAuth2 Token endpoint.
|
||||
|
||||
This implements a POST request with the following parameters:
|
||||
- grant_type: The type of grant to use - only "authorization_code" is supported.
|
||||
- code: The authorization code received from the /authorize request.
|
||||
- redirect_uri: The redirect URI to use - this is the same as the redirect_uri used in the authorization request.
|
||||
- code_verifier: The code verifier that was used to generate the code_challenge. The code_challenge is a sha256 hash
|
||||
of the code_verifier that was sent in the authorization request.
|
||||
|
||||
To comply with RFC 6749, the data must be sent as x-www-form-urlencoded.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class OAuthRevokeTokenView(RevokeTokenView):
|
||||
"""
|
||||
OAuth2 Revoke Token endpoint.
|
||||
|
||||
This endpoint is used to revoke a token. It implements a POST request with the following parameters:
|
||||
- token: The token to revoke.
|
||||
- token_type_hint(optional): The type of token to revoke - either "access_token" or "refresh_token"
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class OAuthIntrospectTokenView(IntrospectTokenView):
|
||||
pass
|
||||
|
||||
|
||||
class OAuthConnectDiscoveryInfoView(ConnectDiscoveryInfoView):
|
||||
pass
|
||||
|
||||
|
||||
class OAuthJwksInfoView(JwksInfoView):
|
||||
pass
|
||||
|
||||
|
||||
class OAuthUserInfoView(UserInfoView):
|
||||
pass
|
||||
26
posthog/api/oauth_application.py
Normal file
26
posthog/api/oauth_application.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from rest_framework import mixins, viewsets
|
||||
from posthog.models.oauth import OAuthApplication
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
class OAuthApplicationPublicMetadataSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = OAuthApplication
|
||||
fields = ["name", "client_id"]
|
||||
read_only_fields = ["name", "client_id"]
|
||||
|
||||
|
||||
class OAuthApplicationPublicMetadataViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet):
|
||||
"""
|
||||
Exposes the public metadata (name, client_id) of an OAuth application,
|
||||
identified by its client_id.
|
||||
Accessible without authentication.
|
||||
"""
|
||||
|
||||
queryset = OAuthApplication.objects.all()
|
||||
serializer_class = OAuthApplicationPublicMetadataSerializer
|
||||
permission_classes = []
|
||||
authentication_classes = []
|
||||
lookup_field = "client_id"
|
||||
lookup_url_kwarg = "client_id"
|
||||
@@ -13,7 +13,7 @@ from posthog.models.utils import generate_random_token_personal, mask_key_value
|
||||
from posthog.permissions import TimeSensitiveActionPermission
|
||||
from posthog.user_permissions import UserPermissions
|
||||
|
||||
MAX_API_KEYS_PER_USER = 10 # Same as in personalAPIKeysLogic.tsx
|
||||
MAX_API_KEYS_PER_USER = 10 # Same as in scopes.tsx
|
||||
|
||||
|
||||
class PersonalAPIKeySerializer(serializers.ModelSerializer):
|
||||
|
||||
@@ -122,13 +122,7 @@ class TeamAndOrgViewSetMixin(_GenericViewSet): # TODO: Rename to include "Env"
|
||||
if self.sharing_enabled_actions:
|
||||
authentication_classes.append(SharingAccessTokenAuthentication)
|
||||
|
||||
authentication_classes.extend(
|
||||
[
|
||||
JwtAuthentication,
|
||||
PersonalAPIKeyAuthentication,
|
||||
SessionAuthentication,
|
||||
]
|
||||
)
|
||||
authentication_classes.extend([JwtAuthentication, PersonalAPIKeyAuthentication, SessionAuthentication])
|
||||
|
||||
return [auth() for auth in authentication_classes]
|
||||
|
||||
|
||||
1923
posthog/api/test/test_oauth.py
Normal file
1923
posthog/api/test/test_oauth.py
Normal file
File diff suppressed because it is too large
Load Diff
72
posthog/api/test/test_oauth_application.py
Normal file
72
posthog/api/test/test_oauth_application.py
Normal file
@@ -0,0 +1,72 @@
|
||||
from rest_framework import status
|
||||
|
||||
from posthog.models.oauth import OAuthApplication
|
||||
from posthog.test.base import APIBaseTest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
|
||||
class TestOAuthApplicationMetadataView(APIBaseTest):
|
||||
public_fields = ["name", "client_id"]
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
self.application = OAuthApplication.objects.create(
|
||||
name="Test App",
|
||||
client_id="client_id",
|
||||
client_secret="client_secret",
|
||||
redirect_uris="https://example.com/callback",
|
||||
user=self.user,
|
||||
organization=self.organization,
|
||||
client_type=OAuthApplication.CLIENT_CONFIDENTIAL,
|
||||
authorization_grant_type=OAuthApplication.GRANT_AUTHORIZATION_CODE,
|
||||
algorithm="RS256",
|
||||
)
|
||||
|
||||
def test_get_application_metadata_success(self):
|
||||
url = f"/api/oauth_application/metadata/{self.application.client_id}/"
|
||||
response = self.client.get(url)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
expected_data = {"name": "Test App", "client_id": self.application.client_id}
|
||||
self.assertEqual(response.data, expected_data)
|
||||
|
||||
def test_get_application_metadata_only_exposes_public_fields(self):
|
||||
url = f"/api/oauth_application/metadata/{self.application.client_id}/"
|
||||
response = self.client.get(url)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data.keys()), len(self.public_fields))
|
||||
self.assertEqual(response.data["name"], self.application.name)
|
||||
self.assertEqual(response.data["client_id"], self.application.client_id)
|
||||
|
||||
self.assertNotIn("client_secret", response.data)
|
||||
self.assertNotIn("redirect_uris", response.data)
|
||||
self.assertNotIn("id", response.data)
|
||||
self.assertNotIn("skip_authorization", response.data)
|
||||
|
||||
def test_get_application_metadata_not_found(self):
|
||||
url = f"/api/oauth_application/metadata/non_existent_client_id/"
|
||||
response = self.client.get(url)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
||||
self.assertIn("detail", response.data)
|
||||
self.assertEqual(response.data["detail"], "Not found.")
|
||||
|
||||
def test_endpoint_is_publicly_accessible_even_if_client_is_authenticated(self):
|
||||
url = f"/api/oauth_application/metadata/{self.application.client_id}/"
|
||||
response = self.client.get(url)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
expected_data = {"name": "Test App", "client_id": self.application.client_id}
|
||||
self.assertEqual(response.data, expected_data)
|
||||
|
||||
def test_endpoint_is_publicly_accessible_with_unauthenticated_client(self):
|
||||
unauthenticated_client = APIClient()
|
||||
|
||||
url = f"/api/oauth_application/metadata/{self.application.client_id}/"
|
||||
response = unauthenticated_client.get(url)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
expected_data = {"name": "Test App", "client_id": self.application.client_id}
|
||||
self.assertEqual(response.data, expected_data)
|
||||
19
posthog/oauth2_urls.py
Normal file
19
posthog/oauth2_urls.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from django.urls import path, re_path
|
||||
from posthog.api import oauth
|
||||
from posthog.utils import opt_slash_path
|
||||
|
||||
app_name = "oauth2_provider" # We need this to match the namepace of django-oauth-toolkit for reverse lookups within their views to work
|
||||
|
||||
urlpatterns = [
|
||||
opt_slash_path("oauth/authorize", oauth.OAuthAuthorizationView.as_view(), name="authorize"),
|
||||
opt_slash_path("oauth/token", oauth.OAuthTokenView.as_view(), name="token"),
|
||||
opt_slash_path("oauth/revoke", oauth.OAuthRevokeTokenView.as_view(), name="revoke"),
|
||||
opt_slash_path("oauth/introspect", oauth.OAuthIntrospectTokenView.as_view(), name="introspect"),
|
||||
re_path(
|
||||
r"^\.well-known/openid-configuration/?$",
|
||||
oauth.OAuthConnectDiscoveryInfoView.as_view(),
|
||||
name="oidc-connect-discovery-info",
|
||||
),
|
||||
path(".well-known/jwks.json", oauth.OAuthJwksInfoView.as_view(), name="jwks-info"),
|
||||
opt_slash_path("oauth/userinfo", oauth.OAuthUserInfoView.as_view(), name="user-info"),
|
||||
]
|
||||
@@ -59,7 +59,6 @@ APIScopeObjectOrNotSupported = Literal[
|
||||
"INTERNAL",
|
||||
]
|
||||
|
||||
|
||||
API_SCOPE_OBJECTS: tuple[APIScopeObject, ...] = get_args(APIScopeObject)
|
||||
API_SCOPE_ACTIONS: tuple[APIScopeActions, ...] = get_args(APIScopeActions)
|
||||
|
||||
|
||||
@@ -488,7 +488,7 @@ OIDC_RSA_PRIVATE_KEY = os.getenv("OIDC_RSA_PRIVATE_KEY", "").replace("\\n", "\n"
|
||||
|
||||
OAUTH2_PROVIDER = {
|
||||
"OIDC_ENABLED": True,
|
||||
"PKCE_REQUIRED": True,
|
||||
"PKCE_REQUIRED": True, # We require PKCE for all OAuth flows - including confidential clients
|
||||
"OIDC_RSA_PRIVATE_KEY": OIDC_RSA_PRIVATE_KEY,
|
||||
"SCOPES": {
|
||||
"openid": "OpenID Connect scope",
|
||||
@@ -498,10 +498,16 @@ OAUTH2_PROVIDER = {
|
||||
**get_scope_descriptions(),
|
||||
},
|
||||
"ALLOWED_REDIRECT_URI_SCHEMES": ["https"],
|
||||
"AUTHORIZATION_CODE_EXPIRE_SECONDS": 60 * 5,
|
||||
"AUTHORIZATION_CODE_EXPIRE_SECONDS": 60
|
||||
* 5, # client has 5 minutes to complete the OAuth flow before the authorization code expires
|
||||
"DEFAULT_SCOPES": ["openid"],
|
||||
"OAUTH2_VALIDATOR_CLASS": "posthog.api.oauth.OAuthValidator",
|
||||
"ACCESS_TOKEN_EXPIRE_SECONDS": 60 * 60,
|
||||
"ACCESS_TOKEN_EXPIRE_SECONDS": 60 * 60, # 1 hour
|
||||
"ROTATE_REFRESH_TOKEN": True, # Rotate the refresh token whenever a new access token is issued
|
||||
"REFRESH_TOKEN_REUSE_PROTECTION": True,
|
||||
# The default grace period where a client can attempt to use the same refresh token
|
||||
# Using a refresh token after this will revoke all refresh and access tokens
|
||||
"REFRESH_TOKEN_GRACE_PERIOD_SECONDS": 60 * 2,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
from collections.abc import Callable
|
||||
from typing import Any, Optional, cast
|
||||
from typing import Any, cast
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import structlog
|
||||
from django.conf import settings
|
||||
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect, HttpResponseServerError
|
||||
from django.template import loader
|
||||
from django.urls import URLPattern, include, path, re_path
|
||||
from django.urls import include, path, re_path
|
||||
from django.utils.http import url_has_allowed_host_and_scheme
|
||||
from django.views.decorators.csrf import (
|
||||
csrf_exempt,
|
||||
@@ -37,8 +36,8 @@ from posthog.api import (
|
||||
uploaded_media,
|
||||
user,
|
||||
)
|
||||
from .api.web_experiment import web_experiments
|
||||
from .api.utils import hostname_in_allowed_url_list
|
||||
from posthog.api.web_experiment import web_experiments
|
||||
from posthog.api.utils import hostname_in_allowed_url_list
|
||||
from products.early_access_features.backend.api import early_access_features
|
||||
from posthog.api.survey import surveys
|
||||
from posthog.constants import PERMITTED_FORUM_DOMAINS
|
||||
@@ -46,7 +45,7 @@ from posthog.demo.legacy import demo_route
|
||||
from posthog.models import User
|
||||
from posthog.models.instance_setting import get_instance_setting
|
||||
|
||||
from .utils import render_template
|
||||
from .utils import opt_slash_path, render_template
|
||||
from .views import (
|
||||
health,
|
||||
login_required,
|
||||
@@ -61,6 +60,7 @@ from .views import (
|
||||
from posthog.api.query import progress
|
||||
|
||||
from posthog.api.slack import slack_interactivity_callback
|
||||
from posthog.oauth2_urls import urlpatterns as oauth2_urls
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
@@ -144,12 +144,6 @@ def authorize_and_redirect(request: HttpRequest) -> HttpResponse:
|
||||
)
|
||||
|
||||
|
||||
def opt_slash_path(route: str, view: Callable, name: Optional[str] = None) -> URLPattern:
|
||||
"""Catches path with or without trailing slash, taking into account query param and hash."""
|
||||
# Ignoring the type because while name can be optional on re_path, mypy doesn't agree
|
||||
return re_path(rf"^{route}/?(?:[?#].*)?$", view, name=name) # type: ignore
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("api/schema/", SpectacularAPIView.as_view(), name="schema"),
|
||||
# Optional UI:
|
||||
@@ -221,6 +215,7 @@ urlpatterns = [
|
||||
path("array/<str:token>/config.js", remote_config.RemoteConfigJSAPIView.as_view()),
|
||||
path("array/<str:token>/array.js", remote_config.RemoteConfigArrayJSAPIView.as_view()),
|
||||
re_path(r"^demo.*", login_required(demo_route)),
|
||||
path("", include((oauth2_urls, "oauth2_provider"), namespace="oauth2_provider")),
|
||||
# ingestion
|
||||
# NOTE: When adding paths here that should be public make sure to update ALWAYS_ALLOWED_ENDPOINTS in middleware.py
|
||||
opt_slash_path("decide", decide.get_decide),
|
||||
|
||||
@@ -6,6 +6,7 @@ import datetime as dt
|
||||
import gzip
|
||||
import hashlib
|
||||
import json
|
||||
from django.urls import URLPattern, re_path
|
||||
import orjson
|
||||
import os
|
||||
import re
|
||||
@@ -14,7 +15,7 @@ import string
|
||||
import time
|
||||
import uuid
|
||||
import zlib
|
||||
from collections.abc import Generator, Mapping
|
||||
from collections.abc import Callable, Generator, Mapping
|
||||
from contextlib import contextmanager
|
||||
from enum import Enum
|
||||
from functools import lru_cache, wraps
|
||||
@@ -1591,3 +1592,9 @@ def to_json(obj: dict) -> bytes:
|
||||
json_string = orjson.dumps(obj, default=JSONEncoder().default, option=option)
|
||||
|
||||
return json_string
|
||||
|
||||
|
||||
def opt_slash_path(route: str, view: Callable, name: Optional[str] = None) -> URLPattern:
|
||||
"""Catches path with or without trailing slash, taking into account query param and hash."""
|
||||
# Ignoring the type because while name can be optional on re_path, mypy doesn't agree
|
||||
return re_path(rf"^{route}/?(?:[?#].*)?$", view, name=name) # type: ignore
|
||||
|
||||
Reference in New Issue
Block a user