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.
- oidc_auth_sentry-0.1.0/.github/workflows/ci.yml +41 -0
- oidc_auth_sentry-0.1.0/.github/workflows/release.yml +49 -0
- oidc_auth_sentry-0.1.0/.gitignore +8 -0
- oidc_auth_sentry-0.1.0/CHANGELOG.md +14 -0
- oidc_auth_sentry-0.1.0/PKG-INFO +207 -0
- oidc_auth_sentry-0.1.0/README.md +188 -0
- oidc_auth_sentry-0.1.0/oidc/__init__.py +2 -0
- oidc_auth_sentry-0.1.0/oidc/apps.py +14 -0
- oidc_auth_sentry-0.1.0/oidc/constants.py +52 -0
- oidc_auth_sentry-0.1.0/oidc/provider.py +118 -0
- oidc_auth_sentry-0.1.0/oidc/views.py +110 -0
- oidc_auth_sentry-0.1.0/pyproject.toml +44 -0
- oidc_auth_sentry-0.1.0/tests/test_oidc_logic.py +79 -0
|
@@ -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,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,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])
|