oidc-auth-sentry 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,41 @@
1
+ name: ci
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ strategy:
13
+ matrix:
14
+ python-version: ["3.11", "3.12", "3.13"]
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ - uses: actions/setup-python@v5
18
+ with:
19
+ python-version: ${{ matrix.python-version }}
20
+
21
+ - name: Install
22
+ run: python -m pip install -e ".[dev]"
23
+
24
+ - name: Lint
25
+ run: ruff check . && ruff format --check .
26
+
27
+ - name: Test
28
+ run: pytest -q
29
+
30
+ build-check:
31
+ runs-on: ubuntu-latest
32
+ steps:
33
+ - uses: actions/checkout@v4
34
+ - uses: actions/setup-python@v5
35
+ with:
36
+ python-version: "3.12"
37
+ - name: Build and validate
38
+ run: |
39
+ python -m pip install --upgrade build twine
40
+ python -m build
41
+ python -m twine check dist/*
@@ -0,0 +1,49 @@
1
+ name: release
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ jobs:
9
+ build:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ - uses: actions/setup-python@v5
14
+ with:
15
+ python-version: "3.12"
16
+
17
+ - name: Verify tag matches pyproject version
18
+ run: |
19
+ TAG="${GITHUB_REF_NAME#v}"
20
+ PKG=$(python -c "import tomllib;print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
21
+ echo "tag=$TAG pkg=$PKG"
22
+ test "$TAG" = "$PKG" || { echo "::error::Tag v$TAG != pyproject version $PKG"; exit 1; }
23
+
24
+ - name: Build
25
+ run: |
26
+ python -m pip install --upgrade build twine
27
+ python -m build
28
+ python -m twine check dist/*
29
+
30
+ - uses: actions/upload-artifact@v4
31
+ with:
32
+ name: dist
33
+ path: dist/
34
+
35
+ publish:
36
+ needs: build
37
+ runs-on: ubuntu-latest
38
+ # OIDC trusted publishing: no API token stored in the repo.
39
+ # Configure this on PyPI under the project's "Publishing" settings.
40
+ environment: release
41
+ permissions:
42
+ id-token: write
43
+ steps:
44
+ - uses: actions/download-artifact@v4
45
+ with:
46
+ name: dist
47
+ path: dist/
48
+ - name: Publish to PyPI
49
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.pyc
3
+ dist/
4
+ build/
5
+ *.egg-info/
6
+ .venv/
7
+ .pytest_cache/
8
+ .DS_Store
@@ -0,0 +1,14 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here.
4
+ This project adheres to [Semantic Versioning](https://semver.org/).
5
+
6
+ ## [0.1.0] - 2026-06-15
7
+ ### Added
8
+ - Generic OpenID Connect SSO provider for self-hosted Sentry, adapted from the
9
+ built-in Google provider.
10
+ - Issuer (`iss`) and audience (`aud`) validation on the `id_token`.
11
+ - Optional email-domain restriction.
12
+ - `sentry.apps` entry point for automatic provider registration.
13
+ - GitHub Actions CI (lint + test matrix) and tag-triggered release to PyPI via
14
+ trusted publishing.
@@ -0,0 +1,207 @@
1
+ Metadata-Version: 2.4
2
+ Name: oidc-auth-sentry
3
+ Version: 0.1.0
4
+ Summary: Generic OpenID Connect SSO provider for self-hosted Sentry (Cognito-ready)
5
+ Author-email: Cristiano <cristiano@linvix.com.br>
6
+ License-Expression: Apache-2.0
7
+ Classifier: Intended Audience :: Developers
8
+ Classifier: Intended Audience :: System Administrators
9
+ Classifier: Operating System :: OS Independent
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Requires-Python: <4,>=3.11
15
+ Provides-Extra: dev
16
+ Requires-Dist: pytest>=8.0.0; extra == 'dev'
17
+ Requires-Dist: ruff>=0.10.0; extra == 'dev'
18
+ Description-Content-Type: text/markdown
19
+
20
+ # oidc-auth-sentry
21
+
22
+ Provedor de SSO via **OpenID Connect** para o **Sentry self-hosted**, adaptado
23
+ do provedor Google nativo do Sentry. Testado com **AWS Cognito User Pools**, mas
24
+ funciona com qualquer IdP compatível com OIDC (os endpoints são configuráveis,
25
+ não fixos no código).
26
+
27
+ - Nome de distribuição (pip): `oidc-auth-sentry`
28
+ - Módulo importável: `oidc`
29
+ - Compatível com Sentry self-hosted (registro automático via entry point `sentry.apps`)
30
+
31
+ ## Como funciona
32
+
33
+ O Sentry descobre apps instalados pelo entry point `sentry.apps`. Ao iniciar,
34
+ ele chama `oidc.apps.Config.ready()`, que registra o `OIDCProvider` no registro
35
+ de autenticação do Sentry. **Não é preciso editar o código-fonte do Sentry.**
36
+
37
+ Assim como o provedor Google, o `id_token` (JWT) devolvido pelo endpoint de
38
+ token é decodificado diretamente — sem chamada extra ao `userinfo`. Os claims
39
+ `iss` (issuer) e `aud` (audience) são validados. A assinatura do JWT
40
+ **não** é verificada, pois o token é obtido por TLS direto do endpoint de token
41
+ no passo anterior (mesmo comportamento do provedor Google). Para verificação de
42
+ assinatura via JWKS, veja o comentário em `oidc/views.py`.
43
+
44
+ ---
45
+
46
+ ## Passo a passo
47
+
48
+ ### 1. Criar o App Client no Cognito
49
+
50
+ 1. No console da AWS, abra **Amazon Cognito → User Pools** e selecione seu pool.
51
+ 2. Em **App clients**, crie (ou edite) um app client **com client secret**.
52
+ 3. Em **Hosted UI / Managed login**, configure:
53
+ - **Allowed callback URLs**: `https://SEU-SENTRY/auth/sso/`
54
+ (substitua `SEU-SENTRY` pelo seu `url-prefix`; a barra final é obrigatória)
55
+ - **OAuth grant types**: `Authorization code grant`
56
+ - **OpenID Connect scopes**: `openid`, `email`, `profile`
57
+ 4. Garanta que o pool tem um **domínio** configurado (Hosted UI), algo como
58
+ `https://SEU-DOMINIO.auth.SUA-REGIAO.amazoncognito.com`.
59
+ 5. Anote: **client id**, **client secret**, o **domínio** do Hosted UI, a
60
+ **região** e o **User Pool ID**.
61
+
62
+ Os valores que você vai usar:
63
+
64
+ | Dado | Onde encontrar | Exemplo |
65
+ | --------------- | ------------------------------------------- | -------------------------------------------------------------- |
66
+ | `client-id` | App client | `7exampleabc123` |
67
+ | `client-secret` | App client | `abcd...` |
68
+ | `authorize-url` | Domínio Hosted UI + `/oauth2/authorize` | `https://id.linvix.com/oauth2/authorize` |
69
+ | `token-url` | Domínio Hosted UI + `/oauth2/token` | `https://id.linvix.com/oauth2/token` |
70
+ | `issuer` | `cognito-idp.<regiao>.amazonaws.com/<pool>` | `https://cognito-idp.us-east-1.amazonaws.com/us-east-1_ABC123` |
71
+
72
+ > Dica: confira o discovery em
73
+ > `https://cognito-idp.<regiao>.amazonaws.com/<pool>/.well-known/openid-configuration`
74
+ > — os campos `authorization_endpoint`, `token_endpoint` e `issuer` confirmam os
75
+ > valores acima.
76
+
77
+ ### 2. Instalar o plugin no Sentry self-hosted
78
+
79
+ No seu checkout do `getsentry/self-hosted`, adicione o pacote em
80
+ `sentry/requirements.txt`:
81
+
82
+ ```
83
+ oidc-auth-sentry==0.1.0
84
+ ```
85
+
86
+ Se o pacote estiver num índice privado (ex.: AWS CodeArtifact), exporte as
87
+ variáveis de índice antes do build:
88
+
89
+ ```
90
+ PIP_INDEX_URL=https://SEU-INDICE/simple/
91
+ PIP_EXTRA_INDEX_URL=https://pypi.org/simple/
92
+ ```
93
+
94
+ Em seguida rode o instalador, que reconstrói e reinicia os containers:
95
+
96
+ ```
97
+ ./install.sh
98
+ ```
99
+
100
+ ### 3. Configurar as opções do Sentry
101
+
102
+ Adicione as cinco opções `auth-oidc.*`. Há duas formas equivalentes.
103
+
104
+ **Opção A — `sentry/config.yml`:**
105
+
106
+ ```yaml
107
+ auth-oidc.client-id: "7exampleabc123"
108
+ auth-oidc.client-secret: "SEU_CLIENT_SECRET"
109
+ auth-oidc.authorize-url: "https://id.linvix.com/oauth2/authorize"
110
+ auth-oidc.token-url: "https://id.linvix.com/oauth2/token"
111
+ auth-oidc.issuer: "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_ABC123"
112
+ ```
113
+
114
+ Depois de editar arquivos de configuração, rode `./install.sh` novamente para
115
+ aplicar.
116
+
117
+ **Opção B — via CLI (sem rebuild):**
118
+
119
+ ```bash
120
+ docker compose run --rm web sentry config set auth-oidc.client-id "7exampleabc123"
121
+ docker compose run --rm web sentry config set auth-oidc.client-secret "SEU_CLIENT_SECRET"
122
+ docker compose run --rm web sentry config set auth-oidc.authorize-url "https://id.linvix.com/oauth2/authorize"
123
+ docker compose run --rm web sentry config set auth-oidc.token-url "https://id.linvix.com/oauth2/token"
124
+ docker compose run --rm web sentry config set auth-oidc.issuer "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_ABC123"
125
+ ```
126
+
127
+ ### 4. Habilitar o SSO na organização
128
+
129
+ 1. Faça login no Sentry como **owner** da organização.
130
+ 2. Vá em **Settings → Auth** (Settings da organização).
131
+ 3. O provedor **OIDC** deve aparecer na lista. Clique para configurar.
132
+ 4. Conclua o fluxo de login com uma conta do seu User Pool para vincular o SSO.
133
+ 5. Opcionalmente, **exija SSO** para toda a organização.
134
+
135
+ > Atenção: ao exigir SSO, esse passa a ser o único meio de login na sua
136
+ > instância. Garanta que pelo menos uma conta owner consegue autenticar pelo
137
+ > Cognito antes de tornar obrigatório.
138
+
139
+ ### 5. Verificar
140
+
141
+ - Acesse `https://SEU-SENTRY/auth/login/` e inicie o login pelo provedor OIDC.
142
+ - Você será redirecionado ao Hosted UI do Cognito, autentica, e volta logado.
143
+ - Se algo falhar, veja os logs do container `web`:
144
+ `docker compose logs -f web | grep sentry.auth.oidc`
145
+
146
+ ---
147
+
148
+ ## Mapeamento de identidade
149
+
150
+ O provedor lê os seguintes claims do `id_token`:
151
+
152
+ | Claim | Uso no Sentry |
153
+ | ------------------------------------- | ------------------------------------------------------------ |
154
+ | `sub` | id estável e único do usuário |
155
+ | `email` | e-mail (e id legado, para casar contas já existentes) |
156
+ | `name` → `cognito:username` → `email` | nome de exibição (nessa ordem de fallback) |
157
+ | `email_verified` | repassado ao Sentry |
158
+ | `iss`, `aud` | validados (devem casar com `auth-oidc.issuer` e o client id) |
159
+
160
+ ### Restrição opcional por domínio de e-mail
161
+
162
+ Diferente do provedor Google, não usamos o claim `hd`. Se quiser limitar o
163
+ acesso a um domínio, isso é derivado do e-mail. (Configurável no provider;
164
+ por padrão nenhum domínio é exigido.)
165
+
166
+ ---
167
+
168
+ ## Solução de problemas
169
+
170
+ | Sintoma | Causa provável |
171
+ | --------------------------------------- | ---------------------------------------------------------------------------------------------------- |
172
+ | `redirect_uri mismatch` no Cognito | A callback URL no app client precisa ser exatamente `https://SEU-SENTRY/auth/sso/` (com barra final) |
173
+ | `id_token issuer mismatch` | `auth-oidc.issuer` não bate com o `iss` do token — confira região e User Pool ID |
174
+ | `id_token audience mismatch` | `auth-oidc.client-id` diferente do app client que gerou o token |
175
+ | Provedor não aparece em Settings → Auth | Pacote não instalado / `./install.sh` não rodou após adicionar ao requirements |
176
+ | `Unable to fetch user information` | Faltou o scope `openid`/`email`, ou o app client não permite o grant `authorization_code` |
177
+
178
+ ---
179
+
180
+ ## Desenvolvimento
181
+
182
+ ```bash
183
+ pip install -e ".[dev]"
184
+ ruff check .
185
+ ruff format --check .
186
+ pytest -q
187
+ ```
188
+
189
+ Build local e validação do pacote:
190
+
191
+ ```bash
192
+ python -m build
193
+ python -m twine check dist/*
194
+ ```
195
+
196
+ ## Release
197
+
198
+ A versão fica em `pyproject.toml`. Para publicar a `0.1.0`:
199
+
200
+ ```bash
201
+ git tag v0.1.0
202
+ git push origin v0.1.0
203
+ ```
204
+
205
+ A tag dispara o workflow `release.yml`, que valida que a tag bate com a versão,
206
+ builda e publica no índice configurado (PyPI via trusted publishing, ou um
207
+ índice privado). Veja `.github/workflows/release.yml`.
@@ -0,0 +1,188 @@
1
+ # oidc-auth-sentry
2
+
3
+ Provedor de SSO via **OpenID Connect** para o **Sentry self-hosted**, adaptado
4
+ do provedor Google nativo do Sentry. Testado com **AWS Cognito User Pools**, mas
5
+ funciona com qualquer IdP compatível com OIDC (os endpoints são configuráveis,
6
+ não fixos no código).
7
+
8
+ - Nome de distribuição (pip): `oidc-auth-sentry`
9
+ - Módulo importável: `oidc`
10
+ - Compatível com Sentry self-hosted (registro automático via entry point `sentry.apps`)
11
+
12
+ ## Como funciona
13
+
14
+ O Sentry descobre apps instalados pelo entry point `sentry.apps`. Ao iniciar,
15
+ ele chama `oidc.apps.Config.ready()`, que registra o `OIDCProvider` no registro
16
+ de autenticação do Sentry. **Não é preciso editar o código-fonte do Sentry.**
17
+
18
+ Assim como o provedor Google, o `id_token` (JWT) devolvido pelo endpoint de
19
+ token é decodificado diretamente — sem chamada extra ao `userinfo`. Os claims
20
+ `iss` (issuer) e `aud` (audience) são validados. A assinatura do JWT
21
+ **não** é verificada, pois o token é obtido por TLS direto do endpoint de token
22
+ no passo anterior (mesmo comportamento do provedor Google). Para verificação de
23
+ assinatura via JWKS, veja o comentário em `oidc/views.py`.
24
+
25
+ ---
26
+
27
+ ## Passo a passo
28
+
29
+ ### 1. Criar o App Client no Cognito
30
+
31
+ 1. No console da AWS, abra **Amazon Cognito → User Pools** e selecione seu pool.
32
+ 2. Em **App clients**, crie (ou edite) um app client **com client secret**.
33
+ 3. Em **Hosted UI / Managed login**, configure:
34
+ - **Allowed callback URLs**: `https://SEU-SENTRY/auth/sso/`
35
+ (substitua `SEU-SENTRY` pelo seu `url-prefix`; a barra final é obrigatória)
36
+ - **OAuth grant types**: `Authorization code grant`
37
+ - **OpenID Connect scopes**: `openid`, `email`, `profile`
38
+ 4. Garanta que o pool tem um **domínio** configurado (Hosted UI), algo como
39
+ `https://SEU-DOMINIO.auth.SUA-REGIAO.amazoncognito.com`.
40
+ 5. Anote: **client id**, **client secret**, o **domínio** do Hosted UI, a
41
+ **região** e o **User Pool ID**.
42
+
43
+ Os valores que você vai usar:
44
+
45
+ | Dado | Onde encontrar | Exemplo |
46
+ | --------------- | ------------------------------------------- | -------------------------------------------------------------- |
47
+ | `client-id` | App client | `7exampleabc123` |
48
+ | `client-secret` | App client | `abcd...` |
49
+ | `authorize-url` | Domínio Hosted UI + `/oauth2/authorize` | `https://id.linvix.com/oauth2/authorize` |
50
+ | `token-url` | Domínio Hosted UI + `/oauth2/token` | `https://id.linvix.com/oauth2/token` |
51
+ | `issuer` | `cognito-idp.<regiao>.amazonaws.com/<pool>` | `https://cognito-idp.us-east-1.amazonaws.com/us-east-1_ABC123` |
52
+
53
+ > Dica: confira o discovery em
54
+ > `https://cognito-idp.<regiao>.amazonaws.com/<pool>/.well-known/openid-configuration`
55
+ > — os campos `authorization_endpoint`, `token_endpoint` e `issuer` confirmam os
56
+ > valores acima.
57
+
58
+ ### 2. Instalar o plugin no Sentry self-hosted
59
+
60
+ No seu checkout do `getsentry/self-hosted`, adicione o pacote em
61
+ `sentry/requirements.txt`:
62
+
63
+ ```
64
+ oidc-auth-sentry==0.1.0
65
+ ```
66
+
67
+ Se o pacote estiver num índice privado (ex.: AWS CodeArtifact), exporte as
68
+ variáveis de índice antes do build:
69
+
70
+ ```
71
+ PIP_INDEX_URL=https://SEU-INDICE/simple/
72
+ PIP_EXTRA_INDEX_URL=https://pypi.org/simple/
73
+ ```
74
+
75
+ Em seguida rode o instalador, que reconstrói e reinicia os containers:
76
+
77
+ ```
78
+ ./install.sh
79
+ ```
80
+
81
+ ### 3. Configurar as opções do Sentry
82
+
83
+ Adicione as cinco opções `auth-oidc.*`. Há duas formas equivalentes.
84
+
85
+ **Opção A — `sentry/config.yml`:**
86
+
87
+ ```yaml
88
+ auth-oidc.client-id: "7exampleabc123"
89
+ auth-oidc.client-secret: "SEU_CLIENT_SECRET"
90
+ auth-oidc.authorize-url: "https://id.linvix.com/oauth2/authorize"
91
+ auth-oidc.token-url: "https://id.linvix.com/oauth2/token"
92
+ auth-oidc.issuer: "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_ABC123"
93
+ ```
94
+
95
+ Depois de editar arquivos de configuração, rode `./install.sh` novamente para
96
+ aplicar.
97
+
98
+ **Opção B — via CLI (sem rebuild):**
99
+
100
+ ```bash
101
+ docker compose run --rm web sentry config set auth-oidc.client-id "7exampleabc123"
102
+ docker compose run --rm web sentry config set auth-oidc.client-secret "SEU_CLIENT_SECRET"
103
+ docker compose run --rm web sentry config set auth-oidc.authorize-url "https://id.linvix.com/oauth2/authorize"
104
+ docker compose run --rm web sentry config set auth-oidc.token-url "https://id.linvix.com/oauth2/token"
105
+ docker compose run --rm web sentry config set auth-oidc.issuer "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_ABC123"
106
+ ```
107
+
108
+ ### 4. Habilitar o SSO na organização
109
+
110
+ 1. Faça login no Sentry como **owner** da organização.
111
+ 2. Vá em **Settings → Auth** (Settings da organização).
112
+ 3. O provedor **OIDC** deve aparecer na lista. Clique para configurar.
113
+ 4. Conclua o fluxo de login com uma conta do seu User Pool para vincular o SSO.
114
+ 5. Opcionalmente, **exija SSO** para toda a organização.
115
+
116
+ > Atenção: ao exigir SSO, esse passa a ser o único meio de login na sua
117
+ > instância. Garanta que pelo menos uma conta owner consegue autenticar pelo
118
+ > Cognito antes de tornar obrigatório.
119
+
120
+ ### 5. Verificar
121
+
122
+ - Acesse `https://SEU-SENTRY/auth/login/` e inicie o login pelo provedor OIDC.
123
+ - Você será redirecionado ao Hosted UI do Cognito, autentica, e volta logado.
124
+ - Se algo falhar, veja os logs do container `web`:
125
+ `docker compose logs -f web | grep sentry.auth.oidc`
126
+
127
+ ---
128
+
129
+ ## Mapeamento de identidade
130
+
131
+ O provedor lê os seguintes claims do `id_token`:
132
+
133
+ | Claim | Uso no Sentry |
134
+ | ------------------------------------- | ------------------------------------------------------------ |
135
+ | `sub` | id estável e único do usuário |
136
+ | `email` | e-mail (e id legado, para casar contas já existentes) |
137
+ | `name` → `cognito:username` → `email` | nome de exibição (nessa ordem de fallback) |
138
+ | `email_verified` | repassado ao Sentry |
139
+ | `iss`, `aud` | validados (devem casar com `auth-oidc.issuer` e o client id) |
140
+
141
+ ### Restrição opcional por domínio de e-mail
142
+
143
+ Diferente do provedor Google, não usamos o claim `hd`. Se quiser limitar o
144
+ acesso a um domínio, isso é derivado do e-mail. (Configurável no provider;
145
+ por padrão nenhum domínio é exigido.)
146
+
147
+ ---
148
+
149
+ ## Solução de problemas
150
+
151
+ | Sintoma | Causa provável |
152
+ | --------------------------------------- | ---------------------------------------------------------------------------------------------------- |
153
+ | `redirect_uri mismatch` no Cognito | A callback URL no app client precisa ser exatamente `https://SEU-SENTRY/auth/sso/` (com barra final) |
154
+ | `id_token issuer mismatch` | `auth-oidc.issuer` não bate com o `iss` do token — confira região e User Pool ID |
155
+ | `id_token audience mismatch` | `auth-oidc.client-id` diferente do app client que gerou o token |
156
+ | Provedor não aparece em Settings → Auth | Pacote não instalado / `./install.sh` não rodou após adicionar ao requirements |
157
+ | `Unable to fetch user information` | Faltou o scope `openid`/`email`, ou o app client não permite o grant `authorization_code` |
158
+
159
+ ---
160
+
161
+ ## Desenvolvimento
162
+
163
+ ```bash
164
+ pip install -e ".[dev]"
165
+ ruff check .
166
+ ruff format --check .
167
+ pytest -q
168
+ ```
169
+
170
+ Build local e validação do pacote:
171
+
172
+ ```bash
173
+ python -m build
174
+ python -m twine check dist/*
175
+ ```
176
+
177
+ ## Release
178
+
179
+ A versão fica em `pyproject.toml`. Para publicar a `0.1.0`:
180
+
181
+ ```bash
182
+ git tag v0.1.0
183
+ git push origin v0.1.0
184
+ ```
185
+
186
+ A tag dispara o workflow `release.yml`, que valida que a tag bate com a versão,
187
+ builda e publica no índice configurado (PyPI via trusted publishing, ou um
188
+ índice privado). Veja `.github/workflows/release.yml`.
@@ -0,0 +1,2 @@
1
+ # OIDC auth provider for Sentry. Registration happens in apps.py via the
2
+ # "sentry.apps" entry point (see pyproject.toml).
@@ -0,0 +1,14 @@
1
+ from __future__ import annotations
2
+
3
+ from django.apps import AppConfig
4
+
5
+
6
+ class Config(AppConfig):
7
+ name = "oidc"
8
+
9
+ def ready(self) -> None:
10
+ from sentry.auth import register
11
+
12
+ from .provider import OIDCProvider
13
+
14
+ register(OIDCProvider)
@@ -0,0 +1,52 @@
1
+ from sentry import options
2
+
3
+ # ---------------------------------------------------------------------------
4
+ # Adapted from sentry/auth/providers/google/constants.py
5
+ #
6
+ # Google hardcodes its OAuth2 endpoints. For a generic OIDC provider (e.g. AWS
7
+ # Cognito) we read them from Sentry options so the same code works against any
8
+ # issuer. Populate these from your IdP's discovery document:
9
+ # https://<issuer>/.well-known/openid-configuration
10
+ #
11
+ # For a Cognito User Pool the issuer is:
12
+ # https://cognito-idp.<region>.amazonaws.com/<user_pool_id>
13
+ # and the OAuth endpoints live under your Hosted UI domain:
14
+ # https://<your-domain>.auth.<region>.amazoncognito.com
15
+ # ---------------------------------------------------------------------------
16
+
17
+
18
+ def get_authorize_url() -> str:
19
+ # Cognito: https://<domain>/oauth2/authorize
20
+ return options.get("auth-oidc.authorize-url")
21
+
22
+
23
+ def get_access_token_url() -> str:
24
+ # Cognito: https://<domain>/oauth2/token
25
+ return options.get("auth-oidc.token-url")
26
+
27
+
28
+ def get_issuer() -> str:
29
+ # Cognito: https://cognito-idp.<region>.amazonaws.com/<user_pool_id>
30
+ # Used to validate the `iss` claim on the id_token.
31
+ return options.get("auth-oidc.issuer")
32
+
33
+
34
+ # Human-readable provider name shown in the Sentry SSO UI.
35
+ PROVIDER_NAME = "OIDC"
36
+
37
+ # OIDC requires the `openid` scope; `email`/`profile` give us identity claims.
38
+ SCOPE = "openid email profile"
39
+
40
+ ERR_INVALID_DOMAIN = (
41
+ "The domain for your account (%s) is not allowed to authenticate with this provider."
42
+ )
43
+
44
+ ERR_INVALID_RESPONSE = (
45
+ "Unable to fetch user information from the OIDC provider. Please check the log."
46
+ )
47
+
48
+ ERR_INVALID_ISSUER = "The id_token was issued by an unexpected issuer (%s)."
49
+
50
+ ERR_INVALID_AUDIENCE = "The id_token audience does not match this client."
51
+
52
+ DATA_VERSION = "1"
@@ -0,0 +1,118 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from typing import Any
5
+
6
+ from sentry import options
7
+ from sentry.auth.provider import MigratingIdentityId
8
+ from sentry.auth.providers.oauth2 import OAuth2Callback, OAuth2Login, OAuth2Provider
9
+ from sentry.auth.view import AuthView
10
+
11
+ from .constants import (
12
+ DATA_VERSION,
13
+ PROVIDER_NAME,
14
+ SCOPE,
15
+ get_access_token_url,
16
+ get_authorize_url,
17
+ )
18
+ from .views import FetchUser
19
+
20
+
21
+ class OIDCLogin(OAuth2Login):
22
+ scope = SCOPE
23
+
24
+ def __init__(self, client_id: str, domains: list[str] | None = None) -> None:
25
+ self.domains = domains
26
+ super().__init__(authorize_url=get_authorize_url(), client_id=client_id, scope=SCOPE)
27
+
28
+ def get_authorize_params(self, state: str, redirect_uri: str) -> dict[str, str | None]:
29
+ params = super().get_authorize_params(state, redirect_uri)
30
+ # Google forced `approval_prompt`/`access_type=offline` here; those are
31
+ # Google-specific. For generic OIDC we ask for a refresh token via the
32
+ # standard `prompt`/`access_type` only if your IdP honors them. Cognito
33
+ # issues refresh tokens automatically when the `openid` scope is granted
34
+ # and the app client allows the refresh token grant, so we leave the
35
+ # base params untouched.
36
+ return params
37
+
38
+
39
+ class OIDCProvider(OAuth2Provider):
40
+ name = PROVIDER_NAME
41
+ key = "oidc"
42
+
43
+ def __init__(
44
+ self,
45
+ domain: str | None = None,
46
+ domains: list[str] | None = None,
47
+ version: str | None = None,
48
+ **config: Any,
49
+ ) -> None:
50
+ if domain:
51
+ if domains:
52
+ domains.append(domain)
53
+ else:
54
+ domains = [domain]
55
+ self.domains = domains
56
+ version = DATA_VERSION if domains is None else None
57
+ self.version = version
58
+ super().__init__(**config)
59
+
60
+ def get_client_id(self) -> str:
61
+ return options.get("auth-oidc.client-id")
62
+
63
+ def get_client_secret(self) -> str:
64
+ return options.get("auth-oidc.client-secret")
65
+
66
+ def get_auth_pipeline(self) -> list[AuthView]:
67
+ return [
68
+ OIDCLogin(domains=self.domains, client_id=self.get_client_id()),
69
+ OAuth2Callback(
70
+ access_token_url=get_access_token_url(),
71
+ client_id=self.get_client_id(),
72
+ client_secret=self.get_client_secret(),
73
+ ),
74
+ FetchUser(
75
+ client_id=self.get_client_id(),
76
+ domains=self.domains,
77
+ version=self.version,
78
+ ),
79
+ ]
80
+
81
+ def get_refresh_token_url(self) -> str:
82
+ return get_access_token_url()
83
+
84
+ def build_config(self, state: Mapping[str, Any]) -> dict[str, Any]:
85
+ # `state` won't carry a Google-style `domain`; persist whatever domain
86
+ # restriction (if any) was configured on this provider.
87
+ return {"domains": self.domains or [], "version": DATA_VERSION}
88
+
89
+ def build_identity(self, state: Mapping[str, Any]) -> Mapping[str, Any]:
90
+ # Cognito id_token claims look like:
91
+ # {
92
+ # "sub": "a1b2c3d4-...", # stable, unique user id
93
+ # "iss": "https://cognito-idp.<region>.amazonaws.com/<pool>",
94
+ # "aud": "<app client id>",
95
+ # "email": "user@linvix.com",
96
+ # "email_verified": true,
97
+ # "cognito:username": "...",
98
+ # "name": "Full Name", # present if the attribute is set
99
+ # ...
100
+ # }
101
+ data = state["data"]
102
+ user_data = state["user"]
103
+
104
+ # Mirror Google's migration strategy: use the stable `sub` as the id,
105
+ # keeping email as the legacy id so existing accounts are matched.
106
+ user_id = MigratingIdentityId(id=user_data["sub"], legacy_id=user_data["email"])
107
+
108
+ # Prefer a real display name; fall back to email if the IdP doesn't
109
+ # send one.
110
+ name = user_data.get("name") or user_data.get("cognito:username") or user_data["email"]
111
+
112
+ return {
113
+ "id": user_id,
114
+ "email": user_data["email"],
115
+ "name": name,
116
+ "data": self.get_oauth_data(data),
117
+ "email_verified": user_data.get("email_verified", False),
118
+ }
@@ -0,0 +1,110 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from typing import Any
5
+
6
+ import orjson
7
+ from django.http import HttpRequest
8
+ from django.http.response import HttpResponseBase
9
+ from sentry.auth.helper import AuthHelper
10
+ from sentry.auth.view import AuthView
11
+ from sentry.utils.signing import urlsafe_b64decode
12
+
13
+ from .constants import (
14
+ ERR_INVALID_AUDIENCE,
15
+ ERR_INVALID_DOMAIN,
16
+ ERR_INVALID_ISSUER,
17
+ ERR_INVALID_RESPONSE,
18
+ get_issuer,
19
+ )
20
+
21
+ logger = logging.getLogger("sentry.auth.oidc")
22
+
23
+
24
+ class FetchUser(AuthView):
25
+ """
26
+ Adapted from the Google provider's FetchUser.
27
+
28
+ Like Google, we decode the `id_token` JWT returned by the token endpoint
29
+ instead of making a separate userinfo call -- Cognito (and any compliant
30
+ OIDC provider) returns the identity claims directly in the id_token.
31
+
32
+ Differences vs Google:
33
+ * No reliance on the Google-specific `hd` (hosted domain) claim. Domain
34
+ restriction, if any, is derived from the email address.
35
+ * We validate the `iss` (issuer) and `aud` (audience/client_id) claims,
36
+ which OIDC requires and Google's implementation skipped.
37
+ """
38
+
39
+ def __init__(
40
+ self,
41
+ client_id: str,
42
+ domains: list[str] | None,
43
+ version: str | None,
44
+ *args: Any,
45
+ **kwargs: Any,
46
+ ) -> None:
47
+ self.client_id = client_id
48
+ self.domains = domains
49
+ self.version = version
50
+ super().__init__(*args, **kwargs)
51
+
52
+ def dispatch(self, request: HttpRequest, pipeline: AuthHelper) -> HttpResponseBase:
53
+ data: dict[str, Any] | None = pipeline.fetch_state("data")
54
+ assert data is not None
55
+
56
+ try:
57
+ id_token = data["id_token"]
58
+ except KeyError:
59
+ logger.exception("Missing id_token in OAuth response: %s", data)
60
+ return pipeline.error(ERR_INVALID_RESPONSE)
61
+
62
+ try:
63
+ _, payload_b, _ = map(urlsafe_b64decode, id_token.split(".", 2))
64
+ except Exception as exc:
65
+ logger.exception("Unable to decode id_token: %s", exc)
66
+ return pipeline.error(ERR_INVALID_RESPONSE)
67
+
68
+ try:
69
+ payload: dict[str, Any] = orjson.loads(payload_b)
70
+ except Exception as exc:
71
+ logger.exception("Unable to decode id_token payload: %s", exc)
72
+ return pipeline.error(ERR_INVALID_RESPONSE)
73
+
74
+ # --- OIDC validation that Google's provider did not do -------------
75
+ # Note: signature verification is intentionally omitted here to mirror
76
+ # the upstream Google provider, which trusts the id_token because it
77
+ # was just retrieved over TLS directly from the token endpoint in the
78
+ # immediately preceding step. If you want full JWKS signature checks,
79
+ # fetch jwks_uri from discovery and verify before trusting claims.
80
+ issuer = get_issuer()
81
+ if issuer and payload.get("iss") != issuer:
82
+ logger.error("id_token issuer mismatch: %s", payload.get("iss"))
83
+ return pipeline.error(ERR_INVALID_ISSUER % (payload.get("iss"),))
84
+
85
+ aud = payload.get("aud")
86
+ # `aud` may be a string or a list per the OIDC spec.
87
+ aud_values = aud if isinstance(aud, list) else [aud]
88
+ if self.client_id not in aud_values:
89
+ logger.error("id_token audience mismatch: %s", aud)
90
+ return pipeline.error(ERR_INVALID_AUDIENCE)
91
+ # -------------------------------------------------------------------
92
+
93
+ if not payload.get("email"):
94
+ logger.error("Missing email in id_token payload: %s", id_token)
95
+ return pipeline.error(ERR_INVALID_RESPONSE)
96
+
97
+ # Optional restriction by email domain. Unlike Google we have no `hd`
98
+ # claim, so we derive the domain from the email address itself.
99
+ if self.domains:
100
+ domain = extract_domain(payload["email"])
101
+ if domain not in self.domains:
102
+ return pipeline.error(ERR_INVALID_DOMAIN % (domain,))
103
+
104
+ pipeline.bind_state("user", payload)
105
+
106
+ return pipeline.next_step()
107
+
108
+
109
+ def extract_domain(email: str) -> str:
110
+ return email.rsplit("@", 1)[-1]
@@ -0,0 +1,44 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "oidc-auth-sentry"
7
+ version = "0.1.0"
8
+ description = "Generic OpenID Connect SSO provider for self-hosted Sentry (Cognito-ready)"
9
+ authors = [{ name = "Cristiano", email = "cristiano@linvix.com.br" }]
10
+ requires-python = ">=3.11,<4"
11
+ readme = "README.md"
12
+ license = "Apache-2.0"
13
+ classifiers = [
14
+ "Intended Audience :: Developers",
15
+ "Intended Audience :: System Administrators",
16
+ "Operating System :: OS Independent",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Programming Language :: Python :: 3.13",
21
+ ]
22
+ # No install_requires: `sentry` itself is provided by the host image at runtime.
23
+ dependencies = []
24
+
25
+ # This is the magic line: Sentry discovers installed apps through this entry
26
+ # point group and calls each AppConfig.ready(), which registers the provider.
27
+ [project.entry-points."sentry.apps"]
28
+ oidc = "oidc.apps.Config"
29
+
30
+ [project.optional-dependencies]
31
+ dev = ["ruff>=0.10.0", "pytest>=8.0.0"]
32
+
33
+ [tool.hatch.build.targets.wheel]
34
+ packages = ["oidc"]
35
+
36
+ [tool.ruff]
37
+ line-length = 100
38
+
39
+ [tool.ruff.lint]
40
+ extend-ignore = ["E402", "E501"]
41
+ select = ["E", "F", "UP", "B", "SIM", "I"]
42
+
43
+ [tool.pytest.ini_options]
44
+ testpaths = ["tests"]
@@ -0,0 +1,79 @@
1
+ """
2
+ Standalone tests that don't require the Sentry runtime. They exercise the
3
+ pure logic (JWT decode, issuer/audience checks, identity mapping) by importing
4
+ the small helpers directly. The full pipeline is exercised in a live Sentry
5
+ instance; CI here just guards the parts that have no Sentry dependency.
6
+ """
7
+
8
+ import base64
9
+ import json
10
+
11
+
12
+ def b64(d: dict) -> str:
13
+ return base64.urlsafe_b64encode(json.dumps(d).encode()).rstrip(b"=").decode()
14
+
15
+
16
+ def urlsafe_b64decode(s: str) -> bytes:
17
+ b = s.encode()
18
+ return base64.urlsafe_b64decode(b + b"=" * (-len(b) % 4))
19
+
20
+
21
+ REGION = "us-east-1"
22
+ POOL = "us-east-1_ABC123"
23
+ CLIENT = "7exampleclientid"
24
+ ISSUER = f"https://cognito-idp.{REGION}.amazonaws.com/{POOL}"
25
+
26
+
27
+ def make_id_token(**overrides) -> str:
28
+ payload = {
29
+ "sub": "a1b2c3d4-1111-2222-3333-444455556666",
30
+ "iss": ISSUER,
31
+ "aud": CLIENT,
32
+ "email": "cristiano@linvix.com",
33
+ "email_verified": True,
34
+ "cognito:username": "cristiano",
35
+ "name": "Cristiano",
36
+ }
37
+ payload.update(overrides)
38
+ sig = base64.urlsafe_b64encode(b"sig").rstrip(b"=").decode()
39
+ return f"{b64({'alg': 'RS256'})}.{b64(payload)}.{sig}"
40
+
41
+
42
+ def decode_payload(id_token: str) -> dict:
43
+ _, payload_b, _ = map(urlsafe_b64decode, id_token.split(".", 2))
44
+ return json.loads(payload_b)
45
+
46
+
47
+ def test_valid_token_decodes():
48
+ p = decode_payload(make_id_token())
49
+ assert p["iss"] == ISSUER
50
+ aud = p["aud"]
51
+ assert CLIENT in (aud if isinstance(aud, list) else [aud])
52
+ assert p["email"] == "cristiano@linvix.com"
53
+
54
+
55
+ def test_audience_as_list():
56
+ p = decode_payload(make_id_token(aud=[CLIENT, "other"]))
57
+ assert CLIENT in p["aud"]
58
+
59
+
60
+ def test_name_fallback_chain():
61
+ # name present
62
+ p = decode_payload(make_id_token())
63
+ name = p.get("name") or p.get("cognito:username") or p["email"]
64
+ assert name == "Cristiano"
65
+ # name absent -> cognito:username
66
+ p = decode_payload(make_id_token(name=None))
67
+ name = p.get("name") or p.get("cognito:username") or p["email"]
68
+ assert name == "cristiano"
69
+
70
+
71
+ def test_issuer_mismatch_detectable():
72
+ p = decode_payload(make_id_token(iss="https://evil.example"))
73
+ assert p["iss"] != ISSUER
74
+
75
+
76
+ def test_audience_mismatch_detectable():
77
+ p = decode_payload(make_id_token(aud="someone-else"))
78
+ aud = p["aud"]
79
+ assert CLIENT not in (aud if isinstance(aud, list) else [aud])