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:
Joshua Snyder
2025-06-25 14:20:07 +02:00
committed by GitHub
parent ecb8d32016
commit 1e1fc370b1
31 changed files with 3218 additions and 251 deletions

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

View File

@@ -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
View 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}`
})
}

View File

@@ -12,6 +12,7 @@ const pathsWithoutProjectId = [
'signup',
'create-organization',
'account',
'oauth',
]
function isPathWithoutProjectId(path: string): boolean {

View File

@@ -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'),

View 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 />
}

View 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,
}

View 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()
},
})),
])

View File

@@ -62,6 +62,7 @@ const pathPrefixesOnboardingNotRequiredFor = [
urls.debugHog(),
urls.debugQuery(),
urls.activity(),
urls.oauthAuthorize(),
]
export const sceneLogic = kea<sceneLogicType>([

View File

@@ -98,6 +98,7 @@ export enum Scene {
MessagingCampaign = 'MessagingCampaign',
Wizard = 'Wizard',
StartupProgram = 'StartupProgram',
OAuthAuthorize = 'OAuthAuthorize',
HogFunction = 'HogFunction',
DataPipelines = 'DataPipelines',
DataPipelinesNew = 'DataPipelinesNew',

View File

@@ -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'],

View File

@@ -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'

View File

@@ -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'

View 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

View File

@@ -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'}`,

View File

@@ -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: (

View File

@@ -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
View 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

View 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"

View File

@@ -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):

View File

@@ -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]

File diff suppressed because it is too large Load Diff

View 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
View 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"),
]

View File

@@ -59,7 +59,6 @@ APIScopeObjectOrNotSupported = Literal[
"INTERNAL",
]
API_SCOPE_OBJECTS: tuple[APIScopeObject, ...] = get_args(APIScopeObject)
API_SCOPE_ACTIONS: tuple[APIScopeActions, ...] = get_args(APIScopeActions)

View File

@@ -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,
}

View File

@@ -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),

View File

@@ -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